diff --git a/.github/ISSUE_TEMPLATE/01_bug.yaml b/.github/ISSUE_TEMPLATE/01_bug.yaml index 6e73d02d..3051920f 100644 --- a/.github/ISSUE_TEMPLATE/01_bug.yaml +++ b/.github/ISSUE_TEMPLATE/01_bug.yaml @@ -25,9 +25,9 @@ body: attributes: label: 日志信息(可选)| Logs (Optional) description: | - (可选)如果你在生成字幕视频过程遇到了错误,请打开根目录下的 AppData/logs/app.log 文件,根据日志的时间复制最近一次运行错误的日志信息并填写。这样可以更好帮助我排查。 + (可选)如果你在生成字幕视频过程遇到了错误,请打开 VideoCaptioner AppData 目录下的 logs/app.log 文件,根据日志的时间复制最近一次运行错误的日志信息并填写。这样可以更好帮助我排查。可运行 `videocaptioner config path` 查看 AppData 目录位置。 - (Optional) Please open the AppData/logs/app.log file in the root directory and copy the log information from the most recent run error. + (Optional) Please open logs/app.log under the VideoCaptioner AppData directory and copy the most recent error logs. Run `videocaptioner config path` to locate the AppData directory. render: shell validations: required: false diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 852b5c86..581615d9 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -37,8 +37,8 @@ jobs: include: - name: Windows x64 os: windows-latest - - name: macOS Intel - os: macos-15-intel + - name: macOS Apple Silicon + os: macos-15 steps: - uses: actions/checkout@v6 @@ -54,19 +54,32 @@ jobs: python-version: "3.12" - name: Install dependencies - run: uv sync --frozen + # --extra ocr:把硬字幕 OCR(rapidocr/onnxruntime/rapidfuzz)装进构建环境,否则 PyInstaller 收不到、桌面包硬字幕不可用。 + run: uv sync --frozen --extra ocr - name: Build desktop bundle - run: uv run --with pyinstaller --with static-ffmpeg python scripts/build_desktop.py --clean + run: uv run --extra ocr --with pyinstaller --with static-ffmpeg python scripts/build_desktop.py --clean - name: Smoke test packaged app run: uv run python scripts/smoke_desktop.py dist/VideoCaptioner + # 安装器 / dmg:给用户首次下载用(便携 zip 仍是自动更新的下载源)。 + - name: Build Windows installer (Inno Setup) + if: runner.os == 'Windows' + run: | + choco install innosetup -y --no-progress + uv run python scripts/build_windows_installer.py + + - name: Build macOS dmg + if: runner.os == 'macOS' + run: uv run python scripts/build_macos_dmg.py + - name: Upload desktop artifact uses: actions/upload-artifact@v7 with: name: desktop-${{ runner.os }} - path: artifacts/*.zip + # zip(自动更新源)+ setup.exe / dmg(人工下载)。manifest job 只挑 zip。 + path: artifacts/* if-no-files-found: error retention-days: 14 @@ -79,4 +92,39 @@ jobs: TAG="${GITHUB_REF#refs/tags/}" gh release view "$TAG" >/dev/null 2>&1 || \ gh release create "$TAG" --title "$TAG" --generate-notes - gh release upload "$TAG" artifacts/*.zip --clobber + gh release upload "$TAG" artifacts/* --clobber + + # 所有平台产物上传完成后,把新版本登记到更新后端(飞书多维表格驱动)。 + # 客户端启动调 /api/update/check 即可拿到;二进制仍在本 Release,后端只存下载地址 + 校验信息。 + register: + name: Register release to update backend + needs: desktop + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install deps + run: pip install requests + + - name: Download all desktop artifacts + uses: actions/download-artifact@v5 + with: + pattern: desktop-* + path: artifacts + merge-multiple: true + + - name: Register release + shell: bash + env: + CI_RELEASE_TOKEN: ${{ secrets.CI_RELEASE_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + python scripts/register_release.py \ + --tag "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --artifacts artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9466ed8..f3c89fb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,13 @@ jobs: - name: Type check CLI run: uv run pyright videocaptioner/cli/ + - name: i18n check (源码 key 集 == .pot;基准 zh_Hans 无空译文) + run: uv run python scripts/i18n.py check + env: + QT_QPA_PLATFORM: offscreen + - name: Unit tests - run: uv run pytest tests/test_cli tests/test_dubbing -q + run: uv run pytest tests/test_cli tests/test_dubbing tests/test_i18n -q - name: Build package run: uv build diff --git a/.gitignore b/.gitignore index 126a59f2..9b930c57 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ *.egg-info/ dist/ build/ +artifacts/ # Virtual environments .venv/ @@ -35,8 +36,11 @@ docs/.vitepress/cache/ docs/.vitepress/dist/ # Project-specific -/resource/bin/ -!/resource/bin/bin_environment.txt +/resource/bin/* +# macsysaudio:macOS 系统声音 helper(arm64 + x86_64 universal),源码在 native/,体积小、 +# 连同产物入库(clone 即用)。voxgate / ffmpeg 体积大且多平台,走 +# core/download/dependencies.py 下载,不入库。 +!/resource/bin/macsysaudio /AppData/ /work-dir/ /*.srt @@ -45,5 +49,9 @@ docs/.vitepress/dist/ # Claude Code .claude/ -CLAUDE.md +# CLAUDE.md is a tracked symlink to AGENTS.md. +!CLAUDE.md videocaptioner/_version.py + +# Local-only archive of superseded design mocks and internal review docs +/design-archive/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3b9ba1b4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,744 @@ +# VideoCaptioner Agent Guide + +This file is the entry point for Codex/Claude-style agents working in this +repository. `CLAUDE.md` is intentionally a symlink to this file so the project +has one source of truth. + +## Product + +VideoCaptioner is a desktop and CLI app for video subtitle workflows: + +```text +video/audio input + -> ASR transcription + -> subtitle splitting / LLM polish / translation + -> optional dubbing + -> soft subtitle, hard subtitle, or dubbed final video +``` + +The app has two product surfaces: + +- CLI: `videocaptioner transcribe|subtitle|synthesize|dub|process|download|` + `models|doctor|style|config|gui` +- GUI: PyQt5 desktop app launched by `uv run videocaptioner`, + `.venv/bin/python -m videocaptioner`, or `videocaptioner gui` + +The UX is Chinese-first in the current desktop app. Many provider names and +settings labels are user-facing Chinese strings; keep them natural and concise. + +## Current Architecture + +Keep these boundaries strict: + +```text +videocaptioner/core/ business logic, no PyQt dependency +videocaptioner/core/application/ + config_store.py shared TOML persistence and defaults + app_config.py plain dataclass settings consumed by business flows + task_builder.py builds workflow task configs from AppConfig +videocaptioner/cli/ argparse commands, no UI imports +videocaptioner/cli/config_adapter.py + TOML/CLI dict -> AppConfig +videocaptioner/ui/ PyQt desktop app, may import core +videocaptioner/ui/config_adapter.py + UI state -> AppConfig +videocaptioner/ui/common/config.py + in-memory UI settings over the shared TOML store +videocaptioner/ui/common/settings_state.py + first-party SettingField state, not qfluent QConfig +videocaptioner/ui/thread/ QThread wrappers around long-running core tasks +videocaptioner/ui/view/ pages +videocaptioner/ui/components/ + reusable first-party widgets +``` + +`core` must not import `ui`. `cli` must not import `ui`. GUI pages should not +construct business config directly from widgets; use `ui.config_adapter` / +`TaskFactory` / `TaskBuilder`. + +## Shared Configuration + +The current source of truth is TOML: + +- Store: `videocaptioner.core.application.config_store` +- Default path: `platformdirs.user_config_dir("videocaptioner") / "config.toml"` +- Test override: `VIDEOCAPTIONER_CONFIG_FILE=/tmp/some-config.toml` +- Priority: CLI flags > environment variables > TOML file > defaults + +Important sections: + +- `[ui]`: theme, language, preview-only UI state +- `[llm]` and `[llm.providers.*]`: LLM provider settings and cached model options +- `[whisper_api]`, `[fun_asr]`: ASR provider settings +- `[transcribe]`: ASR workflow defaults +- `[subtitle]`, `[translate]`: split/polish/translate workflow defaults +- `[synthesize]`: video/subtitle synthesis defaults +- `[dubbing]`: TTS/dubbing defaults and clone reference state + +Secrets must be stripped before saving or sending. API-key newline bugs have +caused invalid request headers such as `Bearer ...\n`; do not bypass the strip +paths in `SettingField`, `config_store`, `TTSConfig`, `SpeechProviderConfig`, +or the CLI/UI adapters. + +The settings page taxonomy accepted by the user is: + +- 转录配置 +- LLM 配置 +- 翻译服务 +- 翻译与优化 +- 字幕合成配置 +- 配音配置 +- 保存配置 +- 个性化 +- 关于 + +Provider-specific fields should only appear when that provider needs them. For +example: Edge dubbing hides key rows; SiliconFlow/Gemini show TTS key/model +rows; Whisper API and Fun-ASR show their own base/key/model fields. + +The transcribe settings page has one unified "测试转录" row for ALL ASR +providers (B 接口 / J 接口 / Fun-ASR / Whisper API / whisper-cpp / +faster-whisper). It runs a real short-audio transcription through +`core/asr/check.py::check_transcribe` with `use_cache=False`, the same entry +`doctor --check-api` uses for `api.transcribe`. Do not reintroduce +per-provider "测试连接" buttons or lightweight auth-only probes. + +## Output Naming And Task Workspace + +`videocaptioner/core/application/output_paths.py` is the single source of +truth for output file naming and task directories. CLI and GUI share one +grammar; never hand-write output filename templates at call sites: + +```text +{stem}.{tag}.{ext} tags: | optimized | subtitled | dubbed +``` + +- Tags name the artifact role and compose in processing order + (`video.dubbed.subtitled.mp4`). Parameters (soft/hard subtitle, provider, + voice, timing) never go into filenames. +- Products land next to the source file. GUI paths go through + `unique_path()` (auto-increment `" (2)"`); CLI default paths overwrite + deterministically for scriptability. +- All intermediates live in a per-run task directory grouped by function: + `{work_dir}/{task_type}/{YYYYMMDD-HHMMSS}-{stem}/` (task_type = + `transcribe`/`synthesis`/`batch`/`dubbing`; pass it to `new_task_dir`) with + fixed names (`transcript.srt`, `subtitle.ass`, `dubbing/…`). The flow owner + (home pipeline tail, batch `JobRunner`, synthesis page controller) deletes the + directory on success unless `app.keep_intermediates` is on; failures keep + it for debugging; cancels always clean it. `cleanup_task_dir` only removes a + dir whose parent name is a known task_type (never rmtrees into user dirs). +- Raw TTS segments are a content-addressed global cache in + `CACHE_PATH/tts_segments` keyed by text + every synthesis-affecting config + field (see `DubbingPipeline._segment_hash`). Adding a config field that + changes audible output requires extending that hash. +- Paths flow through task dataclass fields (`task_dir`, explicit + subtitle/video paths). Do not rediscover files by name patterns or glob; + the legacy `【原始字幕】/【卡卡】` bracket-prefix scheme is removed and must + not come back. +- The grammar is pinned by `tests/test_application/test_output_paths.py`; + changing naming starts there. + +## UI Direction + +The current migration direction is first-party UI components with qfluentwidgets +only as a low-level widget/icon source while the shell is being migrated. + +Do: + +- Use `videocaptioner.ui.common.theme_tokens.app_palette()` for colors. +- Use `videocaptioner.ui.common.app_icons` for app-owned SVG icons. +- Put SVGs in `resource/assets/icons/`. +- Prefer reusable widgets in: + - `ui/components/workbench.py` (design-language atoms: buttons, pills, + drop zones, panels; use these first) + - `ui/components/app_dialog.py` (`AppDialog` shell + `ConfirmDialog`; every + in-app dialog must use these instead of qfluent `MessageBox`/ + `MessageBoxBase`. The shell promotes `parent` to `parent.window()` so + dialogs always center on the whole program window, never a tab page.) + - `ui/components/form_cards.py` + - `ui/components/settings_controls.py` + - `ui/components/subtitle_style_controls.py` +- Keep manual stylesheet inside reusable components or page-specific media + preview areas. Avoid scattering large anonymous styles across pages. +- Keep page layouts stable at compact widths; button clicks must not resize + columns or create large blank bands. + +Do not: + +- Reintroduce qfluentwidgets native setting-card/config binding. +- Recreate deleted legacy files such as `MySettingCard.py`, + `app_setting_cards.py`, `WhisperAPISettingWidget.py`, or + `TranscriptionOutputDialog.py`. +- Use raw unicode arrows for production icons; use `app_icons` or FluentIcon. +- Combine `setWordWrap(True)` with `setTextInteractionFlags(TextSelectableByMouse)` + on QLabels inside grid/row layouts (title+description pairs): the selectable + text-document path can report a stale 2-line heightForWidth and the pair gets + pushed apart (bit the doctor rows under LXGW WenKai; standalone labels don't + reproduce). If a row's spacing balloons, drop one of the two flags first. +- Add explanatory cards just to fill space. This project prefers compact, + task-oriented pages. + +Design mocks live in `docs/dev/design-*.html` (roughly one per page) as a +development-time visual reference; superseded variants are archived under +`design-archive/`. **Do NOT reference these HTML paths or their CSS class names +from code comments / docstrings** — the mocks change and get deleted, so the +references rot into dead links. A comment should describe what the widget IS, not +which mock it came from. Use the mocks while building, then drop the reference. +Take reference screenshots with +`scripts/design_reference_shots.py [selector]`. + +For visual work, follow this rhythm: + +1. Inspect the current PyQt page and the relevant HTML demo. +2. Make the smallest coherent component/page changes. +3. Run offscreen UI smoke screenshots in dark and light themes. +4. Open the contact sheets and inspect alignment, spacing, text overflow, + button centering, blank areas, and theme contrast. +5. Only then call the visual work done. + +UI smoke commands: + +```bash +# Full smoke: screenshots + behavior assertions + compact-window checks. +.venv/bin/python scripts/ui_smoke_check.py /tmp/vc-ui-check-dark --theme dark +.venv/bin/python scripts/ui_smoke_check.py /tmp/vc-ui-check-light --theme light + +# Fast iteration: screenshots only, no assertions (seconds per page). +# --pages implies shots-only; settings subpages are setting-. +.venv/bin/python scripts/ui_smoke_check.py --pages dubbing,setting-dubbing +.venv/bin/python scripts/ui_smoke_check.py --shots-only --theme both +.venv/bin/python scripts/ui_smoke_check.py --list +``` + +The full mode exercises page construction, settings navigation, provider +switching, dubbing clone UI, video synthesis mode changes, subtitle-style +fullscreen state, and compact-window states. Both modes write contact sheets +and print one `shot=` line per screenshot. Pages are registered in +`PAGE_REGISTRY` inside the script; add new pages there. + +## Dubbing And Provider Rules + +Dubbing providers currently include Edge, Gemini TTS, and SiliconFlow CosyVoice. + +- Edge is the no-key baseline, but still needs network access for real TTS. +- Gemini and SiliconFlow require a TTS API key before preview/generation. +- SiliconFlow CosyVoice is the provider that exposes voice-clone controls: + upload audio, record, clear, `clone_audio`, and `clone_text`. +- Edge and Gemini should not show unsupported clone controls. +- If Gemini/SiliconFlow preview fails while Edge works, first check provider key + state; do not diagnose it as a generic audio playback bug. +- If preview errors mention `Invalid header value` or `Bearer ...\n`, inspect + shared config sanitation across `task_factory.py`, `core/entities.py`, + `core/speech/models.py`, `core/tts/tts_data.py`, and adapters. + +Provider switching has historically caused stale base URL/model/voice state. +Verify switching in the real app and in `scripts/ui_smoke_check.py`; do not +patch only one page. + +## Live Caption (实时字幕) + +Real-time speech transcription + translation shown in a draggable desktop +overlay (the "实时字幕" nav page). Backend-agnostic by design: three transcription +backends ship today (voxgate / fun-asr / qwen-asr); adding another only means +implementing the `LiveTranscriber` interface. + +```text +core/realtime/ business logic, NO PyQt. Grouped into subpackages by role; + top level keeps only protocol + orchestration + wiring. + events.py TranscriptSegment / CaptionEntry ← our own protocol (leaf, no deps) + config.py LiveCaptionConfig / LiveCaptionSource + caption.py CaptionAssembler: upsert by seg_id + dual-color + async translate + (segmentation is the BACKEND's job, not here) + factory.py config → backend / translate_fn (conditionally imports the chosen + backend so unused backends don't pull websocket into startup) + session.py orchestrates audio+backend+assembler+recorder (start/pump/stop) + check.py diagnostics: real short transcription through a backend + backends/ transcription backends (import the specific submodule you need; the + package __init__ does NOT re-export, to keep backends lazy) + base.py LiveTranscriber ABC + audio contract consts (SAMPLE_RATE/CHANNELS/ + SAMPLE_WIDTH) + On* callback types + TranscriberState/LiveCaptionError + + shared `_reconnect_with_backoff` (exp backoff + log throttle + give-up + cap) reused by the WS backends + voxgate.py `voxgate transcribe -` subprocess (stdio: stdin PCM16 → stdout + `-f protocol` raw Doubao frames); own per-sentence seg_id (not raw `index`); also + holds find_voxgate_binary. No server/WS. Doubao is a zh/en BILINGUAL + model; `-l` is a hint not a limit, `auto` → factory passes `zh` + (still recognizes English). Only zh/en + auto. + fun_asr.py Alibaba DashScope realtime WS client; per-`sentence_id` seg_id; VAD + segmentation (semantic off); reconnects on drop AND mid-session + task-finished. `_language_hints` uses languages.FUN_ASR_MTL_LANGS / + FUN_ASR_REALTIME_LANGS (model-aware). mtl model = zh/yue/en/ja/th/vi/id + (NOT Spanish etc.); realtime model = zh/en/ja. + qwen_asr.py DashScope `qwen3-asr-flash-realtime` WS (OpenAI-realtime style: + session.update/input_audio_buffer.append; transcription in `.text` + event's `stash` (text empty), final in `.completed.transcript`); + per-`item_id` seg_id; 27 languages incl Spanish; `auto` omits language + → server auto-detects. Reconnect治理 shared with fun-asr via base + `_reconnect_with_backoff`; frequent "Connection lost" is the network, + not a code bug (real-API soak: idle 130s / busy 117s both 0 drops). + languages.py SINGLE SOURCE OF TRUTH for which source languages each provider + supports (codes only, no Qt/labels): voxgate=zh/en, fun-asr=mtl 7, + qwen-asr=27; all 3 include "auto" (always first). UI labels (中文) live + in ui/common/config.py `source_language_options(provider)`; backends + + the source-language validator import from here. Don't redefine lang sets. + audio/ capture sources, all → 16k/mono/s16le, drop-in start()/read()/stop() + capture.py sounddevice/PortAudio input device (mic/loopback) + device enumeration. + `_sd()` lazy-imports sounddevice (loads PortAudio) — KEEP lazy: it both + defers PortAudio load and gracefully degrades if PortAudio is missing + (realtime is imported at startup via main_window). + system_mac.py macOS native system audio (ScreenCaptureKit subprocess), no BlackHole + recording/ session recording + history persistence + history.py LiveCaptionStore + LiveCaptionRecord/CaptionSegment (persist/list/ + search/delete/export SRT,TXT); records live in {work_dir}/live-caption/ + (a user work product, not app data); migrate_legacy_root moves the old + APPDATA/live_captions once on GUI startup (from main.py, NOT widget ctor) + recorder.py SessionRecorder: tee PCM→WAV + collect paragraphs → LiveCaptionRecord + debug_tap.py optional debug dump (raw events + fed PCM + assembler output; + enabled by `VC_DEBUG=live`, the project-wide debug switch in + core/utils/debug.py) +ui/thread/live_caption_thread.py WorkerThread shell; Qt signals (caption/level/state/ + error/recorded) + checkpoint cancel +ui/components/caption_overlay.py frameless translucent always-on-top overlay +ui/components/caption_overlay_settings.py in-overlay settings popover +ui/components/live_caption/ in-app page widgets (app_palette, reuse workbench): + transcript.py TranscriptEntry/TranscriptList (timeline bubbles, live + detail) + player.py AudioPlayerBar (QMediaPlayer: seek, per-sentence marks, click-to-jump) + views.py SessionView (ready/live/paused/ended/error) / HistoryView / DetailView +ui/view/live_caption_interface.py QStackedWidget host of the 3 views + thread/overlay/ + recording lifecycle; never builds business config from widgets directly +``` + +Protocol rules: + +- Our protocol is `TranscriptSegment(seg_id, text, is_final, start_time, end_time)` + → `CaptionEntry(seg_id, seq, source_text, source_stable_len, target_text, + is_final, started_at, start_time, end_time)`. UI upserts captions by `seg_id`. + The active paragraph and the history paragraph it settles into share one + `seg_id` — finalization is a state flip, not a new row. `text` is ONE cumulative + growing full text for that sentence (rewritten, not just appended). +- **Segmentation is the BACKEND's job, NOT the assembler's.** Each backend assigns + a stable per-sentence `seg_id` and flips `is_final` on its own sentence-boundary + signal. The `CaptionAssembler` only upserts by `seg_id`, computes dual-color, runs + async translate, and guards finalized segments. There is no pause/over-length + splitting in the assembler anymore (`_PARAGRAPH_PAUSE_S`/`_para_cut` removed). +- Audio contract is 16 kHz / mono / s16le PCM, fed in arbitrary chunks. Backends + re-frame internally; do not pre-chunk to 20 ms in the client. +- Dual-color uses longest-common-prefix (`source_stable_len`) because backends + rewrite interim text, not just append: the common prefix is stable (bright), the + tail is "floaty" (dim); on `is_final` the whole line goes stable. +- voxgate runs `voxgate transcribe - --input-format pcm16 --stream -f protocol -l + ` as a subprocess (raw Doubao send/recv frames, one JSON per line; full + research in `docs/dev/voxgate-protocol.md`). Each `result_json.results[]` item + carries `index` (the sentence number), a cumulative full `text`, and sentence + `start/end_time`. **DO NOT use `index` directly as the seg_id.** Short/paused + audio increments `index` and emits `is_vad_finished` per sentence — but + **continuous speech (watching video / meetings, no clear pauses) keeps the same + `index` for the whole session, never fires VAD, and at the ~118-char per-sentence + cap "rolling-resets" `text` (sudden shrink, prefix fully replaced)**. Keying on + `index` alone overwrites the whole session down to the last fragment (real + evidence: a 118s video session stored only 1 sentence). So `_consume_results` + keeps its own global sentence counter `_sentence_no` and treats **`index` change + OR a rolling reset (`_is_reset`)** as a sentence boundary — finalize the in-flight + segment there, start a new one. `_is_reset(old,new)` is length-halving ONLY: + `bool(old) and len(new)*2 < len(old)` (e.g. 211→15, 118→1). NEVER add a + "common-prefix shrank" rule — twopass rewrites (whose fault→who spots, punctuation + edits) shorten by a char or two with a changed prefix and that rule splits one + growing sentence into repeated prefixes (the real cause of the "English opening 3 + duplicate lines"; those were all vad=0 growth frames, not VAD-triggered). + **Finalize on `is_vad_finished` (real pause); `is_force_finished` is a twopass + mid-sentence flush — the same `index` keeps growing after it, never finalize on + it.** Skip the trailing empty `text:""` placeholder. `stop()` closes stdin (EOF) + and keeps the receive thread alive until voxgate flushes and exits — killing first + truncates the last sentence. No server, no port, no WS. (The old `-f ndjson` / + `snapshot` / `stream_asr_finish` / cover-count model is gone.) +- fun-asr keys each sentence by `funasr#{sentence_id}` and finalizes the previous + sentence when the next `sentence_id` appears (`sentence_end` is unreliable). + +Hard rules: + +- `core/realtime` must not import PyQt; widgets only touch the overlay from the + GUI thread. The transcribe/translate callbacks fire on the backend receive + thread and translation executor — they reach the overlay ONLY through + `LiveCaptionThread`'s Qt signals (queued to the GUI thread). Calling + `overlay.upsert_caption` off-thread aborts with "Cannot create children for a + parent in a different thread". +- The overlay is its own fixed dark-glass visual world — it uses the design + tokens in `caption_overlay.py`, NOT `app_palette()`, and does not recolor with + the app theme. Its translucency intentionally bleeds the video behind it; do + not "fix" the neutral card color to look bluer (that tint is the wallpaper + showing through in design screenshots). +- Ship a `voxgate` binary per platform (CGO build; the repo's are arm64 macOS + only and gitignored). `VoxgateBackend` spawns it directly as a `voxgate + transcribe` subprocess (no `voxgate serve`); users override the path in + 设置 → 实时字幕配置. Binary discovery is `find_voxgate_binary` in + `backends/voxgate.py` (configured path → bundled bin → user bin → PATH). +- Translation reuses `TranslatorFactory` (default Bing: no key, low latency). + Per-sentence finalize translates once; the active sentence translates on a + debounce. Do not add a separate live-translation backend. +- `LiveCaptionInterface` MUST be in `main_window.closeEvent`'s shutdown tuple and + stop its thread + overlay on close, or exit aborts on a running QThread. Before + deleting the overlay, disconnect BOTH `thread.caption` (→ host `_on_caption`) and + `thread.level` (→ `overlay.set_level`); a queued signal to a freed overlay aborts. +- Every session auto-records: `SessionRecorder` tees PCM to `audio.wav` and collects + finalized paragraphs into a `LiveCaptionRecord` saved under + `{work_dir}/live-caption/{YYYYMMDD-HHMMSS}/transcript.json`. One `LiveCaptionStore` + (rooted at the configured work_dir) is built by the interface and passed through the + thread into the session, so recording-writes and history-reads share one root. + Empty sessions are + discarded. Segment `start` = paragraph `started_at − session_start` (aligns with the + continuously-recorded WAV). The detail page plays that WAV with per-sentence marks; + clicking a sentence/mark seeks. Don't invent a new on-disk layout — go through + `LiveCaptionStore`. +- The in-app page (`ui/components/live_caption/`) is the normal app visual world + (`app_palette()`, reuse `workbench`); only the floating `caption_overlay` is the + separate dark-glass world. Pixel-anchor the 3 views to + `docs/dev/design-live-caption-proposals.html` (render with Chrome via + `/opt/homebrew/bin/python3.11` + playwright; capture each `data-mode`). + +The overlay has two states — standard (compact current line, for overlaying video) +and tall (full timeline) — plus the in-overlay settings popover and a paused state; +WIDE / DOCK / COMPACT were removed. Verify overlay edits by rendering the card +offscreen and diffing against `scripts/design_reference_shots.py`-style shots. + +## Online Download And Diagnostics + +The yt-dlp download engine lives in `core/download/media.py` +(`MediaDownloader`, no Qt) and is shared by the GUI thread +(`ui/thread/media_download_thread.py`, a thin signal shell), the CLI +`download` command, and the diagnostics source check +(`core/download/source_check.py`). Network environment helpers (proxy +routing, cookies, bilibili buvid, browser-cookie fallback ladder, friendly +errors) live in `core/download/net.py`. Keep all three callers on the shared +engine so "diagnostics says OK" always matches real download behavior: + +- Proxy is routed per site (`proxy_for_url`): Bilibili connects directly + (global proxies usually have overseas exits and trigger Bilibili risk + control); other sites use `system_proxy()` (env vars, then OS proxy — + GUI processes do not inherit shell `HTTP_PROXY`). +- Bilibili returns `HTTP 412 Precondition Failed` for anonymous requests + without a `buvid` device cookie. `inject_bilibili_buvid` fetches one from + Bilibili's public `x/frontend/finger/spi` endpoint before parsing. +- Even with buvid + direct connection + browser headers, Bilibili sometimes + 412-blocks python/yt-dlp TLS fingerprints while `curl` works. This is an + upstream yt-dlp arms race; do NOT burn time re-deriving it. +- Login-state failures go through ONE fallback ladder + (`net.run_with_browser_cookie_fallback`): anonymous/cookies.txt first, then + installed browsers' login cookies. Download AND diagnostics use it — so the + source check reports "可用(已通过 X 登录态验证)" when the fallback works, + and only reports unavailable after the ladder is exhausted. Do not + reintroduce a diagnostics path that fails on anonymous 412 with a + "downloads will probably still work" hedge. +- `doctor --check-api` and the doctor page both resolve one stable public + video per source (`api.download.youtube` = "Me at the zoo", + `api.download.bilibili` = official MV) via `check_download_sources()`. +- Frequent probing gets rate-limited by both sites for minutes. Space out + real network verification runs; a 412/bot-check after repeated tests is + risk control, not a code regression. + +## FFmpeg And Subtitle Rendering + +Do not treat `ffmpeg` existence as enough. ASS/rounded subtitle rendering needs +real filter support. + +Quick checks: + +```bash +.venv/bin/python - <<'PY' +from videocaptioner.core.subtitle.ass_renderer import ffmpeg_supports_ass_filter +print(ffmpeg_supports_ass_filter()) +PY +.venv/bin/python -m videocaptioner doctor +``` + +Known failure signatures: + +- `Unknown filter 'ass'` +- `Unknown filter 'subtitles'` +- `No option name near ... ass=...:fontsdir=...` +- `Error parsing filterchain` +- `Exception: FFmpeg Return code: 234` + +All app-owned media probing goes through `core/utils/media_info.py` +(`probe_media`, parses `ffmpeg -i` stderr — do not add new ffprobe callers or +ad-hoc ffmpeg output parsers). ffprobe is still bundled solely because pydub +(dubbing audio assembly) reads mp3 via ffprobe; it goes away when pydub does. + +If `resource/bin/ffmpeg` is relinked or replaced, +restart the running desktop app before retesting. The live process can keep an +old binary/path snapshot. + +## Testing Standards + +Use fast local checks before expensive or online checks: + +```bash +.venv/bin/python -m ruff check videocaptioner tests scripts +.venv/bin/python -m compileall videocaptioner scripts tests +.venv/bin/python -m pytest tests/test_cli/test_config.py tests/test_cli/test_parser.py +.venv/bin/python -m pytest tests/test_asr/test_chunking.py tests/test_asr/test_chunked_asr.py tests/test_asr/test_check.py +.venv/bin/python -m pytest tests/test_tts/test_tts_core.py tests/test_subtitle/test_ass_renderer.py +.venv/bin/python -m pytest tests/test_dubbing/test_pipeline.py tests/test_dubbing/test_presets.py +.venv/bin/python -m pytest tests/test_download tests/test_thread tests/test_ui +``` + +Full `pytest` includes tests that hit online ASR, Bing/Google translation, and +LLM paths. In restricted shell environments these often fail with DNS or missing +key errors. Classify those failures honestly instead of calling the whole app +broken. For external-service checks, report which host/provider failed and +whether the local validation path passed. + +When the user asks for broad acceptance or says the app should be "actually +tested", use `docs/dev/e2e-acceptance-checklist.md` as the working checklist. +Do not replace that with a single unit test run. A useful acceptance pass +includes: + +- GUI click smoke through home, transcription, subtitle processing, subtitle + style, video synthesis, dubbing, doctor, logs, and settings. +- Settings changes with an isolated `VIDEOCAPTIONER_CONFIG_FILE`, followed by a + reload check that proves TOML persistence and UI state agree. +- Provider switching for ASR, LLM, translation, and dubbing, including + provider-specific row visibility and key/no-key error states. +- Local fixture-based CLI checks for subtitle synthesis and other flows that do + not require a paid or online provider. +- Clear separation between deterministic local proof, mocked-provider proof, + and live external-provider proof. +- Screenshot/contact-sheet inspection for visual regressions, not just "script + exited 0". + +Useful real CLI smoke paths with local fixture assets: + +```bash +# Create a tiny input video from fixture audio in /tmp. +mkdir -p /tmp/vc-e2e-assets /tmp/vc-e2e-out +cp tests/fixtures/audio/zh.mp3 /tmp/vc-e2e-assets/source-zh.mp3 +cp tests/fixtures/audio/zh.srt /tmp/vc-e2e-assets/source-zh.srt +ffmpeg -y -hide_banner -loglevel error \ + -f lavfi -i color=c=0x202323:s=1280x720:d=3 \ + -i /tmp/vc-e2e-assets/source-zh.mp3 \ + -shortest -c:v libx264 -pix_fmt yuv420p -c:a aac \ + /tmp/vc-e2e-assets/input-video.mp4 + +# Soft subtitles. +.venv/bin/python -m videocaptioner synthesize \ + /tmp/vc-e2e-assets/input-video.mp4 \ + -s /tmp/vc-e2e-assets/source-zh.srt \ + --subtitle-mode soft \ + -o /tmp/vc-e2e-out/synth-soft.mp4 + +# Hard ASS subtitles. +.venv/bin/python -m videocaptioner synthesize \ + /tmp/vc-e2e-assets/input-video.mp4 \ + -s /tmp/vc-e2e-assets/source-zh.srt \ + --subtitle-mode hard --render-mode ass --quality low \ + -o /tmp/vc-e2e-out/synth-hard-ass.mp4 + +# Rounded subtitle rendering with style override. +.venv/bin/python -m videocaptioner synthesize \ + /tmp/vc-e2e-assets/input-video.mp4 \ + -s /tmp/vc-e2e-assets/source-zh.srt \ + --subtitle-mode hard --render-mode rounded --quality low \ + --style-override '{"font_size":52,"background_radius":28}' \ + -o /tmp/vc-e2e-out/synth-hard-rounded.mp4 +``` + +For UI tests, prefer `/tmp` output folders. Do not commit generated screenshots, +`__pycache__`, `.pytest_cache`, or `.DS_Store`. `work-dir/`, `AppData/`, and +`screenshots/` are local/runtime artifact areas. + +Keep acceptance artifacts out of the source tree unless the user explicitly +asks for a persistent design/reference artifact. Use `/tmp/vc-*` for generated +videos, audios, subtitles, screenshots, and isolated config files. Before +calling a broad pass done, clean or at least report any generated artifacts that +remain in the worktree. + +## Code Quality Rules + +- Prefer deletion and simplification over compatibility layers when old code is + no longer part of the desired architecture. +- Keep names literal and domain-specific: provider, preset, voice, clone_audio, + subtitle_mode, render_mode, etc. +- Keep core functions testable without PyQt. +- Keep UI state and business config connected through adapters, not widget + imports in core/CLI. +- Add focused tests for shared config, parser behavior, rendering command + quoting, provider normalization, and cache behavior when changing those areas. +- When fixing a visual bug, include screenshot verification. When fixing a + runtime bug, include the command, test, or log signature that proves the path. +- Be explicit about external limits: no network, missing API key, provider quota, + or stale running app process. +- Treat open IDE tabs as hints only. If the tab points at a deleted legacy file, + inspect the current filesystem and imports before recreating it. +- Prefer a small shared component over repeated page-local styling when two + pages share the same shape: card rows, segmented controls, status pills, + file/action rows, provider cards, or preview panels. +- When extracting UI components, keep them visually boring and predictable: + stable width/height, no surprise relayout on click, no one-off color tokens, + no hidden persistence side effects. +- For provider model lists, separate "load available models" from "test this + connection". Cache loaded model options per provider in shared config state + only when they are safe to reuse. +- Comments describe what the code IS now, not how it got there. Delete debugging + anecdotes ("实测 100ms", "线上 bug", "用户反馈…"), changelog-style narration, + line-number references, and "we chose NOT to do X" justifications for absent + features. Keep only non-obvious WHY: anti-regression constraints and domain rules. +- When you change code, fix its comment/docstring in the SAME edit — a comment that + still describes the old behavior is a stale lie the next reader trusts. +- Don't cite design-mock HTML files or their CSS class names in comments/docstrings + (see UI Direction); describe the widget, not the mock it came from. + +## Workspace Hygiene + +This repo often contains large user-approved refactors. Before deleting or +renaming anything, inspect current imports with `rg` and preserve unrelated user +changes. Good cleanup targets are generated artifacts and dead legacy UI files; +bad cleanup targets are user-created design demos or work-in-progress docs that +are still referenced by the conversation. + +Legacy files intentionally removed during the settings/component migration +should stay removed unless the user explicitly asks to restore them: + +- `videocaptioner/ui/components/MySettingCard.py` +- `videocaptioner/ui/components/app_setting_cards.py` +- `videocaptioner/ui/components/WhisperAPISettingWidget.py` +- `videocaptioner/ui/components/WhisperCppSettingWidget.py` +- `videocaptioner/ui/components/FasterWhisperSettingWidget.py` +- `videocaptioner/ui/components/TranscriptionSettingDialog.py` +- `videocaptioner/ui/components/TranscriptionOutputDialog.py` +- `videocaptioner/ui/components/SubtitleSettingDialog.py` + +If an old import is still needed, replace the usage with the current first-party +component or settings page section instead of reviving the old file. + +## Current High-Risk Files + +Large page files still contain too much UI and state logic: + +- `videocaptioner/ui/view/setting_interface.py` +- `videocaptioner/ui/view/dubbing_interface.py` +- `videocaptioner/ui/view/video_synthesis_interface.py` +- `videocaptioner/ui/view/subtitle_style_interface.py` +- `videocaptioner/ui/view/subtitle_interface.py` + +Refactor them by extracting reusable rows/panels into `ui/components/`, not by +adding more page-local helper classes. Keep every extraction backed by a smoke +screenshot if it touches layout. + +`signal_bus.py` currently exists mostly for video preview playback events. Do +not use it for broad configuration propagation; config changes should flow +through the shared settings/config store. + +## Internationalization (i18n) + +UI 国际化是 **key-based gettext**,**只翻 UI(PyQt);core 与 CLI 不翻译**。 + +- 写文案:`from videocaptioner.ui.i18n import tr`(顶部模块级导入),`label.setText(tr("域.语义"))`。 + key 命名 `<域>.<组件>.<语义>`,通用词用 `common.*`。**禁止** `self.tr(...)` 或把中文当 msgid。 +- 枚举下拉标签不手写:`options_from(...)`(`ui/components/settings_controls.py`,内部走 `enum_label`) + 或 `enum_options(EnumCls)`(`ui/common/enum_labels.py`)自动经 `tr(enum.<类>.<成员>)`。 + 新增需翻译枚举:加进 `enum_labels.py` 的 `TRANSLATABLE_ENUMS`。 +- 动态拼接的 key(配音 provider/voice/tag、识别语言 lclang.\*)和 `tr(常量)` 的 key:前者由 + `dubbing_options.i18n_base_map()`/`config.source_language_i18n_map()` 注册表注入 `.pot`,后者在 + 常量定义处用 `N_("key")` 标记让 pybabel 抽取。 +- 资源在 `resource/i18n//LC_MESSAGES/videocaptioner.{po,mo}`;基准 `zh_Hans` 是 key→中文 + 真相源;en/zh_Hant 缺译时运行时回退基准中文(不显示 key)。 +- 改了文案后跑 `scripts/i18n.py extract→update→fill-base→translate→compile`(详见 + `docs/dev/i18n-workflow.md`)。CI 跑 `scripts/i18n.py check`(源码 key 集==.pot、基准无空译文)。 +- 语言切换 = 保存即弹确认自动重启(不做逐页热刷新)。完整方案见 `docs/dev/i18n-plan.md`。 + +## Software Update (自动更新) + +应用内自动更新 + 公告:启动后台调**自建后端** `GET /api/update/check` → 拿 `block`(版本封禁)/ +`update`(新版)/`announcement`(公告)→ 有新版弹「更新提示条」一键下载(带校验)→「重启并安装」; +公告按 id 弹一次。后端(飞书多维表格驱动)决定一切版本逻辑,客户端只渲染。**二进制仍在 GitHub +Release**,响应给直链。契约见 `docs/dev/update-api.md`(与 vc-backend 的 `docs/update-api.md` 同步)。 +**业务在 `core/update`(无 PyQt),UI 只是薄壳。** + +```text +core/update/client.py 调 /api/update/check(headers 带版本/平台/channel/client_id)→ CheckResult + (block, update, announcement);app_channel();UpdateInfo.urls 镜像兜底 +core/update/installer.py 下载(复用 core/download,sha256 校验)+ 退出后替换重启 + (download_update / apply_update / can_self_update / install_root) +ui/thread/update_thread.py UpdateCheckThread(启动后台检查,resultReady/checkFailed)/ UpdateDownloadThread +ui/components/update_banner.py 提示条状态机:可用→下载中 NN%→重启并安装;失败可重试;blocked 态 +scripts/register_release.py 发版时 POST /api/admin/release 登记新版(CI 跑,带 CI_RELEASE_TOKEN) +``` + +硬规则: + +- **后端是真相源**:版本封禁、选最新版、灰度、公告时间窗全部后端算,客户端不做版本比较。端点写死 + `config.UPDATE_CHECK_URL`(`https://backend.videocaptioner.cn/api/update/check`),不走环境变量。 +- **三个字段都可空,缺值一律按「无」处理**:`block`(非空字符串=封禁,文案直接展示)/ `update` + `{version,notes,url,sha256,size}` / `announcement` `{id,title,content}`。`check_update` 任何 + 失败(网络/非 JSON/`ok:false`)返回 None,调用方静默忽略、不阻断启动。 +- **channel 决定能否自更新**:`app_channel()` = `desktop`(frozen) / `dev`(源码或 editable,包不在 + site-packages) / `pip`(wheel 装进 site-packages)。**dev 也检查**(看得到更新/公告便于调试); + `pip` 后端返回 `update=null`(交给 `pip install -U`)。不再有 `VERSION.startswith("0.0.0")` 跳过。 +- **block = 锁死整个应用**:`block` 非空且 `can_self_update()` 时 main_window `stackedWidget.setEnabled(False)` + + 提示条不可关,逼用户更新;不能自更新(dev/pip)时不锁、按钮变「前往下载」,避免卡死。 +- **CI 发版几乎零手工**:`tag + push` → build-desktop 构建上传 zip 到 Release → `register` job 跑 + `register_release.py` POST `/api/admin/release` 登记(version/notes/各平台 url+sha256+size)。 + 封禁老版本/灰度/发公告改飞书表,无需重新发版。改产物命名要同步 `register_release.py` 的 `_platform_key`。 +- **安装器/dmg 只给人工首次下载,不参与自动更新**:`build_windows_installer.py`(Inno,per-user 装到 + `%LOCALAPPDATA%\Programs` → 可写、自更新照常)出 `*-setup.exe`;`build_macos_dmg.py`(ad-hoc 签名) + 出 `*.dmg`。自更新只拉 onedir-zip(Windows)/app-zip(macOS) 走 rm+mv 换装,`register_release.py` 只 + `rglob VideoCaptioner-*.zip` 且跳过 macOS 裸 onedir。**无 Apple 证书 macOS 首开必被 Gatekeeper 提示**。 +- **onedir 运行中无法原地覆盖自身**:`apply_update` 解压到临时目录 → 写平台 helper(Win `.cmd` / + Unix `.sh`,等本进程 PID 退出后 rm+mv 换装并重启,macOS 还清 quarantine)→ 调用方必须立即 + `QApplication.quit()`,否则 helper 一直等。 +- 下载走 `core/download/downloader.download_file`(镜像兜底 + 续传 + sha256),**不要**另起一套下载。 +- **macOS 打包/解压必须用 `ditto`,不能用 Python `zipfile`**:zipfile 会把 .app 的符号链接拍平、 + 丢可执行位,解压出的 .app 起不来。`build_desktop._archive_dir` 与 `installer._extract` 在 Darwin + 分支都走 ditto;Windows onedir 无软链/执行位继续用 zipfile。改这两处务必保持 ditto。 +- 更新检查/下载线程必须在 `main_window.closeEvent` 里停掉(`updateBanner.stop()` + + `updateCheckThread.wait()`),否则退出销毁运行中 QThread 触发 abort。 +- **client_id 与反馈复用同一个**(`get_or_create_client_id`,存 `APPDATA/feedback_client_id`), + 随 check 走 `X-Client-Id` 头给后端统计;`X-App-Version/Platform/Channel` 同走头,无任何密钥。 +- 公告按 `id` 去重只弹一次,去重态存 `get_version_state_cache()`(`announcement_shown_`),与是否 + 有新版互相独立(最新版用户也能收)。旧的 GitHub `latest.json` / `gen_update_manifest.py` / + `manifest.py` / `vc.bkfeng.top` 轮询 / `mandatory`+`min_supported` 客户端逻辑均已删除,不要复活。 +- 新增更新/公告 UI 文案要走 i18n(`app.update.*` / `app.announcement.*`),改完重跑 + `scripts/i18n.py extract→update→…→compile`。 + +## Useful Docs + +- `docs/dev/config-architecture.md` +- `docs/dev/view-structure.md` +- `docs/dev/asr-chunking.md` +- `docs/dev/translate-module.md` +- `docs/dev/tts-provider-research.md` +- `docs/dev/i18n-workflow.md` · `docs/dev/i18n-plan.md` +- `docs/dev/packaging-and-update-plan.md` +- `videocaptioner/core/subtitle/README.md` + +`docs/dev/architecture.md`, `api.md`, and `contributing.md` are public pages +of the VitePress docs site (see `docs/.vitepress/config.*`) — do not move or +delete them. Superseded design mocks and internal review notes live in the +local-only `design-archive/` directory (gitignored). + +## Common Commands + +```bash +# Launch desktop app. +uv run videocaptioner +.venv/bin/python -m videocaptioner gui + +# CLI help. +.venv/bin/python -m videocaptioner --help +.venv/bin/python -m videocaptioner process --help + +# Local ASR models (whisper-cpp / faster-whisper). Mirror fallback +# HuggingFace -> hf-mirror -> ModelScope, resumable downloads. Core logic in +# videocaptioner/core/download/, shared by this CLI and the settings UI. +.venv/bin/python -m videocaptioner models list +.venv/bin/python -m videocaptioner models download whisper-cpp tiny + +# Config with an isolated test TOML. +VIDEOCAPTIONER_CONFIG_FILE=/tmp/vc-config.toml \ + .venv/bin/python -m videocaptioner config init --non-interactive --force +VIDEOCAPTIONER_CONFIG_FILE=/tmp/vc-config.toml \ + .venv/bin/python -m videocaptioner config show +``` + +Always check the actual worktree state before editing. This repo often has +large in-progress diffs, HTML design mocks, and generated comparison artifacts. +Do not revert user changes unless explicitly asked. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5c8d412c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +# Contributing to VideoCaptioner + +完整贡献指南见在线文档: +https://weifeng2333.github.io/VideoCaptioner/dev/contributing + +快速开始: + +```bash +git clone https://github.com/YOUR_USERNAME/VideoCaptioner.git +cd VideoCaptioner +uv sync +uv run videocaptioner # GUI +uv run ruff check videocaptioner tests scripts # Lint +uv run pytest tests/test_cli tests/test_application -q # Tests +``` + +开发约定(架构边界、UI 设计语言、测试标准)见仓库根目录的 `AGENTS.md`。 diff --git a/README.md b/README.md index 161ae3a7..ffd6917a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
VideoCaptioner Logo

VideoCaptioner

-

基于大语言模型的视频字幕处理工具 — 语音识别、字幕优化、翻译、视频合成一站式处理

+

基于大语言模型的视频字幕处理工具 — 语音识别、字幕优化、翻译、配音、视频合成一站式处理

[在线文档](https://weifeng2333.github.io/VideoCaptioner/) · [CLI 使用](#cli-命令行) · [GUI 桌面版](#gui-桌面版) · [Claude Code Skill](#claude-code-skill)
@@ -12,7 +12,7 @@ pip install videocaptioner # 安装 CLI + GUI 桌面版 ``` -免费功能(必剪语音识别、必应/谷歌翻译)**无需任何配置,安装即用**。 +免费功能(必剪语音识别、必应/谷歌翻译、Edge 配音)**无需任何配置,安装即用**。 ## CLI 命令行 @@ -29,8 +29,15 @@ videocaptioner process video.mp4 --target-language ja # 字幕烧录到视频 videocaptioner synthesize video.mp4 -s subtitle.srt -# 下载在线视频 +# 根据字幕生成配音视频(Edge 免费,免 API Key) +videocaptioner dub subtitle.srt --video video.mp4 --preset edge-cn-female + +# 下载在线视频(同语言字幕一并保存) videocaptioner download "https://youtube.com/watch?v=xxx" + +# 本地转录模型管理(HuggingFace / 国内镜像自动兜底,断点续传) +videocaptioner models list +videocaptioner models download whisper-cpp tiny ``` 需要 LLM 功能(字幕优化、大模型翻译)时,配置 API Key: @@ -49,12 +56,15 @@ videocaptioner config set llm.model gpt-4o-mini | 命令 | 说明 | |------|------| | `gui` | 打开桌面版。也可以直接运行 `videocaptioner-gui` | -| `transcribe` | 语音转字幕。引擎:`faster-whisper`、`whisper-api`、`bijian`(免费)、`jianying`(免费)、`whisper-cpp` | +| `transcribe` | 语音转字幕。引擎:`bijian`(免费)、`jianying`(免费)、`fun-asr`、`whisper-api`、`whisper-cpp`、`faster-whisper` | | `subtitle` | 字幕优化/翻译。翻译服务:`llm`、`bing`(免费)、`google`(免费) | -| `dub` | 根据字幕生成配音音轨或配音视频 | +| `dub` | 根据字幕生成配音音轨或配音视频。提供商:Edge(免费)、Gemini、SiliconFlow CosyVoice(支持音色克隆) | | `synthesize` | 字幕烧录到视频(软字幕/硬字幕) | | `process` | 全流程处理 | -| `download` | 下载 YouTube、B站等平台视频 | +| `download` | 下载 YouTube、B站等平台视频,同语言字幕一并保存;登录态失败时自动尝试浏览器 cookies | +| `models` | 本地转录模型管理(`list`、`download`),多镜像兜底、断点续传 | +| `doctor` | 环境诊断:依赖、API Key、本地程序与模型;`--check-api` 用真实请求验证转录、配音与视频下载源 | +| `style` | 查看字幕样式预设 | | `config` | 配置管理(`show`、`set`、`get`、`path`、`init`) | 运行 `videocaptioner <命令> --help` 查看完整参数。完整 CLI 文档见 [docs/cli.md](docs/cli.md)。 @@ -83,12 +93,17 @@ curl -fsSL https://raw.githubusercontent.com/WEIFENG2333/VideoCaptioner/master/s - +
+ 主页 +
+ +| 语音转录 | 字幕优化与翻译 | +|---|---| +| ![语音转录](./docs/images/preview-transcription.png) | ![字幕优化](./docs/images/preview-subtitle.png) | -![页面预览](https://h1.appinn.me/file/1731487410170_preview1.png) -![页面预览](https://h1.appinn.me/file/1731487410832_preview2.png) +| 批量处理 | 配音 | +|---|---| +| ![批量处理](./docs/images/preview-batch.png) | ![配音](./docs/images/preview-dubbing.png) | ## LLM API 配置 @@ -120,14 +135,20 @@ cp skills/SKILL.md ~/.claude/skills/videocaptioner/SKILL.md ## 工作原理 ``` -音视频输入 → 语音识别 → 字幕断句 → LLM 优化 → 翻译 → 视频合成 +音视频输入 → 语音识别 → 字幕断句 → LLM 优化 → 翻译 → 配音(可选)→ 视频合成 ``` - 词级时间戳 + VAD 语音活动检测,识别准确率高 - LLM 语义理解断句,字幕阅读体验自然流畅 - 上下文感知翻译,支持反思优化机制 +- 配音支持时长自适应变速、多角色声线与音色克隆(SiliconFlow CosyVoice) - 批量并发处理,效率高 +成品始终落在源文件旁,按阶段命名:`video.srt`(转录)、`video.zh-Hans.srt` +(翻译,播放器可自动加载)、`video.subtitled.mp4`(字幕视频)、 +`video.dubbed.mp4` / `video.dubbed.wav`(配音)。中间文件集中在工作目录的 +任务文件夹内,任务成功后自动清理(可在设置中保留)。 + ## 开发 ```bash @@ -135,6 +156,7 @@ git clone https://github.com/WEIFENG2333/VideoCaptioner.git cd VideoCaptioner uv sync && uv run videocaptioner # 运行 GUI uv run videocaptioner --help # 运行 CLI +uv run ruff check videocaptioner tests scripts # 代码检查 uv run pyright # 类型检查 uv run pytest tests/test_cli/ -q # 运行测试 ``` diff --git a/VideoCaptioner.spec b/VideoCaptioner.spec index 29bc29ab..bf3788fe 100644 --- a/VideoCaptioner.spec +++ b/VideoCaptioner.spec @@ -5,10 +5,23 @@ import os import sys from pathlib import Path -from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import ( + collect_data_files, + collect_dynamic_libs, + collect_submodules, +) block_cipher = None + +def _safe(fn, name): + """collect_* 对未安装的包会抛错;可选依赖(ocr extra)缺失时返回空,base 包仍可打。""" + try: + return fn(name) + except Exception as exc: # noqa: BLE001 + print(f"[spec] skip {fn.__name__}({name!r}): {exc}") + return [] + ROOT = Path(SPECPATH) RUNTIME_DIR = Path(os.environ.get("VIDEOCAPTIONER_DESKTOP_RUNTIME_DIR", ROOT / "build" / "desktop-runtime")) @@ -20,8 +33,8 @@ def _data(src: Path, dest: str): datas = [ _data(ROOT / "resource" / "assets", "resource/assets"), _data(ROOT / "resource" / "fonts", "resource/fonts"), - _data(ROOT / "resource" / "subtitle_style", "resource/subtitle_style"), - _data(ROOT / "resource" / "translations", "resource/translations"), + _data(ROOT / "resource" / "subtitle_styles", "resource/subtitle_styles"), + _data(ROOT / "resource" / "i18n", "resource/i18n"), _data(ROOT / "videocaptioner" / "core" / "prompts", "videocaptioner/core/prompts"), ] @@ -43,7 +56,6 @@ hiddenimports = [ "edge_tts", "diskcache", "yt_dlp", - "modelscope", "psutil", "json_repair", "langdetect", @@ -58,6 +70,23 @@ hiddenimports = [ "fontTools.ttLib", ] hiddenimports += collect_submodules("qfluentwidgets") +# yt-dlp 的 ~900 个 extractor 子模块动态加载,必须显式收集,否则包内下载报 extractor 缺失。 +hiddenimports += collect_submodules("yt_dlp") +# 实时字幕:sounddevice 经 cffi dlopen PortAudio;websocket-client 提供顶层 websocket 包。 +hiddenimports += ["sounddevice", "_sounddevice_data", "cffi", "_cffi_backend", "websocket", "numpy"] +# 硬字幕 OCR(可选 ocr extra):rapidocr 自带 PP-OCR 模型 + yaml 配置(Path(__file__) 读取,需打进包,离线可用); +# onnxruntime 的原生库(libonnxruntime.*.dylib / onnxruntime_pybind11_state.so)。ocr 未装时这些为空、不影响 base 包。 +hiddenimports += ["onnxruntime", "rapidocr", "rapidfuzz"] +hiddenimports += _safe(collect_submodules, "rapidocr") +# 公益大模型经 curl_cffi(浏览器 TLS 指纹过 Cloudflare):wheel 内带原生 libcurl-impersonate +# 与 CA 证书数据,需显式收集,否则打包后 import 就崩。 +hiddenimports += ["curl_cffi"] +hiddenimports += collect_submodules("curl_cffi") + +datas += _safe(collect_data_files, "rapidocr") +datas += collect_data_files("curl_cffi") +native_binaries = _safe(collect_dynamic_libs, "onnxruntime") +native_binaries += collect_dynamic_libs("curl_cffi") excludes = [ "tkinter", @@ -74,7 +103,7 @@ excludes = [ a = Analysis( [str(ROOT / "videocaptioner" / "__main__.py")], pathex=[str(ROOT)], - binaries=[], + binaries=native_binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], @@ -99,7 +128,8 @@ exe = EXE( bootloader_ignore_signals=False, strip=False, upx=True, - console=True, + # 桌面 GUI 包:Windows 双击不弹控制台。命令行用户走 pip 安装的 CLI。 + console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 00000000..23147c65 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,4 @@ +# i18n 抽取配置:扫描抽取根(videocaptioner)下所有 .py 中的 tr()/N_() 调用。 +# 模式相对抽取目录,故用 **.py(不要带 videocaptioner/ 前缀)。 +# 用法见 scripts/i18n.py(pybabel extract -F babel.cfg -k tr -k N_)。 +[python: **.py] diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 295e1ee9..28f4edc5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -288,6 +288,7 @@ export default defineConfig({ items: [ { text: '架构设计', link: '/dev/architecture' }, { text: 'API 文档', link: '/dev/api' }, + { text: 'Agent 反馈循环', link: '/dev/agent-feedback-loop' }, { text: '贡献指南', link: '/dev/contributing' } ] } diff --git a/docs/cli.md b/docs/cli.md index 984f1c6c..4b43359f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,7 +48,7 @@ videocaptioner transcribe <文件> [选项] | 选项 | 说明 | |------|------| -| `--asr` | ASR 引擎:`bijian`(默认,免费) `jianying`(免费) `whisper-api` `whisper-cpp`。bijian/jianying 仅支持中英文,其他语言用 whisper-api 或 whisper-cpp | +| `--asr` | ASR 引擎:`bijian`(默认,免费) `jianying`(免费) `fun-asr` `whisper-api` `whisper-cpp` `faster-whisper`。bijian/jianying 仅支持中英文,其他语言用 fun-asr / whisper-api / whisper-cpp / faster-whisper | | `--language CODE` | 源语言 ISO 639-1 代码,如 `zh` `en` `ja`,或 `auto`(默认) | | `--word-timestamps` | 输出词级时间戳(配合字幕断句使用) | | `--whisper-api-key` | Whisper API 密钥(仅 `--asr whisper-api`) | @@ -163,11 +163,10 @@ videocaptioner dub input.srt \ --tts-api-key "$VIDEOCAPTIONER_TTS_API_KEY" \ -o output.wav -# 多说话人音色映射,并输出视频 +# 多说话人音色映射,并输出视频(默认输出 video.dubbed.mp4 + video.dubbed.wav) videocaptioner dub input.srt --video video.mp4 \ --speaker-voice Alice=anna \ - --speaker-voice Bob=benjamin \ - -o video_dubbed.mp4 + --speaker-voice Bob=benjamin ``` | 选项 | 说明 | @@ -183,7 +182,7 @@ videocaptioner dub input.srt --video video.mp4 \ | `--adapt-length` | 使用 LLM 缩短明显过长的台词 | | `--audio-mode replace/mix/duck` | 输出视频时替换原声、混合原声,或压低原声作为背景 | -命令会额外生成 `*.dubbing.json` 报告,记录每句使用的说话人、音色、生成时长、变速倍数和时间轴 warning。 +分段超时等 warning 会直接打印在命令行;TTS 原始分段按内容寻址缓存在应用缓存目录,重复运行同样的字幕和音色配置会直接复用。 --- @@ -231,7 +230,10 @@ videocaptioner process input.mp4 \ videocaptioner download [-o 目录] ``` -支持 YouTube、B站等 yt-dlp 支持的平台。 +支持 YouTube、B站等 yt-dlp 支持的平台,与桌面端共用同一下载引擎: +按站点分流代理(B 站直连)、自动注入 B 站匿名 buvid、可用的同语言 +字幕会以 `{标题}.{语言}.vtt` sidecar 一并保存。遇到登录态错误 +(如 B 站 412 风控)时自动尝试本机浏览器登录态重试。 --- @@ -259,14 +261,38 @@ videocaptioner config init --print-template --- +### `models` — 本地转录模型管理 + +```bash +videocaptioner models list # 列出模型与安装状态 +videocaptioner models download whisper-cpp tiny # 下载模型 +videocaptioner models download faster-whisper base --models-dir /path/to/models +``` + +支持 whisper-cpp(单文件 ggml)与 faster-whisper(目录式)两类模型。下载按 +HuggingFace → hf-mirror → ModelScope 镜像链自动兜底,国内外网络都可用; +中断后重新执行会自动断点续传,whisper-cpp 模型带 SHA1 校验。 + +GUI 中对应入口:设置 → 转录配置 → 本地模型 → 管理模型。 + +--- + ### `doctor` — 环境诊断 ```bash -videocaptioner doctor # 检查依赖和配置 -videocaptioner doctor --json # Agent/CI 友好的 JSON 输出 +videocaptioner doctor # 检查依赖和配置 +videocaptioner doctor --json # Agent/CI 友好的 JSON 输出 +videocaptioner doctor --check-api # 额外用真实请求验证转录与配音服务 ``` -会检查 Python、FFmpeg/FFprobe、yt-dlp、配置文件、ASR、LLM、翻译和配音关键配置。缺失项会给出对应修复命令。 +会检查 Python、FFmpeg/FFprobe、yt-dlp、配置文件、ASR(含本地运行程序与模型文件)、LLM、翻译和配音关键配置。缺失项会给出对应修复命令。 + +`--check-api` 会用内置短音频真实跑一次当前转录服务(`api.transcribe`,与 +设置页「测试转录」按钮共用同一入口),发起一次真实 TTS 请求 +(`api.dubbing`),并用 yt-dlp 真实解析 YouTube 与哔哩哔哩的稳定公开视频 +(`api.download.youtube` / `api.download.bilibili`,与桌面端诊断页共用)。 +需要 Key 的服务在 Key 缺失时跳过并给出 warn;下载源失败时给出代理 / +cookies / 风控等待等修复建议。 --- @@ -294,7 +320,7 @@ videocaptioner doctor --json # Agent/CI 友好的 JSON 输出 ### 配置文件 -位置:`~/.config/videocaptioner/config.toml`(macOS/Linux) +位置:VideoCaptioner AppData 目录下的 `config.toml`。可运行 `videocaptioner config path` 查看当前路径。 推荐先运行: diff --git a/docs/config/cookies.md b/docs/config/cookies.md index ad4b913d..b089562b 100644 --- a/docs/config/cookies.md +++ b/docs/config/cookies.md @@ -17,7 +17,7 @@ ## 配置方法 1. 获取 `cookies.txt` 文件 -2. 放置到 `AppData/` 目录 +2. 放置到 VideoCaptioner AppData 目录 3. 重启软件 --- diff --git a/docs/dev/agent-feedback-loop.md b/docs/dev/agent-feedback-loop.md new file mode 100644 index 00000000..9f6cf898 --- /dev/null +++ b/docs/dev/agent-feedback-loop.md @@ -0,0 +1,873 @@ +# Agent 反馈循环与验收规范 + +本文档定义 Agent 在 VideoCaptioner 中完成一次开发任务后的反馈循环。目标不是把测试做成形式,而是让每次改动都能被实际运行、实际点击、实际观察,并留下足够清楚的验收记录。 + +VideoCaptioner 同时有 CLI 和桌面 UI。底层能力、流程编排、配置、音视频处理、TTS/ASR/LLM 接入等改动,必须优先用 CLI 做可重复验证;页面、交互、布局、设置项、错误提示等改动,必须实际打开 UI、点击、截图、审查。 + +## 基本原则 + +1. 先确认改动影响面,再选择验收范围。 +2. 先跑低成本、可重复的 CLI/单元测试,再跑耗时或需要网络/API 的真实流程。 +3. UI 改动不能只靠代码审查,必须截图观察。 +4. 截图必须保存在 `screenshots//` 下,命名要能看出页面、状态和轮次。 +5. 每张截图至少主动指出 2 个问题;如果使用 subagent 审查,必须记录 subagent 的主要批评和处理结果。 +6. 不要把测试产物、截图、临时音视频、API Key 提交到仓库,除非用户明确要求。 +7. 用户要求“整体验收”时,不能只验局部;大重构也默认进入整体验收。 +8. 用户只要求局部修改时,至少验该模块的直接功能、相邻入口和回归风险点。 +9. 验收失败不应该被包装成“已完成”。要说明失败点、复现步骤、影响范围和下一步。 +10. Agent 的最终回复必须写清楚实际运行过什么,而不是笼统说“已测试”。 + +## 验收分级 + +### Level 0:静态与快速反馈 + +适用场景: + +- 文档改动。 +- 小范围纯 Python 改动。 +- 参数命名、配置映射、错误文案等低风险改动。 + +最低要求: + +```bash +uv run python -m py_compile +uv run ruff check +``` + +如果改动涉及 CLI 参数、配置、解析器,还要跑: + +```bash +uv run pytest tests/test_cli -q +uv run videocaptioner --help +uv run videocaptioner --help +``` + +通过标准: + +- 命令退出码为 0。 +- 没有新增 traceback。 +- CLI help 中用户关心的参数能看懂,高级/内部参数不应无必要暴露。 +- 错误文案能告诉用户哪里错、影响什么、下一步怎么做。 + +### Level 1:模块功能反馈 + +适用场景: + +- 修改单个 CLI 命令。 +- 修改配音、字幕、翻译、ASR、下载、合成中的一个模块。 +- 修改设置项或配置加载。 +- 修复一个明确 bug。 + +最低要求: + +1. 跑该模块对应测试。 +2. 跑一条真实或接近真实的 CLI 命令。 +3. 检查输出文件是否存在、大小是否合理、格式是否正确。 +4. 检查错误路径:缺文件、缺 key、缺依赖、非法参数时是否有清楚提示。 + +示例: + +```bash +uv run pytest tests/test_dubbing tests/test_tts -q +uv run videocaptioner doctor --json +``` + +配音局部验收示例: + +```bash +cat > /tmp/vc-dub-sample.srt <<'EOF' +1 +00:00:00,000 --> 00:00:01,200 +你好,这是验收测试。 + +2 +00:00:01,300 --> 00:00:02,600 +我们正在检查配音流程。 +EOF + +uv run videocaptioner dub /tmp/vc-dub-sample.srt \ + --preset edge-cn-female \ + -o /tmp/vc-dub-sample.wav + +ffprobe -hide_banner /tmp/vc-dub-sample.wav +``` + +通过标准: + +- 输出音频存在且非空。 +- 音频时长与字幕整体时长合理接近。 +- 无截断、无空文件、无明显异常静音。 +- 缺 API Key 的 provider 给出明确错误,不应刷重复 traceback。 + +### Level 2:页面反馈 + +适用场景: + +- 修改 UI 页面、组件、布局、交互、设置页。 +- 修改会影响用户操作路径的功能。 +- 修改主窗口导航或页面之间跳转。 + +最低要求: + +1. 实际实例化相关页面。 +2. 实际点击主要按钮、开关、下拉、输入框。 +3. 截图至少包含默认态、操作后状态、错误态或空态。 +4. 每张截图至少列出 2 个问题并修复明显问题。 +5. 如果用户对 UI 质量要求较高,必须用空白上下文 subagent 做严格审查。 + +推荐截图目录: + +```text +screenshots// + 01-home-default.png + 02-synthesis-output-selected.png + 03-dubbing-provider-edge.png + 04-dubbing-provider-siliconflow-missing-key.png + 05-doctor-running.png + 06-doctor-finished.png +``` + +UI 截图检查维度: + +- 页面第一眼是否知道这是做什么的。 +- 是否有明确主操作。 +- 禁用按钮是否说明原因。 +- 空状态是否告诉用户下一步。 +- 文字是否重叠、截断、溢出。 +- 按钮文字、标题、标签字号是否统一。 +- 浅色/深色主题是否都能读。 +- 是否出现黑底白卡、白底白字、低对比度、像页面坏了的状态。 +- 控件是否符合 qfluentwidgets 风格,避免原生 PyQt 拼凑感。 +- 一页内是否存在过多同等级绿色按钮。 +- 是否把 API Key、模型名、ffmpeg 等技术词过早暴露给普通用户。 +- 页面是否有大面积无意义空白。 +- 页面是否有“这里一坨、那里一坨”的割裂布局。 + +### Level 3:整体验收 + +适用场景: + +- 大重构。 +- 改动跨 CLI、core、UI 多层。 +- 改动任务流程、配置系统、打包发布、音视频核心路径。 +- 用户明确要求“完整跑一遍”“确保可用”。 + +最低要求: + +1. Level 0 全部通过。 +2. 相关模块测试通过。 +3. `doctor --json` 通过或警告可解释。 +4. 至少一个 CLI 端到端流程通过。 +5. 至少一个 UI 流程通过。 +6. 首页、设置、诊断、受影响页面截图审查。 +7. 输出文件人工检查。 +8. 记录所有失败、跳过和外部依赖限制。 + +整体验收建议命令: + +```bash +uv run videocaptioner --help +uv run videocaptioner doctor --json +uv run pytest tests/test_cli -q +uv run pytest tests/test_dubbing tests/test_tts -q +uv run pytest tests/test_subtitle tests/test_translate tests/test_split -q +``` + +如果改动涉及 GUI: + +```bash +QT_QPA_PLATFORM=offscreen uv run python - <<'PY' +import sys +from PyQt5.QtWidgets import QApplication +from videocaptioner.ui.view.task_creation_interface import TaskCreationInterface +from videocaptioner.ui.view.video_synthesis_interface import VideoSynthesisInterface +from videocaptioner.ui.view.dubbing_interface import DubbingInterface +from videocaptioner.ui.view.doctor_interface import DoctorInterface + +app = QApplication(sys.argv) +for cls in [TaskCreationInterface, VideoSynthesisInterface, DubbingInterface, DoctorInterface]: + w = cls() + w.resize(1200, 800) + w.show() + app.processEvents() + print(cls.__name__, "ok") + w.close() +PY +``` + +注意:offscreen 截图只能用于结构和基本渲染检查,不能替代真实桌面观察。最终 UI 质量仍要看主窗口实际截图。 + +## CLI 验收标准 + +### 通用 CLI + +每次涉及 CLI 的改动至少检查: + +```bash +uv run videocaptioner --help +uv run videocaptioner --help +``` + +检查点: + +- 命令名称是否清晰。 +- 必填参数是否明确。 +- 高级参数是否被隐藏或降级。 +- 错误参数是否有明确错误。 +- 默认值是否符合普通用户预期。 +- 配置优先级是否仍是:命令行参数 > 环境变量 > 配置文件 > 默认值。 + +### doctor + +基础检查: + +```bash +uv run videocaptioner doctor +uv run videocaptioner doctor --json +``` + +深度检查: + +```bash +uv run videocaptioner doctor --check-api +``` + +检查点: + +- JSON 可被 `python -m json.tool` 解析。 +- 每个 check 有 name/status/message/fix。 +- status 只能是 ok/warn/error 等约定值。 +- 缺 key 是 warn 还是 error 要合理。 +- 深度诊断需要真实请求 API 时必须提示可能产生费用。 +- 诊断结果要能映射到 UI:问题、影响、下一步。 + +### config + +涉及配置时检查: + +```bash +uv run videocaptioner config show +uv run videocaptioner config path +uv run videocaptioner config init --print-template +``` + +检查点: + +- 默认配置可用。 +- 非交互模式对 Agent/CI 友好。 +- 用户每视频都会变化的参数不要放成全局 onboarding 默认项。 +- API Key、Base URL、模型、provider 能配置。 +- 配置文件缺失时有创建提示。 + +### download + +涉及下载时检查: + +```bash +uv run videocaptioner download "" -o /tmp/vc-download-test +``` + +检查点: + +- 网络失败、cookie 缺失、yt-dlp 过旧时有明确提示。 +- 输出目录可找到下载结果。 +- 不应把下载中间错误吞掉。 + +在线视频可能受网络、cookie、地区限制影响。失败时要区分: + +- URL 无效。 +- cookie 缺失。 +- yt-dlp 版本旧。 +- 网络不可达。 +- 站点限制。 + +### transcribe + +涉及 ASR 时检查: + +```bash +uv run videocaptioner transcribe tests/fixtures/audio/zh.mp3 --asr bijian -o /tmp/vc-transcribe.srt +``` + +如果使用本地模型或 API: + +- 本地模型缺失要提示下载或切换 ASR。 +- API Key 缺失要提示设置位置。 +- 输出字幕要非空、有时间轴、有文本。 + +### subtitle + +涉及字幕优化/翻译/断句时检查: + +```bash +uv run videocaptioner subtitle tests/fixtures/subtitle/sample_en.srt \ + --translator bing \ + --target-language zh-Hans \ + -o /tmp/vc-subtitle.srt +``` + +检查点: + +- 输出格式正确。 +- 时间轴不乱。 +- 文本不为空。 +- 双语/单语布局符合设置。 +- 大模型不可用时有降级或明确错误。 + +### dub + +涉及配音时检查: + +```bash +uv run videocaptioner dub tests/fixtures/subtitle/sample_en.srt \ + --preset edge-en-female \ + -o /tmp/vc-dub.wav +``` + +如果测试中文: + +```bash +uv run videocaptioner dub tests/fixtures/audio/zh.srt \ + --preset edge-cn-female \ + -o /tmp/vc-dub-cn.wav +``` + +检查点: + +- Edge 默认免 Key 可跑。 +- Gemini/SiliconFlow 缺 Key 时错误清楚。 +- 生成音频存在且可被 ffprobe 读取。 +- 并发参数不导致乱序、漏句。 +- 较长句子不会明显截断。 +- 多说话人字幕格式仍能解析。 + +### synthesize + +涉及视频合成时检查: + +```bash +uv run videocaptioner synthesize \ + -s \ + --subtitle-mode hard \ + -o /tmp/vc-captioned.mp4 +``` + +检查点: + +- 输出视频存在。 +- ffprobe 可读。 +- 软字幕/硬字幕模式符合选择。 +- 字幕样式、圆角背景、ASS 渲染没有崩。 +- 缺视频、缺字幕时错误清楚。 + +### process + +全流程 CLI: + +```bash +uv run videocaptioner process \ + --asr bijian \ + --translator bing \ + --target-language zh-Hans \ + --with-dubbing \ + --dub-preset edge-cn-female +``` + +检查点: + +- 输出目录可找到最终结果。 +- 中间字幕、翻译字幕、配音音频、最终视频命名清楚。 +- 用户能知道失败发生在哪一步。 +- 如果只生成部分结果,日志要说明。 + +## UI 验收标准 + +UI 验收不能只看是否启动。必须实际操作、截图、审查。 + +### 通用 UI 启动 + +入口: + +```bash +uv run videocaptioner gui +uv run videocaptioner-gui +uv run videocaptioner +``` + +检查点: + +- 启动无 traceback。 +- 主窗口标题正常。 +- 左侧导航图标可见。 +- 页面切换不卡死。 +- 深色/浅色主题都可读。 +- 关闭窗口后没有残留进程。 + +### 首页/任务创建 + +必须检查: + +1. 默认态。 +2. 输入在线视频 URL。 +3. 选择本地文件。 +4. 拖拽文件。 +5. 无效 URL 或无效文件。 + +验收流程: + +- 打开首页。 +- 观察标题是否说明“导入视频,生成字幕与配音”。 +- 输入框是否说明支持粘贴链接/拖拽文件。 +- 空输入时主按钮应是选择文件。 +- 有输入时主按钮应变成开始处理。 +- 点击日志、帮助等次要入口不应抢主流程。 + +失败标准: + +- 用户不知道怎么开始。 +- 只有图标没有文字。 +- 输入框像禁用。 +- 选择文件后没有下一步。 +- 错误 URL 没有清晰提示。 + +### 主页全流程步骤 + +主页内部步骤: + +1. 任务创建。 +2. 语音转录。 +3. 字幕优化与翻译。 +4. 字幕视频合成。 + +必须检查: + +- 每一步完成后是否跳到下一步。 +- 进度是否更新。 +- 错误是否停在正确步骤。 +- 用户能不能返回查看结果。 +- 全流程 task_id 不应混乱。 + +### 语音转录页 + +检查点: + +- 音频/视频输入显示正确。 +- 音轨选择可用。 +- 开始按钮可用/禁用状态合理。 +- 进度条更新。 +- 输出字幕路径可找到。 +- 失败时显示原因。 + +### 字幕优化与翻译页 + +检查点: + +- 字幕表格内容不截断。 +- 单击字幕行能定位/预览。 +- 开始优化/翻译按钮状态合理。 +- 取消按钮可用。 +- 双语/单语输出符合设置。 +- LLM/API 缺 key 时提示清楚。 +- 长字幕、空字幕、特殊字符不破坏布局。 + +### 字幕视频合成页 + +必须检查: + +1. 默认无输出选择。 +2. 只选字幕视频。 +3. 只选配音音轨。 +4. 字幕视频 + 配音音轨都选。 +5. 输入字幕但不输入视频。 +6. 输入视频但不输入字幕。 +7. 打开音色库。 +8. 试听当前音色。 +9. 点击生成。 + +验收标准: + +- 用户能看到“导出内容”。 +- 禁用按钮说明原因。 +- 只配音时,字幕必填,视频可选。 +- 字幕视频时,字幕和视频都应必填。 +- 配音 + 视频时,输出应说明最终成片和配音音频。 +- 生成后进度条、状态和输出位置清楚。 + +失败标准: + +- 用户不知道要先选字幕视频还是配音音轨。 +- 顶部工具栏和主面板状态不同步。 +- 生成按钮能点但立即报缺文件。 +- 输出模式显示与实际输出不一致。 +- 文字重叠或按钮过多抢主操作。 + +### 配音页 + +必须检查: + +1. Edge provider。 +2. Gemini provider。 +3. SiliconFlow provider。 +4. 内置试听。 +5. 文本试听。 +6. 缺 Key 状态。 +7. 去设置填写 Key。 +8. 音色切换。 +9. 克隆区输入不完整。 +10. 克隆区输入完整后试听克隆。 + +验收标准: + +- 用户知道 `试听` 是播放样例。 +- 用户知道 `使用` 是选择最终导出音色。 +- 右侧显示最终会使用哪个音色。 +- 内置试听不要求 API Key。 +- 文本试听对非 Edge provider 要求 Key,并提示去哪里配置。 +- 缺 Key 按钮不能是死按钮,应能跳设置或给明确提示。 +- 克隆区说明参考音频和原文用途。 +- 克隆区有明确下一步,比如 `试听克隆`。 +- 播放失败不应刷重复 traceback。 + +失败标准: + +- 音色卡片空白。 +- 只有小图标没有文字。 +- 当前选中态不明显。 +- 缺 Key 用户不知道怎么解决。 +- 克隆区填完后不知道点什么。 +- 浅色主题出现黑底异常。 + +### 字幕样式页 + +必须检查: + +- 样式列表显示。 +- 选择样式后预览更新。 +- 字体、颜色、描边、背景、位置修改后预览更新。 +- 新建样式、打开样式目录可用。 +- 长字幕预览不溢出。 +- 竖屏/横屏背景都可预览。 + +失败标准: + +- 预览图不更新。 +- 控件值和配置不一致。 +- 文字超出画面。 +- 色彩对比不可读。 + +### 设置页 + +必须检查: + +- 转录配置。 +- LLM 配置。 +- 翻译服务。 +- 翻译与优化。 +- 字幕合成配置。 +- 配音配置。 +- 保存配置。 +- 个性化。 +- 关于。 + +关键点击: + +- 切换 ASR provider。 +- 填写 API Base/API Key/Model。 +- 测试 LLM 连接。 +- 测试 Whisper 连接。 +- 测试配音。 +- 切换配音 provider。 +- 选择配音音色。 +- 修改配音并发。 +- 修改缓存开关。 +- 修改主题。 + +验收标准: + +- Provider 先选,再显示该 provider 需要的字段。 +- 不同 provider 的字段差异要清楚。 +- 缺 key 时测试按钮给清楚提示。 +- 设置后回到相关页面能同步。 +- 用户每个视频都会变化的配置不应放在全局设置里。 + +### 诊断页 + +必须检查: + +1. 初始态。 +2. 自动快速检查中。 +3. 快速检查完成。 +4. 深度诊断点击前提示。 +5. 深度诊断运行中。 +6. 深度诊断完成。 +7. 缺 key/缺依赖状态。 + +验收标准: + +- 用户知道正在检查什么。 +- 完成后知道哪些正常、哪些警告、哪些错误。 +- 每个警告/错误要说明影响。 +- 有下一步修复建议。 +- 深度诊断说明会真实请求 API。 +- 检查中不要使用成功色误导。 +- 浅色/深色主题背景正常。 + +失败标准: + +- 只有技术检查项,没有人话解释。 +- 只有 ffmpeg/ffprobe 路径,用户不知道影响。 +- 深度诊断按钮像禁用但不知道原因。 +- 检查结果为空或一直检查中。 + +### 批量处理页 + +必须检查: + +- 添加多个文件。 +- 清空列表。 +- 开始全部任务。 +- 单个失败不阻塞其他任务。 +- 每个任务状态、进度、输出清楚。 +- 拖拽多个文件。 + +失败标准: + +- 多任务状态串台。 +- 清空后后台仍在跑。 +- 失败没有定位到具体文件。 + +### 请求日志页 + +必须检查: + +- 刷新日志。 +- 清空日志。 +- 翻页。 +- 复制请求。 +- 复制响应。 +- 空日志状态。 + +失败标准: + +- 日志包含完整 API Key。 +- 大日志卡死。 +- 复制按钮复制错内容。 + +## 全流程验收矩阵 + +### 本地媒体到字幕视频 + +输入:本地视频或音频。 + +流程: + +1. 首页导入文件。 +2. 转录生成字幕。 +3. 字幕优化/翻译。 +4. 合成字幕视频。 +5. 打开输出目录。 +6. ffprobe 检查最终视频。 + +检查: + +- 输出视频存在。 +- 字幕可见。 +- 音频保留。 +- 输出文件名清楚。 + +### 在线链接到字幕视频 + +输入:YouTube/Bilibili/TED 等链接。 + +流程: + +1. 首页粘贴 URL。 +2. 下载。 +3. 转录或读取字幕。 +4. 翻译/优化。 +5. 合成。 + +检查: + +- 下载失败时区分 cookie、网络、yt-dlp。 +- 下载成功后文件位置清楚。 +- 如果站点不可用,要记录原因,不要误判为功能失败。 + +### 字幕到配音音频 + +输入:SRT。 + +流程: + +1. CLI 或合成页选择配音音轨。 +2. 选择 Edge 音色。 +3. 生成配音音频。 +4. 播放检查。 + +检查: + +- 输出 wav/mp3 存在。 +- 无明显截断。 +- 句子顺序正确。 +- 时长合理。 + +### 视频 + 字幕到配音视频 + +输入:视频 + SRT。 + +流程: + +1. 合成页选择配音音轨。 +2. 输入字幕和视频。 +3. 选择音色。 +4. 生成。 +5. 检查输出视频。 + +检查: + +- 原声处理符合当前策略。 +- 配音轨和画面大致同步。 +- 输出音量合理。 + +### 外语视频到中文配音视频 + +流程: + +1. 导入外语视频。 +2. 转录。 +3. 翻译为中文。 +4. 选择中文配音音色。 +5. 生成配音视频。 + +检查: + +- 翻译字幕完整。 +- 中文配音没有明显截断。 +- 长句处理合理。 +- 输出文件容易找到。 + +## subagent UI 审查规范 + +当用户要求 UI 美观、体验、截图审查,或 Agent 自己改动了 UI 页面时,应使用空白上下文 subagent。 + +subagent 输入: + +- 只给最新截图。 +- 不给“我已经修好了”这类暗示。 +- 明确要求严厉批评。 +- 要求按 P0/P1/P2 分级。 +- 要求每页至少指出具体问题。 + +推荐 prompt: + +```text +你是一个没有项目历史上下文的严格 UI/UX 审查员。请只根据这些截图审查界面实际效果,不要假设代码正确。目标用户是普通非技术用户。请指出布局、空白、层级、字体大小、颜色、按钮主次、组件是否低质、用户是否知道下一步、是否有多余文字、是否有信息缺失。每张图至少给具体批评,并按 P0/P1/P2 给整改优先级。不要安慰,不要泛泛而谈。 +``` + +Agent 必须处理 subagent 的 P0。P1 根据范围和风险判断,不能完全忽略。最终回复中要说明: + +- subagent 看了哪些截图。 +- 提了哪些 P0。 +- 已修哪些。 +- 哪些暂不修,为什么。 + +## 截图规范 + +目录: + +```text +screenshots// +``` + +命名: + +```text +01-home-default.png +02-home-url-entered.png +03-synthesis-empty.png +04-synthesis-output-selected.png +05-dubbing-edge.png +06-dubbing-siliconflow-missing-key.png +07-doctor-running.png +08-doctor-finished.png +``` + +要求: + +- 不要覆盖旧截图。 +- 每轮改动用新的目录或后缀。 +- 最终回复告诉用户截图路径。 +- 截图默认不提交。 + +## 失败判定 + +以下任一情况视为验收失败: + +- 命令 traceback。 +- UI 页面无法打开。 +- 点击主按钮无反应且无提示。 +- 输出文件不存在或为空。 +- 生成视频/音频 ffprobe 不可读。 +- 缺 key/缺依赖提示不清楚。 +- 用户不知道下一步。 +- 文字重叠、截断、按钮文字溢出。 +- 浅色/深色主题不可读。 +- API Key 出现在日志、截图、提交或错误弹窗中。 +- 页面看起来像未加载、禁用、调试面板。 + +失败时最终回复必须包含: + +- 失败命令或点击路径。 +- 失败现象。 +- 可能原因。 +- 已尝试的排查。 +- 当前影响范围。 +- 下一步建议。 + +## 验收记录模板 + +每次任务完成后,Agent 最终回复建议包含: + +```text +改动范围: +- ... + +CLI 验收: +- uv run ...:通过/失败 +- 输出文件:... + +UI 验收: +- 截图目录:screenshots/... +- 点击路径:... +- 发现并修复的问题:... + +subagent 审查: +- 使用/未使用,原因: +- P0: +- 处理结果: + +未覆盖/跳过: +- ... + +当前风险: +- ... +``` + +## 本文档编写时的实测基线 + +本文档编写时,以下命令已在当前项目中实际运行: + +```bash +uv run videocaptioner --help +uv run videocaptioner doctor --json +uv run pytest tests/test_cli tests/test_dubbing tests/test_tts -q +``` + +结果: + +- CLI help 正常列出 transcribe/gui/subtitle/dub/synthesize/process/download/config/doctor/style。 +- `doctor --json` 可输出结构化检查结果。 +- `tests/test_cli tests/test_dubbing tests/test_tts`:102 passed,4 skipped,1 warning。 +- Edge 配音小样可生成非空 wav。 + +因此后续 Agent 可以把这些命令作为低成本基线,但具体任务仍要按影响面增加相应验收。 diff --git a/docs/dev/api.md b/docs/dev/api.md index 4422838e..c51e264c 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -7,7 +7,7 @@ ### `transcribe()` ```python -from app.core.asr import transcribe +from videocaptioner.core.asr import transcribe result = transcribe( audio_path="video.mp4", diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 2f824a39..0a7392c4 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -4,36 +4,61 @@ VideoCaptioner 的系统架构设计。 ## 技术栈 -- **UI 框架**: PyQt5 + QFluentWidgets -- **ASR 引擎**: Whisper (FasterWhisper/WhisperCpp) -- **LLM 集成**: OpenAI/DeepSeek/Gemini/Ollama 等 -- **视频处理**: FFmpeg +- **UI 框架**: PyQt5 + 项目自研第一方组件层(qfluentwidgets 仅作底层控件/图标来源,正在逐步退场) +- **ASR 引擎**: B 接口(必剪)/ J 接口(剪映)/ 百炼 Fun-ASR / Whisper API / whisper-cpp / faster-whisper +- **LLM 集成**: OpenAI 兼容接口(OpenAI / DeepSeek / SiliconFlow / Gemini / Ollama 等) +- **配音 TTS**: Edge / Gemini TTS / SiliconFlow CosyVoice(含音色克隆) +- **视频处理**: FFmpeg(软字幕 / ASS 硬字幕 / 圆角背景字幕渲染) +- **在线下载**: yt-dlp(YouTube / 哔哩哔哩等) ## 核心模块 -### 1. ASR 模块 (`app/core/asr/`) +业务逻辑全部在 `videocaptioner/core/`,不依赖 PyQt: -语音识别模块,支持多种 ASR 引擎。 +### 1. ASR 模块 (`videocaptioner/core/asr/`) -### 2. 字幕处理模块 (`app/core/split/`, `app/core/optimize/`) +语音识别。各引擎实现 `BaseASR`,由 `transcribe.py` 按 `TranscribeConfig` +组装成 `ChunkedASR`(长音频分块 + 合并)。`check.py` 提供统一的真实 +短音频连通性检查(设置页「测试转录」与 `doctor --check-api` 共用)。 -字幕分割和优化模块,使用 LLM 进行智能处理。 +### 2. 字幕处理模块 (`core/split/`, `core/optimize/`, `core/subtitle/`) -### 3. 翻译模块 (`app/core/translate/`) +字幕智能断句与 LLM 优化;`core/subtitle/` 负责样式与 ASS/圆角渲染。 -字幕翻译模块,支持多种翻译服务。 +### 3. 翻译模块 (`videocaptioner/core/translate/`) -### 4. UI 模块 (`app/view/`) +字幕翻译,支持 LLM / 必应 / 谷歌等服务。 -PyQt5 用户界面模块。 +### 4. 配音模块 (`core/dubbing/`, `core/speech/`, `core/tts/`) + +按字幕生成配音音轨或配音视频,预设 / 音色 / 克隆引用统一管理。 + +### 5. 下载模块 (`videocaptioner/core/download/`) + +- 本地 ASR 模型与运行程序下载(多镜像兜底、断点续传、SHA1 校验) +- 在线视频下载的网络环境(`net.py`:按站点分流代理、B 站 buvid 风控 + 缓解、错误翻译)与下载源诊断(`source_check.py`) + +### 6. 应用配置 (`core/application/`) + +共享 TOML 配置存储(`config_store.py`)、业务配置数据类 +(`app_config.py`)、任务构建(`task_builder.py`)。CLI 与 GUI 各自 +通过 adapter 转换到同一份 `AppConfig`。 + +### 7. UI 模块 (`videocaptioner/ui/view/`) + +桌面界面。页面只负责组合交互,通用按钮、卡片、表单、弹窗 +(`AppDialog`)、导航、提示和图标放在 `videocaptioner/ui/components/` +与 `videocaptioner/ui/common/`;长任务包装在 `videocaptioner/ui/thread/`。 ## 数据流 ``` -视频/音频 → ASR → ASRData → 分割 → 优化 → 翻译 → 字幕文件 → 视频合成 +视频/音频/链接 → (下载) → ASR → ASRData → 断句 → LLM 优化 → 翻译 + → 字幕文件 → (配音) → 视频合成(软字幕 / 硬字幕 / 配音视频) ``` -详细架构说明请参考 `CLAUDE.md` 文件。 +详细约定请参考仓库根目录 `AGENTS.md`(`CLAUDE.md` 是它的符号链接)。 --- diff --git a/docs/dev/asr-chunk-merger.md b/docs/dev/asr-chunk-merger.md deleted file mode 100644 index e08b41ce..00000000 --- a/docs/dev/asr-chunk-merger.md +++ /dev/null @@ -1,251 +0,0 @@ -# ChunkMerger 使用指南 - -## 概述 - -`ChunkMerger` 用于合并多个音频分块的 ASR(语音识别)结果。当处理长音频时,通常需要将音频分割成多个片段分别识别,然后合并结果。本模块使用精确文本匹配算法(基于 Groq API Cookbook)来智能处理重叠区域。 - -## 核心特性 - -- ✅ **精确文本匹配**:使用滑动窗口找最长公共序列,不使用模糊相似度 -- ✅ **自动时间戳调整**:正确处理每个 chunk 的时间偏移 -- ✅ **重叠区域智能处理**:自动检测和去除重复的识别内容 -- ✅ **多语言支持**:支持中文、英文、混合文本等 -- ✅ **词级/句子级时间戳**:两种时间戳类型均可正确处理 - -## 基本用法 - -### 示例 1:合并两个有重叠的音频片段 - -```python -from app.core.asr.chunk_merger import ChunkMerger -from app.core.asr.asr_data import ASRData, ASRDataSeg - -# 创建合并器 -merger = ChunkMerger(min_match_count=2) - -# Chunk 1: 0-30s 的识别结果 -chunk1_segments = [ - ASRDataSeg("Hello", 0, 1000), - ASRDataSeg("world", 1000, 2000), - ASRDataSeg("this", 2000, 3000), - # ... 更多片段 -] -chunk1 = ASRData(chunk1_segments) - -# Chunk 2: 20-50s 的识别结果(重叠 10s) -chunk2_segments = [ - ASRDataSeg("this", 0, 1000), # 实际时间 20-21s - ASRDataSeg("is", 1000, 2000), # 实际时间 21-22s - ASRDataSeg("test", 2000, 3000), # 实际时间 22-23s - # ... 更多片段 -] -chunk2 = ASRData(chunk2_segments) - -# 合并 -merged = merger.merge_chunks( - chunks=[chunk1, chunk2], - chunk_offsets=[0, 20000], # chunk2 实际从 20s 开始 - overlap_duration=10000 # 10s 重叠 -) - -print(f"合并后片段数: {len(merged.segments)}") -``` - -### 示例 2:合并多个音频片段 - -```python -# 模拟长音频:3 个 30s 的片段,每个重叠 10s -chunk1 = ASRData([...]) # 0-30s -chunk2 = ASRData([...]) # 20-50s -chunk3 = ASRData([...]) # 40-70s - -# 一次性合并所有片段 -merged = merger.merge_chunks( - chunks=[chunk1, chunk2, chunk3], - chunk_offsets=[0, 20000, 40000], - overlap_duration=10000 -) -``` - -### 示例 3:自动推断时间偏移 - -```python -# 如果不提供 chunk_offsets,会自动推断 -merged = merger.merge_chunks( - chunks=[chunk1, chunk2, chunk3], - overlap_duration=10000 # 只需指定重叠时长 -) -``` - -## 参数说明 - -### ChunkMerger 构造函数 - -```python -ChunkMerger(min_match_count: int = 2) -``` - -- `min_match_count`: 最小匹配词数阈值,低于此值视为无效匹配(默认 2) - -### merge_chunks 方法 - -```python -merge_chunks( - chunks: List[ASRData], - chunk_offsets: Optional[List[int]] = None, - overlap_duration: int = 10000 -) -> ASRData -``` - -**参数**: - -- `chunks`: ASRData 对象列表(必需) -- `chunk_offsets`: 每个 chunk 的起始时间(毫秒),如为 None 则自动推断 -- `overlap_duration`: 重叠时长(毫秒),默认 10 秒 - -**返回**: - -- 合并后的 `ASRData` 对象 - -## 算法原理 - -### 1. 精确文本匹配 - -使用滑动窗口遍历所有可能的对齐方式,计算每个位置的精确匹配词数(要求连续匹配): - -``` -Chunk1 末尾: ["and", "we", "need", "to", "find", "the", "best"] -Chunk2 开头: ["need", "to", "find", "the", "best", "solution"] - -最佳匹配: ["need", "to", "find", "the", "best"] (5个词) -``` - -### 2. 时间戳调整 - -```python -# Chunk2 的时间戳加上偏移量 -adjusted_time = original_time + chunk_offset -``` - -### 3. 合并策略 - -- **有匹配**:保留 chunk1 的重叠部分,丢弃 chunk2 的重叠部分 -- **无匹配**:使用时间边界切分 - -## 实际应用场景 - -### 场景 1:长视频字幕生成 - -```python -# 60 分钟视频,每 30 秒一个片段,重叠 10 秒 -chunks = [] -offsets = [] - -for i in range(0, 3600, 20): # 每 20s 一个起点(30s 片段 - 10s 重叠) - audio_chunk = extract_audio(video_path, start=i, duration=30) - asr_result = transcribe(audio_chunk) - chunks.append(asr_result) - offsets.append(i * 1000) # 转换为毫秒 - -# 合并所有片段 -final_result = merger.merge_chunks( - chunks=chunks, - chunk_offsets=offsets, - overlap_duration=10000 -) - -# 保存字幕 -final_result.save("output.srt") -``` - -### 场景 2:在线流式识别 - -```python -class StreamingASR: - def __init__(self): - self.merger = ChunkMerger() - self.chunks = [] - self.offsets = [] - - def on_chunk_received(self, chunk_audio, timestamp): - # 识别当前片段 - asr_result = transcribe(chunk_audio) - self.chunks.append(asr_result) - self.offsets.append(timestamp) - - # 实时合并 - if len(self.chunks) >= 2: - merged = self.merger.merge_chunks( - chunks=self.chunks, - chunk_offsets=self.offsets, - overlap_duration=5000 # 5s 重叠 - ) - return merged -``` - -## 注意事项 - -### 1. 重叠时长建议 - -- **推荐**:10 秒重叠(足以捕获句子边界) -- **最小**:3-5 秒(太短可能匹配失败) -- **最大**:不超过 chunk 长度的 1/3 - -### 2. 匹配阈值 - -```python -# 对于短句子,可以降低阈值 -merger = ChunkMerger(min_match_count=1) - -# 对于长句子,可以提高阈值以提高准确性 -merger = ChunkMerger(min_match_count=3) -``` - -### 3. 时间戳连续性 - -合并后,请验证时间戳的连续性: - -```python -# 验证时间戳 -for i in range(len(merged.segments) - 1): - seg1 = merged.segments[i] - seg2 = merged.segments[i + 1] - gap = seg2.start_time - seg1.end_time - if gap > 2000: # 间隔超过 2s - print(f"警告: 片段 {i} 和 {i+1} 之间有 {gap}ms 间隔") -``` - -## 测试 - -运行测试套件: - -```bash -# 运行所有测试 -uv run pytest tests/test_asr/test_chunk_merger.py -v - -# 运行特定测试 -uv run pytest tests/test_asr/test_chunk_merger.py::TestChunkMergerBasic -v -``` - -## 常见问题 - -### Q1: 合并后丢失了部分内容? - -**A**: 检查重叠区域是否足够长,确保 `overlap_duration` 至少为 5 秒。 - -### Q2: 匹配失败,使用了时间边界切分? - -**A**: 可能是重叠区域的文本差异太大(识别错误)。可以: - -1. 降低 `min_match_count` 阈值 -2. 增加重叠时长 -3. 检查 ASR 质量 - -### Q3: 时间戳不连续? - -**A**: 检查 `chunk_offsets` 是否正确,应该准确反映每个 chunk 的实际起始时间。 - -## 相关文档 - -- [ASRData 数据结构](../asr_data.py) -- [Groq Audio Chunking Tutorial](https://github.com/groq/groq-api-cookbook/blob/main/tutorials/audio-chunking/audio_chunking_tutorial.ipynb) diff --git a/docs/dev/asr-chunked-usage.md b/docs/dev/asr-chunking.md similarity index 50% rename from docs/dev/asr-chunked-usage.md rename to docs/dev/asr-chunking.md index fef5d171..216d6d1e 100644 --- a/docs/dev/asr-chunked-usage.md +++ b/docs/dev/asr-chunking.md @@ -1,4 +1,10 @@ -# ChunkedASR 使用指南 +# ASR 长音频分块:ChunkedASR 与 ChunkMerger + +> 长音频转录的完整链路:ChunkedASR 负责分块与并发转录,ChunkMerger 负责重叠区合并。两者配合使用,本文上下两部分分别说明。 + +--- + +## ChunkedASR 使用指南 ## 概述 @@ -17,7 +23,7 @@ ### 基本用法 ```python -from app.core.asr import BcutASR, ChunkedASR +from videocaptioner.core.asr import BcutASR, ChunkedASR # 1. 创建基础 ASR 实例 base_asr = BcutASR(audio_path, need_word_time_stamp=True) @@ -39,8 +45,8 @@ result = chunked_asr.run(callback=my_callback) `transcribe()` 函数已经自动为 `BIJIAN` 和 `JIANYING` 启用了分块: ```python -from app.core.asr import transcribe -from app.core.entities import TranscribeConfig, TranscribeModelEnum +from videocaptioner.core.asr import transcribe +from videocaptioner.core.entities import TranscribeConfig, TranscribeModelEnum config = TranscribeConfig( transcribe_model=TranscribeModelEnum.BIJIAN, @@ -143,7 +149,7 @@ result = chunked_asr.run(callback=progress_callback) ```python # 为 FasterWhisper 添加分块(处理超长音频) -from app.core.asr import FasterWhisperASR, ChunkedASR +from videocaptioner.core.asr import FasterWhisperASR, ChunkedASR base_asr = FasterWhisperASR( audio_path, @@ -262,3 +268,257 @@ A: 可以,但通常不推荐。本地 ASR(如 FasterWhisper)通常足够 - [ChunkMerger 使用指南](./CHUNK_MERGER_USAGE.md) - [ASR 模块开发指南](./README.md) - [测试指南](../../tests/test_asr/TEST_GUIDE.md) + +--- + +## ChunkMerger 使用指南 + +## 概述 + +`ChunkMerger` 用于合并多个音频分块的 ASR(语音识别)结果。当处理长音频时,通常需要将音频分割成多个片段分别识别,然后合并结果。本模块使用精确文本匹配算法(基于 Groq API Cookbook)来智能处理重叠区域。 + +## 核心特性 + +- ✅ **精确文本匹配**:使用滑动窗口找最长公共序列,不使用模糊相似度 +- ✅ **自动时间戳调整**:正确处理每个 chunk 的时间偏移 +- ✅ **重叠区域智能处理**:自动检测和去除重复的识别内容 +- ✅ **多语言支持**:支持中文、英文、混合文本等 +- ✅ **词级/句子级时间戳**:两种时间戳类型均可正确处理 + +## 基本用法 + +### 示例 1:合并两个有重叠的音频片段 + +```python +from videocaptioner.core.asr.chunk_merger import ChunkMerger +from videocaptioner.core.asr.asr_data import ASRData, ASRDataSeg + +# 创建合并器 +merger = ChunkMerger(min_match_count=2) + +# Chunk 1: 0-30s 的识别结果 +chunk1_segments = [ + ASRDataSeg("Hello", 0, 1000), + ASRDataSeg("world", 1000, 2000), + ASRDataSeg("this", 2000, 3000), + # ... 更多片段 +] +chunk1 = ASRData(chunk1_segments) + +# Chunk 2: 20-50s 的识别结果(重叠 10s) +chunk2_segments = [ + ASRDataSeg("this", 0, 1000), # 实际时间 20-21s + ASRDataSeg("is", 1000, 2000), # 实际时间 21-22s + ASRDataSeg("test", 2000, 3000), # 实际时间 22-23s + # ... 更多片段 +] +chunk2 = ASRData(chunk2_segments) + +# 合并 +merged = merger.merge_chunks( + chunks=[chunk1, chunk2], + chunk_offsets=[0, 20000], # chunk2 实际从 20s 开始 + overlap_duration=10000 # 10s 重叠 +) + +print(f"合并后片段数: {len(merged.segments)}") +``` + +### 示例 2:合并多个音频片段 + +```python +# 模拟长音频:3 个 30s 的片段,每个重叠 10s +chunk1 = ASRData([...]) # 0-30s +chunk2 = ASRData([...]) # 20-50s +chunk3 = ASRData([...]) # 40-70s + +# 一次性合并所有片段 +merged = merger.merge_chunks( + chunks=[chunk1, chunk2, chunk3], + chunk_offsets=[0, 20000, 40000], + overlap_duration=10000 +) +``` + +### 示例 3:自动推断时间偏移 + +```python +# 如果不提供 chunk_offsets,会自动推断 +merged = merger.merge_chunks( + chunks=[chunk1, chunk2, chunk3], + overlap_duration=10000 # 只需指定重叠时长 +) +``` + +## 参数说明 + +### ChunkMerger 构造函数 + +```python +ChunkMerger(min_match_count: int = 2) +``` + +- `min_match_count`: 最小匹配词数阈值,低于此值视为无效匹配(默认 2) + +### merge_chunks 方法 + +```python +merge_chunks( + chunks: List[ASRData], + chunk_offsets: Optional[List[int]] = None, + overlap_duration: int = 10000 +) -> ASRData +``` + +**参数**: + +- `chunks`: ASRData 对象列表(必需) +- `chunk_offsets`: 每个 chunk 的起始时间(毫秒),如为 None 则自动推断 +- `overlap_duration`: 重叠时长(毫秒),默认 10 秒 + +**返回**: + +- 合并后的 `ASRData` 对象 + +## 算法原理 + +### 1. 精确文本匹配 + +使用滑动窗口遍历所有可能的对齐方式,计算每个位置的精确匹配词数(要求连续匹配): + +``` +Chunk1 末尾: ["and", "we", "need", "to", "find", "the", "best"] +Chunk2 开头: ["need", "to", "find", "the", "best", "solution"] + +最佳匹配: ["need", "to", "find", "the", "best"] (5个词) +``` + +### 2. 时间戳调整 + +```python +# Chunk2 的时间戳加上偏移量 +adjusted_time = original_time + chunk_offset +``` + +### 3. 合并策略 + +- **有匹配**:保留 chunk1 的重叠部分,丢弃 chunk2 的重叠部分 +- **无匹配**:使用时间边界切分 + +## 实际应用场景 + +### 场景 1:长视频字幕生成 + +```python +# 60 分钟视频,每 30 秒一个片段,重叠 10 秒 +chunks = [] +offsets = [] + +for i in range(0, 3600, 20): # 每 20s 一个起点(30s 片段 - 10s 重叠) + audio_chunk = extract_audio(video_path, start=i, duration=30) + asr_result = transcribe(audio_chunk) + chunks.append(asr_result) + offsets.append(i * 1000) # 转换为毫秒 + +# 合并所有片段 +final_result = merger.merge_chunks( + chunks=chunks, + chunk_offsets=offsets, + overlap_duration=10000 +) + +# 保存字幕 +final_result.save("output.srt") +``` + +### 场景 2:在线流式识别 + +```python +class StreamingASR: + def __init__(self): + self.merger = ChunkMerger() + self.chunks = [] + self.offsets = [] + + def on_chunk_received(self, chunk_audio, timestamp): + # 识别当前片段 + asr_result = transcribe(chunk_audio) + self.chunks.append(asr_result) + self.offsets.append(timestamp) + + # 实时合并 + if len(self.chunks) >= 2: + merged = self.merger.merge_chunks( + chunks=self.chunks, + chunk_offsets=self.offsets, + overlap_duration=5000 # 5s 重叠 + ) + return merged +``` + +## 注意事项 + +### 1. 重叠时长建议 + +- **推荐**:10 秒重叠(足以捕获句子边界) +- **最小**:3-5 秒(太短可能匹配失败) +- **最大**:不超过 chunk 长度的 1/3 + +### 2. 匹配阈值 + +```python +# 对于短句子,可以降低阈值 +merger = ChunkMerger(min_match_count=1) + +# 对于长句子,可以提高阈值以提高准确性 +merger = ChunkMerger(min_match_count=3) +``` + +### 3. 时间戳连续性 + +合并后,请验证时间戳的连续性: + +```python +# 验证时间戳 +for i in range(len(merged.segments) - 1): + seg1 = merged.segments[i] + seg2 = merged.segments[i + 1] + gap = seg2.start_time - seg1.end_time + if gap > 2000: # 间隔超过 2s + print(f"警告: 片段 {i} 和 {i+1} 之间有 {gap}ms 间隔") +``` + +## 测试 + +运行测试套件: + +```bash +# 运行所有测试 +uv run pytest tests/test_asr/test_chunk_merger.py -v + +# 运行特定测试 +uv run pytest tests/test_asr/test_chunk_merger.py::TestChunkMergerBasic -v +``` + +## 常见问题 + +### Q1: 合并后丢失了部分内容? + +**A**: 检查重叠区域是否足够长,确保 `overlap_duration` 至少为 5 秒。 + +### Q2: 匹配失败,使用了时间边界切分? + +**A**: 可能是重叠区域的文本差异太大(识别错误)。可以: + +1. 降低 `min_match_count` 阈值 +2. 增加重叠时长 +3. 检查 ASR 质量 + +### Q3: 时间戳不连续? + +**A**: 检查 `chunk_offsets` 是否正确,应该准确反映每个 chunk 的实际起始时间。 + +## 相关文档 + +- [ASRData 数据结构](../asr_data.py) +- [Groq Audio Chunking Tutorial](https://github.com/groq/groq-api-cookbook/blob/main/tutorials/audio-chunking/audio_chunking_tutorial.ipynb) diff --git a/docs/dev/config-architecture.md b/docs/dev/config-architecture.md new file mode 100644 index 00000000..ea2f74b7 --- /dev/null +++ b/docs/dev/config-architecture.md @@ -0,0 +1,65 @@ +# Configuration Architecture Notes + +## Original Problem + +`qfluentwidgets` setting cards directly mutated global config state: + +- The native switch and options cards wrote through qfluent's global config + setter. +- The setter mutated the item, wrote JSON immediately, and emitted restart/theme + signals. + +That makes the widget, persistence format, validation, and business config source the same object. It is hard to reuse in CLI and hard to replace visually. + +## Current Boundary + +Shared persistence now lives in core: + +- `videocaptioner.core.application.config_store` +- default file: `videocaptioner.config.APPDATA_PATH / "config.toml"` +- test override: `VIDEOCAPTIONER_CONFIG_FILE=/path/to/config.toml` + +There is no separate CLI config module anymore. CLI code imports the core store +directly. + +Business execution should consume plain application config: + +- `videocaptioner.core.application.app_config.AppConfig` +- `videocaptioner.core.application.task_builder.TaskBuilder` + +Adapters are responsible for translating existing surfaces: + +- shared TOML/CLI dict -> `videocaptioner.cli.config_adapter.app_config_from_cli` +- UI memory state -> `videocaptioner.ui.config_adapter.app_config_from_ui` + +`videocaptioner.ui.common.config` is an in-memory binding over the shared TOML +file. It does not read or write qfluentwidgets' old JSON settings file. +`cfg.set(...)` and direct `SettingField.value` changes are synced back to TOML. +The UI config state is implemented by `videocaptioner.ui.common.settings_state`, +not qfluentwidgets' `QConfig`. + +Existing UI pages still call `TaskFactory`, but task construction itself lives +in `TaskBuilder`. + +The settings page no longer uses qfluentwidgets' native setting-card classes. +`videocaptioner.ui.components.form_cards` owns the page's card/group +widgets and writes through the shared settings state. + +All settings controls now read from our `SettingField` objects and write through +`cfg.set(...)`. Theme and language state are stored in the shared TOML-backed +settings state instead of a widget-library config object. + +## Next Migration Direction + +Next UI refactors should keep the same clean boundary: + +- Read and write through `config_store` or a small repository wrapper around it. +- Keep UI-only settings under `[ui]`; keep workflow settings under `[transcribe]`, + `[subtitle]`, `[translate]`, `[synthesize]`, `[dubbing]`, and provider sections. +- Keep moving large page-local layouts into first-party reusable widgets when + those pages are redesigned. +- Keep theme state first-party through `theme_tokens` and `settings_state`. + +The removed `signal_bus` pattern should not return for configuration +propagation. Config changes should move through the shared config store and +small page-local signals only where a specific widget needs immediate refresh. diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index 09d901c0..bc76db92 100644 --- a/docs/dev/contributing.md +++ b/docs/dev/contributing.md @@ -4,39 +4,40 @@ ## 开发环境设置 -1. Fork 本仓库 -2. 克隆你的 Fork -3. 安装开发依赖 +1. Fork 本仓库并克隆你的 Fork +2. 使用 [uv](https://docs.astral.sh/uv/) 安装依赖并运行 ```bash git clone https://github.com/YOUR_USERNAME/VideoCaptioner.git cd VideoCaptioner -pip install -r requirements.txt +uv sync +uv run videocaptioner # 运行 GUI +uv run videocaptioner --help # 运行 CLI ``` -## 代码规范 - -- 使用 `pyright` 进行类型检查 -- 使用 `ruff` 进行代码格式化 +## 代码检查与测试 ```bash -# 类型检查 -uv run pyright +uv run ruff check videocaptioner tests scripts # 代码检查 +uv run pyright # 类型检查 +uv run pytest tests/test_cli tests/test_application -q # 快速测试 +``` -# 代码格式化 -uv run ruff check --select I --fix . +UI 改动请附带冒烟截图验证: + +```bash +uv run python scripts/ui_smoke_check.py /tmp/vc-ui-check --theme dark ``` ## 提交 Pull Request 1. 创建新分支 -2. 提交你的修改 -3. 推送到你的 Fork -4. 创建 Pull Request +2. 提交你的修改(UI 改动附截图,运行时改动附测试或日志证据) +3. 推送到你的 Fork 并创建 Pull Request ## 注释要求 -保持简洁清晰,只需要必要的注释即可。 +保持简洁清晰,只写必要的注释(解释"为什么",而不是复述代码)。 --- diff --git a/docs/dev/design-batch.html b/docs/dev/design-batch.html new file mode 100644 index 00000000..fdaa2404 --- /dev/null +++ b/docs/dev/design-batch.html @@ -0,0 +1,831 @@ + + + + + + VideoCaptioner 批量处理设计 A + + + +
+
+
+

批量处理设计 A:队列工作台

+

保留当前四种处理类型,把空态、队列、运行、失败恢复做成同一套批量管理体验。

+
+
1440 × 900方案 A4 个状态
+
+
+
+ + + + diff --git a/docs/dev/design-doctor.html b/docs/dev/design-doctor.html new file mode 100644 index 00000000..d99230c2 --- /dev/null +++ b/docs/dev/design-doctor.html @@ -0,0 +1,449 @@ + + + + + + VideoCaptioner 诊断设计 + + + +
+
+
+

诊断页重设计

+

诊断只显示用户需要判断和处理的事情:能不能跑通、哪里要改、下一步点哪里。

+
+ 功能已对齐真实诊断页 +
+ +
+

状态 A:未检查

+ 任务上下文清楚,检查项待运行 +
+
+
卡卡字幕助手 -- VideoCaptioner
+ +
+
+
+

诊断

+

按当前任务配置检查工具、转录、下载、翻译和配音。

+
+ +
+
+
转录B 接口
+
字幕处理校正 + 智能断句
+
翻译微软翻译
+
配音Gemini TTS
+
导出字幕 + 配音
+
+
+
+
+

检查清单

+
+
+
核心工具
+
+
-
+
FFmpeg / FFprobe
生成视频、压入字幕、合入配音都需要它。
+ 待检查 + +
+
在线能力
+
+
-
+
转录服务
把视频或音频转成原文字幕,免费接口需要网络。
+ 待检查 + +
+
+
-
+
视频下载
验证 YouTube 与哔哩哔哩链接是否能解析下载。
+ 待检查 + +
+
后续处理
+
+
-
+
翻译服务
生成目标语言字幕,失败只影响译文。
+ 待检查 + +
+
+
-
+
配音服务
按当前提供商和音色生成配音,部分服务需要 Key。
+ 待检查 + +
+
+
-
+
实时字幕
实时转录需要本机 voxgate 转录程序,或一个外部实时服务。
+ 待检查 + +
+
+
+
+
+
+ +
+

状态 B:诊断完成,有两项需要处理

+ 错误行置顶,但行高和按钮列保持稳定 +
+
+
卡卡字幕助手 -- VideoCaptioner
+ +
+
+
+

诊断

+

当前配置可以继续处理,但硬字幕和配音需要先修正。

+
+ +
+
+
转录Faster Whisper
+
字幕处理校正 + 翻译
+
翻译LLM 大模型翻译
+
配音Gemini TTS
+
导出视频 + 字幕 + 配音
+
+
+
+
+

检查清单

+
+
+
需要处理
+
+
!
+
FFmpeg 不支持 ASS 硬字幕
当前 FFmpeg 缺少 ASS 字幕滤镜。安装完整版本,或切换为圆角背景。
+ 未通过 + +
+
+
!
+
配音服务
Gemini / SiliconFlow 需要配音 Key;Edge 可免 Key。
+ 未通过 + +
+
已通过
+
+
+
转录服务
当前转录方式可用,可生成原文字幕。
+ 正常 + +
+
+
+
视频下载
YouTube 与哔哩哔哩解析正常,可直接粘贴链接下载。
+ 正常 + +
+
+
+
翻译服务
翻译服务可用,可生成目标语言字幕。
+ 正常 + +
+
+
+
+
+
+
+ + diff --git a/docs/dev/design-dubbing-clone.html b/docs/dev/design-dubbing-clone.html new file mode 100644 index 00000000..aaa2d409 --- /dev/null +++ b/docs/dev/design-dubbing-clone.html @@ -0,0 +1,102 @@ + + + + + + 配音页声音克隆设计 + + + +
+

配音

+
+
Edge 免费配音免 API Key,适合快速生成。
+
Gemini TTS英文表达自然,需要 Key。
+
SiliconFlow CosyVoice支持参考音频克隆。
+
+
+ 声线 + 全部 + 女声 + 男声 +
+
+
+
音色试听
+
Anna自然中文女声,可配合参考音频克隆
+
Alex自然中文男声,可配合参考音频克隆
+
Bella热情中文女声,可克隆
+
+ +
+
+ + diff --git a/docs/dev/design-dubbing.html b/docs/dev/design-dubbing.html new file mode 100644 index 00000000..34fd4b19 --- /dev/null +++ b/docs/dev/design-dubbing.html @@ -0,0 +1,645 @@ + + + + + + VideoCaptioner 配音设计 + + + +
+
+
+

配音页重设计

+

把提供商、音色选择、试听文案和克隆能力收在一张稳定工作台里,按钮和状态不跳位置。

+
+ 功能已对齐真实配音页 +
+ +
+

状态 A:Gemini TTS,选择英文音色并试听

+ 适合常规音色浏览,Key 已配置 +
+
+
卡卡字幕助手 -- VideoCaptioner
+ +
+
+
+

配音

+

选择提供商和音色,先试听一句,再用于字幕视频合成。

+
+
+ + Gemini Key 已配置 +
+
+
+ + + +
+
+
+
+

音色库

+
+
+ + + + +
+
+
+
+
+
+ Achird +

友好自然的英文表达,适合课程旁白和解释型内容。

+
英文推荐需 Key
+
+
+
+
+
+ Kore +

清晰稳定的自然英文,更适合长段字幕配音。

+
英文推荐
+
+
+
+
+
+ Puck +

更有能量的英文表达,语气更轻快。

+
英文Upbeat
+
+
+
+
+
+ Zephyr +

明亮清爽的英文声音,适合轻知识视频。

+
英文Bright
+
+
+
+
+
+ Autonoe +

均衡清晰,适合默认英文音色。

+
英文Even
+
+
+
+
+
+ +
+
+
+ +
+

状态 B:SiliconFlow,参考音频克隆

+ 克隆控件只在支持的提供商出现 +
+
+
卡卡字幕助手 -- VideoCaptioner
+ +
+
+
+

配音

+

中文配音时可以直接选预置音色,也可以加参考音频做声音克隆。

+
+
+ + 参考音频已就绪 +
+
+
+ + + +
+
+
+
+

中文音色

+
+
+ + + + +
+
+
+
+
+
+ Anna +

自然中文女声,可配合参考音频克隆。

+
中文女声克隆
+
+
+
+
+
+ Alex +

自然中文男声,可配合参考音频克隆。

+
中文男声克隆
+
+
+
+
+
+ Bella +

热情中文女声,适合短视频讲解。

+
中文女声
+
+
+
+
+
+ Benjamin +

沉稳低沉的中文男声。

+
中文男声
+
+
+
+
+
+ +
+
+
+
+ + diff --git a/docs/dev/design-hard-subtitle-extract.html b/docs/dev/design-hard-subtitle-extract.html new file mode 100644 index 00000000..4b13ef76 --- /dev/null +++ b/docs/dev/design-hard-subtitle-extract.html @@ -0,0 +1,1184 @@ + + + + + + VideoCaptioner 硬字幕提取设计 + + + +
+ + + diff --git a/docs/dev/design-home.html b/docs/dev/design-home.html new file mode 100644 index 00000000..872f0573 --- /dev/null +++ b/docs/dev/design-home.html @@ -0,0 +1,581 @@ + + + + + + VideoCaptioner 首页中间区优化 Demo + + + +
+
+
+

VideoCaptioner 首页中间区优化

+

只看 logo、标题和输入区。

+
+
+ + + + +
+
+ +
+ + +
+
+
+ + + + +
+
+ +
+
+
+
+

导入视频,生成字幕与配音

+
+
+ + 粘贴视频链接,或拖拽文件到这里 +
+ +
+ +
+
+
+ +
+
+
+
+

导入视频,生成字幕与配音

+
+
拖入文件
+
+
+ + 粘贴视频链接,或拖拽文件到这里 +
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+

导入视频,生成字幕与配音

+
+
+
+
+
+ + 粘贴视频链接,或拖拽文件到这里 +
+ +
+
+ +
+
+
+ +
+
+
+
+
VideoCaptioner
+

导入视频

+
+
+ + 粘贴视频链接,或拖拽文件到这里 +
+ +
+ +
+
+
+
+
+
+ + + + diff --git a/docs/dev/design-live-caption-proposals.html b/docs/dev/design-live-caption-proposals.html new file mode 100644 index 00000000..53903449 --- /dev/null +++ b/docs/dev/design-live-caption-proposals.html @@ -0,0 +1,1790 @@ + + + + + + VideoCaptioner 实时字幕页面设计 + + + +
+
+
+ + + + + + + + +
+
+ +
+
+
+ 卡卡字幕助手 -- VideoCaptioner +
+ + + +
+
+
+
+

实时字幕

+
+
+ +
+
+
+
+
+
+

开始新的实时字幕

+

2026-06-17 16:49 记录

+

启动失败

+

选择声音来源后即可开始

+

00:52 · 已生成 8 句

+

00:52 · 记录已保留

+

06:18 · 记录已保存

+

未能捕获系统声音

+
+
+
+ + +
+
+ +
+
+
+
+

最近记录

+ +
+
+
+
2026-06-17 16:49 记录06:18 · 系统声音 · 微软翻译
+ +
+
+
2026-06-15 12:04 记录18:42 · 麦克风 · 原文记录
+ +
+
+
2026-06-14 20:03 记录02:46 · 系统声音 · 微软翻译
+ +
+
+
2026-06-13 09:18 记录08:31 · 麦克风 · 原文记录
+ +
+
+
2026-06-11 18:40 记录12:06 · 系统声音 · 微软翻译
+ +
+
+
2026-06-10 21:22 记录04:37 · 麦克风 · 原文记录
+ +
+
+
2026-06-09 15:08 记录09:12 · 系统声音 · 微软翻译
+ +
+
+
+
+ +
+
+

没有检测到系统声音

+

切换音频来源后再开始实时字幕。

+ +
+
+ +
+
+
+
+
发言人00:03
+
+

Hello everyone, before we start the main topic, I want to quickly align on what we expect from today's live caption review and how people will actually use it during a meeting.

+

大家好,在进入主题前,我想先对齐一下今天实时字幕评审的目标,以及用户在会议里真正会怎么使用它。

+
+ +
+
+
+
+ +
+
+
+
发言人00:24
+
+

Last week we spent most of our time on the floating window, but the record page itself also needs to feel reliable enough for users to pause, continue, search, and save without guessing where anything went.

+

上周我们主要在看浮窗,但记录页本身也必须足够可靠,让用户能暂停、继续、搜索和保存,不需要猜每个东西去了哪里。

+
+ +
+
+
+
+ +
+
+
+
+ 当前句 + 发言人 + 00:52 +
+
+

So the next step is to keep the main controls stable on the right side and let the transcript area focus on the sentence stream, especially when the current sentence is still being corrected by the backend.

+

所以接下来要让右侧的主要控制保持稳定,把中间区域留给句子流,尤其是在当前句还会被后端继续修正的时候。

+
+ +
+
+
+
+
+
+
+ + +
+
+ +
+
+
+

实时字幕历史

+
+
+ +
+
+ +
+
+
搜索记录名称或正文
+
+ + +
+
+ +
+
+ 记录 + 来源 + 时长 + 创建时间 + 操作 +
+
+
+
+ +
+ 2026-06-17 16:49 记录 +
+
+
系统声音
+
06:18
+
今天 16:49
+
+ + +
+
+ +
+
+ +
+ 2026-06-15 12:04 记录 +
+
+
麦克风
+
18:42
+
06-15 12:04
+
+ + +
+
+ +
+
+ +
+ 2026-06-14 20:03 记录 +
+
+
系统声音
+
02:46
+
06-14 20:03
+
+ + +
+
+ +
+
+ +
+ 2026-06-13 09:18 记录 +
+
+
麦克风
+
00:00
+
06-13 09:18
+
+ +
+
+
+
+
+

暂无记录

+

保存后的实时字幕会显示在这里。

+
+
+
+
+
+ +
+
+
+

记录详情

+
+
+ + +
+
+ +
+
+
+ +
+

2026-06-17 16:49 记录

+

系统声音 · 微软翻译 · 06:18

+
+
+
+ +
+
+
+
+
+
发言人00:03
+
+

Hello everyone, before we start the main topic, I want to quickly align on what we expect from today's live caption review and how people will actually use it during a meeting.

+

大家好,在进入主题前,我想先对齐一下今天实时字幕评审的目标,以及用户在会议里真正会怎么使用它。

+
+ +
+
+
+
+ +
+
+
+
发言人00:24
+
+

Last week we spent most of our time on the floating window, but the record page itself also needs to feel reliable enough for users to pause, continue, search, and save without guessing where anything went.

+

上周我们主要在看浮窗,但记录页本身也必须足够可靠,让用户能暂停、继续、搜索和保存,不需要猜每个东西去了哪里。

+
+ +
+
+
+
+ +
+
+
+
发言人00:52
+
+

So the next step is to keep the main controls stable on the right side and let the transcript area focus on the sentence stream, especially when the current sentence is still being corrected by the backend.

+

所以接下来要让右侧的主要控制保持稳定,把中间区域留给句子流,尤其是在当前句还会被后端继续修正的时候。

+
+ +
+
+
+
+
+ + +
+ +
+ +
+
+ 00:56 + + 06:18 +
+
正在播放 00:24 的句子42 句
+
+
+ +
+
+
+
+
+
+
+ + + + diff --git a/docs/dev/design-live-caption.html b/docs/dev/design-live-caption.html new file mode 100644 index 00000000..29987ca8 --- /dev/null +++ b/docs/dev/design-live-caption.html @@ -0,0 +1,290 @@ + + + + + + 实时语音翻译浮窗 · 终稿 + + + +
+
+
+

实时语音翻译浮窗 · 终稿(精简版)

+

静默时只有字幕本身——连指示灯都没有(字幕在滚动就说明在听);鼠标移上去才淡淡浮出语言/时间与工具条,不画任何边线、不弹盒子。拖任意边缘即可缩放(光标变形),拉高长出历史,拉成侧条当实录栏。

+
+
+ + + + + + + + +
+
+ +
+
▶ 教父 The Godfather (1972) · 本地播放
+
+
1:12:38
2:55:04
+
+ +
+
+ + 英 → 中 + 12:38 + +
+ +
+
1:10:22
don't ever take sides against the family again.
永远别再站到家族的对立面。
+
1:11:02
i'm gonna make him an offer he can't refuse.
我会给他一个无法拒绝的条件。
+
1:11:40
a man who doesn't spend time with his family can never be a real man.
不顾家的男人,永远算不上真正的男人。
+
1:12:20
great men are not born great, they grow great.
伟人并非生而伟大,而是逐渐成就伟大。
+
+ +
+
+ + +
+ +
i believe in america. america has made my fortune
+
我相信美国,美国成就了我的财富
+ +
+ +
+ + + +
+ + + + +
+ + +
+ + +
+ +
+
+ + + + diff --git a/docs/dev/design-llm-logs.html b/docs/dev/design-llm-logs.html new file mode 100644 index 00000000..69d01288 --- /dev/null +++ b/docs/dev/design-llm-logs.html @@ -0,0 +1,945 @@ + + + + + + VideoCaptioner LLM 请求日志设计 + + + +
+
+
+

LLM 请求日志设计

+

日志页是排查工具,不需要大面积空表格。搜索、表格、详情和分页保持在同一屏内完成。

+
+ 功能已对齐真实日志页 +
+ +
+

状态 A:有日志,表格优先

+ 双击行打开详情,主页面先保证日志内容完整可扫 +
+
+
LLM 请求日志
+ +
+
+ + + + +
+
+
+
时间
任务 ID
文件
阶段
模型
耗时
Tokens
+
+
06-15 11:42
8F2A
linear-algebra-demo.mp4
断句
gpt-4o-mini
1.8s
2,418
+
06-15 11:41
8F2A
linear-algebra-demo.mp4
翻译
gpt-4o-mini
2.6s
3,102
+
06-15 11:39
7C19
matrix-space-p02.mp4
校正
claude-3-5-sonnet
3.1s
4,880
+
06-15 11:20
6E08
vector-intro.mp4
翻译
gpt-4o-mini
1.2s
1,984
+
+
+
+
+
共 128 条 · 筛选后 42 条 · 双击任意行查看完整 JSON
+
1 / 3
+
+
+
+ +
+

状态 B:空日志

+ 减少空表格压迫感,仍保留搜索和操作位置 +
+
+
LLM 请求日志
+ +
+
+ + 暂无记录 + + +
+
+
+
+

暂无 LLM 请求日志

+

启用字幕校正、智能断句或 LLM 翻译后,页面会自动记录请求与响应。

+
+
+
+
共 0 条
+
1 / 1
+
+
+
+ +
+

状态 C:详情弹窗,请求体优先

+ 用户打开详情就是为了看原始 Request / Response,不再插入解释性侧栏 +
+
+
LLM 请求日志
+ +
+
+ + + + +
+
+
+
时间
任务 ID
文件
阶段
模型
耗时
Tokens
+
+
06-15 17:28
A91C
linear-algebra-demo.mp4
翻译
deepseek-ai/DeepSeek-V4-Flash
16.0s
471
+
06-15 17:27
A91C
linear-algebra-demo.mp4
校正
deepseek-ai/DeepSeek-V4-Flash
9.4s
529
+
+
+
+
+
共 2 条 · 双击查看完整请求
+
1 / 1
+
+
+ +
+ +
+

状态 D:失败详情,请求仍是主体

+ 失败时右侧显示错误响应,左侧 Request 不让位 +
+
+
LLM 请求日志
+ +
+
+ + + + +
+
+
+
时间
任务 ID
文件
阶段
模型
耗时
Tokens
+
+
06-15 17:31
B04D
matrix-space-p02.mp4
校正
gpt-4o-mini
0.8s
-
+
+
+
+
+
共 1 条 · 请求失败
+
1 / 1
+
+
+ +
+
+ + diff --git a/docs/dev/design-model-download.html b/docs/dev/design-model-download.html new file mode 100644 index 00000000..1508081a --- /dev/null +++ b/docs/dev/design-model-download.html @@ -0,0 +1,981 @@ + + + + + + VideoCaptioner 本地模型弹窗设计 A + + + +
+
+
+

本地模型管理弹窗设计 A

+

选择本地转录引擎,检查运行程序,并管理离线模型。

+
+
入口转录配置 · 模型管理
+
+ +
+
+

状态 A:已可使用

+

运行程序可用时,用户只需要确认当前模型或下载其它模型。

+
+
+
+
卡卡字幕助手 -- VideoCaptioner
+ +
+
+

转录配置

+
转录服务WhisperCpp
WhisperCpp 模型tiny
源语言自动检测
+
+
+ +
+
+ +
+
+

状态 B:运行程序缺失

+

程序缺失时,给出当前系统最直接的安装方式,同时保留模型管理。

+
+
+
+
卡卡字幕助手 -- VideoCaptioner
+ +
+

转录配置

转录服务WhisperCpp
WhisperCpp 模型base
+
+ +
+
+ +
+
+

状态 C:Faster Whisper 程序选择

+

CPU 版和 GPU 版分开放在运行程序区;用户先选程序,再管理模型。

+
+
+
+
卡卡字幕助手 -- VideoCaptioner
+ +
+

转录配置

转录服务Faster Whisper
Faster Whisper 模型tiny
+
+ +
+
+ +
+
+

状态 D:模型下载中

+

下载时只显示当前文件进度和取消入口,保持其它操作位置稳定。

+
+
+
+
卡卡字幕助手 -- VideoCaptioner
+ +
+

转录配置

转录服务Faster Whisper
Faster Whisper 模型large-v3-turbo
+
+ +
+
+
+ + diff --git a/docs/dev/design-overlay-card.html b/docs/dev/design-overlay-card.html new file mode 100644 index 00000000..44db5276 --- /dev/null +++ b/docs/dev/design-overlay-card.html @@ -0,0 +1,253 @@ + + + + + + 桌面浮窗 · 形态 2 灵动悬浮卡 + + + +
+
+
+

桌面浮窗 · 形态 2「灵动悬浮卡」

+

开会 / 桌面办公首选:一张可拖到任意位置的圆角卡,能展开历史、收成小胶囊。下面把它放在「Zoom 会议」上看真实效果。

+
+
+ + + + + +
+
+ +
+
+
A
Alex Chen(主讲)
+
M
Maria
+
You
+
K
Kenji
+
+
+ + +
+
+
+
监听中英语 → 简体中文12:38
+
so the main goal for this sprint is to ship the onboarding flow
+
所以这个迭代的主要目标是交付新手引导流程
+
+
+ + +
+
+
+
监听中英语 → 简体中文12:38
+
so the main goal for this sprint is to ship the onboarding flow
+
所以这个迭代的主要目标是交付新手引导流程
+
+ + + + + + + + +
+
+
+ + +
+
+
+
本次记录 · 14 句
+
12:36:02
let's start with a quick recap of last week.
先快速回顾一下上周。
+
12:37:21
the prototype got really positive feedback.
原型得到了非常正面的反馈。
+
+
+
监听中英语 → 简体中文12:38
+
so the main goal for this sprint is to ship the onboarding flow
+
所以这个迭代的主要目标是交付新手引导流程
+
+ + + + + + + +
+
+
+ + +
+
+
+
监听中英语 → 简体中文12:38
+
so the main goal for this sprint is to ship the onboarding flow
+
所以这个迭代的主要目标是交付新手引导流程
+
+ + + + + + + + +
+
+
+

字幕外观

+
显示
+
字号
+
窗口不透明度90%
+
+
+ + +
+
+
+ 所以这个迭代的主要目标是交付新手引导流程 + +
+
+
+ +
收起成胶囊时只占一行、最不打扰,点向上箭头重新展开。会议场景里多人发言可在历史里区分说话人(后续扩展)。
+
+ + + + diff --git a/docs/dev/design-settings.html b/docs/dev/design-settings.html new file mode 100644 index 00000000..c6eec13e --- /dev/null +++ b/docs/dev/design-settings.html @@ -0,0 +1,558 @@ + + + + + + VideoCaptioner 设置设计 + + + +
+
+
+

设置页重设计

+

左侧分类保持稳定,页面内直接编辑当前设置。动态字段只在当前提供商需要时出现。

+
+ 功能已对齐真实设置页 +
+ +
+

状态 A:转录配置,模型可用

+ 九个设置页全部列出,当前页只显示必要字段 +
+
+
设置
+
+ +
+
+
+

转录配置

+

选择转录服务、输出格式和本地模型。测试转录对所有服务共用同一入口。

+
+ 可转录 +
+
+

基础

B 接口
+
转录模型

选择生成原始字幕时使用的语音识别服务。

B 接口
+
输出格式

转录完成后保存的字幕文件格式。

SRT
+
源语言

音视频中说话的语言,不确定时保持自动检测。

自动检测
+
+
+

本地模型

按服务显示
+
WhisperCpp 模型

仅在选择 whisper.cpp 时出现;列表只显示已下载模型。

tiny
+
管理模型

查看运行程序状态、下载和管理模型文件。

管理模型
+
测试转录

用内置短音频真实转录一次,验证当前服务能跑通。

测试转录
+
+
+

设置目录

9 页
+
+
转录配置

ASR 服务、格式、语言、本地模型、测试转录。

+
LLM 配置

提供商、Key、Base URL、模型、加载模型和测试连接。

+
翻译服务

翻译服务、反思翻译、DeepLX、批处理和并发。

+
翻译与优化

校正、翻译、断句、目标语言、长度和提示词。

+
字幕合成配置

样式页、字幕布局、渲染模式、视频输出和质量。

+
配音配置

默认配音、提供商、音色、轨道、对齐、原声和 Key。

+
保存配置

工作目录、保留中间文件、输出策略。

+
个性化

主题、颜色、语言和界面偏好。

+
关于

版本、更新、项目链接和反馈入口。

+
+
+
+
+
+ +
+

状态 B:LLM 配置,缺少 Key

+ 错误状态内联显示,按钮位置不变 +
+
+
设置
+
+ +
+
+
+

LLM 配置

+

用于字幕断句、校正和 LLM 翻译。模型列表和连接测试分开执行。

+
+ 缺少 API Key +
+
+

模型服务

待配置
+
LLM 提供商

选择当前字幕处理使用的大模型服务。

OpenAI 兼容
+
API Key

调用大模型时使用。保存前会自动去掉多余空白。

未填写
+
Base URL

仅 OpenAI 兼容或本地服务需要修改。

https://api.openai.com/v1
+
模型

用于断句、校正、翻译的大模型名称。

gpt-4o-mini
+
模型服务

先加载可用模型,再用当前模型测试连通性。

加载模型测试连接
+
+
+

相关开关

影响诊断
+
字幕校正

开启后处理字幕时会调用 LLM。

+
字幕断句

开启后会按语义重新切分字幕。

+
字幕翻译

如果翻译服务选择 LLM,会复用本页 Key。

微软翻译
+
+
+
+
+ +
+

状态 C:设置总览

+ 九个设置页与关键设置项完整展示,方便确认信息架构 +
+
+
设置
+
+ +
+
+
+

设置总览

+

所有页面保持同一行高、同一控件宽度;服务相关字段只在对应提供商下出现。

+
+ 9 页 +
+
+
+

转录配置

+
+
转录模型B 接口
+
输出格式SRT
+
源语言自动检测
+
本地模型按服务显示
+
测试转录统一入口
+
+
+
+

LLM 配置

+
+
提供商OpenAI 兼容
+
API Key未填写
+
Base URL可编辑
+
模型gpt-4o-mini
+
加载 / 测试分开
+
+
+
+

翻译服务

+
+
翻译服务微软翻译
+
反思翻译关闭
+
DeepLX 后端按服务显示
+
批处理大小可调
+
并发数可调
+
+
+
+

翻译与优化

+
+
字幕校正开关
+
字幕翻译开关
+
字幕断句开关
+
目标语言简体中文
+
提示词设置页内
+
+
+
+

字幕合成配置

+
+
字幕样式样式页
+
字幕布局单语 / 双语
+
渲染模式ASS / 圆角
+
合成视频开关
+
视频质量中等
+
+
+
+

配音配置

+
+
默认配音开关
+
提供商Edge / Gemini
+
默认音色音色页选择
+
文本轨道自动选择
+
克隆字段按服务显示
+
+
+
+

保存配置

+
+
工作目录可选择
+
保留中间文件开关
+
输出位置源文件目录
+
+
+
+

个性化

+
+
主题跟随系统
+
语言简体中文
+
界面偏好本机保存
+
+
+
+

关于

+
+
版本1.4.3
+
更新检查更新
+
项目链接GitHub
+
+
+
+
+
+
+
+ + diff --git a/docs/dev/design-subtitle-style.html b/docs/dev/design-subtitle-style.html new file mode 100644 index 00000000..bf35c924 --- /dev/null +++ b/docs/dev/design-subtitle-style.html @@ -0,0 +1,2067 @@ + + + + + + VideoCaptioner 字幕样式设计 + + + +
+
+ 状态 A · ASS 描边默认编辑 + 样式管理动作在左侧;主字幕和副字幕可分开调整 +
+ +
+ + +
+
+ + +
+
+
+ 预览 + linear-algebra-demo.mp4 +
+
+ + + +
+
+
+
+
+
+
+
+
我们今天讲矩阵和向量空间。
+
Today we will talk about matrices and vector spaces.
+
+
+
+
+
课程白字描边硬字幕合成会使用当前样式
+
+ + +
+
+
+
+ + +
+
+
+ +
+ 状态 B · 圆角背景编辑 + 背景参数和双语排布同页处理,位置仍使用数值步进 +
+ +
+ + +
+
+ + +
+
+
+ 预览 + lecture-clip.mp4 +
+
+ + + +
+
+
+
+
+
+
+
+
欢迎报考百年名校华南师范大学
+
Welcome to apply for South China Normal University.
+
+
+
+
+
青色双行胶囊当前样式已更新
+
+ + +
+
+
+
+ + +
+
+
+ +
+ 状态 C · 样式很多时 + 左侧样式库独立滚动;右侧参数也独立滚动,预览区域不被挤压 +
+ +
+ + +
+
+ + +
+
+
+ 预览 + portrait-demo.mp4 +
+
+ + + +
+
+
+
+
+
+
+
+
先看矩阵的形式。
+
Let's look at the matrix form first.
+
+
+
+
+
直播课大字描边竖屏预览保持居中
+ +
+
+
+ + +
+
+
+ +
状态 D · 两栏方案预览优先,样式和参数合并到右侧工作区
+
+ + +
+
+
+
+
+ 预览 + linear-algebra-demo.mp4 +
+
+ + + +
+
+
+
+
+
+
+
+
我们今天讲矩阵和向量空间。
+
Today we will talk about matrices and vector spaces.
+
+
+
+
+
课程白字描边预览区域保持优先
+ +
+
+
+ + +
+
+
+ +
状态 E · 底部样式管理右侧参数常驻;当前样式固定,其他样式同层横向浏览
+
+ + +
+
+
+
+
+ 预览 + linear-algebra-demo.mp4 +
+
+ + + +
+
+
+
+
+
+
+
+
我们今天讲矩阵和向量空间。
+
Today we will talk about matrices and vector spaces.
+
+
+
+
+
课程白字描边硬字幕合成会使用当前样式
+
+ + +
+
+
+
+ + + +
+
+
+
ASS 描边
+
圆角背景
+
+
样式管理 · 当前样式固定,右侧浏览其他样式
+
+ + +
+
+
+
+
+
+
+
+ 课程白字描边 +

双语 · 原文在上 · 黄字译文

+
+
+
+
+ + + +
+
+
+
+
+
+
+
短视频强描边

大字号 · 居中 · 单语优先

+
+
我的可选
+
+
+
+
+
青色双行胶囊

圆角背景 · 主副字幕一起包裹

+
+
圆角可选
+
+
+
+
+
访谈轻描边

小字号 · 留出人物和画面信息

+
+
我的可选
+
+
+
+
+
黑底圆角

整组背景 · 适合复杂画面

+
+
圆角可选
+
+
+
+
+
英文练习黄字

译文更突出 · 课堂复习用

+
+
我的可选
+
+
+
+
+
+
+
+
+
+
+ + diff --git a/docs/dev/design-subtitle.html b/docs/dev/design-subtitle.html new file mode 100644 index 00000000..0a80737b --- /dev/null +++ b/docs/dev/design-subtitle.html @@ -0,0 +1,825 @@ + + + + + + VideoCaptioner 字幕处理设计 B + + + +
+
+
+

字幕处理设计 B:两栏审校版

+

把三栏收成两栏:左侧保留宽表格,右侧固定处理项、当前行和配置入口,适合用户边看字幕边决定处理方式。

+
+
1440 × 900版本 B5 个状态
+
+
+
+ + + + diff --git a/docs/dev/design-synthesis.html b/docs/dev/design-synthesis.html new file mode 100644 index 00000000..8a1dd436 --- /dev/null +++ b/docs/dev/design-synthesis.html @@ -0,0 +1,1001 @@ + + + + + + VideoCaptioner 字幕视频合成设计 A + + + +
+
+
+

字幕视频合成设计 A:组合开关版

+

保留当前代码的“字幕视频 / 配音音轨”两个输出开关,但把输入要求、按钮文案、参数显隐收敛成同一套状态。

+
+
1440 × 900方案 A7 个状态
+
+
+
+ + + + diff --git a/docs/dev/design-task-create.html b/docs/dev/design-task-create.html new file mode 100644 index 00000000..f04cc57a --- /dev/null +++ b/docs/dev/design-task-create.html @@ -0,0 +1,1032 @@ + + + + + + VideoCaptioner 任务创建设计 A + + + +
+
+
+

任务创建设计 A:首页入口版

+

全局功能保留在侧边栏;首页负责导入、开始、版本和轻量状态。

+
+
1440 × 900方案 A4 个状态
+
+
+
+ + + + diff --git a/docs/dev/design-transcription.html b/docs/dev/design-transcription.html new file mode 100644 index 00000000..2f34b504 --- /dev/null +++ b/docs/dev/design-transcription.html @@ -0,0 +1,1417 @@ + + + + + + VideoCaptioner 语音转录页设计稿 + + + +
+
+
+

语音转录页设计稿

+

最终方向:单文件转录工作台。当前文件、预览结果、转录参数始终固定在同一套结构里。

+
+
+ 1440 × 900 + 1 组最终方案 + 6 个真实状态 +
+
+ +
+
+ + + + diff --git a/docs/dev/e2e-acceptance-checklist.md b/docs/dev/e2e-acceptance-checklist.md new file mode 100644 index 00000000..003af9a1 --- /dev/null +++ b/docs/dev/e2e-acceptance-checklist.md @@ -0,0 +1,229 @@ +# E2E Acceptance Checklist + +Date: 2026-06-09 + +## Scope + +This checklist tracks a hands-on acceptance pass for VideoCaptioner after the +settings/page/component refactor. It covers user-facing UI behavior, CLI/core +workflow behavior, generated artifacts, cross-theme screenshots, and code +structure. + +## Test Assets + +- Short Chinese audio: `tests/fixtures/audio/zh.mp3` +- Short English audio: `tests/fixtures/audio/en.mp3` +- Sample subtitle: `tests/fixtures/subtitle/sample_en.srt` +- Generated temporary video: `/tmp/vc-e2e-assets/input-video.mp4` +- Generated outputs: `/tmp/vc-e2e-output` +- Generated UI screenshots: `/tmp/vc-e2e-ui-dark`, `/tmp/vc-e2e-ui-light` +- Isolated config file: `/tmp/vc-e2e-config.toml` + +## Latest Run Summary + +Completed on 2026-06-09. + +Passed deterministic checks: + +- `ffmpeg -version` and `ffprobe -version`: 8.1.1. +- ASS filter probe: `True`. +- `videocaptioner doctor`: OK with two warnings only: old `yt-dlp` warning and + missing LLM API key warning. +- UI smoke: dark and light themes both returned `ui-smoke=ok`. +- CLI synthesis: + - `synth-soft.mp4` contains video, audio, and `mov_text` subtitle stream. + - `synth-hard-ass.mp4` writes a playable hard-subtitled video. + - `synth-hard-rounded.mp4` writes a playable rounded-subtitle video. +- CLI transcription: + - `bijian` succeeded once on `source-zh.mp3`. + - Bad input path returned exit code 3 with a clear "Input file not found" + message. +- Subtitle processing: + - no-op SRT read/write passed. + - LLM split/optimize/translate paths passed through mocked tests. +- Dubbing: + - TTS core, dubbing pipeline, presets, Edge provider mock, and dubbing thread + tests passed. + - SiliconFlow clone without key returned a clear `TTS API key is not + configured` error and suggested valid fixes. +- Config: + - isolated TOML init/set/get path worked. + - dummy API key was stripped in the file. + - environment value correctly overrode TOML in effective `config get` output. +- Quality gates: + - `ruff check videocaptioner tests scripts`: passed. + - `compileall videocaptioner scripts tests`: passed. + - `pytest`: `355 passed, 30 skipped, 1 warning`. + +Live external checks not proven in this environment: + +- Bing translation failed to resolve `edge.microsoft.com`. +- Edge TTS failed to resolve `speech.platform.bing.com`. +- Bcut/B interface ASR failed during full process with DNS failure for + `member.bilibili.com`. +- The short standalone `bijian` transcription succeeded once, but full process + later failed on the same external service family, so live ASR should be + treated as environment-dependent until retried on a stable network. + +Test-suite changes made during this pass: + +- `tests/test_translate/test_bing_translator.py` is now opt-in through + `RUN_BING_TRANSLATOR_TESTS=1` or `RUN_LIVE_TRANSLATION_TESTS=1`. +- `tests/test_subtitle/test_subtitle_thread.py::test_translate_bing` follows + the same opt-in rule. +- `tests/test_asr/test_bcut_asr.py` is now opt-in through + `RUN_BCUT_ASR_TESTS=1` or `RUN_LIVE_ASR_TESTS=1`. +- Removed one unused import from `videocaptioner/ui/view/subtitle_interface.py`. + +## Acceptance Matrix + +### 1. Environment And Assets + +- [x] FFmpeg exists and reports version. +- [x] FFprobe exists and reports version. +- [x] ASS/subtitles filter capability is detected. +- [x] Temporary sample video can be generated. +- [x] Temporary output directory was cleaned before test. + +### 2. Settings And Config Sync + +- [x] Settings pages render: transcribe, LLM, translate service, translate, + subtitle synthesis, dubbing, save, personal, about. +- [x] Provider-specific rows are covered by UI smoke states for settings and + dubbing provider switching. +- [x] LLM model loading and connection testing are separate UI controls in the + settings smoke screenshots. +- [x] Changed settings persist to TOML through isolated CLI config checks. +- [x] Secrets are stripped before persistence/use. +- [~] GUI reload-after-edit was not manually driven in a live window; UI smoke + verifies page construction with isolated config, and CLI/unit tests verify + store behavior. + +### 3. Page Click And Layout Smoke + +- [x] Home, task creation, transcription, subtitle, subtitle style, synthesis, + dubbing, doctor, settings, and navigation states construct without exceptions. +- [x] Dark theme screenshot sheet inspected: + `/tmp/vc-e2e-ui-dark/contact-sheet.png`. +- [x] Dark settings screenshot sheet inspected: + `/tmp/vc-e2e-ui-dark/settings-contact-sheet.png`. +- [x] Dark compact-width screenshot sheet inspected: + `/tmp/vc-e2e-ui-dark/compact-contact-sheet.png`. +- [x] Light theme screenshot sheet inspected: + `/tmp/vc-e2e-ui-light/contact-sheet.png`. +- [x] No obvious crash, clipping, bottom-border loss, or large accidental blank + bands were visible in the smoke sheets. +- [~] Product/UI debt: the home/task creation area still reads visually sparse + with large unused space. This is not a runtime blocker but should remain on + the design backlog. + +### 4. Standalone Transcription + +- [x] CLI parser accepts transcription options. +- [x] A local audio path was submitted to `bijian`; one run succeeded and wrote + `/tmp/vc-e2e-output/transcribe-bijian-plain.srt`. +- [x] Output subtitle exists and has non-empty text. +- [x] Bad input paths fail with a clear error and do not hang. +- [~] `--word-timestamps` produced a valid but very granular single-character + SRT for the short Chinese fixture. This may be acceptable for internal + alignment, but it is not a pleasant direct output format. + +### 5. Subtitle Processing + +- [x] Existing SRT can be read and written without split/optimize/translate. +- [x] Existing SRT can be split through tested LLM/mock path. +- [x] Existing SRT can be optimized through tested LLM/mock path. +- [x] Existing SRT can be translated through tested LLM/mock path. +- [x] Output SRT files are valid and keep timestamps. +- [~] Bing live translation is now opt-in and was not proven because DNS failed. + +### 6. Subtitle Style And Video Synthesis + +- [x] Subtitle preview smoke covers menu/custom/fullscreen states. +- [x] ASS style synthesis writes a playable video. +- [x] Rounded subtitle style synthesis writes a playable video. +- [x] Soft subtitle synthesis writes a playable video/container. +- [x] Extracted frames from hard ASS and rounded outputs visibly contain + subtitles. +- [x] Mode toggles did not cause obvious layout jumps in smoke screenshots. + +### 7. Dubbing And Voice Clone + +- [x] Provider switching keeps valid voice list UI states in smoke screenshots. +- [x] Edge no-key path is represented in UI and parser tests. +- [x] Gemini/SiliconFlow key-required parser/error states are covered by tests. +- [x] SiliconFlow clone UI supports reference audio/reference text states in + smoke screenshots. +- [x] Dubbing pipeline creates timeline audio with mocked TTS. +- [~] Real Edge TTS generation was not proven because DNS failed for + `speech.platform.bing.com`. +- [~] Real SiliconFlow clone generation was not proven because no live API key + check was run in this acceptance pass. + +### 8. Full Process + +- [~] Local video full process without dubbing was attempted, but current + external Bcut/B interface ASR failed on DNS for `member.bilibili.com`. +- [~] Local video full process with dubbing was attempted, but it failed at the + same ASR step before reaching Edge TTS. +- [x] Equivalent local downstream pieces were proven separately: subtitle + processing, soft/hard/rounded synthesis, and mocked dubbing. +- [ ] A single real run from input video to final dubbed video remains unproven + until live ASR and live TTS endpoints are reachable. +- [ ] URL input path was not run in this pass because network/provider DNS was + already failing for required live services. + +### 9. Code Structure Review + +- [x] `core` does not import `ui`. +- [x] `cli` only imports `ui` in the lazy `videocaptioner gui` entrypoint. +- [x] Legacy setting-card files have no runtime references. +- [x] qfluent native setting-card/config surfaces are not imported by runtime UI + code. +- [x] Provider-specific configuration is covered by adapters/config store tests. +- [x] New reusable widgets exist in `ui/components/workbench.py`, + `form_cards.py`, `settings_controls.py`, `subtitle_style_controls.py`, and + `model_manager_dialog.py`. +- [~] Large page files remain high-risk and should be split further: + `setting_interface.py`, `dubbing_interface.py`, `video_synthesis_interface.py`, + `subtitle_style_interface.py`, `subtitle_interface.py`. +- [~] Several negative-path tests intentionally log `ERROR` traces while + passing; this is behaviorally correct but noisy. + +### 10. Dialogs, Diagnostics And Model Management (added 2026-06-12) + +- [x] All in-app dialogs use the first-party `AppDialog` shell + (`ui/components/app_dialog.py`); no runtime qfluent `MessageBox` / + `MessageBoxBase` remain. Centering is asserted against the whole program + window even when the dialog is opened from a tab page + (`tests/test_ui/test_app_dialog.py` + dual-theme dialog screenshots). +- [x] The transcribe settings page has one unified 测试转录 row for all ASR + providers; a real short-audio transcription succeeded via B 接口 and local + whisper-cpp, and an invalid Whisper API key surfaced the real 401. +- [x] `doctor --check-api` runs real requests: `api.transcribe`, + `api.dubbing`, `api.download.youtube`, `api.download.bilibili`. +- [x] The doctor page resolves both download sources on each run; failures + show actionable hints (proxy for YouTube, risk-control wait / + cookies.txt for Bilibili) consistent with real download behavior. +- [x] Model manager dialog supports download / resume / delete / cancel with + a real tiny-model download verified end-to-end (SHA1 match + real + transcription through the downloaded model). + +## Re-run Commands + +```bash +.venv/bin/python scripts/ui_smoke_check.py /tmp/vc-e2e-ui-dark --theme dark +.venv/bin/python scripts/ui_smoke_check.py /tmp/vc-e2e-ui-light --theme light +.venv/bin/python -m ruff check videocaptioner tests scripts +.venv/bin/python -m compileall videocaptioner scripts tests +.venv/bin/python -m pytest +``` + +Live external tests are intentionally opt-in: + +```bash +RUN_BCUT_ASR_TESTS=1 .venv/bin/python -m pytest tests/test_asr/test_bcut_asr.py +RUN_BING_TRANSLATOR_TESTS=1 .venv/bin/python -m pytest tests/test_translate/test_bing_translator.py +RUN_LIVE_ASR_TESTS=1 .venv/bin/python -m pytest tests/test_asr/test_jianying_asr.py +RUN_GOOGLE_TRANSLATOR_TESTS=1 .venv/bin/python -m pytest tests/test_translate/test_google_translator.py +``` diff --git a/docs/dev/feedback-api.md b/docs/dev/feedback-api.md new file mode 100644 index 00000000..230d42e7 --- /dev/null +++ b/docs/dev/feedback-api.md @@ -0,0 +1,95 @@ +# 反馈提交:客户端实现说明 + +> 「意见反馈」弹窗(`ui/components/feedback_dialog.py`)收集用户的描述 + 可选截图,附带 +> 已脱敏的诊断信息和最近日志,POST 到反馈后端。 +> +> **后端契约的唯一真相源是 `vc-backend` 仓库的 `api.md`**(端点、表单字段、落表字段、Telegram +> 通知、错误码语义都以那份为准)。本文件只记录**桌面客户端这一侧**的实现决定,方便在本仓库里 +> 改反馈功能时对齐——不复述后端内部职责。 + +数据流:`桌面客户端 → 反馈后端 → 飞书多维表格(+ 维护者 Telegram 通知)`。 + +## 端点(写死,不可配) + +端点写死在 [`videocaptioner/config.py`](../../videocaptioner/config.py) 的 `FEEDBACK_API_URL`: + +``` +POST https://vc-feedback-backend.weifeng.workers.dev/api/feedback +``` + +**不走环境变量、不走配置文件**。要换端点就改这个常量。客户端代码集中在 +`videocaptioner/core/feedback/`(无 PyQt)+ `ui/components/feedback_dialog.py`(弹窗)+ +`ui/thread/feedback_thread.py`(QThread 薄壳)。 + +## 请求:始终 multipart + +后端只收 `multipart/form-data`,urlencoded 会被拒(`invalid_request`)。`requests` 在没有任何 +文件时会把 `data=` 退化成 urlencoded,所以客户端把**每个文本字段也作为 multipart part** +(`(key, (None, value))`)发送,保证无截图/无日志时请求仍是 multipart。见 +[`client.py`](../../videocaptioner/core/feedback/client.py) `FeedbackClient.submit`。 + +请求头:`X-App-Version` / `X-App-Platform` / `User-Agent`。不带 `Authorization`(后端本期 +未启用鉴权)。`X-App-Platform` 只发契约枚举 `windows-x64` / `macos-x64` / `macos-arm64` +(`platform_tag()` 把 dev/linux、windows-arm64 收敛进来)。 + +### 表单字段(客户端实际发送) + +| 字段 | 必填 | 说明 | +|---|---|---| +| `category` | 是 | `bug` / `feature` / `question` / `other` | +| `message` | 是 | 用户描述,1–5000 字符 | +| `contact` | 否 | 联系方式,≤200 字节(按字节算);为空则不发 | +| `client_id` | 是 | 匿名设备 UUID,持久化到 `APPDATA/feedback_client_id`(不入配置文件) | +| `request_id` | 是 | 每次提交新生成的 UUID,仅供排查;**后端本期不做幂等**,重试会新建一条记录 | +| `diagnostics` | 否 | 环境诊断 JSON 字符串(见下),为空则不发 | +| `files` | 否 | 截图,0–3 个,重复字段;仅 `image/png` / `image/jpeg` | +| `logs` | 否 | 最近日志附件,独立于 `files` 与 `diagnostics`;默认开启(见下) | + +### 响应 + +成功 HTTP 200 `{"ok": true, "id": "VC-0001"}`(`id` 是飞书自增编号,不回显给用户,本期也无 +状态查询接口、无 `ticket_url`)。失败 `{"ok": false, "code", "error"}`。`_parse_response` +认错误码 `invalid_request` / `unauthorized` / `too_large` / `server_error`,并对非 JSON 体按 +HTTP 状态兜底(400/401/413→对应码,其余→`server_error`)。**没有 429/限流码。** + +## 诊断信息(默认附带,已脱敏) + +[`diagnostics.py`](../../videocaptioner/core/feedback/diagnostics.py) `gather_diagnostics()` +**只读一个非敏感白名单**(provider/语言/模型名称),再过 `_is_safe` 兜底(值里含 +`key`/`token`/`sk-`/`://` 等或超 64 字符即丢弃)。实际键: + +```json +{ + "app_version": "...", "platform": "macos-arm64", "os": "...", "python": "...", + "frozen": true, "ffmpeg": "ok", "language": "zh_Hans", + "llm_service": "deepseek", "llm_model": "deepseek-chat", + "translate_service": "bing", "translate_target": "简体中文", + "dubbing_provider": "edge" +} +``` + +`gather_diagnostics` 是这些键的真相源;缺失字段后端容错。**绝不含 api_key / base_url。** + +## 最近日志(默认附带、静默、已脱敏) + +[`logs.py`](../../videocaptioner/core/feedback/logs.py) `collect_recent_logs()` 取 `app.log` +尾部(≤256KB),脱敏后作为**一个 `recent.log`(`text/plain`)** 走独立的 `logs` 表单字段 +上传。提交时默认附带、**无 UI 开关、不在界面提示**。 + +发送前 `scrub_log_text` 按硬规则「logs 不得含 API key / base URL / access token」打码: +Bearer/Basic 凭据、`sk-` 与 Google `AIza` 密钥、`key=value` 与 query `?key=` 凭据、URL 内嵌 +账密、`API Base:` / `base_url` / `endpoint` 后的地址。公共站点 URL(下载源/镜像等)保留以便 +排查。改动可审计输出的字段时要同步扩展脱敏规则与 `tests/test_feedback/test_logs.py`。 + +## 本地预校验(后端上限的真超集) + +[`models.py`](../../videocaptioner/core/feedback/models.py) `FeedbackReport.validate()`:类型 +枚举、`message` 1–5000 字符、`contact` ≤200 字节、截图/日志各 ≤3 个且单个 ≤5MB、仅 PNG/JPEG +截图。整请求 ≤12MB 的 `total` **计入文本字段 + multipart 框架余量**,使本地校验严格覆盖后端的 +整体请求大小检查(否则正好 12MB 二进制会被后端 413)。后端仍会二次校验,本地只为提前给出友好 +提示。 + +## 后端行为(以 vc-backend/api.md 为准) + +落表字段映射、飞书列名、Telegram 通知(展示截图+内容+环境摘要,**不含 logs**)、安全与防滥用、 +未来扩展,都在 `vc-backend` 的 `api.md`。本仓库不复制,避免两边漂移。 diff --git a/docs/dev/i18n-plan.md b/docs/dev/i18n-plan.md new file mode 100644 index 00000000..fca3e65b --- /dev/null +++ b/docs/dev/i18n-plan.md @@ -0,0 +1,67 @@ +# VideoCaptioner 国际化(i18n)架构 + +> 现状架构文档(已实施)。**只翻 UI(PyQt);core 与 CLI 不翻译。** +> 日常操作/维护/排错见 [`docs/dev/i18n-workflow.md`](./i18n-workflow.md)。 + +## 一、总体 + +UI 国际化用 **key-based gettext**:源码里写稳定 key(`tr("dubbing.btn.start")`),各语言文案存 +`.po`、编译成 `.mo`,运行时用 Python 标准库 `gettext` 读取。基准语言 `zh_Hans` 是 key→中文的唯一真相源; +`en`/`zh_Hant` 从它翻译,缺译时运行时回退基准中文(不显示 key)。 + +- 资源格式 = gettext `.po/.mo`(译者工具/平台通用;core/CLI 翻译零 Qt 依赖)。 +- 源串 = key(不是中文):改中文不丢译文、key 可 grep、可静态校验。 +- Qt 侧只保留一件事:启动时装 `qfluentwidgets` 的 `FluentTranslator`,让 qfluent 自带控件随语言走。 +- **范围**:只翻 UI。core 异常文案保持中文现状(`worker.error` 直接 `str(exc)`);CLI help 英文、不接 i18n。 + +## 二、组成 + +``` +videocaptioner/ui/i18n/ + __init__.py tr(key, **params) / N_(key) / init / set_language / current_language + catalog.py gettext catalog 加载/缓存;非基准语言缺译回退基准中文 +videocaptioner/ui/common/enum_labels.py + 枚举→UI 标签:程序化 enum_key(member)=f"enum.{类名}.{成员名}" + TRANSLATABLE_ENUMS + + enum_options()/enum_from_label()/enum_base_map()(不手写 dict) +resource/i18n/ + videocaptioner.pot + zh_Hans|zh_Hant|en/LC_MESSAGES/videocaptioner.po / .mo +babel.cfg 抽取配置(`[python: **.py]`;关键字 -k tr -k N_ 在 scripts/i18n.py 命令行传) +scripts/i18n.py 工具链:extract / update / fill-base / translate / compile / check / sync +``` + +- 运行时入口 `ui/main.py`:`FluentTranslator(locale)` + `init_i18n(I18N_PATH, locale.name())`(在构造主窗口前)。 +- 配置:`config.py` 的 `Language` 枚举(简体/繁体/English/跟随系统)+ `I18N_PATH`。 +- 打包:`pyproject` wheel force-include `resource/i18n`、`VideoCaptioner.spec` data 同步。 + +## 三、key 与文案约定 + +- key 命名 `<域>.<组件>.<语义>`(域=页面/模块);通用词用 `common.*`。 +- 静态文案:`tr("dom.key")`。带变量:`tr("dom.key", n=3)`,`.po` 写 `"… {n} …"`。 +- **枚举下拉**不手写标签:`options_from(...)`(定义在 `ui/components/settings_controls.py`,内部走 + `enum_label`)或 `enum_options(EnumCls)`(在 `enum_labels.py`)。新增需翻译枚举→加进 `TRANSLATABLE_ENUMS`。 +- **`tr(变量)` 的 key**(表头/状态等常量):定义处用 `N_("key")` 让 pybabel 抽到,渲染处 `tr(那个常量)`。 +- **动态拼接 key**(配音 provider/voice/tag、识别语言 `lclang.*`):由 `dubbing_options.i18n_base_map()` + / `config.source_language_i18n_map()` 注册表经 `scripts/i18n.py` 的 `_runtime_keys()` 注入 `.pot`。 +- **禁止**:`self.tr(...)`、把中文当 key(`tr("中文")`)、函数内局部 import `tr`。 + +## 四、语言切换 + +设置 → 个性化 → 界面语言。改后弹确认对话框 → `QProcess.startDetached` 重拉 + `app.quit()` 自动重启, +重启后以新语言构造界面(`setting_interface._show_restart_tip`)。**不做逐页 `retranslate`**(26 个大页面 +维护成本高、易遗漏;自动重启 100% 无遗漏,且切语言是低频操作)。`set_language()` 作为公共 API 保留, +未来若要真热切换可在其上补 `QEvent.LanguageChange` 派发。 + +## 五、工具链与 CI + +- 改了文案/key/枚举后跑 `scripts/i18n.py extract→update→fill-base→translate→compile`(细节见 workflow 文档)。 +- `translate` 用 OpenAI 兼容端点 + 结构化输出(`beta.parse`)+ 并发,端点由 `VC_TRANSLATE_*` 环境变量配置。 +- CI(`.github/workflows/ci.yml`)跑 `scripts/i18n.py check`(源码 key 集==.pot、基准 `zh_Hans` 无空译文) + + `tests/test_i18n`。改了 `tr/N_` 没同步会被自动拦住。 + +## 六、已知限制 + +- **英文文本膨胀**:部分定宽控件(设置左侧导航、窄卡片)的较长英文会省略号截断(优雅降级、不破版)。 + 本应用 Chinese-first,英文尽力而为;需要时再针对性放宽对应控件宽度。 +- `en/zh_Hant` 为机器翻译(结构化 LLM,占位符/术语已约束保留);上线前可在 Poedit 人工校对 `.po`。 +- 复数(`ngettext`)暂未使用;命名占位 `{name}` 已支持。 diff --git a/docs/dev/i18n-workflow.md b/docs/dev/i18n-workflow.md new file mode 100644 index 00000000..f7370463 --- /dev/null +++ b/docs/dev/i18n-workflow.md @@ -0,0 +1,154 @@ +# i18n 工作流与维护指南 + +VideoCaptioner 的界面国际化是 **key-based gettext**,**只翻 UI(PyQt);core 与 CLI 不翻译** +(完整架构见 `docs/dev/i18n-plan.md`)。本文是**日常怎么写、改完怎么同步、怎么发现并避免不一致**的操作手册。 + +## 0. 心智模型(先理解这个,后面都顺) + +``` +源码: tr("dubbing.btn.start") ← 只有 key,没有中文 +基准真相源: resource/i18n/zh_Hans/...po msgid=key, msgstr=中文 ← 中文住在这里 +其它语言: en / zh_Hant 的 .po msgstr=译文,从 zh_Hans 翻译而来 +运行时: 读 .mo;缺译 → 自动回退 zh_Hans 中文(不显示 key) +``` + +- **中文文案不在 Python 源码里**,在 `zh_Hans` 的 `.po`。改中文 = 改 `zh_Hans.po`(或它的来源:枚举 `.value` / 数据文件)。 +- `en`/`zh_Hant` 是**派生**的;改了中文,它们的旧译文会过时,需要主动同步(见 §3)。 +- 三种语言的 key 必须始终一致(CI 保证)。 + +## 1. 写代码(加/用文案) + +```python +from videocaptioner.ui.i18n import tr # 顶部模块级导入(禁止函数内局部导入) + +label.setText(tr("dubbing.btn.start")) # 静态文案 +label.setText(tr("hardsub.toast.done", n=3)) # 命名占位:zh_Hans.po 写 "已提取 {n} 条字幕" +``` + +- key 命名 `<域>.<组件>.<语义>`(域=页面/模块);通用词用 `common.*`。 +- **不要** `self.tr(...)`,**不要**把中文当 key(`tr("中文")`)——会污染抽取。 +- **枚举下拉不手写标签**:`options_from(...)`(`ui/components/settings_controls.py`,内部走 `enum_label`) + 或 `enum_options(EnumCls)`(`ui/common/enum_labels.py`)自动 `tr(enum.<类>.<成员>)`。 + 新增需翻译的枚举:把类加进 `enum_labels.py` 的 `TRANSLATABLE_ENUMS`。 +- **`tr(变量)` 的 key**(表头/状态等常量):在常量定义处用 `N_("key")` 包一层,pybabel 才抽得到。例: + `HEADER_KEYS = (N_("subtitle.col.start"), ...)` 然后 `tr(self.HEADER_KEYS[i])`。 +- **动态拼接的 key**(配音 provider/voice/tag、识别语言 `lclang.*`):由 + `dubbing_options.i18n_base_map()` / `config.source_language_i18n_map()` 注册表注入,无需 N_。 + +## 2. 翻译端点配置(translate 用) + +`scripts/i18n.py translate` 用 OpenAI 兼容接口 + 结构化输出(`beta.parse`)。端点由环境变量配置: + +| 变量 | 含义 | 默认 | +|---|---|---| +| `VC_TRANSLATE_API_KEY` | API Key(缺省回退 `OPENAI_API_KEY`)| — | +| `VC_TRANSLATE_BASE_URL` | OpenAI 兼容 base url | `https://api.openai.com/v1` | +| `VC_TRANSLATE_MODEL` | 模型名 | `gpt-5` | +| `VC_TRANSLATE_WORKERS` | 并发批数 | `8` | + +示例(任选其一,**key 只走环境变量,绝不写进文件**): + +```bash +# 本地网关(CherryStudio 等,gpt-5.5 官方结构化输出) +VC_TRANSLATE_API_KEY=xxx VC_TRANSLATE_BASE_URL=http://127.0.0.1:8317/v1 \ + VC_TRANSLATE_MODEL=gpt-5.5 VC_TRANSLATE_WORKERS=12 \ + .venv/bin/python scripts/i18n.py translate + +# SiliconFlow +VC_TRANSLATE_API_KEY=sk-xxx VC_TRANSLATE_BASE_URL=https://api.siliconflow.cn/v1 \ + VC_TRANSLATE_MODEL=deepseek-ai/DeepSeek-V4-Pro VC_TRANSLATE_WORKERS=12 \ + .venv/bin/python scripts/i18n.py translate +``` + +`translate` 只翻**空 msgstr**的条目(已译的不动),并发跑、缺失自动重试一次。要重译某条先清空它的 msgstr。 + +## 3. 改完之后该做什么(按场景,照着做就不会不一致) + +> 经验法则:**动了源码的 `tr/N_`、或动了枚举/数据文件 → 跑 `extract`→`update`**; +> **动了中文 → 填 `zh_Hans` → `translate` → `compile`**。最后永远 `check`。 + +### A. 加了新 UI 文案 / 新 key +```bash +.venv/bin/python scripts/i18n.py extract # 源码 tr/N_ → .pot(含新 key) +.venv/bin/python scripts/i18n.py update # .pot → 各 .po(新 key 空 msgstr) +# 填基准中文:编辑 zh_Hans.po 给新 key 写中文;或用 json 批量导入: +.venv/bin/python scripts/i18n.py fill-base /tmp/new-keys.json # {"dom.key":"中文"} +.venv/bin/python scripts/i18n.py translate # 翻 en/zh_Hant 的空条 +.venv/bin/python scripts/i18n.py compile +``` + +### B. 改了某个 key 的**中文**(key 不变) +中文在 `zh_Hans.po`,直接改它的 `msgstr`。要让 en/zh_Hant 跟进(否则它们仍是旧译文): +```bash +# 编辑 zh_Hans.po 改中文;再清空 en/zh_Hant 中该 key 的 msgstr(置为 msgstr "") +.venv/bin/python scripts/i18n.py translate # 只补空的 → 重译这几条 +.venv/bin/python scripts/i18n.py compile +``` +(不清空就不会重译——`translate` 只填空条。) + +### C. **重命名 key / 改了控件名导致 key 变**(你最担心的「名称改了怎么同步」) +改源码后 key 集变化,`extract`+`update` 会自动处理: +```bash +.venv/bin/python scripts/i18n.py extract +.venv/bin/python scripts/i18n.py update # 旧 key 被标 #~ obsolete,新 key 为空 +``` +- **新 key** 当作场景 A 处理(填中文+翻译)。 +- **旧 key** 变成 `.po` 顶部的 `#~` obsolete 注释,不影响运行;可留着(下次 lupdate 风格清理)或手动删。 +- **CI 会拦住忘记同步的情况**:`scripts/i18n.py check` 比对「源码抽出的 key 集 == 提交的 .pot」, + 改了源码没重跑 extract → key 集不一致 → CI 失败并列出差异。这就是「自动发现不一致」。 + +### D. 删了页面 / 删了文案 +源码没了 `tr`,`extract` 后那些 key 不再出现 → `update` 把它们标 obsolete。跑 `extract`+`update`+`check` 即可。 + +### E. 加了枚举成员 / 新配音 provider / voice / 新识别语言 code +这些是 §1 的注册表/数据,中文来自 `.value`/数据字段,`extract` 会经 `_runtime_keys()` 自动带上 key, +`fill-base` 自动派生中文: +```bash +.venv/bin/python scripts/i18n.py extract +.venv/bin/python scripts/i18n.py update +.venv/bin/python scripts/i18n.py fill-base /tmp/empty.json # 传 {},枚举/数据中文自动并入 +.venv/bin/python scripts/i18n.py translate +.venv/bin/python scripts/i18n.py compile +``` + +### F. 加一门新语言(例如日语 ja) +在 `scripts/i18n.py` 的 `LANGS` 加 `"ja"`、`TARGET_NAME` 加 `"ja": "Japanese"`;catalog 的 `_normalize` +按需加映射。然后 `update`→`translate ja`→`compile`。无需改任何页面代码。 + +### 一把梭 +```bash +.venv/bin/python scripts/i18n.py sync /tmp/new-keys.json +# = extract → update → fill-base(json,并自动并入枚举/数据中文) → translate(en/zh_Hant) → compile +``` + +## 4. 怎么发现不一致(排错对照表) + +| 症状 | 原因 | 处理 | +|---|---|---| +| 界面显示 `dom.xxx` 这样的 key | 该 key 在 .mo 里没有(没 fill/没 compile,或 key 拼错) | 跑 §3.A;确认 `zh_Hans.po` 有该 key 且非空,`compile` | +| 英文界面显示中文 | 该 key 的 en 未译(回退中文,正常降级) | 跑 `translate` 补 en | +| CI `i18n check` 失败:key 集不一致 | 改了源码 `tr/N_` 没重跑 extract/update | 跑 `extract`+`update`,提交 .pot/.po | +| CI `i18n check` 失败:zh_Hans 有空译文 | 新 key 没填中文 | 填 `zh_Hans.po` 或 `fill-base` | +| 下拉项不翻译 | 枚举没进 `TRANSLATABLE_ENUMS`,或代码用了 `[x.value for x in Enum]` | 加进注册表;下拉改 `options_from`/`enum_options` | +| 启动报未使用导入 / `tr` 未定义 | 加了 `tr/N_` 没在文件顶部 import | 顶部 `from videocaptioner.ui.i18n import tr`(或 `N_`) | + +**本地自查**(提交前必跑): +```bash +.venv/bin/python scripts/i18n.py check # 源码 key 集==.pot;zh_Hans 无空译文 +.venv/bin/python -m pytest tests/test_i18n -q +``` +CI(`.github/workflows/ci.yml`)会自动跑 `check` + `tests/test_i18n`,PR 漏同步会被拦住。 + +## 5. 防不一致铁律 + +1. **改了 `tr/N_`/枚举/数据文件,必跑 `extract`+`update`**,并把 `resource/i18n/**`(.pot/.po/.mo)一起提交。 +2. `.po` 和 `.mo` 都提交(pip/源码安装没有 CI 也要能用);改了 `.po` 必 `compile` 重生成 `.mo`。 +3. **基准 `zh_Hans` 不能有空译文**(CI 拦);它是唯一中文真相源。 +4. key 只在源码(`tr("...")`/`N_("...")`);中文只在 `.po`。两者别混。 +5. 提交前跑 `scripts/i18n.py check`。 + +## 6. 已知限制 + +- **英文文本膨胀**:部分定宽控件(设置左侧导航、窄卡片)的较长英文会省略号截断(优雅降级,不破版)。 + 本应用 Chinese-first,英文为尽力而为;需要时再针对性放宽对应控件宽度。 +- **en/zh_Hant 译文是机器翻译**(结构化 LLM),术语/占位符已约束保留;上线前可在 Poedit 人工校对 `.po`。 diff --git a/docs/dev/packaging-and-update-plan.md b/docs/dev/packaging-and-update-plan.md new file mode 100644 index 00000000..ac7d36b2 --- /dev/null +++ b/docs/dev/packaging-and-update-plan.md @@ -0,0 +1,113 @@ +# 打包与自动更新:现状评估 + 改造方案 + +> 调研日期 2026-06-22(5 维并行调研 + 真机实测交叉验证)。本文回答:现在还能不能打包/打出来能不能用、 +> 单文件是否可行、目录设计是否合理、更新机制怎么重构成自动更新。两块强关联(打包形态决定更新落地方式、 +> macOS 签名/公证既是「易安装」也是「自更新不被拦」的前提),一并给方案。 + +> **实现状态(2026-06-22)**:打包缺口已修(spec collect OCR/onnxruntime、CI `--extra ocr`、smoke 校验 +> bundled 负载,mac 真打包实测 OCR/下载/实时字幕可用)。自动更新已落地为 `core/update`(manifest + 下载 +> sha256 校验 + 退出后 helper 换装重启)+ 应用内「更新提示条」+ 设置页「检查更新」+ 实时公告 + CI +> `latest.json` 生成;旧 `vc.bkfeng.top` 轮询已删。 +> +> **安装形态已补(2026-06-22)**:Windows 出 Inno Setup `Setup.exe`(`packaging/windows/VideoCaptioner.iss` +> + `scripts/build_windows_installer.py`,per-user 装到 `%LOCALAPPDATA%\Programs` → 自更新照常);macOS 出 +> 拖拽安装 `.dmg`(`scripts/build_macos_dmg.py`,ad-hoc 签名规避「已损坏」硬拦截)。便携 zip 仍是自动更新 +> 下载源。**无 Apple 付费证书 → macOS 首次打开仍需右键→打开**(消除提示需证书 + 公证,本项目暂不做)。 +> 架构索引见 `AGENTS.md` 的「Software Update」;下文为设计依据,按需查阅。 + +## 一、打包现状 + +PyInstaller **onedir**:`scripts/build_desktop.py` 驱动 `VideoCaptioner.spec` → `dist/VideoCaptioner/`(含 `_internal/`), +macOS 经 `BUNDLE` 另出 `.app` + zip。 +- ffmpeg/ffprobe 由 `static-ffmpeg` 按平台下载(应用探测统一走 `ffmpeg -i`;ffprobe 仅为 pydub 配音链路保留)、`macsysaudio`(swift) 现编,staged 进 `RUNTIME_DIR/resource/bin` → spec 收进 `resource/bin`。 +- datas 收 `resource/{assets,fonts,subtitle_styles,i18n}` + `core/prompts`;版本来自 git tag(hatch-vcs→`_version.py`)。 +- CI `.github/workflows/build-desktop.yml`:tag `v*` 触发,矩阵 = **Windows x64 + macOS Intel(x86_64)**,`uv sync --frozen` → `uv run --with pyinstaller --with static-ffmpeg python scripts/build_desktop.py` → `gh release upload`。 +- **零签名零公证**(spec `codesign_identity=None`)。 + +## 二、「加了新东西后还能打包/能用吗」——缺口清单 + +| # | 项 | 状态 | 说明 | +|---|---|---|---| +| 1 | **硬字幕 OCR** | 🔴 包里不可用 | CI `uv sync --frozen` **不带 `--extra ocr`** → onnxruntime/rapidocr/rapidfuzz 根本不进构建环境;且 spec 未 `collect_data_files('rapidocr')`(config.yaml + 自带 8 个 PP-OCR `.onnx` ~46M)/`collect_dynamic_libs('onnxruntime')`。用户点硬字幕 → 「缺少 OCR 引擎依赖」,frozen 包无 pip 无法自救。**功能上线即坏。** | +| 2 | **voxgate 二进制** | 🟡 不随包 | build 只 stage ffmpeg+macsysaudio,voxgate 没收。实时字幕 voxgate 后端首启无引擎——但有运行时下载兜底(设置/诊断页点「下载」,`dependencies.py` 已注册)。fun-asr/qwen-asr 走 websocket 不受影响。 | +| 3 | **yt-dlp extractor** | 🟡 风险 | 943 个 extractor 动态加载,spec 仅 `hiddenimports=['yt_dlp']`、未 `collect_submodules('yt_dlp')` → 包里下载可能报 extractor 缺失。需真包验证。 | +| 4 | **opencv full** | 🟡 隐患 | 装的是 `opencv-python`(119M, 自带 Qt) 而非 pyproject 注释建议的 `opencv-python-headless`;体积大 + 可能与 PyQt5 的 Qt 冲突。 | +| 5 | **smoke 不覆盖新功能** | 🟡 | `smoke_desktop.py` 只测 version/style/doctor/synthesize;不验 OCR/sounddevice/voxgate/i18n → 所以 OCR 全缺 CI 也绿,缺陷潜伏。 | +| ✅ | i18n / sounddevice(PortAudio) / onnxruntime 原生库 / numpy / edge_tts / yt_dlp 纯 py / qfluentwidgets / 字体 | OK | 标准 hook / 现有 collect_submodules 覆盖(前提:依赖在构建环境里)。 | + +**修复清单(让 onedir 包真能用)** +1. `build-desktop.yml`:`uv sync --frozen` → `uv sync --frozen --extra ocr`。 +2. `VideoCaptioner.spec` 顶部 `from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules`,并补: + - `datas += collect_data_files('rapidocr')`(yaml + 自带 onnx 模型;或决定走运行时下载模型以省 46M) + - `binaries += collect_dynamic_libs('onnxruntime')` + - `hiddenimports += collect_submodules('yt_dlp')` + - `hiddenimports += ['onnxruntime','rapidocr','rapidfuzz','sounddevice','_sounddevice_data','cffi','_cffi_backend','websocket']` +3. `opencv-python` → `opencv-python-headless`(pyproject `ocr` extra 改)。 +4. voxgate:二选一——发布前把各平台预编译 voxgate stage 进 `RUNTIME_DIR/resource/bin`(与 ffmpeg 同模式 + `verify_bundle` 软校验);或接受「实时字幕首次用需下载引擎」写进发布说明。 +5. `smoke_desktop.py` / `verify_bundle` 增:`import sounddevice` 烟测、(若随包)OCR/voxgate 存在校验。 +6. 体积预期:onnxruntime ~68M + rapidocr 模型 ~46M + opencv → 包数百 MB;可接受或改运行时下载模型。 +7. CI 矩阵建议加 `macos-14`(arm64),给 Apple Silicon 原生包。 +8. `pyproject.toml:53-54` 注释「模型首次从 ModelScope 下载」已过期(rapidocr 3.x 自带模型)——同步修。 + +## 三、单文件可行性 + +**结论:不推荐做成单文件 exe / 单 Mach-O。** +- **Windows onefile**:每次启动把整包(PyQt5 + 可能的 onnxruntime + ffmpeg + 字体)解压到 `%TEMP%` → 冷启动慢、临时盘翻倍、PyInstaller 自解压壳是 Defender/国产杀软**误报重灾区**;且 `_MEIPASS` 是每次新建/退出清理的临时目录,破坏「自带 bin」稳定性。 +- **macOS 单 Mach-O**:Gatekeeper 不认、无法装订公证、双击被拦——mac「双击即用」的本质是**签名+公证的 `.app`/`.dmg`**,不是单文件。 + +**推荐形态(= 用户要的「单文件般轻松安装」)** +- **Windows**:维持 onedir + **Inno Setup**(或 NSIS)打成**单个 `.exe` 安装包**。用户感知就是「下一个文件、装好即用」,但运行时是解压好的目录 → 无 onefile 的解压/误报/启动慢。 +- **macOS**:维持 `.app` + **Developer ID 签名 + 公证(notarytool) + 装订(stapler)** → 出 **`.dmg`**。这才是 mac 标准易装形态。 + +## 四、目录设计是否合理 + +**基本合理,无需大改。** `config.py` 已正确分层: +- **可写用户态**(`APPDATA` 的 cache/log/work/model/bin、`WORK_PATH`)全走 `platformdirs`,**不在包内** → onedir / onefile / .app 三形态都成立(模型、下载的 ffmpeg/voxgate、日志都写得进)。 +- **只读资源**(`resource/*`)frozen 下走 `_MEIPASS/resource` → onedir/.app 稳定。 +- **唯一注意点**:`BUNDLED_BIN_PATH = _MEIPASS/resource/bin` 仅在 **onedir** 下是稳定路径;onefile 下它是临时目录(每次变/被清)→ 又一个不该用 onefile 的理由。维持 onedir 则此设计无需改;规约里写明「自带 bin 仅 onedir 稳定,禁 onefile」。 + +## 五、更新机制现状 + +`version_checker_thread.py` 后台 GET `https://vc.bkfeng.top/api/version`(作者私有域名)→ `QVersionNumber` 比较 → +`main_window.onNewVersion` 弹框 → 点「立即更新」只 `QDesktopServices.openUrl(download_url)` **开浏览器**让用户手动下载重装。 + +问题: +- 只通知不下载、**无应用内安装**、**无 sha/签名校验**、单 `download_url` 不分平台/架构。 +- 源是私有自建域名(单点不可控,失效=全员收不到更新;被劫持则 openUrl 无防护)。 +- dev 版 `VERSION=0.0.0-dev` 被判永远过期 → 开发期每次误弹。 +- 强制更新只禁 home/batch 两页、仍只能跳浏览器,半禁用可绕过。 +- 与产物形态脱节(onedir/.app zip,无增量、无原地更新、无回滚)。 + +## 六、更新重构方案(自动后台下载 + 应用内一键安装) + +**核心优势:项目已有可复用基建**,自更新 90% 是它们的组合,**不必引入 Sparkle/velopack/tufup**(且 mac 零签名,那些框架依赖签名生态): +- `core/download/downloader.py`:多镜像兜底 + Range 续传 + 校验 + 进度 + 取消。 +- `core/download/dependencies.py`:os×arch 矩阵 + GitHub Release + ghproxy 镜像 + 解压落地。 +- GitHub Release 发布管线已就绪。 + +**设计** +1. **manifest** `latest.json` 挂在 GitHub Release(与资产同源、天然可回滚): + ```json + {"version":"1.5.0","pub_date":"...","notes":"...","mandatory":false,"min_supported":"1.0.0", + "platforms":{"windows-x64":{"url":"...","sha256":"...","size":123,"kind":"onedir-zip"}, + "macos-x64":{"url":"...","sha256":"...","kind":"app-zip"}, + "macos-arm64":{"...回落 x64 或原生..."}}} + ``` + CI 在 `archive()` 后用脚本算 sha256/size 自动生成并 `gh release upload`。 +2. **客户端**:`VersionChecker` 改拉 manifest → 比版本 → 后台 QThread 复用 `download_file` 静默下载本平台 zip 到 `CACHE/updates`(sha256 校验 + 进度 + 取消)→ UI 按钮态 **「更新可用 → 下载中(进度) → 重启并安装」**(复用 `ConfirmDialog`/`InfoBar`/线程薄壳)。 +3. **应用阶段**(onedir → 「替换 + 重启」,运行中不能原地覆盖自身): + - **Windows**:解压到临时目录 → 写 helper `.cmd`(等主进程退出 → `robocopy` 换 `_internal`+exe → 重启)→ `QApplication.quit()`。 + - **macOS**:解压 `-app.zip` → `xattr -dr com.apple.quarantine` 新 `.app` → 替换原 `.app`(以当前运行可执行实际路径为基准)→ 重启。 +4. 配套:`downloader` 加 sha256 分支;dev 版跳过检查;`onNewVersion` 删「跳浏览器」改 manifest 流程;公告 `expire` 语义核对;强制更新给下载进度 + 失败退路。 + +**分阶段** +- **MVP**:manifest + per-platform 下载 + sha256 + 「重启并安装」(Win 批处理换目录 / mac 解压换 .app + 清 quarantine)。 +- **完善**:失败回滚(换前备份旧目录,新版自检失败则还原)、强制更新打磨、CI 加 macos-arm64、申请 Apple 签名+公证(彻底解决 Gatekeeper/quarantine,mac 自更新长期正解)、视体积上增量(zsync/bsdiff)。 +- **先不要**:Sparkle/velopack/tufup(与现状打包/签名形态错配);改 onefile(牺牲 onedir 可增量替换 + 现有 verify)。 + +## 七、关键风险 +- macOS 零签名零公证:自更新替换后的新 `.app` 带 `com.apple.quarantine` 可能被拦/打不开;MVP 必须替换后 `xattr -dr` 清除,长期正解是签名+公证。一旦公证,bundle 内 voxgate/macsysaudio/ffmpeg 也须一并签名(macsysaudio 还需 ScreenCaptureKit entitlement)。 +- onedir 运行中无法原地覆盖:靠 helper 在主进程退出后替换+重启;helper 健壮性(中文/空格路径、权限、被杀)是失败高发区,需回滚兜底。 +- 安装位置权限:Win 装 Program Files / mac 装 /Applications 替换需提权;以「当前运行可执行实际路径」为基准,不硬编码。 +- Apple Silicon 当前无原生包(CI 只产 Intel);manifest 的 macos-arm64 暂回落 Intel(Rosetta)。 +- 更新源建议与资产同源(GitHub Release),避免「版本声明 vs 实际可下文件」不一致。 diff --git a/docs/dev/qt-migration-plan.md b/docs/dev/qt-migration-plan.md new file mode 100644 index 00000000..4719178b --- /dev/null +++ b/docs/dev/qt-migration-plan.md @@ -0,0 +1,97 @@ +# qfluentwidgets / PyQt5 Removal Record + +This is the migration record for removing `PyQt-Fluent-Widgets`, removing the +hidden VLC player path, and moving the desktop UI to PySide6. + +## Baseline + +- Checkpoint commit before the migration: `55532cf chore: checkpoint shared config and ui refresh`. +- Baseline screenshots: + - `/tmp/vc-qfluent-baseline-20260609-dark/contact-sheet.png` + - `/tmp/vc-qfluent-baseline-20260609-dark/settings-contact-sheet.png` + - `/tmp/vc-qfluent-baseline-20260609-dark/compact-contact-sheet.png` + - `/tmp/vc-qfluent-baseline-20260609-light/contact-sheet.png` + - `/tmp/vc-qfluent-baseline-20260609-light/settings-contact-sheet.png` + - `/tmp/vc-qfluent-baseline-20260609-light/compact-contact-sheet.png` +- Last verified local suite before migration: + - `126 passed, 1 warning` + - `ruff check videocaptioner tests scripts` passed. + +## Current Dependency Surface + +The cutover is complete at the source/dependency level: + +- Runtime UI code imports PySide6 directly. +- Runtime UI code no longer imports `qfluentwidgets`. +- `PyQt-Fluent-Widgets`, PyQt5, PyQt5-Qt5, and PyQt5-sip are removed from + `pyproject.toml` and `uv.lock`. +- `VideoCaptioner.spec` hidden imports target PySide6 and `shiboken6`. +- The app uses first-party Qt controls from + `videocaptioner/ui/components/controls.py`. + +VLC removal has started: + +- Removed `videocaptioner/ui/components/video_widget.py`. +- Removed `videocaptioner/ui/common/signal_bus.py`, because it only served the + hidden VLC player. +- Removed `PYTHON_VLC_MODULE_PATH` setup from `videocaptioner/config.py`. +- Subtitle row clicks now only select rows; they no longer emit hidden playback + signals. + +## Target Architecture + +Use PySide6 directly and keep project-owned UI controls in one place: + +- `videocaptioner/ui/components/` + - reusable cards, setting rows, file pickers, pill/tag widgets, buttons, + progress/status rows, and form controls. +- `videocaptioner/ui/common/` + - app theme tokens, icon loading, settings state, and Qt-only helpers. +- `videocaptioner/ui/view/` + - page composition only; avoid local copies of button/card/input styling when + a reusable component exists. + +The UI must not depend on qfluentwidgets for configuration, navigation, theme, +icons, messages, buttons, cards, combo boxes, scroll areas, dialogs, or menus. + +## Completed Migration Steps + +- Added PySide6 dependency and updated packaging. +- Converted Qt imports from PyQt5 to PySide6. +- Converted `pyqtSignal` to `Signal`. +- Updated multimedia API usage: + - `QMediaPlayer.setMedia(QMediaContent(...))` became `setSource(QUrl)`. + - Preview players now use `QAudioOutput`. + - Voice-clone recording now uses `QMediaCaptureSession`, `QAudioInput`, and + `QMediaRecorder`. +- Removed qfluent runtime/dependency surface. +- Kept first-party controls clean: + - Do not introduce a qfluent-compatible shim or old class names. + - Keep generic controls in `ui/components/controls.py`. + - Keep workflow design-language atoms in `workbench.py`, form cards in + `form_cards.py`, and settings controls in `settings_controls.py`. + +## Remaining Follow-Up + +- Continue visual polish of the first-party controls, especially native-looking + combo boxes/buttons in light mode and compact navigation. +- Keep screenshot smoke tooling producing dark/light/compact contact sheets. +- Use `docs/dev/e2e-acceptance-checklist.md` for broader live-provider and + end-to-end validation. + +## Acceptance Standard + +- `rg "qfluentwidgets|PyQt-Fluent-Widgets|pyqt-fluent|python-vlc|\\bvlc\\b"` + returns no relevant project dependency or import. +- `rg "from PyQt5|import PyQt5|PyQt5-Qt5|PyQt5-sip"` returns no relevant + project dependency or import. +- `uv run videocaptioner` starts the GUI with PySide6. +- Baseline pages can be screenshot in dark, light, normal width, and compact + width. +- Core local tests and ruff pass. +- No hidden VLC player dependency remains. +- Settings values still persist to the shared TOML config and survive page + switching. +- Provider-specific fields, model loading, connection tests, dubbing preview, + subtitle processing, subtitle style preview, video synthesis, diagnostics, and + full flow still behave according to `docs/dev/e2e-acceptance-checklist.md`. diff --git a/docs/dev/translate-module.md b/docs/dev/translate-module.md index d9b6af35..d0ad93b3 100644 --- a/docs/dev/translate-module.md +++ b/docs/dev/translate-module.md @@ -5,7 +5,7 @@ ## 模块结构 ``` -app/core/translate/ +videocaptioner/core/translate/ ├── __init__.py # 模块导出 ├── types.py # 翻译器类型枚举 ├── base.py # 翻译器基类 @@ -49,7 +49,7 @@ app/core/translate/ ### 基础使用 ```python -from app.core.translate import TranslatorFactory, TranslatorType +from videocaptioner.core.translate import TranslatorFactory, TranslatorType # 创建 LLM 翻译器 translator = TranslatorFactory.create_translator( @@ -179,7 +179,7 @@ translator = TranslatorFactory.create_translator( 3. 在 `factory.py` 中注册 ```python -from app.core.translate.base import BaseTranslator +from videocaptioner.core.translate.base import BaseTranslator class MyTranslator(BaseTranslator): def _translate_chunk(self, subtitle_chunk: Dict[str, str]) -> Dict[str, str]: diff --git a/docs/dev/siliconflow-gemini-api-research.md b/docs/dev/tts-provider-research.md similarity index 100% rename from docs/dev/siliconflow-gemini-api-research.md rename to docs/dev/tts-provider-research.md diff --git a/docs/dev/update-api.md b/docs/dev/update-api.md new file mode 100644 index 00000000..afca55c3 --- /dev/null +++ b/docs/dev/update-api.md @@ -0,0 +1,207 @@ +# VideoCaptioner 更新与公告后端 API 规范 + +> 桌面客户端启动时调用本接口,一次拿到三件事:**当前版本能不能继续用、有没有新版、要不要弹公告**。 +> 这次请求同时带上匿名设备信息,用于统计用户数 / 版本分布。 +> +> 这套取代旧的「GitHub `latest.json`」:版本控制、公告、灰度、封禁全部由后端(一张飞书多维表格 + +> 一个 Worker)决定,客户端只渲染。**二进制仍放 GitHub Release**(免费稳定),后端只给下载地址。 +> +> 本文件是客户端与后端的唯一契约。一个 agent 可据此直接把后端 + 飞书表建出来。 + +--- + +## 一、客户端怎么调 + +``` +GET https:///api/update/check +``` + +- 客户端**每次启动**调一次(开发版 / pip 版 / 安装版都调)。这次调用本身就是一次使用上报。 +- 失败(超时 / 报错)客户端静默忽略,不打扰、不阻断启动。 +- 端点 URL 写死在客户端 `config.py`。 + +### 请求头 + +| Header | 示例 | 说明 | +|---|---|---| +| `X-App-Version` | `2.1.0` | 客户端版本(开发版为 `0.0.0-dev`) | +| `X-App-Platform` | `macos-arm64` | `windows-x64` / `macos-x64` / `macos-arm64` | +| `X-App-Channel` | `desktop` | `desktop`=打包版 / `dev`=源码 / `pip`=pip 安装 | +| `X-Client-Id` | `` | 匿名设备 ID,本地随机生成持久化,**与反馈接口同一个**。非个人信息 | + +`X-App-Channel` 决定后端怎么发更新: + +| channel | 含义 | 后端对 `update` 的处理 | +|---|---|---| +| `desktop` | 打包版(能自更新) | 正常下发 | +| `dev` | 源码运行(开发调试) | **正常下发**(开发者要能看到更新与公告) | +| `pip` | pip 安装(用 `pip install -U` 升级) | **`update` 一律返回 null**(不走自更新) | + +--- + +## 二、响应(HTTP 200) + +```json +{ + "block": null, + "update": { + "version": "2.2.0", + "notes": "修复若干问题;新增…", + "url": "https://github.com/WEIFENG2333/VideoCaptioner/releases/download/v2.2.0/VideoCaptioner-2.2.0-macos-arm64-app.zip", + "sha256": "…", + "size": 12345678 + }, + "announcement": { + "id": "2026-summer", + "title": "重要通知", + "content": "公告正文,支持多行纯文本。" + } +} +``` + +三个字段,都可为 `null`: + +| 字段 | 含义 | +|---|---| +| `block` | `null`=当前版本正常;**非空字符串=当前版本已被封禁**,该字符串是给用户看的原因。客户端会锁死整个应用,只留更新出口 | +| `update` | `null`=无新版(或 channel=pip);否则是当前平台的可用新版 | +| `announcement` | `null`=无公告;否则是要弹的公告 | + +`update` 子字段(仅必要项): + +| 字段 | 说明 | +|---|---| +| `version` | 目标版本号 | +| `notes` | 更新说明(纯文本,可多行) | +| `url` | 资产下载地址(GitHub Release 直链;客户端自动套国内镜像兜底) | +| `sha256` | 校验和,客户端下载后强校验 | +| `size` | 字节数(进度展示用) | + +> 安装方式(macOS 换 `.app` / Windows 换目录)由客户端按自己平台推导,无需后端下发。 + +`announcement` 子字段: + +| 字段 | 说明 | +|---|---| +| `id` | 去重键,客户端按它**只弹一次**。换内容务必换 id | +| `title` | 标题(可空,空则用默认「通知」) | +| `content` | 正文(纯文本,必填) | + +> 公告是否生效(启用、时间窗)由后端判断:满足才放进响应,否则 `announcement=null`。客户端只按 id 去重。 + +--- + +## 三、客户端会怎么做(后端需知道) + +| 响应 | 客户端行为 | +|---|---| +| `block` 非空 | 弹**不可关闭**的强制更新条 + **禁用整个应用**,显示该原因;不更新就用不了。(`dev`/`pip` 不能自更新时按钮变「前往下载」、不强行锁死) | +| `update` 非空(普通) | 顶部可关提示条,按钮「下载更新 → NN% → 重启并安装」;下载校验 sha256,安装后自动重启 | +| `update` 非空但不能自更新 | 按钮变「前往下载」开 Release 页(`dev` 调试用;`pip` 后端已返回 null 不会到这) | +| `announcement` 非空 | 按 `id` 弹一次(确认弹窗 +「知道了」) | + +--- + +## 四、发版流程(CI 自动登记,几乎零手工) + +**发新版你只做**:改版本号 → `git tag v2.2.0 && git push --tags`。其余 CI 自动完成。 + +CI 在构建并上传 zip 到 GitHub Release 后,多走一步登记: + +``` +POST https:///api/admin/release +Authorization: Bearer # 仅发版用的 CI secret +Content-Type: application/json + +{ + "version": "2.2.0", + "notes": "本次更新…", + "platforms": { + "windows-x64": {"url": "https://…/VideoCaptioner-2.2.0-windows-x64.zip", "sha256": "…", "size": 1}, + "macos-arm64": {"url": "https://…/VideoCaptioner-2.2.0-macos-arm64-app.zip","sha256": "…", "size": 1}, + "macos-x64": {"url": "https://…/VideoCaptioner-2.2.0-macos-x64-app.zip", "sha256": "…", "size": 1} + } +} +``` + +后端收到后:在「版本」表插入一行,**状态=启用、最新=勾选(同时取消其它行的"最新")、灰度=100**。 +→ 发版即「tag + push」一条龙,版本表自动更新,无需手工录入。 + +**只有这几件事才手动**(直接改飞书表,不用重新发版): + +- **延后 / 灰度放量**:把某版「灰度」从 100 调小(先放一部分),或新版先填 0 再择时调 100。 +- **封禁老版本**:把那一行「状态」改 `停用` —— 用户一打开就被挡、强制更新。 +- **发 / 改公告**:在「公告」表加一行、勾「启用」,随时生效。 + +### 后端「取最新版」逻辑(不轮询 GitHub) + +`/api/update/check` 收到请求后: + +1. 查「版本」表里 `X-App-Version` 对应行:状态=`停用` → `block` 返回封禁原因。 +2. 否则取「最新=勾选、状态=启用、灰度命中该 client_id、且版本 > 请求版本」的行 → 组 `update` 返回 + (`channel=pip` 时直接 `update=null`)。资产从该行「资产」字段按 `X-App-Platform` 取。 +3. 查「公告」表里启用且在时间窗内的公告 → 组 `announcement` 返回。 + +> 灰度命中:用 `X-Client-Id` 哈希取模和「灰度」比较,**同一设备结果稳定**(不会今天有明天没)。 + +--- + +## 五、飞书多维表格设计 + +后端就读这两张表回答 `check`。agent 可直接照建。 + +### 表「版本」 + +| 字段 | 类型 | 说明 | +|---|---|---| +| 版本号 | 文本 | 主键,如 `2.2.0`(CI 写入) | +| 状态 | 单选:`启用` / `停用` | `停用` = 封禁该版本,用户必须更新(CI 默认写 `启用`) | +| 最新 | 复选框 | 客户端比对的"最新版";CI 登记新版时勾上、清除旧版 | +| 更新说明 | 多行文本 | 给用户看的 notes(CI 写入) | +| 资产 | 多行文本(JSON) | `{"windows-x64":{"url","sha256","size"},"macos-arm64":{…},"macos-x64":{…}}`(CI 写入) | +| 灰度 | 数字 | 0–100,默认 100;放量比例 | +| 登记时间 | 日期 | CI 写入时间 | + +> 日常你只手动碰「状态」(封禁)、「灰度」(放量)、「最新」(极少)。其余 CI 自动填。 + +### 表「公告」 + +| 字段 | 类型 | 说明 | +|---|---|---| +| 公告ID | 文本 | 去重键,客户端只弹一次;换内容务必换 | +| 启用 | 复选框 | 总开关 | +| 标题 | 文本 | 可空 | +| 内容 | 多行文本 | 必填 | +| 开始日期 | 日期 | 可空,生效起(含当天) | +| 结束日期 | 日期 | 可空,生效止(含当天) | + +--- + +## 六、统计 + +每次 `check` 后端按请求头记一条:时间、`X-Client-Id`、版本、平台、渠道(可加 IP/地区)。 +由此算日活设备(按 client_id 去重)、版本分布、平台/渠道占比。 + +> Feishu 写入有频率限制,**统计不要写进多维表格**——用 Worker 的 KV / D1 / 日志另存。 +> 多维表格只放「版本」「公告」这两张人工要看要改的表。 + +--- + +## 七、安全 + +- 客户端接口(`/api/update/check`)**无鉴权**;登记接口(`/api/admin/release`)用 `CI_RELEASE_TOKEN`。 +- 响应里 `sha256` 必给,客户端强校验,防损坏 / 中间人。 +- 不含任何 API key / token / 密钥;`X-Client-Id` 是随机 UUID,非个人信息。 +- 限流:按 `X-Client-Id` + IP(启动检查频率低,阈值可宽,如单设备 60 次/小时)。 +- 字段缺失客户端一律按「无」处理,绝不崩;后端可渐进加字段。 + +--- + +## 八、交付清单(你回填) + +1. 最终域名 + 端点 URL。 +2. `CI_RELEASE_TOKEN`(发版自动登记用)。 +3. 飞书多维表格按 [§五](#五飞书多维表格设计) 建好(版本 / 公告两张表)。 +4. 确认响应字段就用 [§二](#二响应http-200) 这套;要改名先告诉我,客户端对齐。 + +字段名 / 响应格式以本文件为准;任何改动两边同步本文件。 diff --git a/docs/dev/view-structure.md b/docs/dev/view-structure.md index 144cd78d..56c4b0c4 100644 --- a/docs/dev/view-structure.md +++ b/docs/dev/view-structure.md @@ -1,24 +1,33 @@ -view/ 目录结构:用户界面 (UI) 模块 - -下面是本软件的一个主要页面结构,方便开发者查看和修改。 +view/ 目录结构:用户界面 (UI) 模块 +下面是本软件的主要页面结构,方便开发者查看和修改。 ``` -├── main_window.py ------------------ 主窗口 (应用程序框架) +├── main_window.py ------------------ 主窗口 (FluentWindow 外壳与导航) │ │ -│ └── -│ ├── home_interface.py -------- 主页窗口 (程序主界面,包含核心功能) +│ └── 导航页面: +│ ├── home_interface.py -------- 主页 (工作流标签容器) │ │ │ -│ │ └── 包含以下子功能模块: -│ │ ├── task_creation_interface.py - 任务创建窗口 -│ │ ├── transcription_interface.py - 语音转录窗口 -│ │ ├── subtitle_interface.py -------- 字幕优化窗口 -│ │ └── video_synthesis_interface.py - 视频合成窗口 +│ │ └── 包含以下工作流页面: +│ │ ├── task_creation_interface.py - 任务创建 (本地文件 / 链接下载) +│ │ ├── transcription_interface.py - 语音转录 +│ │ ├── subtitle_interface.py ------ 字幕优化与翻译 +│ │ └── video_synthesis_interface.py - 字幕视频合成 │ │ -│ ├── batch_process_interface.py ------- 批量处理窗口 -│ ├── subtitle_style_interface.py ------ 字幕样式窗口 -│ └── setting_interface.py -------------- 设置窗口 +│ ├── batch_process_interface.py ------- 批量处理 +│ ├── subtitle_style_interface.py ------ 字幕样式 +│ ├── dubbing_interface.py ------------- 配音 (音色库与试听) +│ ├── llm_logs_interface.py ------------ 请求日志 +│ ├── doctor_interface.py -------------- 诊断 +│ └── setting_interface.py ------------- 设置 (全屏 chrome) │ -├── log_window.py -------------------- 日志窗口 (独立窗口,集成在 home_interface) +├── log_window.py -------------------- 日志窗口 (task_creation 的「查看日志」入口) +``` + +配套层次: -``` \ No newline at end of file +- `ui/components/workbench.py`:工作流页面共用的设计语言组件。 +- `ui/components/app_dialog.py`:弹窗壳 `AppDialog` + 确认框 `ConfirmDialog`, + 应用内所有弹窗的统一基座(强制基于整个程序窗口居中)。 +- `ui/components/model_manager_dialog.py`:本地模型管理弹窗(设置页入口)。 +- `ui/thread/`:页面后台线程(`worker.py` 统一基类,协作式取消)。 diff --git a/docs/dev/voxgate-protocol.md b/docs/dev/voxgate-protocol.md new file mode 100644 index 00000000..a19681a6 --- /dev/null +++ b/docs/dev/voxgate-protocol.md @@ -0,0 +1,223 @@ +# voxgate `-f protocol` 原生协议调研 + +> 调研对象:`voxgate transcribe - --input-format pcm16 --stream -f protocol` +> 把 PCM 上行,把服务端下行的 send/recv 报文**逐条**打到 stdout(每行一个 JSON)。 +> +> 复现命令(实时节奏喂一段真实录音,含停顿/错字/中英混说): +> +> ```bash +> # 用既有录音当样例(16k/mono),按 100ms/块、每 100ms 一块的真实节奏喂送 +> ffmpeg -i audio.wav -f s16le -ar 16000 -ac 1 - \ +> | \ +> | voxgate transcribe - --input-format pcm16 --stream -f protocol -l zh +> ``` +> +> 直接 `cat pcm | voxgate ... --realtime` 不会真正按实时节奏(管道一次性灌满,voxgate +> 秒处理完),看不到流式 interim;必须自己按 100ms 节奏喂 stdin。 +> +> 本文依据 `/tmp/vox-rt4.tsv`(一段 ~31s、中间含停顿/错字/中英混说的真实录音)逐帧取证。 + +`-f`/`--format` 取值:`text|json|verbose_json|srt|vtt|ndjson|protocol`。我们用 **`protocol`** +(未消化的原始报文,信息最全:自带 `index` 分句 + 句级/词级时间戳)。 + +--- + +## 1. 报文信封(每行一条) + +```jsonc +{ + "direction": "send" | "recv", // 客户端发出 / 服务端下行 + "method_name": "StartTask", // 仅 send:方法名 + "message_type": "TaskStarted", // 仅 recv 控制消息:消息类型 + "request_id": "cccb303c-…", // 整个连接一个 + "task_id": "297d8c38-…", // 每条下行可能不同(服务端分配) + "status_code": 20000000, // 20000000 = OK + "status_message": "OK", + "time": "2026-06-19T11:36:13+08:00", // 本地收/发时刻 + "payload": { … }, // 仅部分 send(StartSession) + "result_json": { … } // 仅 recv 的转录结果消息 +} +``` + +`status_code == 20000000` 表示成功;其它值为错误(按错误处理,走 `on_error`)。 + +--- + +## 2. 会话生命周期 + +```text +send StartTask → recv TaskStarted (status 20000000,握手) +send StartSession → recv SessionStarted (带音频参数 + 业务开关) +(喂音频…服务端持续下行 result_json) +send FinishSession → recv SessionFinished (收尾,本次实测不带 result_json) +``` + +一次会话只有一对 Task / 一对 Session。中间全是 `result_json`。 + +### `StartSession.payload`(客户端上行的会话配置,了解即可,**我们不构造**) + +```jsonc +{ + "audio_info": { "channel": 1, "format": "raw", "sample_rate": 16000 }, + "enable_punctuation": true, // 自动标点 + "enable_speech_rejection": false, + "extra": { + "enable_asr_twopass": true, // 二次(非流式)精修 → 文本会被改写 + "enable_asr_threepass": true, // 三次精修 + "use_twopass_retry": true, + "strong_ddc": true, // 强反流畅性修正(去口水词/规整) + "remove_space_between_han_eng": true,// 中英之间去空格 + "remove_space_between_han_num": true, + "input_mode": "tool", "app_name": "…", "did": "…", "context": "" + } +} +``` + +> 关键:`enable_asr_twopass/threepass + strong_ddc` 就是**同一句文本被反复改写**的根因—— +> 流式先出快稿,二/三次再回头精修(补标点、去口水词、纠错)。消费端必须按「会被改写」设计, +> 不能假定只追加。**但改写发生在同一个 `index` 内(见下),所以分句标识依然稳定。** + +--- + +## 3. `result_json` 结构(新协议核心) + +```jsonc +{ + "extra": { + "audio_duration": 24300, // 已处理音频毫秒数 + "model_avg_rtf": 0.0844, // 实时率 + "model_total_process_time": 2053, + "req_payload": { "end_smooth_silence_proportion": 0.9 }, + "speech_adaptation_version": "1" + }, + "results": [ { …一句… }, … ] | null // null = 心跳(无内容,跳过) +} +``` + +### `results[]` —— **每条 = 一句,自带 `index`(句号)** + +新协议**不再**是「`results[0]`=全局累积、`results[1:]`=分句」那套。现在每帧的 `results` 通常 +**只有一条**,且自带 `index` 字段(0、1、2…)作为**句号**。同一句被多遍解码改写时**复用同一个 +`index`**——**`index` 就是天然的稳定 seg_id**,分句与稳定标识服务端已替我们做好。 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `index` | int | **句号 = 稳定 seg_id**。同句多次改写复用,跨帧不变 | +| `text` | string | 该句到此刻的**累积全文**(会被 twopass 改写,非纯追加) | +| `is_interim` | bool | `true`=临时(还会变);`false`=本帧不再变(见下两种) | +| `is_vad_finished` | bool | **该句 VAD 停顿结束 = 真·定稿**(句边界信号,我们认它) | +| `is_force_finished` | bool | **同句 twopass 二次冲刷**,其后同 `index` 仍继续生长,**不是定稿** | +| `start_time` / `end_time` | float(秒) | 该句覆盖的音频时间区间(相对会话起点) | +| `confidence` | float | 置信度 | +| `alternatives` | array | 候选(见下,含词级时间戳) | + +### `alternatives[0]` —— 含**词级时间戳** + +```jsonc +{ + "start_time": 1.88, "end_time": 3.219, + "text": "你好呀", + "semantic_related_to_prev": null, + "oi_decoding_info": { … }, // 内部解码信息,无需消费 + "words": [ + { "start_time": 1.88, "end_time": 3.666, "word": "你" }, + { "start_time": 2.326, "end_time": 4.113, "word": "好" }, + { "start_time": 2.773, "end_time": 4.559, "word": "呀" } + ] +} +``` + +`words[]` = 逐词时间戳。**坑**:① 每句开头常有一个垃圾占位 `{" ", -0.001, -0.001}`,需跳过; +② `word.end_time` 普遍 overshoot(超过句尾 `end_time`),是平滑估计,不能直接当词尾。 + +--- + +## 4. 一句的完整生命周期(实测,`/tmp/vox-rt4.tsv`) + +以第 0 句为例(中文 + 英文混说): + +```text +index=0 is_interim=true "你好呀" ← 生长 +index=0 is_interim=true "你好呀,你觉得今天的天气怎么样呢?我觉得今天的天气非常的不错" ← 继续长 +index=0 is_force_finished=true interim=false "…非常的不错" ← twopass 冲刷(句子没结束!) +index=0 is_interim=true "…非常的不错。i think the weather today" ← 同 index 继续长出英文 +index=0 is_vad_finished=true interim=false "…very nice。" ← 真·停顿,定稿 +``` + +第 1 句紧随其后(`index=1`,`start_time` 跳到新句起点),同样生长到 `is_vad_finished` 定稿。 +定稿帧还会**多带一条 `text:""` 的下一句占位**(`start/end=-0.001`),跳过空文本即可。 + +要点: +- **句边界 = `is_vad_finished`(真停顿)**,不按标点切——一句可含好几小句(如第 0 句中英各一段)。 +- **`is_force_finished` 绝不能当定稿**:它是 twopass 对当前 chunk 的中途冲刷,其后**同一个 + `index` 还会继续生长**。据它定稿会把句子提前收尾、丢掉后半句(如丢掉英文部分)。 +- 本次实测 `SessionFinished` **不带 result_json**,没有「整段 recap」帧;末句靠 `is_vad_finished` + 自然定稿,若末句没等到 VAD 就停,交装配器 `close()` 兜底。 + +--- + +## 4b. 🔴 连续语音的「滚动重置」(看视频/会议,无停顿)—— 致命坑 + +§4 是**短音频/带停顿**的理想情形(index 递增 + 每句 VAD)。但**连续语音**完全不同,实测取证 +(`_debug/20260619-121546/backend_events.ndjson`,118s 看视频会话): + +```text +整场 176 个 result 帧:index 自始至终只有 0;is_vad_finished 仅 1 次(结尾); +index=0 的 text 涨到 ~118 字后骤跌回 1~5 字、再涨…… 共重置 7 次。 +``` + +在连续语音下**不递增 index、整场不触发 VAD**,把整场当「一句」;但单句有 ~118 字累积上限, +到顶就**滚动重置 `text`**(丢前面、从最近重新累积)却仍复用 `index=0`。 + +**后果**:若直接拿 `index` 当 seg_id(只按 index upsert),同一句被覆盖 176 次,最后只定稿那一次 +(结尾残段)——整场 118s 只存下 1 句,前面全丢。这正是用户报的「到一定长度又重置、不另开分句、 +最终只剩一句」。 + +--- + +## 5. 我们怎么接 + +**不能拿 `index` 当 seg_id。** 后端维护自己的全局句号 `_sentence_no`,把「`index` 变化 OR +文本滚动重置」都当句边界——边界处把在途上一段补定稿、开新句: + +```python +def _is_reset(old, new): # 滚动重置:只看长度腰斩(绝不看前缀——twopass 改写会误判) + return bool(old) and len(new) * 2 < len(old) + +for r in results: + text = r.get("text") or "" + if not text.strip(): # 跳过 text:"" 空占位 + continue + idx, is_final = r.get("index", 0), bool(r.get("is_vad_finished")) + if (last_index is not None and idx != last_index) or _is_reset(cur_text, text): + if cur_text.strip(): # 边界:在途上一段补定稿,开新句 + emit(seg_id=f"voxgate#{n}", text=cur_text, is_final=True, ...); n += 1; cur_text = "" + last_index = idx + emit(seg_id=f"voxgate#{n}", text=text, is_final=is_final, start=..., end=...) + if is_final: n += 1; cur_text = "" # 自带 VAD 定稿:开新句 + else: cur_text = text +``` + +回放验证:118s 中文连续语音 dump → **8 句**、英文 dump → **5 句、零重复**、`vox-rt4` 短音频 → 仍 **2 句**。 +**`_is_reset` 只看长度腰斩,绝不看前缀**:twopass 改写(whose fault→who spots、改/补标点)会让前缀变、 +长度只降一两字,若据前缀切会把同一句的生长前缀重复定稿成多句(线上「英文开头 3 句重复」真因,dump 实测 +45→43→53… 全是 vad=0 生长帧,不是 VAD 触发)。 + +| 用途 | 取哪 | +|---|---| +| 句号 / seg_id | **自维护 `_sentence_no`**(不是 `index`!);边界 = index 变化 OR 滚动重置 | +| 当前正在说(浮窗双色) | 同句 interim 更新;装配器用最长公共前缀算双色 | +| 句定稿(落历史 + 句子时间轴) | `is_vad_finished` 帧 或 边界处补定稿:`text` + `[start_time,end_time]` | +| 词级时间戳(详情页逐词高亮,**暂未消费**) | `alternatives[0].words[]`(注意去开头占位 + end_time overshoot) | +| 跳过 | `results==null` 心跳;`text:""` 占位;`is_force_finished`(不定稿) | + +**与旧协议对比**:旧形态(`results[0]`=全局、`stream_asr_finish`、`nonstream_result`、三条 +并行流、cover-count)已**全部作废**。新二进制把分句 + 稳定 `index` + 句/词级时间戳都做进了协议, +消费端从「全局累积 + 偏移切段 / cover-count 防重复分段」简化成「按 index upsert」一行逻辑。 + +> ⚠️ 二进制必须由本仓库源码编译并放进 `resource/bin/voxgate`(BUNDLED_BIN_PATH 优先于 PATH)。 +> 旧构建若被 PATH 命中、协议对不上(仍发旧的 `stream_asr_finish` 形态)会整链行为错乱。 + +回归测试:`tests/test_realtime/test_voxgate_backend.py`(按 index upsert / force≠定稿 / VAD 定稿 / +空占位跳过)、`test_caption.py`(双色/定稿守卫)。真实帧回放:把 `/tmp/vox-rt4.tsv` 喂 +`VoxgateBackend._dispatch` → `CaptionAssembler`,应产出**恰好 2 段、零重复、时间正确**。 diff --git a/docs/guide/cookies-config.md b/docs/guide/cookies-config.md index 3c5ca852..5c5d5681 100644 --- a/docs/guide/cookies-config.md +++ b/docs/guide/cookies-config.md @@ -67,20 +67,20 @@ VideoCaptioner 的 AppData 目录通常位于: ``` -VideoCaptioner/ -├─ app/ -├─ resource/ -├─ AppData/ # Cookie 文件放这里 -│ ├─ cache/ -│ ├─ logs/ -│ ├─ models/ -│ ├─ cookies.txt # ← 将文件放在这里 -│ └─ settings.json -└─ work-dir/ +AppData/ +├─ config.toml +├─ cache/ +├─ logs/ +├─ models/ +├─ bin/ +└─ cookies.txt # ← 将文件放在这里 ``` +应用设置保存在同一个 AppData 目录下的 `config.toml`,不再写入旧 JSON 设置文件。 + :::tip 快速定位 在 VideoCaptioner 中点击 **设置 → 打开日志文件夹**,然后返回上一级目录即可看到 `AppData` 文件夹。 +也可以运行 `videocaptioner config path` 查看 `config.toml` 所在目录。 ::: ### 4. 验证配置 diff --git a/docs/guide/desktop-release.md b/docs/guide/desktop-release.md index a1554769..463b59fd 100644 --- a/docs/guide/desktop-release.md +++ b/docs/guide/desktop-release.md @@ -13,9 +13,13 @@ uv run python scripts/smoke_desktop.py dist/VideoCaptioner ``` The build script downloads static `ffmpeg` and `ffprobe` for the current platform -and bundles them under `resource/bin` inside the PyInstaller app. Runtime user data -is kept in the system user-data directory, so app upgrades do not overwrite -settings, logs, cache, models, or custom subtitle styles. +and bundles them under `resource/bin` inside the PyInstaller app. On macOS it also +builds the `macsysaudio` ScreenCaptureKit helper (`native/macsysaudio/build.sh`, +needs the Swift toolchain / Xcode CLT) and bundles it the same way, so live-caption +system-audio capture works out of the box — no download, no virtual sound card. The +helper is unsigned; users grant「屏幕录制」once on first use. Runtime user data is +kept in the system user-data directory, so app upgrades do not overwrite settings, +logs, cache, models, or custom subtitle styles. ## CI and releases diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 696372ac..5bde94b2 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -210,7 +210,7 @@ LLM 用于字幕断句、优化和翻译。软件内置了基础模型,但配 2. 拖拽视频文件到窗口,或点击选择文件 - 也可以输入 YouTube、B站等视频链接 3. 点击 **"开始全流程处理"** 按钮 -4. 等待处理完成,输出文件保存在 `work-dir/` 目录 +4. 等待处理完成,输出文件保存在默认输出目录。可在设置里修改保存目录。 ::: info 处理流程 全流程会依次执行: @@ -257,7 +257,7 @@ LLM 用于字幕断句、优化和翻译。软件内置了基础模型,但配 - **硬字幕**:烧录到视频中 - **软字幕**:内嵌字幕轨道(需要播放器支持) 4. 点击 **"开始合成"** -5. 输出视频保存在 `work-dir/` 目录 +5. 输出视频保存在默认输出目录。可在设置里修改保存目录。 ## 实用技巧 diff --git a/docs/guide/llm-config.md b/docs/guide/llm-config.md index 99516662..4840149f 100644 --- a/docs/guide/llm-config.md +++ b/docs/guide/llm-config.md @@ -150,7 +150,7 @@ SiliconCloud 对并发请求有限制,建议将 **线程数** 设置为 **5 ### API Key 安全吗? -- 所有 API Key 都保存在本地 `AppData/settings.json` 文件中 +- 所有 API Key 都保存在 VideoCaptioner AppData 目录的 `config.toml` 文件中 - 不会上传到任何服务器 - 建议定期轮换 API Key diff --git a/docs/images/preview-batch.png b/docs/images/preview-batch.png new file mode 100644 index 00000000..526f2945 Binary files /dev/null and b/docs/images/preview-batch.png differ diff --git a/docs/images/preview-dubbing.png b/docs/images/preview-dubbing.png new file mode 100644 index 00000000..2ea2fecc Binary files /dev/null and b/docs/images/preview-dubbing.png differ diff --git a/docs/images/preview-home.png b/docs/images/preview-home.png new file mode 100644 index 00000000..b374fbcd Binary files /dev/null and b/docs/images/preview-home.png differ diff --git a/docs/images/preview-subtitle.png b/docs/images/preview-subtitle.png new file mode 100644 index 00000000..5dd30c5b Binary files /dev/null and b/docs/images/preview-subtitle.png differ diff --git a/docs/images/preview-transcription.png b/docs/images/preview-transcription.png new file mode 100644 index 00000000..4736f2bf Binary files /dev/null and b/docs/images/preview-transcription.png differ diff --git a/native/macsysaudio/build.sh b/native/macsysaudio/build.sh new file mode 100755 index 00000000..3d580efe --- /dev/null +++ b/native/macsysaudio/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# 编译 macsysaudio(ScreenCaptureKit 系统声音捕获 helper)到 resource/bin/macsysaudio。 +# 仅 macOS(需 swiftc + ScreenCaptureKit,macOS 13+ SDK)。出 arm64 + x86_64 universal 二进制, +# 一份同时覆盖 Apple Silicon 与 Intel;产物连同源码一起入库(见 .gitignore 放行)。 +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +out="$repo/resource/bin/macsysaudio" +mkdir -p "$repo/resource/bin" + +frameworks=(-framework ScreenCaptureKit -framework AVFoundation -framework CoreMedia -framework Foundation) +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# 逐架构编译(min target macOS 13 = ScreenCaptureKit 起始版本),再 lipo 合并成 universal。 +# 某架构缺 SDK slice 时降级跳过(至少出当前架构),不让整体失败。 +slices=() +for arch in arm64 x86_64; do + if swiftc -O -target "${arch}-apple-macos13.0" "${frameworks[@]}" \ + -o "$tmp/macsysaudio.$arch" "$here/main.swift" 2>"$tmp/err.$arch"; then + slices+=("$tmp/macsysaudio.$arch") + else + echo "WARNING: $arch 构建失败(缺对应 SDK slice?),跳过:" >&2 + sed 's/^/ /' "$tmp/err.$arch" >&2 + fi +done + +if [ ${#slices[@]} -eq 0 ]; then + echo "ERROR: arm64 与 x86_64 均构建失败" >&2 + exit 1 +fi + +lipo -create "${slices[@]}" -output "$out" +echo "built: $out ($(lipo -archs "$out"))" diff --git a/native/macsysaudio/main.swift b/native/macsysaudio/main.swift new file mode 100644 index 00000000..345a747f --- /dev/null +++ b/native/macsysaudio/main.swift @@ -0,0 +1,142 @@ +// macsysaudio — 用 ScreenCaptureKit 捕获本机「系统输出」音频(你电脑正在播放的声音), +// 输出 16kHz / mono / s16le 原始 PCM 到 stdout,供实时字幕父进程读取(与 voxgate 同样的 +// 子进程 stdio 模型)。父进程关 stdin(EOF)即停止退出。 +// +// 需要「屏幕录制」权限(ScreenCaptureKit 的音频捕获归在该权限下)。未授权时以退出码 2 + +// stderr 标记 "PERMISSION" 退出,父进程据此提示用户去 系统设置 → 隐私与安全性 → 屏幕录制 +// 允许后重启。排除本进程自身音频,避免把我们自己的回放也录进去。 +// +// 构建:见同目录 build.sh(swiftc 链接 ScreenCaptureKit)。仅 macOS 13+。 + +import AVFoundation +import CoreMedia +import Foundation +import ScreenCaptureKit + +let kSampleRate = 16000 +let kChannels = 1 + +func logErr(_ s: String) { + FileHandle.standardError.write(Data((s + "\n").utf8)) +} + +// 退出码约定(父进程解析):2=无权限/拿不到内容 3=无显示器 4=启动失败 5=运行中出错 +func die(_ code: Int32, _ tag: String, _ detail: String = "") { + logErr(detail.isEmpty ? tag : "\(tag): \(detail)") + exit(code) +} + +final class SystemAudioGrabber: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { + private let out = FileHandle.standardOutput + private var stream: SCStream? + + func start() async { + signal(SIGPIPE, SIG_IGN) // 父进程关读端时别被 SIGPIPE 杀掉,靠写失败/EOF 自行退出 + + let content: SCShareableContent + do { + // 触发/校验屏幕录制权限;未授权会 throw。 + content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: false) + } catch { + die(2, "PERMISSION", error.localizedDescription) + return + } + guard let display = content.displays.first else { + die(3, "NO_DISPLAY") + return + } + + let filter = SCContentFilter(display: display, excludingWindows: []) + let config = SCStreamConfiguration() + config.capturesAudio = true + config.sampleRate = kSampleRate + config.channelCount = kChannels + config.excludesCurrentProcessAudio = true + // 我们只读音频;视频配到最小、低帧率,降低无谓开销(不添加 .screen 输出 → 不交付视频帧)。 + config.width = 2 + config.height = 2 + config.minimumFrameInterval = CMTime(value: 1, timescale: 1) + config.queueDepth = 6 + + let s = SCStream(filter: filter, configuration: config, delegate: self) + do { + try s.addStreamOutput( + self, type: .audio, sampleHandlerQueue: DispatchQueue(label: "vc.sysaudio")) + try await s.startCapture() + } catch { + die(4, "START_ERROR", error.localizedDescription) + return + } + self.stream = s + logErr("STARTED") // 父进程据此确认已开始(与 voxgate 的就绪信号类似) + } + + func stop() async { + if let s = stream { + try? await s.stopCapture() + stream = nil + } + } + + // 音频样本回调(在专用队列):Float32 → Int16,写 stdout。 + func stream( + _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { + guard type == .audio, CMSampleBufferDataIsReady(sampleBuffer) else { return } + var blockBuffer: CMBlockBuffer? + var abl = AudioBufferList() + let status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer( + sampleBuffer, + bufferListSizeNeededOut: nil, + bufferListOut: &abl, + bufferListSize: MemoryLayout.size, + blockBufferAllocator: nil, + blockBufferMemoryAllocator: nil, + flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, + blockBufferOut: &blockBuffer + ) + guard status == noErr, let raw = abl.mBuffers.mData else { return } + let byteCount = Int(abl.mBuffers.mDataByteSize) + let n = byteCount / MemoryLayout.size + if n == 0 { return } + let floats = raw.bindMemory(to: Float32.self, capacity: n) + var pcm = [Int16](repeating: 0, count: n) + for i in 0..=2.32.4", - "openai>=1.97.1", + "requests>=2.34.2", + "openai>=2.41.1", "diskcache>=5.6.3", - "yt-dlp>=2025.7.21", - "json-repair>=0.49.0", + "yt-dlp>=2026.6.9", + "json-repair>=0.60.1", "langdetect>=1.0.9", "pydub>=0.25.1", - "tenacity>=8.2.0", - "pillow>=12.0.0", - "fonttools>=4.61.1", - "platformdirs>=4.0.0", + "tenacity>=9.1.4", + "pillow>=12.2.0", + "fonttools>=4.63.0", + "platformdirs>=4.10.0", "tomli>=2.0.0; python_version < '3.11'", "PyQt5==5.15.11", "PyQt-Fluent-Widgets==1.8.4", - "modelscope>=1.32.0", - "psutil>=7.0.0", + "psutil>=7.2.2", "GPUtil>=1.4.0", "edge-tts>=7.2.8", + # request_logger 直接 import httpx;不能依赖 openai 的传递依赖兜底 + "httpx>=0.27.0", + # 公益大模型网关在 Cloudflare 后,需浏览器 TLS 指纹(impersonate)过 managed challenge + "curl-cffi>=0.7.0", + # 实时字幕:本地麦克风/系统音频采集(PortAudio)、流式 ASR WebSocket、电平计算 + "sounddevice>=0.4.6", + "websocket-client>=1.7.0", + "numpy>=1.26.0", ] [project.optional-dependencies] gui = [] +# 硬字幕提取(OCR)。RapidOCR = PaddleOCR 的 PP-OCR 模型转 ONNX:中英文质量好、torch-free、 +# 纯 CPU、多平台一致;rapidocr 3.x 自带 PP-OCR mobile 模型(随包/离线可用,约 46M)。opencv 是 +# rapidocr 的传递依赖(opencv_python full);体积/Qt 冲突敏感时可改 opencv-python-headless(需 uv override)。 +# rapidfuzz 缺失时代码回退 difflib。桌面打包须 uv sync --extra ocr,否则包内硬字幕不可用。 +ocr = [ + "rapidocr>=3.0", + "onnxruntime>=1.19", + "rapidfuzz>=3.0", +] [project.urls] Homepage = "https://github.com/WEIFENG2333/VideoCaptioner" @@ -62,6 +78,7 @@ build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" fallback-version = "0.0.0-dev" +raw-options = { version_scheme = "no-guess-dev" } [tool.hatch.build.hooks.vcs] version-file = "videocaptioner/_version.py" @@ -82,9 +99,13 @@ exclude = [ packages = ["videocaptioner"] [tool.hatch.build.targets.wheel.force-include] +# repo 的 resource/ 是唯一来源;安装后落到 videocaptioner/resources/, +# 与 config.py 的 _PACKAGE_RESOURCE_PATH 解析一致。fonts 必须包含: +# 默认字幕样式主字体是 LXGW WenKai,缺了它 pip 用户默认渲染就回退错字体。 "resource/assets" = "videocaptioner/resources/assets" -"resource/subtitle_style" = "videocaptioner/resources/subtitle_style" -"resource/translations" = "videocaptioner/resources/translations" +"resource/subtitle_styles" = "videocaptioner/resources/subtitle_styles" +"resource/i18n" = "videocaptioner/resources/i18n" +"resource/fonts" = "videocaptioner/resources/fonts" [tool.uv] environments = [ @@ -102,11 +123,12 @@ dev = [ "pyright>=1.1.0", "ruff>=0.4.0", "pytest>=8.0.0", + # i18n 开发期工具:抽取 .pot / 维护 .po / 编译 .mo(运行时只用标准库 gettext,不需 Babel) + "babel>=2.14.0", # GUI deps for development "PyQt5==5.15.11", "PyQt-Fluent-Widgets==1.8.4", - "modelscope>=1.32.0", - "psutil>=7.0.0", + "psutil>=7.2.2", "GPUtil>=1.4.0", ] diff --git a/resource/assets/hero-logo-mark.svg b/resource/assets/hero-logo-mark.svg new file mode 100644 index 00000000..bc7fe392 --- /dev/null +++ b/resource/assets/hero-logo-mark.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resource/assets/icons/README.md b/resource/assets/icons/README.md new file mode 100644 index 00000000..6ba33c87 --- /dev/null +++ b/resource/assets/icons/README.md @@ -0,0 +1,15 @@ +# Custom UI Icons + +Place app-owned SVG icons in this directory and load them through +`videocaptioner.ui.common.app_icons`. + +Prefer 24x24 viewBox line icons for toolbar and navigation actions. Use +`FluentIcon` first when a standard icon already exists. + +For app-owned icons: + +- add the SVG here with a 24x24 `viewBox`; +- use `currentColor` for stroke/fill color; +- register the file name in `AppIcon`; +- render it with `render_svg_icon()` or apply it to buttons with + `apply_button_icon()`. diff --git a/resource/assets/icons/add.svg b/resource/assets/icons/add.svg new file mode 100644 index 00000000..b7166cde --- /dev/null +++ b/resource/assets/icons/add.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/alignment.svg b/resource/assets/icons/alignment.svg new file mode 100644 index 00000000..6ad80cf5 --- /dev/null +++ b/resource/assets/icons/alignment.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/arrow-left.svg b/resource/assets/icons/arrow-left.svg new file mode 100644 index 00000000..c84dc6fc --- /dev/null +++ b/resource/assets/icons/arrow-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resource/assets/icons/brush.svg b/resource/assets/icons/brush.svg new file mode 100644 index 00000000..f77c4757 --- /dev/null +++ b/resource/assets/icons/brush.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/cancel.svg b/resource/assets/icons/cancel.svg new file mode 100644 index 00000000..f8c6ee7d --- /dev/null +++ b/resource/assets/icons/cancel.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/chevron-down.svg b/resource/assets/icons/chevron-down.svg new file mode 100644 index 00000000..2058b452 --- /dev/null +++ b/resource/assets/icons/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/close.svg b/resource/assets/icons/close.svg new file mode 100644 index 00000000..e5195297 --- /dev/null +++ b/resource/assets/icons/close.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/copy.svg b/resource/assets/icons/copy.svg new file mode 100644 index 00000000..a88c087a --- /dev/null +++ b/resource/assets/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/delete.svg b/resource/assets/icons/delete.svg new file mode 100644 index 00000000..2a0aaed1 --- /dev/null +++ b/resource/assets/icons/delete.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/diagnostic.svg b/resource/assets/icons/diagnostic.svg new file mode 100644 index 00000000..289ccfb8 --- /dev/null +++ b/resource/assets/icons/diagnostic.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/document.svg b/resource/assets/icons/document.svg new file mode 100644 index 00000000..e73055f9 --- /dev/null +++ b/resource/assets/icons/document.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/download.svg b/resource/assets/icons/download.svg new file mode 100644 index 00000000..1a6dab83 --- /dev/null +++ b/resource/assets/icons/download.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/edit.svg b/resource/assets/icons/edit.svg new file mode 100644 index 00000000..5ffc9862 --- /dev/null +++ b/resource/assets/icons/edit.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/file.svg b/resource/assets/icons/file.svg new file mode 100644 index 00000000..4344f56e --- /dev/null +++ b/resource/assets/icons/file.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resource/assets/icons/folder.svg b/resource/assets/icons/folder.svg new file mode 100644 index 00000000..35f50977 --- /dev/null +++ b/resource/assets/icons/folder.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/folder_add.svg b/resource/assets/icons/folder_add.svg new file mode 100644 index 00000000..7d10b176 --- /dev/null +++ b/resource/assets/icons/folder_add.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/font.svg b/resource/assets/icons/font.svg new file mode 100644 index 00000000..b31ea0c6 --- /dev/null +++ b/resource/assets/icons/font.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/font_size.svg b/resource/assets/icons/font_size.svg new file mode 100644 index 00000000..bcc2d0e8 --- /dev/null +++ b/resource/assets/icons/font_size.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/github.svg b/resource/assets/icons/github.svg new file mode 100644 index 00000000..53347462 --- /dev/null +++ b/resource/assets/icons/github.svg @@ -0,0 +1 @@ +GitHub diff --git a/resource/assets/icons/globe.svg b/resource/assets/icons/globe.svg new file mode 100644 index 00000000..8afe1927 --- /dev/null +++ b/resource/assets/icons/globe.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/hardsub.svg b/resource/assets/icons/hardsub.svg new file mode 100644 index 00000000..652c9fb4 --- /dev/null +++ b/resource/assets/icons/hardsub.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/heart.svg b/resource/assets/icons/heart.svg new file mode 100644 index 00000000..4b3c0b0d --- /dev/null +++ b/resource/assets/icons/heart.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/history.svg b/resource/assets/icons/history.svg new file mode 100644 index 00000000..6d5d5c17 --- /dev/null +++ b/resource/assets/icons/history.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/home.svg b/resource/assets/icons/home.svg new file mode 100644 index 00000000..05668dab --- /dev/null +++ b/resource/assets/icons/home.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/language.svg b/resource/assets/icons/language.svg new file mode 100644 index 00000000..201c58d8 --- /dev/null +++ b/resource/assets/icons/language.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/layout.svg b/resource/assets/icons/layout.svg new file mode 100644 index 00000000..4f910565 --- /dev/null +++ b/resource/assets/icons/layout.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/left_arrow.svg b/resource/assets/icons/left_arrow.svg new file mode 100644 index 00000000..952a24ea --- /dev/null +++ b/resource/assets/icons/left_arrow.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/link.svg b/resource/assets/icons/link.svg new file mode 100644 index 00000000..e4b8fcc2 --- /dev/null +++ b/resource/assets/icons/link.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/menu.svg b/resource/assets/icons/menu.svg new file mode 100644 index 00000000..66d2bbed --- /dev/null +++ b/resource/assets/icons/menu.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/message.svg b/resource/assets/icons/message.svg new file mode 100644 index 00000000..80d218c5 --- /dev/null +++ b/resource/assets/icons/message.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/microphone.svg b/resource/assets/icons/microphone.svg new file mode 100644 index 00000000..04c43783 --- /dev/null +++ b/resource/assets/icons/microphone.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/music.svg b/resource/assets/icons/music.svg new file mode 100644 index 00000000..598d2ea6 --- /dev/null +++ b/resource/assets/icons/music.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/palette.svg b/resource/assets/icons/palette.svg new file mode 100644 index 00000000..a0165356 --- /dev/null +++ b/resource/assets/icons/palette.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/photo.svg b/resource/assets/icons/photo.svg new file mode 100644 index 00000000..5bde7876 --- /dev/null +++ b/resource/assets/icons/photo.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/play.svg b/resource/assets/icons/play.svg new file mode 100644 index 00000000..48036d8f --- /dev/null +++ b/resource/assets/icons/play.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/right_arrow.svg b/resource/assets/icons/right_arrow.svg new file mode 100644 index 00000000..c471b31f --- /dev/null +++ b/resource/assets/icons/right_arrow.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/robot.svg b/resource/assets/icons/robot.svg new file mode 100644 index 00000000..cf5c2f17 --- /dev/null +++ b/resource/assets/icons/robot.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/save.svg b/resource/assets/icons/save.svg new file mode 100644 index 00000000..b768cf1f --- /dev/null +++ b/resource/assets/icons/save.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/search.svg b/resource/assets/icons/search.svg new file mode 100644 index 00000000..a3af7cc1 --- /dev/null +++ b/resource/assets/icons/search.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/setting.svg b/resource/assets/icons/setting.svg new file mode 100644 index 00000000..56709202 --- /dev/null +++ b/resource/assets/icons/setting.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/subtitle.svg b/resource/assets/icons/subtitle.svg new file mode 100644 index 00000000..a17ca89b --- /dev/null +++ b/resource/assets/icons/subtitle.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/sync.svg b/resource/assets/icons/sync.svg new file mode 100644 index 00000000..9dac0473 --- /dev/null +++ b/resource/assets/icons/sync.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/terminal.svg b/resource/assets/icons/terminal.svg new file mode 100644 index 00000000..8ac8ed49 --- /dev/null +++ b/resource/assets/icons/terminal.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resource/assets/icons/video.svg b/resource/assets/icons/video.svg new file mode 100644 index 00000000..23107811 --- /dev/null +++ b/resource/assets/icons/video.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/view.svg b/resource/assets/icons/view.svg new file mode 100644 index 00000000..86a8ba9c --- /dev/null +++ b/resource/assets/icons/view.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/volume.svg b/resource/assets/icons/volume.svg new file mode 100644 index 00000000..b74b8970 --- /dev/null +++ b/resource/assets/icons/volume.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/icons/zoom.svg b/resource/assets/icons/zoom.svg new file mode 100644 index 00000000..d9ce2fa1 --- /dev/null +++ b/resource/assets/icons/zoom.svg @@ -0,0 +1 @@ + diff --git a/resource/assets/logo-vector.svg b/resource/assets/logo-vector.svg new file mode 100644 index 00000000..358fe185 --- /dev/null +++ b/resource/assets/logo-vector.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/resource/assets/voice-previews/edge-cn-female.mp3 b/resource/assets/voice-previews/edge-cn-female.mp3 new file mode 100644 index 00000000..dde333a5 Binary files /dev/null and b/resource/assets/voice-previews/edge-cn-female.mp3 differ diff --git a/resource/assets/voice-previews/edge-cn-male.mp3 b/resource/assets/voice-previews/edge-cn-male.mp3 new file mode 100644 index 00000000..9e447141 Binary files /dev/null and b/resource/assets/voice-previews/edge-cn-male.mp3 differ diff --git a/resource/assets/voice-previews/edge-cn-xiaoyi.mp3 b/resource/assets/voice-previews/edge-cn-xiaoyi.mp3 new file mode 100644 index 00000000..88224b41 Binary files /dev/null and b/resource/assets/voice-previews/edge-cn-xiaoyi.mp3 differ diff --git a/resource/assets/voice-previews/edge-cn-yunjian.mp3 b/resource/assets/voice-previews/edge-cn-yunjian.mp3 new file mode 100644 index 00000000..29b473c1 Binary files /dev/null and b/resource/assets/voice-previews/edge-cn-yunjian.mp3 differ diff --git a/resource/assets/voice-previews/edge-cn-yunyang.mp3 b/resource/assets/voice-previews/edge-cn-yunyang.mp3 new file mode 100644 index 00000000..00016947 Binary files /dev/null and b/resource/assets/voice-previews/edge-cn-yunyang.mp3 differ diff --git a/resource/assets/voice-previews/edge-en-andrew.mp3 b/resource/assets/voice-previews/edge-en-andrew.mp3 new file mode 100644 index 00000000..644cd0f2 Binary files /dev/null and b/resource/assets/voice-previews/edge-en-andrew.mp3 differ diff --git a/resource/assets/voice-previews/edge-en-ava.mp3 b/resource/assets/voice-previews/edge-en-ava.mp3 new file mode 100644 index 00000000..42d7da16 Binary files /dev/null and b/resource/assets/voice-previews/edge-en-ava.mp3 differ diff --git a/resource/assets/voice-previews/edge-en-brian.mp3 b/resource/assets/voice-previews/edge-en-brian.mp3 new file mode 100644 index 00000000..f5937d9e Binary files /dev/null and b/resource/assets/voice-previews/edge-en-brian.mp3 differ diff --git a/resource/assets/voice-previews/edge-en-emma.mp3 b/resource/assets/voice-previews/edge-en-emma.mp3 new file mode 100644 index 00000000..00053b6b Binary files /dev/null and b/resource/assets/voice-previews/edge-en-emma.mp3 differ diff --git a/resource/assets/voice-previews/edge-en-female.mp3 b/resource/assets/voice-previews/edge-en-female.mp3 new file mode 100644 index 00000000..5c2aea65 Binary files /dev/null and b/resource/assets/voice-previews/edge-en-female.mp3 differ diff --git a/resource/assets/voice-previews/edge-en-libby.mp3 b/resource/assets/voice-previews/edge-en-libby.mp3 new file mode 100644 index 00000000..89b43101 Binary files /dev/null and b/resource/assets/voice-previews/edge-en-libby.mp3 differ diff --git a/resource/assets/voice-previews/edge-en-male.mp3 b/resource/assets/voice-previews/edge-en-male.mp3 new file mode 100644 index 00000000..ef801201 Binary files /dev/null and b/resource/assets/voice-previews/edge-en-male.mp3 differ diff --git a/resource/assets/voice-previews/edge-en-ryan.mp3 b/resource/assets/voice-previews/edge-en-ryan.mp3 new file mode 100644 index 00000000..abed140d Binary files /dev/null and b/resource/assets/voice-previews/edge-en-ryan.mp3 differ diff --git a/resource/assets/voice-previews/edge-hk-hiugaai.mp3 b/resource/assets/voice-previews/edge-hk-hiugaai.mp3 new file mode 100644 index 00000000..4d95a336 Binary files /dev/null and b/resource/assets/voice-previews/edge-hk-hiugaai.mp3 differ diff --git a/resource/assets/voice-previews/edge-hk-wanlung.mp3 b/resource/assets/voice-previews/edge-hk-wanlung.mp3 new file mode 100644 index 00000000..107e15e8 Binary files /dev/null and b/resource/assets/voice-previews/edge-hk-wanlung.mp3 differ diff --git a/resource/assets/voice-previews/edge-tw-hsiaoyu.mp3 b/resource/assets/voice-previews/edge-tw-hsiaoyu.mp3 new file mode 100644 index 00000000..26d84cc7 Binary files /dev/null and b/resource/assets/voice-previews/edge-tw-hsiaoyu.mp3 differ diff --git a/resource/assets/voice-previews/edge-tw-yunjhe.mp3 b/resource/assets/voice-previews/edge-tw-yunjhe.mp3 new file mode 100644 index 00000000..1f0e36fb Binary files /dev/null and b/resource/assets/voice-previews/edge-tw-yunjhe.mp3 differ diff --git a/resource/assets/voice-previews/gemini-achernar.mp3 b/resource/assets/voice-previews/gemini-achernar.mp3 new file mode 100644 index 00000000..47b81a90 Binary files /dev/null and b/resource/assets/voice-previews/gemini-achernar.mp3 differ diff --git a/resource/assets/voice-previews/gemini-algenib.mp3 b/resource/assets/voice-previews/gemini-algenib.mp3 new file mode 100644 index 00000000..f4a57764 Binary files /dev/null and b/resource/assets/voice-previews/gemini-algenib.mp3 differ diff --git a/resource/assets/voice-previews/gemini-algieba.mp3 b/resource/assets/voice-previews/gemini-algieba.mp3 new file mode 100644 index 00000000..106a5891 Binary files /dev/null and b/resource/assets/voice-previews/gemini-algieba.mp3 differ diff --git a/resource/assets/voice-previews/gemini-alnilam.mp3 b/resource/assets/voice-previews/gemini-alnilam.mp3 new file mode 100644 index 00000000..32fe2c44 Binary files /dev/null and b/resource/assets/voice-previews/gemini-alnilam.mp3 differ diff --git a/resource/assets/voice-previews/gemini-aoede.mp3 b/resource/assets/voice-previews/gemini-aoede.mp3 new file mode 100644 index 00000000..1d0ee281 Binary files /dev/null and b/resource/assets/voice-previews/gemini-aoede.mp3 differ diff --git a/resource/assets/voice-previews/gemini-autonoe.mp3 b/resource/assets/voice-previews/gemini-autonoe.mp3 new file mode 100644 index 00000000..bf83eed1 Binary files /dev/null and b/resource/assets/voice-previews/gemini-autonoe.mp3 differ diff --git a/resource/assets/voice-previews/gemini-callirrhoe.mp3 b/resource/assets/voice-previews/gemini-callirrhoe.mp3 new file mode 100644 index 00000000..766708a3 Binary files /dev/null and b/resource/assets/voice-previews/gemini-callirrhoe.mp3 differ diff --git a/resource/assets/voice-previews/gemini-charon.mp3 b/resource/assets/voice-previews/gemini-charon.mp3 new file mode 100644 index 00000000..4baca43f Binary files /dev/null and b/resource/assets/voice-previews/gemini-charon.mp3 differ diff --git a/resource/assets/voice-previews/gemini-despina.mp3 b/resource/assets/voice-previews/gemini-despina.mp3 new file mode 100644 index 00000000..bae73acb Binary files /dev/null and b/resource/assets/voice-previews/gemini-despina.mp3 differ diff --git a/resource/assets/voice-previews/gemini-en-friendly.mp3 b/resource/assets/voice-previews/gemini-en-friendly.mp3 new file mode 100644 index 00000000..37484966 Binary files /dev/null and b/resource/assets/voice-previews/gemini-en-friendly.mp3 differ diff --git a/resource/assets/voice-previews/gemini-en-neutral.mp3 b/resource/assets/voice-previews/gemini-en-neutral.mp3 new file mode 100644 index 00000000..2ae45aa1 Binary files /dev/null and b/resource/assets/voice-previews/gemini-en-neutral.mp3 differ diff --git a/resource/assets/voice-previews/gemini-en-upbeat.mp3 b/resource/assets/voice-previews/gemini-en-upbeat.mp3 new file mode 100644 index 00000000..0f015c56 Binary files /dev/null and b/resource/assets/voice-previews/gemini-en-upbeat.mp3 differ diff --git a/resource/assets/voice-previews/gemini-enceladus.mp3 b/resource/assets/voice-previews/gemini-enceladus.mp3 new file mode 100644 index 00000000..6457f785 Binary files /dev/null and b/resource/assets/voice-previews/gemini-enceladus.mp3 differ diff --git a/resource/assets/voice-previews/gemini-erinome.mp3 b/resource/assets/voice-previews/gemini-erinome.mp3 new file mode 100644 index 00000000..7f4d6609 Binary files /dev/null and b/resource/assets/voice-previews/gemini-erinome.mp3 differ diff --git a/resource/assets/voice-previews/gemini-fenrir.mp3 b/resource/assets/voice-previews/gemini-fenrir.mp3 new file mode 100644 index 00000000..a35d4864 Binary files /dev/null and b/resource/assets/voice-previews/gemini-fenrir.mp3 differ diff --git a/resource/assets/voice-previews/gemini-gacrux.mp3 b/resource/assets/voice-previews/gemini-gacrux.mp3 new file mode 100644 index 00000000..506189c9 Binary files /dev/null and b/resource/assets/voice-previews/gemini-gacrux.mp3 differ diff --git a/resource/assets/voice-previews/gemini-iapetus.mp3 b/resource/assets/voice-previews/gemini-iapetus.mp3 new file mode 100644 index 00000000..51987d17 Binary files /dev/null and b/resource/assets/voice-previews/gemini-iapetus.mp3 differ diff --git a/resource/assets/voice-previews/gemini-laomedeia.mp3 b/resource/assets/voice-previews/gemini-laomedeia.mp3 new file mode 100644 index 00000000..c9e82dbd Binary files /dev/null and b/resource/assets/voice-previews/gemini-laomedeia.mp3 differ diff --git a/resource/assets/voice-previews/gemini-leda.mp3 b/resource/assets/voice-previews/gemini-leda.mp3 new file mode 100644 index 00000000..9289e7db Binary files /dev/null and b/resource/assets/voice-previews/gemini-leda.mp3 differ diff --git a/resource/assets/voice-previews/gemini-orus.mp3 b/resource/assets/voice-previews/gemini-orus.mp3 new file mode 100644 index 00000000..238f6b87 Binary files /dev/null and b/resource/assets/voice-previews/gemini-orus.mp3 differ diff --git a/resource/assets/voice-previews/gemini-pulcherrima.mp3 b/resource/assets/voice-previews/gemini-pulcherrima.mp3 new file mode 100644 index 00000000..f139b269 Binary files /dev/null and b/resource/assets/voice-previews/gemini-pulcherrima.mp3 differ diff --git a/resource/assets/voice-previews/gemini-rasalgethi.mp3 b/resource/assets/voice-previews/gemini-rasalgethi.mp3 new file mode 100644 index 00000000..6f0b9a6f Binary files /dev/null and b/resource/assets/voice-previews/gemini-rasalgethi.mp3 differ diff --git a/resource/assets/voice-previews/gemini-sadachbia.mp3 b/resource/assets/voice-previews/gemini-sadachbia.mp3 new file mode 100644 index 00000000..a1f85b37 Binary files /dev/null and b/resource/assets/voice-previews/gemini-sadachbia.mp3 differ diff --git a/resource/assets/voice-previews/gemini-sadaltager.mp3 b/resource/assets/voice-previews/gemini-sadaltager.mp3 new file mode 100644 index 00000000..314bbf0a Binary files /dev/null and b/resource/assets/voice-previews/gemini-sadaltager.mp3 differ diff --git a/resource/assets/voice-previews/gemini-schedar.mp3 b/resource/assets/voice-previews/gemini-schedar.mp3 new file mode 100644 index 00000000..10a7dbae Binary files /dev/null and b/resource/assets/voice-previews/gemini-schedar.mp3 differ diff --git a/resource/assets/voice-previews/gemini-sulafat.mp3 b/resource/assets/voice-previews/gemini-sulafat.mp3 new file mode 100644 index 00000000..5a08111b Binary files /dev/null and b/resource/assets/voice-previews/gemini-sulafat.mp3 differ diff --git a/resource/assets/voice-previews/gemini-umbriel.mp3 b/resource/assets/voice-previews/gemini-umbriel.mp3 new file mode 100644 index 00000000..69ce72bf Binary files /dev/null and b/resource/assets/voice-previews/gemini-umbriel.mp3 differ diff --git a/resource/assets/voice-previews/gemini-vindemiatrix.mp3 b/resource/assets/voice-previews/gemini-vindemiatrix.mp3 new file mode 100644 index 00000000..1b845afa Binary files /dev/null and b/resource/assets/voice-previews/gemini-vindemiatrix.mp3 differ diff --git a/resource/assets/voice-previews/gemini-zephyr.mp3 b/resource/assets/voice-previews/gemini-zephyr.mp3 new file mode 100644 index 00000000..e60807c8 Binary files /dev/null and b/resource/assets/voice-previews/gemini-zephyr.mp3 differ diff --git a/resource/assets/voice-previews/gemini-zubenelgenubi.mp3 b/resource/assets/voice-previews/gemini-zubenelgenubi.mp3 new file mode 100644 index 00000000..554afd60 Binary files /dev/null and b/resource/assets/voice-previews/gemini-zubenelgenubi.mp3 differ diff --git a/resource/assets/voice-previews/siliconflow-cn-bella.mp3 b/resource/assets/voice-previews/siliconflow-cn-bella.mp3 new file mode 100644 index 00000000..b1cfb8b6 Binary files /dev/null and b/resource/assets/voice-previews/siliconflow-cn-bella.mp3 differ diff --git a/resource/assets/voice-previews/siliconflow-cn-charles.mp3 b/resource/assets/voice-previews/siliconflow-cn-charles.mp3 new file mode 100644 index 00000000..6867a391 Binary files /dev/null and b/resource/assets/voice-previews/siliconflow-cn-charles.mp3 differ diff --git a/resource/assets/voice-previews/siliconflow-cn-claire.mp3 b/resource/assets/voice-previews/siliconflow-cn-claire.mp3 new file mode 100644 index 00000000..8cd6aedb Binary files /dev/null and b/resource/assets/voice-previews/siliconflow-cn-claire.mp3 differ diff --git a/resource/assets/voice-previews/siliconflow-cn-david.mp3 b/resource/assets/voice-previews/siliconflow-cn-david.mp3 new file mode 100644 index 00000000..9da59258 Binary files /dev/null and b/resource/assets/voice-previews/siliconflow-cn-david.mp3 differ diff --git a/resource/assets/voice-previews/siliconflow-cn-deep-male.mp3 b/resource/assets/voice-previews/siliconflow-cn-deep-male.mp3 new file mode 100644 index 00000000..d2708862 Binary files /dev/null and b/resource/assets/voice-previews/siliconflow-cn-deep-male.mp3 differ diff --git a/resource/assets/voice-previews/siliconflow-cn-diana.mp3 b/resource/assets/voice-previews/siliconflow-cn-diana.mp3 new file mode 100644 index 00000000..5c1919c9 Binary files /dev/null and b/resource/assets/voice-previews/siliconflow-cn-diana.mp3 differ diff --git a/resource/assets/voice-previews/siliconflow-cn-female.mp3 b/resource/assets/voice-previews/siliconflow-cn-female.mp3 new file mode 100644 index 00000000..fead883c Binary files /dev/null and b/resource/assets/voice-previews/siliconflow-cn-female.mp3 differ diff --git a/resource/assets/voice-previews/siliconflow-cn-male.mp3 b/resource/assets/voice-previews/siliconflow-cn-male.mp3 new file mode 100644 index 00000000..59c0ca73 Binary files /dev/null and b/resource/assets/voice-previews/siliconflow-cn-male.mp3 differ diff --git a/resource/bin/macsysaudio b/resource/bin/macsysaudio new file mode 100755 index 00000000..b134264f Binary files /dev/null and b/resource/bin/macsysaudio differ diff --git a/resource/i18n/en/LC_MESSAGES/videocaptioner.mo b/resource/i18n/en/LC_MESSAGES/videocaptioner.mo new file mode 100644 index 00000000..5c71cc11 Binary files /dev/null and b/resource/i18n/en/LC_MESSAGES/videocaptioner.mo differ diff --git a/resource/i18n/en/LC_MESSAGES/videocaptioner.po b/resource/i18n/en/LC_MESSAGES/videocaptioner.po new file mode 100644 index 00000000..064df2b1 --- /dev/null +++ b/resource/i18n/en/LC_MESSAGES/videocaptioner.po @@ -0,0 +1,4624 @@ +# English translations for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-06-25 20:45+0800\n" +"PO-Revision-Date: 2026-06-21 23:08+0800\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +msgid "app.announcement.got_it" +msgstr "Got it" + +msgid "app.announcement.title" +msgstr "Announcement" + +msgid "app.ffmpeg.missing_body" +msgstr "" +"FFmpeg is required to process audio and video files. Please install it " +"first." + +msgid "app.ffmpeg.missing_title" +msgstr "FFmpeg is not installed" + +msgid "app.github.body" +msgstr "" +"VideoCaptioner was independently developed by me in my spare time and is " +"currently hosted on GitHub. Stars and forks are welcome. The project " +"still has many areas for improvement. If you encounter any software " +"issues or BUGs, please submit an Issue.\n" +"\n" +" https://github.com/WEIFENG2333/VideoCaptioner" + +msgid "app.github.open" +msgstr "Open GitHub" + +msgid "app.github.support_author" +msgstr "Support the Author" + +msgid "app.github.title" +msgstr "GitHub Info" + +msgid "app.nav.batch" +msgstr "Batch Processing" + +msgid "app.nav.doctor" +msgstr "Diagnostics" + +msgid "app.nav.dubbing" +msgstr "Dubbing" + +msgid "app.nav.hardsub" +msgstr "Hard Subtitle Extraction" + +msgid "app.nav.home" +msgstr "Home" + +msgid "app.nav.live_caption" +msgstr "Live Subtitles" + +msgid "app.nav.request_logs" +msgstr "Request Logs" + +msgid "app.nav.settings" +msgstr "Settings" + +msgid "app.nav.subtitle_style" +msgstr "Subtitle Style" + +msgid "app.sidebar.collapse" +msgstr "Collapse" + +msgid "app.sidebar.expand" +msgstr "Expand" + +msgid "app.update.available" +msgstr "A new version is available" + +msgid "app.update.cancel" +msgstr "Cancel" + +msgid "app.update.check_failed" +msgstr "Update check failed" + +msgid "app.update.checking" +msgstr "Checking for updates…" + +msgid "app.update.download" +msgstr "Download update" + +msgid "app.update.downloading" +msgstr "Downloading {percent}%" + +msgid "app.update.failed" +msgstr "Download failed: {error}" + +msgid "app.update.go_download" +msgstr "Go to download" + +msgid "app.update.install" +msgstr "Restart & install" + +msgid "app.update.install_failed" +msgstr "Install failed" + +msgid "app.update.mandatory" +msgstr "You must update to the latest version to continue" + +msgid "app.update.ready" +msgstr "Download complete" + +msgid "app.update.retry" +msgstr "Retry" + +msgid "app.update.title" +msgstr "Update available: {version}" + +msgid "app.update.up_to_date" +msgstr "You're up to date" + +msgid "app.window_title" +msgstr "Kaka Subtitle Assistant -- VideoCaptioner" + +msgid "batch.added.count" +msgstr "Added {n} files" + +msgid "batch.added.duplicated" +msgstr "{n} already in queue" + +msgid "batch.added.ignored" +msgstr "Ignored {n} unsupported files" + +msgid "batch.added.title" +msgstr "Added successfully" + +msgid "batch.btn.add_file" +msgstr "Add files" + +msgid "batch.btn.add_folder" +msgstr "Add folder" + +msgid "batch.btn.clear" +msgstr "Clear list" + +msgid "batch.btn.pause" +msgstr "Pause queue" + +msgid "batch.btn.resume" +msgstr "Resume processing" + +msgid "batch.btn.start" +msgstr "Start processing" + +msgid "batch.cannot_retry" +msgstr "Cannot retry" + +msgid "batch.cannot_start" +msgstr "Cannot start" + +msgid "batch.concurrency" +msgstr "Concurrency {n}" + +msgid "batch.count" +msgstr "{n} tasks" + +msgid "batch.detail.error" +msgstr "" +"Error message:\n" +"{error}" + +msgid "batch.detail.file" +msgstr "File: {path}" + +msgid "batch.detail.got_it" +msgstr "Got it" + +msgid "batch.detail.outputs" +msgstr "Output file:" + +msgid "batch.detail.progress" +msgstr "Progress: {note}" + +msgid "batch.detail.status" +msgstr "Status: {status} · {progress}%" + +msgid "batch.detail.title" +msgstr "Task details" + +msgid "batch.dialog.pick_files" +msgstr "Select file" + +msgid "batch.dialog.pick_folder" +msgstr "Select folder" + +msgid "batch.done.body" +msgstr "All {completed} tasks completed" + +msgid "batch.done.title" +msgstr "Batch processing completed" + +msgid "batch.drop.format" +msgstr "Current mode: {mode} · Supports {kinds}; folders can be dropped in" + +msgid "batch.drop.media_kinds" +msgstr "Audio, video" + +msgid "batch.drop.subtitle_kinds" +msgstr "Subtitle files" + +msgid "batch.drop.title" +msgstr "Drop files or folders here" + +msgid "batch.empty.duplicated" +msgstr "File is already in the queue" + +msgid "batch.empty.no_media" +msgstr "No supported audio/video files found" + +msgid "batch.empty.no_subtitle" +msgstr "No subtitle files found" + +msgid "batch.empty.title" +msgstr "No files added" + +msgid "batch.error.empty_output" +msgstr "{stage}: Output path is empty" + +msgid "batch.error.empty_video_output" +msgstr "{stage}: Output video path is empty" + +msgid "batch.filter.all" +msgstr "All" + +msgid "batch.filter.empty" +msgstr "No tasks match the current filter" + +msgid "batch.filter.media" +msgstr "Audio/video files ({patterns})" + +msgid "batch.filter.subtitle" +msgstr "Subtitle files ({patterns})" + +msgid "batch.filtered.body" +msgstr "" +"{n} files do not match the \"{mode}\" input type and have been removed " +"from the queue" + +msgid "batch.filtered.title" +msgstr "Queue filtered" + +msgid "batch.finished.body" +msgstr "{completed} completed, {failed} failed. You can retry them in the list." + +msgid "batch.finished.title" +msgstr "Batch processing completed" + +msgid "batch.metric.concurrency" +msgstr "Concurrency" + +msgid "batch.metric.progress" +msgstr "Current progress" + +msgid "batch.metric.queue" +msgstr "Queued tasks" + +msgid "batch.metric.success_rate" +msgstr "Success rate" + +msgid "batch.mode.full.desc" +msgstr "Transcribe, translate, synthesize final video" + +msgid "batch.mode.full.title" +msgstr "Full process" + +msgid "batch.mode.subtitle.desc" +msgstr "Optimize and translate existing subtitles" + +msgid "batch.mode.subtitle.title" +msgstr "Batch subtitle translation" + +msgid "batch.mode.trans_sub.desc" +msgstr "Generate subtitles, translate and optimize" + +msgid "batch.mode.trans_sub.title" +msgstr "Transcription + subtitles" + +msgid "batch.mode.transcribe.desc" +msgstr "Generate subtitles from audio/video" + +msgid "batch.mode.transcribe.title" +msgstr "Batch Transcription" + +msgid "batch.note.completed" +msgstr "Processing Complete" + +msgid "batch.note.failed" +msgstr "Processing Failed" + +msgid "batch.note.output" +msgstr "Output {name}" + +msgid "batch.panel.ended" +msgstr "Ended" + +msgid "batch.panel.not_started" +msgstr "Not Started" + +msgid "batch.panel.paused" +msgstr "Paused" + +msgid "batch.panel.ready" +msgstr "Ready to Start" + +msgid "batch.panel.running" +msgstr "Running" + +msgid "batch.panel.title" +msgstr "This Batch" + +msgid "batch.preflight.dubbing_key" +msgstr "" +"The current voice requires an API Key. Please check the voice " +"configuration or switch to a free Edge voice." + +msgid "batch.preflight.ffmpeg" +msgstr "" +"Video composition requires FFmpeg: please install it and ensure ffmpeg is" +" in PATH." + +msgid "batch.preflight.ffprobe" +msgstr "" +"Dubbing needs ffprobe for audio processing. Please install the full " +"FFmpeg suite (with ffprobe)." + +msgid "batch.preflight.llm" +msgstr "" +"Subtitle processing requires a large model: please configure a valid API " +"Key, API URL, and model first." + +msgid "batch.row.details_tip" +msgstr "Click to view task details" + +msgid "batch.row.open_output" +msgstr "Open output folder" + +msgid "batch.row.remove" +msgstr "Remove task" + +msgid "batch.row.retry" +msgstr "Retry task" + +msgid "batch.stage.dubbing.desc" +msgstr "Generate audio track from subtitles" + +msgid "batch.stage.dubbing.title" +msgstr "Dubbing" + +msgid "batch.stage.settings_tip" +msgstr "{title} Settings" + +msgid "batch.stage.subtitle.desc" +msgstr "Sentence Splitting, Optimization, Translation" + +msgid "batch.stage.subtitle.title" +msgstr "Subtitle Processing" + +msgid "batch.stage.synthesis.desc" +msgstr "Export Final Video" + +msgid "batch.stage.synthesis.title" +msgstr "Video Composition" + +msgid "batch.stage.transcribe.desc" +msgstr "Generate Original Subtitles" + +msgid "batch.stage.transcribe.title" +msgstr "Speech Transcription" + +msgid "batch.status.completed" +msgstr "Completed" + +msgid "batch.status.failed" +msgstr "Failed" + +msgid "batch.status.preparing" +msgstr "Preparing" + +msgid "batch.status.running" +msgstr "Processing" + +msgid "batch.status.waiting" +msgstr "Waiting" + +msgid "batch.subtitle.done" +msgstr "Batch Task Completed" + +msgid "batch.subtitle.done_failed" +msgstr "Batch task completed, {n} files need processing" + +msgid "batch.subtitle.empty" +msgstr "Drag in a batch of files, select a processing type, then start" + +msgid "batch.subtitle.paused" +msgstr "Paused · Will stop after the current task finishes" + +msgid "batch.subtitle.ready" +msgstr "{n} files added to the queue" + +msgid "batch.subtitle.running" +msgstr "Processing in queue order" + +msgid "batch.switch.busy_body" +msgstr "" +"Please pause and wait for the current task to finish before switching " +"processing type" + +msgid "batch.switch.busy_title" +msgstr "Processing" + +msgid "batch.switched.body" +msgstr "Switch to “{mode}” based on file type" + +msgid "batch.switched.title" +msgstr "Processing type switched" + +msgid "batch.title" +msgstr "Batch processing" + +msgid "colorpicker.clear" +msgstr "Clear" + +msgid "colorpicker.section.presets" +msgstr "Common subtitle colors" + +msgid "colorpicker.section.recent" +msgstr "Recently used" + +msgid "colorpicker.title" +msgstr "Select color" + +msgid "common.cancel" +msgstr "Cancel" + +msgid "common.close" +msgstr "Close" + +msgid "common.copy" +msgstr "Copy" + +msgid "common.delete" +msgstr "Delete" + +msgid "common.error" +msgstr "Error" + +msgid "common.ok" +msgstr "OK" + +msgid "common.open_folder" +msgstr "Open folder" + +msgid "common.retry" +msgstr "Retry" + +msgid "common.save" +msgstr "Save" + +msgid "common.tip" +msgstr "Tip" + +msgid "common.warning" +msgstr "Warning" + +msgid "ctrl.change" +msgstr "Change" + +msgid "ctrl.open" +msgstr "Open" + +msgid "ctrl.settings_title" +msgstr "Settings" + +msgid "depdl.body.intro" +msgstr "" +"Automatically select the appropriate version for your system and download" +" it locally; domestic networks will prioritize accelerated mirrors." + +msgid "depdl.btn.done" +msgstr "Done" + +msgid "depdl.btn.download" +msgstr "Download" + +msgid "depdl.btn.install_all_missing" +msgstr "Install missing dependencies" + +msgid "depdl.btn.open_install_dir" +msgstr "Open installation folder" + +msgid "depdl.error.download_failed" +msgstr "Download failed" + +msgid "depdl.info.done_body" +msgstr "{name} download complete." + +msgid "depdl.info.nothing_body" +msgstr "All dependencies are ready." + +msgid "depdl.info.nothing_title" +msgstr "No installation needed" + +msgid "depdl.status.connecting" +msgstr "Connecting to mirror…" + +msgid "depdl.status.failed" +msgstr "Failed" + +msgid "depdl.status.installed" +msgstr "Installed" + +msgid "depdl.status.unsupported" +msgstr "Not supported yet" + +msgid "depdl.title" +msgstr "Download runtime dependencies" + +msgid "doctor.btn.download_install" +msgstr "Download and install" + +msgid "doctor.btn.download_voxgate" +msgstr "Download voxgate" + +msgid "doctor.btn.dubbing_settings" +msgstr "Voiceover settings" + +msgid "doctor.btn.how_to_handle" +msgstr "Processing method" + +msgid "doctor.btn.install_tool" +msgstr "Install tools" + +msgid "doctor.btn.live_caption_settings" +msgstr "Live Subtitle Settings" + +msgid "doctor.btn.llm_settings" +msgstr "LLM Settings" + +msgid "doctor.btn.rerun" +msgstr "Diagnose Again" + +msgid "doctor.btn.run" +msgstr "Run Diagnostics" + +msgid "doctor.btn.running" +msgstr "Diagnosing" + +msgid "doctor.btn.transcribe_settings" +msgstr "Transcription Settings" + +msgid "doctor.btn.translate_settings" +msgstr "Translation Settings" + +msgid "doctor.btn.usage" +msgstr "Instructions" + +msgid "doctor.checklist" +msgstr "Checklist" + +msgid "doctor.chip.dubbing" +msgstr "Dubbing" + +msgid "doctor.chip.export" +msgstr "Export" + +msgid "doctor.chip.subtitle" +msgstr "Subtitle Processing" + +msgid "doctor.chip.transcribe" +msgstr "Transcription" + +msgid "doctor.chip.translate" +msgstr "Translation" + +msgid "doctor.done.all_passed" +msgstr "All current checks passed" + +msgid "doctor.done.errors" +msgstr "{count} items need attention" + +msgid "doctor.done.title" +msgstr "Diagnostics complete" + +msgid "doctor.done.warnings" +msgstr "{count} optional items need attention (does not affect the main workflow)" + +msgid "doctor.download" +msgstr "Video Download" + +msgid "doctor.download.desc" +msgstr "" +"Parse YouTube and Bilibili links and verify whether online videos can be " +"downloaded." + +msgid "doctor.download.ok" +msgstr "" +"YouTube and Bilibili parsing is working. Paste a link to download " +"directly." + +msgid "doctor.download.ok_login" +msgstr "" +"YouTube and Bilibili parsing is working (some sites use browser login " +"state). Paste a link to download directly." + +msgid "doctor.download.unavailable" +msgstr "{detail}" + +msgid "doctor.dubbing.desc" +msgstr "" +"Generate voiceover using the current provider and voice; some services " +"require a Key." + +msgid "doctor.dubbing.fail.desc" +msgstr "" +"Gemini / SiliconFlow requires a voiceover Key; Edge does not require a " +"Key." + +msgid "doctor.dubbing.ok.desc" +msgstr "" +"The current voiceover configuration is available. You can continue " +"generating voiceover." + +msgid "doctor.dubbing.title" +msgstr "Voiceover Service" + +msgid "doctor.failed.title" +msgstr "Diagnosis failed" + +msgid "doctor.ffmpeg.desc" +msgstr "" +"It is required to generate videos, burn in subtitles, and merge " +"voiceovers." + +msgid "doctor.ffmpeg.missing.desc" +msgstr "" +"Without it, videos cannot be generated, subtitles cannot be burned in, or" +" voiceovers merged." + +msgid "doctor.ffmpeg.missing.title" +msgstr "Missing FFmpeg" + +msgid "doctor.ffmpeg.no_ass.desc" +msgstr "" +"The current FFmpeg is missing the ASS subtitle filter. Please install the" +" full version, or switch the subtitle rendering mode to rounded " +"background." + +msgid "doctor.ffmpeg.no_ass.title" +msgstr "FFmpeg does not support ASS hard subtitles" + +msgid "doctor.ffmpeg.ok.desc" +msgstr "Tools are complete. You can generate videos and voiceover videos." + +msgid "doctor.help.download" +msgstr "" +"YouTube needs a working system proxy. If it asks for sign-in " +"verification: log in with Firefox, or export cookies.txt via a browser " +"extension into the app data folder (recent Chrome/Edge encryption blocks " +"cookie reading on Windows). Bilibili risk control (412) usually clears " +"after a few minutes." + +msgid "doctor.help.ffmpeg" +msgstr "" +"ASS hard subtitles require the full FFmpeg with libass. On macOS, you can" +" install ffmpeg-full; you can also switch to rounded background first." + +msgid "doctor.jump_failed.body" +msgstr "No corresponding settings page found." + +msgid "doctor.jump_failed.title" +msgstr "Failed to navigate" + +msgid "doctor.label.disabled" +msgstr "Not enabled" + +msgid "doctor.label.dubbing" +msgstr "Voiceover" + +msgid "doctor.label.optimize" +msgstr "Correction" + +msgid "doctor.label.split" +msgstr "Smart sentence splitting" + +msgid "doctor.label.subtitle" +msgstr "Subtitles" + +msgid "doctor.label.subtitle_file" +msgstr "Subtitle file" + +msgid "doctor.label.video" +msgstr "Video" + +msgid "doctor.live_caption.desc" +msgstr "" +"Real-time transcription requires a local voxgate transcription program or" +" an external real-time service." + +msgid "doctor.live_caption.funasr_no_key.desc" +msgstr "" +"Fun-ASR real-time transcription is missing the Bailian API Key. Fill it " +"in Settings." + +msgid "doctor.live_caption.not_found.desc" +msgstr "" +"Optional feature: voxgate transcription program or external real-time " +"service not found. Configure it later if needed." + +msgid "doctor.live_caption.ok.desc" +msgstr "" +"Real-time subtitle backend is ready. You can start real-time " +"transcription and translation." + +msgid "doctor.live_caption.title" +msgstr "Real-time subtitles" + +msgid "doctor.llm.desc.default" +msgstr "" +"Correction, terminology correction, and smart sentence splitting require " +"a valid Key." + +msgid "doctor.llm.desc.translate" +msgstr "The current translation will call an LLM and requires a valid Key." + +msgid "doctor.llm.fail.desc" +msgstr "" +"Subtitle correction, terminology correction, and smart sentence splitting" +" require a valid Key." + +msgid "doctor.llm.ok.desc" +msgstr "LLM configuration is available and can be used for subtitle enhancement." + +msgid "doctor.llm.title.optimize" +msgstr "Subtitle correction" + +msgid "doctor.llm.title.optimize_split" +msgstr "Subtitle correction and smart sentence splitting" + +msgid "doctor.llm.title.split" +msgstr "Smart sentence splitting" + +msgid "doctor.llm.title.translate" +msgstr "LLM translation" + +msgid "doctor.llm.unavailable.title" +msgstr "LLM configuration unavailable" + +msgid "doctor.status.checking" +msgstr "Checking" + +msgid "doctor.status.error" +msgstr "Failed" + +msgid "doctor.status.ok" +msgstr "Normal" + +msgid "doctor.status.pending" +msgstr "Pending check" + +msgid "doctor.status.warning" +msgstr "Needs attention" + +msgid "doctor.subtitle" +msgstr "" +"Check the services and tools used by the current task. Disabled features " +"will not appear in the list." + +msgid "doctor.summary.all_passed" +msgstr "All passed" + +msgid "doctor.summary.errors" +msgstr "{count} failed" + +msgid "doctor.summary.pending" +msgstr "{count} pending check" + +msgid "doctor.summary.warnings" +msgstr "{count} need attention" + +msgid "doctor.title" +msgstr "Diagnostics" + +msgid "doctor.transcribe.desc.free" +msgstr "" +"Convert video or audio to source subtitles; the free API requires " +"internet access." + +msgid "doctor.transcribe.desc.local" +msgstr "Convert video or audio to source subtitles; requires a local model." + +msgid "doctor.transcribe.desc.whisper" +msgstr "Convert video or audio to source subtitles; requires a Whisper Key." + +msgid "doctor.transcribe.fail.desc" +msgstr "" +"The current transcription method is unavailable. Check the network, Key, " +"or local model." + +msgid "doctor.transcribe.ok.desc" +msgstr "" +"The current transcription method is available and can generate source " +"subtitles." + +msgid "doctor.transcribe.title" +msgstr "Transcription Service" + +msgid "doctor.translate.desc.default" +msgstr "Generate target-language subtitles. Failure only affects the translation." + +msgid "doctor.translate.desc.uses_llm" +msgstr "" +"Generate target-language subtitles. LLM translation will reuse the LLM " +"Key." + +msgid "doctor.translate.ok.desc" +msgstr "" +"Translation service is available and can generate target-language " +"subtitles." + +msgid "doctor.translate.title" +msgstr "Translation Service" + +msgid "doctor.translate.uses_llm.desc" +msgstr "Large model translation will reuse the LLM Key." + +msgid "donate.alipay" +msgstr "Alipay" + +msgid "donate.desc" +msgstr "" +"I currently have limited time and energy. Your support motivates me to " +"keep working on this project!\n" +"Thank you for your love and support for open source!" + +msgid "donate.title" +msgstr "Support the Author" + +msgid "donate.wechat" +msgstr "WeChat" + +msgid "dubbing.badge.clone" +msgstr "Cloneable" + +msgid "dubbing.badge.need_key" +msgstr "Key Required" + +msgid "dubbing.badge.no_key" +msgstr "No Key Required" + +msgid "dubbing.btn.audition" +msgstr "Preview" + +msgid "dubbing.btn.config" +msgstr "Voiceover Settings" + +msgid "dubbing.btn.generate_clone" +msgstr "Generate Cloned Audio" + +msgid "dubbing.btn.generate_preview" +msgstr "Generate Preview Audio" + +msgid "dubbing.btn.stop" +msgstr "Stop" + +msgid "dubbing.btn.synthesizing" +msgstr "Synthesizing…" + +msgid "dubbing.clone.clear" +msgstr "Clear" + +msgid "dubbing.clone.hint" +msgstr "" +"If no reference audio is uploaded, the text above will be used directly " +"to preview the current voice." + +msgid "dubbing.clone.missing_file" +msgstr "Reference audio file does not exist. Please select again or clear it." + +msgid "dubbing.clone.no_audio" +msgstr "No reference audio selected" + +msgid "dubbing.clone.record" +msgstr "Record" + +msgid "dubbing.clone.ref_text" +msgstr "Reference Text" + +msgid "dubbing.clone.ref_text_placeholder" +msgstr "Enter the text actually spoken in the reference audio" + +msgid "dubbing.clone.title" +msgstr "Voice Cloning" + +msgid "dubbing.clone.upload" +msgstr "Upload" + +msgid "dubbing.clone.uploaded" +msgstr "Uploaded" + +msgid "dubbing.dialog.audio_filter" +msgstr "Audio files (*.wav *.mp3 *.m4a *.aac *.flac *.ogg *.opus);;All files (*.*)" + +msgid "dubbing.dialog.choose_audio" +msgstr "Select Reference Audio" + +msgid "dubbing.filter.all" +msgstr "All" + +msgid "dubbing.filter.clone" +msgstr "Clone" + +msgid "dubbing.filter.female" +msgstr "Female voice" + +msgid "dubbing.filter.male" +msgstr "Male voice" + +msgid "dubbing.form.current_voice" +msgstr "Current Voice" + +msgid "dubbing.form.gen_type" +msgstr "Generation Type" + +msgid "dubbing.form.not_selected" +msgstr "Not selected" + +msgid "dubbing.form.type_preview" +msgstr "Preview Audio" + +msgid "dubbing.preview.char_count" +msgstr "{count} characters" + +msgid "dubbing.preview.desc" +msgstr "Enter test copy, generate audio, then check the voice and tone." + +msgid "dubbing.preview.desc_clone" +msgstr "" +"You can preview preset voices directly, or add reference audio for voice " +"cloning." + +msgid "dubbing.preview.input_placeholder" +msgstr "Enter a sentence to preview the selected voice" + +msgid "dubbing.preview.sample_text" +msgstr "" +"Hello, this is the voiceover script I want to use for testing. Please " +"read this sentence in a natural and clear tone." + +msgid "dubbing.preview.title" +msgstr "Voiceover Script" + +msgid "dubbing.provider.edge.desc" +msgstr "" +"No API Key required, ideal for quickly generating Chinese or English " +"voiceovers by default." + +msgid "dubbing.provider.edge.title" +msgstr "Free Edge Voiceover" + +msgid "dubbing.provider.gemini.desc" +msgstr "Google Gemini speech model, ideal for natural English expression." + +msgid "dubbing.provider.gemini.title" +msgstr "Gemini TTS" + +msgid "dubbing.provider.siliconflow.desc" +msgstr "" +"CosyVoice offers stable Chinese performance and supports reference audio " +"cloning." + +msgid "dubbing.provider.siliconflow.title" +msgstr "SiliconFlow CosyVoice" + +msgid "dubbing.ready.clone" +msgstr "Supports voice cloning" + +msgid "dubbing.ready.default" +msgstr "Ready" + +msgid "dubbing.ready.need_key" +msgstr "API Key required" + +msgid "dubbing.ready.no_key" +msgstr "No Key needed" + +msgid "dubbing.subtitle" +msgstr "Select a provider and voice, then enter your own preview text." + +msgid "dubbing.tag.breathy" +msgstr "Breathy" + +msgid "dubbing.tag.bright" +msgstr "Bright" + +msgid "dubbing.tag.casual" +msgstr "Casual" + +msgid "dubbing.tag.clear" +msgstr "Clear" + +msgid "dubbing.tag.clone" +msgstr "Clone" + +msgid "dubbing.tag.easy_going" +msgstr "Easy-going" + +msgid "dubbing.tag.en" +msgstr "English" + +msgid "dubbing.tag.even" +msgstr "Even" + +msgid "dubbing.tag.female" +msgstr "Female voice" + +msgid "dubbing.tag.firm" +msgstr "Firm" + +msgid "dubbing.tag.forward" +msgstr "Forward" + +msgid "dubbing.tag.free" +msgstr "Free" + +msgid "dubbing.tag.gentle" +msgstr "Gentle" + +msgid "dubbing.tag.gravelly" +msgstr "Gravelly" + +msgid "dubbing.tag.informative" +msgstr "Informative" + +msgid "dubbing.tag.knowledgeable" +msgstr "Knowledgeable" + +msgid "dubbing.tag.lively" +msgstr "Lively" + +msgid "dubbing.tag.male" +msgstr "Male voice" + +msgid "dubbing.tag.mature" +msgstr "Mature" + +msgid "dubbing.tag.need_key" +msgstr "Requires Key" + +msgid "dubbing.tag.recommended" +msgstr "Recommended" + +msgid "dubbing.tag.smooth" +msgstr "Smooth" + +msgid "dubbing.tag.soft" +msgstr "Soft" + +msgid "dubbing.tag.upbeat" +msgstr "Upbeat" + +msgid "dubbing.tag.warm" +msgstr "Warm" + +msgid "dubbing.tag.yue" +msgstr "Cantonese" + +msgid "dubbing.tag.zh" +msgstr "Chinese" + +msgid "dubbing.title" +msgstr "Dubbing" + +msgid "dubbing.toast.audio_missing" +msgstr "Reference audio does not exist" + +msgid "dubbing.toast.audio_missing_body" +msgstr "Please upload or record the reference audio again." + +msgid "dubbing.toast.empty_text" +msgstr "Please enter preview text" + +msgid "dubbing.toast.empty_text_body" +msgstr "The text preview will generate audio in real time using your input." + +msgid "dubbing.toast.missing_ref_text" +msgstr "Missing reference text" + +msgid "dubbing.toast.missing_ref_text_body" +msgstr "" +"Please enter the text actually spoken in the reference audio, or clear " +"the reference audio for a normal preview." + +msgid "dubbing.toast.need_api_key" +msgstr "API Key required" + +msgid "dubbing.toast.need_api_key_body" +msgstr "" +"Custom text preview requires a real request. Please enter the API Key for" +" the current voice service first." + +msgid "dubbing.toast.play_failed" +msgstr "Playback failed" + +msgid "dubbing.toast.play_failed_body" +msgstr "" +"The current system is missing audio decoding components and no available " +"external player was found." + +msgid "dubbing.toast.please_wait" +msgstr "Please wait" + +msgid "dubbing.toast.please_wait_body" +msgstr "Synthesizing another preview." + +msgid "dubbing.toast.preview_failed" +msgstr "Preview failed" + +msgid "dubbing.toast.record_done" +msgstr "Recording complete" + +msgid "dubbing.toast.record_done_body" +msgstr "Saved as reference audio" + +msgid "dubbing.voice.edge-cn-female.desc" +msgstr "Clear, natural Mandarin female voice" + +msgid "dubbing.voice.edge-cn-male.desc" +msgstr "Young, natural Mandarin male voice" + +msgid "dubbing.voice.edge-cn-xiaoyi.desc" +msgstr "Gentle, bright Mandarin female voice" + +msgid "dubbing.voice.edge-cn-yunjian.desc" +msgstr "Male voice better suited for speeches and narration" + +msgid "dubbing.voice.edge-cn-yunyang.desc" +msgstr "Mandarin male voice with a stronger broadcast style" + +msgid "dubbing.voice.edge-en-andrew.desc" +msgstr "Clear, steady American English male voice" + +msgid "dubbing.voice.edge-en-ava.desc" +msgstr "Crisp, natural American English female voice" + +msgid "dubbing.voice.edge-en-brian.desc" +msgstr "Natural, steady American English male voice" + +msgid "dubbing.voice.edge-en-emma.desc" +msgstr "Soft, natural American English female voice" + +msgid "dubbing.voice.edge-en-female.desc" +msgstr "American English female voice" + +msgid "dubbing.voice.edge-en-libby.desc" +msgstr "British English female voice" + +msgid "dubbing.voice.edge-en-male.desc" +msgstr "American English male voice" + +msgid "dubbing.voice.edge-en-ryan.desc" +msgstr "British English male voice" + +msgid "dubbing.voice.edge-hk-hiugaai.desc" +msgstr "Cantonese female voice" + +msgid "dubbing.voice.edge-hk-wanlung.desc" +msgstr "Cantonese male voice" + +msgid "dubbing.voice.edge-tw-hsiaoyu.desc" +msgstr "Taiwan Mandarin female voice" + +msgid "dubbing.voice.edge-tw-yunjhe.desc" +msgstr "Taiwan Mandarin male voice" + +msgid "dubbing.voice.gemini-achernar.desc" +msgstr "Soft English voice" + +msgid "dubbing.voice.gemini-algenib.desc" +msgstr "More textured English voice" + +msgid "dubbing.voice.gemini-algieba.desc" +msgstr "Smooth English voice" + +msgid "dubbing.voice.gemini-alnilam.desc" +msgstr "Firm, clear English voice" + +msgid "dubbing.voice.gemini-aoede.desc" +msgstr "Bright, natural English voice" + +msgid "dubbing.voice.gemini-autonoe.desc" +msgstr "Balanced, clear English voice" + +msgid "dubbing.voice.gemini-callirrhoe.desc" +msgstr "Relaxed, natural English voice" + +msgid "dubbing.voice.gemini-charon.desc" +msgstr "Calmer English voice" + +msgid "dubbing.voice.gemini-despina.desc" +msgstr "Smooth, natural English voice" + +msgid "dubbing.voice.gemini-en-friendly.desc" +msgstr "Friendly, natural English expression" + +msgid "dubbing.voice.gemini-en-neutral.desc" +msgstr "Clear, stable natural English" + +msgid "dubbing.voice.gemini-en-upbeat.desc" +msgstr "More energetic English expression" + +msgid "dubbing.voice.gemini-enceladus.desc" +msgstr "Breathier English voice" + +msgid "dubbing.voice.gemini-erinome.desc" +msgstr "Clear, direct English voice" + +msgid "dubbing.voice.gemini-fenrir.desc" +msgstr "Deep, powerful English voice" + +msgid "dubbing.voice.gemini-gacrux.desc" +msgstr "Mature, steady English voice" + +msgid "dubbing.voice.gemini-iapetus.desc" +msgstr "Clear, stable English voice" + +msgid "dubbing.voice.gemini-laomedeia.desc" +msgstr "Light, lively English voice" + +msgid "dubbing.voice.gemini-leda.desc" +msgstr "Light, bright English voice" + +msgid "dubbing.voice.gemini-orus.desc" +msgstr "More narration-like English voice" + +msgid "dubbing.voice.gemini-pulcherrima.desc" +msgstr "More forward English voice" + +msgid "dubbing.voice.gemini-rasalgethi.desc" +msgstr "More informative English voice" + +msgid "dubbing.voice.gemini-sadachbia.desc" +msgstr "Vivid, light English voice" + +msgid "dubbing.voice.gemini-sadaltager.desc" +msgstr "Knowledge-focused English voice" + +msgid "dubbing.voice.gemini-schedar.desc" +msgstr "Smooth, balanced English voice" + +msgid "dubbing.voice.gemini-sulafat.desc" +msgstr "Warm, natural English voice" + +msgid "dubbing.voice.gemini-umbriel.desc" +msgstr "Relaxed, natural English voice" + +msgid "dubbing.voice.gemini-vindemiatrix.desc" +msgstr "Gentle, smooth English voice" + +msgid "dubbing.voice.gemini-zephyr.desc" +msgstr "Bright, refreshing English voice" + +msgid "dubbing.voice.gemini-zubenelgenubi.desc" +msgstr "Casual, natural English voice" + +msgid "dubbing.voice.library" +msgstr "Voice Library" + +msgid "dubbing.voice.library_zh" +msgstr "Chinese voices" + +msgid "dubbing.voice.siliconflow-cn-bella.desc" +msgstr "Warm Chinese female voice, clonable" + +msgid "dubbing.voice.siliconflow-cn-charles.desc" +msgstr "Magnetic Chinese male voice, clonable" + +msgid "dubbing.voice.siliconflow-cn-claire.desc" +msgstr "Gentle Chinese female voice, clonable" + +msgid "dubbing.voice.siliconflow-cn-david.desc" +msgstr "Cheerful Chinese male voice, clonable" + +msgid "dubbing.voice.siliconflow-cn-deep-male.desc" +msgstr "Calm, deep Chinese male voice, clonable" + +msgid "dubbing.voice.siliconflow-cn-diana.desc" +msgstr "Cheerful Chinese female voice, clonable" + +msgid "dubbing.voice.siliconflow-cn-female.desc" +msgstr "Natural Chinese female voice, can be cloned with reference audio" + +msgid "dubbing.voice.siliconflow-cn-male.desc" +msgstr "Natural Chinese male voice, can be cloned with reference audio" + +msgid "enum.FasterWhisperModelEnum.BASE" +msgstr "base" + +msgid "enum.FasterWhisperModelEnum.LARGE_V1" +msgstr "large-v1" + +msgid "enum.FasterWhisperModelEnum.LARGE_V2" +msgstr "large-v2" + +msgid "enum.FasterWhisperModelEnum.LARGE_V3" +msgstr "large-v3" + +msgid "enum.FasterWhisperModelEnum.LARGE_V3_TURBO" +msgstr "large-v3-turbo" + +msgid "enum.FasterWhisperModelEnum.MEDIUM" +msgstr "medium" + +msgid "enum.FasterWhisperModelEnum.SMALL" +msgstr "small" + +msgid "enum.FasterWhisperModelEnum.TINY" +msgstr "tiny" + +msgid "enum.LLMServiceEnum.CHATGLM" +msgstr "ChatGLM" + +msgid "enum.LLMServiceEnum.DEEPSEEK" +msgstr "DeepSeek" + +msgid "enum.LLMServiceEnum.GEMINI" +msgstr "Gemini" + +msgid "enum.LLMServiceEnum.IMMERSIVE" +msgstr "Free Community LLM" + +msgid "enum.LLMServiceEnum.LM_STUDIO" +msgstr "LM Studio" + +msgid "enum.LLMServiceEnum.OLLAMA" +msgstr "Ollama" + +msgid "enum.LLMServiceEnum.OPENAI" +msgstr "OpenAI Compatible" + +msgid "enum.LLMServiceEnum.SILICON_CLOUD" +msgstr "SiliconCloud" + +msgid "enum.SubtitleLayoutEnum.ONLY_ORIGINAL" +msgstr "Original Only" + +msgid "enum.SubtitleLayoutEnum.ONLY_TRANSLATE" +msgstr "Translation Only" + +msgid "enum.SubtitleLayoutEnum.ORIGINAL_ON_TOP" +msgstr "Original on Top" + +msgid "enum.SubtitleLayoutEnum.TRANSLATE_ON_TOP" +msgstr "Translation on Top" + +msgid "enum.SubtitleRenderModeEnum.ASS_STYLE" +msgstr "ASS Style" + +msgid "enum.SubtitleRenderModeEnum.ROUNDED_BG" +msgstr "Rounded Background" + +msgid "enum.TargetLanguage.ARABIC" +msgstr "Arabic" + +msgid "enum.TargetLanguage.BULGARIAN" +msgstr "Bulgarian" + +msgid "enum.TargetLanguage.CANTONESE" +msgstr "Cantonese" + +msgid "enum.TargetLanguage.CZECH" +msgstr "Czech" + +msgid "enum.TargetLanguage.DANISH" +msgstr "Danish" + +msgid "enum.TargetLanguage.DUTCH" +msgstr "Dutch" + +msgid "enum.TargetLanguage.ENGLISH" +msgstr "English" + +msgid "enum.TargetLanguage.ENGLISH_UK" +msgstr "English (UK)" + +msgid "enum.TargetLanguage.ENGLISH_US" +msgstr "English (United States)" + +msgid "enum.TargetLanguage.FINNISH" +msgstr "Finnish" + +msgid "enum.TargetLanguage.FRENCH" +msgstr "French" + +msgid "enum.TargetLanguage.GERMAN" +msgstr "German" + +msgid "enum.TargetLanguage.GREEK" +msgstr "Greek" + +msgid "enum.TargetLanguage.HEBREW" +msgstr "Hebrew" + +msgid "enum.TargetLanguage.HUNGARIAN" +msgstr "Hungarian" + +msgid "enum.TargetLanguage.INDONESIAN" +msgstr "Indonesian" + +msgid "enum.TargetLanguage.ITALIAN" +msgstr "Italian" + +msgid "enum.TargetLanguage.JAPANESE" +msgstr "Japanese" + +msgid "enum.TargetLanguage.KOREAN" +msgstr "Korean" + +msgid "enum.TargetLanguage.MALAY" +msgstr "Malay" + +msgid "enum.TargetLanguage.NORWEGIAN" +msgstr "Norwegian" + +msgid "enum.TargetLanguage.PERSIAN" +msgstr "Persian" + +msgid "enum.TargetLanguage.POLISH" +msgstr "Polish" + +msgid "enum.TargetLanguage.PORTUGUESE" +msgstr "Portuguese" + +msgid "enum.TargetLanguage.PORTUGUESE_BR" +msgstr "Portuguese (Brazil)" + +msgid "enum.TargetLanguage.PORTUGUESE_PT" +msgstr "Portuguese (Portugal)" + +msgid "enum.TargetLanguage.ROMANIAN" +msgstr "Romanian" + +msgid "enum.TargetLanguage.RUSSIAN" +msgstr "Russian" + +msgid "enum.TargetLanguage.SIMPLIFIED_CHINESE" +msgstr "Simplified Chinese" + +msgid "enum.TargetLanguage.SPANISH" +msgstr "Spanish" + +msgid "enum.TargetLanguage.SPANISH_LATAM" +msgstr "Spanish (Latin America)" + +msgid "enum.TargetLanguage.SWEDISH" +msgstr "Swedish" + +msgid "enum.TargetLanguage.TAGALOG" +msgstr "Filipino" + +msgid "enum.TargetLanguage.THAI" +msgstr "Thai" + +msgid "enum.TargetLanguage.TRADITIONAL_CHINESE" +msgstr "Traditional Chinese" + +msgid "enum.TargetLanguage.TURKISH" +msgstr "Turkish" + +msgid "enum.TargetLanguage.UKRAINIAN" +msgstr "Ukrainian" + +msgid "enum.TargetLanguage.VIETNAMESE" +msgstr "Vietnamese" + +msgid "enum.TranscribeLanguageEnum.AFRIKAANS" +msgstr "Afrikaans" + +msgid "enum.TranscribeLanguageEnum.ALBANIAN" +msgstr "Albanian" + +msgid "enum.TranscribeLanguageEnum.AMHARIC" +msgstr "Amharic" + +msgid "enum.TranscribeLanguageEnum.ARABIC" +msgstr "Arabic" + +msgid "enum.TranscribeLanguageEnum.ARMENIAN" +msgstr "Armenian" + +msgid "enum.TranscribeLanguageEnum.ASSAMESE" +msgstr "Assamese" + +msgid "enum.TranscribeLanguageEnum.AUTO" +msgstr "Auto detect" + +msgid "enum.TranscribeLanguageEnum.AZERBAIJANI" +msgstr "Azerbaijani" + +msgid "enum.TranscribeLanguageEnum.BASHKIR" +msgstr "Bashkir" + +msgid "enum.TranscribeLanguageEnum.BASQUE" +msgstr "Basque" + +msgid "enum.TranscribeLanguageEnum.BELARUSIAN" +msgstr "Belarusian" + +msgid "enum.TranscribeLanguageEnum.BENGALI" +msgstr "Bengali" + +msgid "enum.TranscribeLanguageEnum.BOSNIAN" +msgstr "Bosnian" + +msgid "enum.TranscribeLanguageEnum.BRETON" +msgstr "Breton" + +msgid "enum.TranscribeLanguageEnum.BULGARIAN" +msgstr "Bulgarian" + +msgid "enum.TranscribeLanguageEnum.CANTONESE" +msgstr "Cantonese" + +msgid "enum.TranscribeLanguageEnum.CATALAN" +msgstr "Catalan" + +msgid "enum.TranscribeLanguageEnum.CHINESE" +msgstr "Chinese" + +msgid "enum.TranscribeLanguageEnum.CROATIAN" +msgstr "Croatian" + +msgid "enum.TranscribeLanguageEnum.CZECH" +msgstr "Czech" + +msgid "enum.TranscribeLanguageEnum.DANISH" +msgstr "Danish" + +msgid "enum.TranscribeLanguageEnum.DUTCH" +msgstr "Dutch" + +msgid "enum.TranscribeLanguageEnum.ENGLISH" +msgstr "English" + +msgid "enum.TranscribeLanguageEnum.ESTONIAN" +msgstr "Estonian" + +msgid "enum.TranscribeLanguageEnum.FAROESE" +msgstr "Faroese" + +msgid "enum.TranscribeLanguageEnum.FINNISH" +msgstr "Finnish" + +msgid "enum.TranscribeLanguageEnum.FRENCH" +msgstr "French" + +msgid "enum.TranscribeLanguageEnum.GALICIAN" +msgstr "Galician" + +msgid "enum.TranscribeLanguageEnum.GEORGIAN" +msgstr "Georgian" + +msgid "enum.TranscribeLanguageEnum.GERMAN" +msgstr "German" + +msgid "enum.TranscribeLanguageEnum.GREEK" +msgstr "Greek" + +msgid "enum.TranscribeLanguageEnum.GUJARATI" +msgstr "Gujarati" + +msgid "enum.TranscribeLanguageEnum.HAITIAN_CREOLE" +msgstr "Haitian Creole" + +msgid "enum.TranscribeLanguageEnum.HAUSA" +msgstr "Hausa" + +msgid "enum.TranscribeLanguageEnum.HAWAIIAN" +msgstr "Hawaiian" + +msgid "enum.TranscribeLanguageEnum.HEBREW" +msgstr "Hebrew" + +msgid "enum.TranscribeLanguageEnum.HINDI" +msgstr "Hindi" + +msgid "enum.TranscribeLanguageEnum.HUNGARIAN" +msgstr "Hungarian" + +msgid "enum.TranscribeLanguageEnum.ICELANDIC" +msgstr "Icelandic" + +msgid "enum.TranscribeLanguageEnum.INDONESIAN" +msgstr "Indonesian" + +msgid "enum.TranscribeLanguageEnum.ITALIAN" +msgstr "Italian" + +msgid "enum.TranscribeLanguageEnum.JAPANESE" +msgstr "Japanese" + +msgid "enum.TranscribeLanguageEnum.JAVANESE" +msgstr "Javanese" + +msgid "enum.TranscribeLanguageEnum.KANNADA" +msgstr "Kannada" + +msgid "enum.TranscribeLanguageEnum.KAZAKH" +msgstr "Kazakh" + +msgid "enum.TranscribeLanguageEnum.KHMER" +msgstr "Khmer" + +msgid "enum.TranscribeLanguageEnum.KOREAN" +msgstr "Korean" + +msgid "enum.TranscribeLanguageEnum.LAO" +msgstr "Lao" + +msgid "enum.TranscribeLanguageEnum.LATIN" +msgstr "Latin" + +msgid "enum.TranscribeLanguageEnum.LATVIAN" +msgstr "Latvian" + +msgid "enum.TranscribeLanguageEnum.LINGALA" +msgstr "Lingala" + +msgid "enum.TranscribeLanguageEnum.LITHUANIAN" +msgstr "Lithuanian" + +msgid "enum.TranscribeLanguageEnum.LUXEMBOURGISH" +msgstr "Luxembourgish" + +msgid "enum.TranscribeLanguageEnum.MACEDONIAN" +msgstr "Macedonian" + +msgid "enum.TranscribeLanguageEnum.MALAGASY" +msgstr "Malagasy" + +msgid "enum.TranscribeLanguageEnum.MALAY" +msgstr "Malay" + +msgid "enum.TranscribeLanguageEnum.MALAYALAM" +msgstr "Malayalam" + +msgid "enum.TranscribeLanguageEnum.MALTESE" +msgstr "Maltese" + +msgid "enum.TranscribeLanguageEnum.MAORI" +msgstr "Maori" + +msgid "enum.TranscribeLanguageEnum.MARATHI" +msgstr "Marathi" + +msgid "enum.TranscribeLanguageEnum.MONGOLIAN" +msgstr "Mongolian" + +msgid "enum.TranscribeLanguageEnum.MYANMAR" +msgstr "Myanmar" + +msgid "enum.TranscribeLanguageEnum.NEPALI" +msgstr "Nepali" + +msgid "enum.TranscribeLanguageEnum.NORWEGIAN" +msgstr "Norwegian" + +msgid "enum.TranscribeLanguageEnum.NYNORSK" +msgstr "Nynorsk" + +msgid "enum.TranscribeLanguageEnum.OCCITAN" +msgstr "Occitan" + +msgid "enum.TranscribeLanguageEnum.PASHTO" +msgstr "Pashto" + +msgid "enum.TranscribeLanguageEnum.PERSIAN" +msgstr "Persian" + +msgid "enum.TranscribeLanguageEnum.POLISH" +msgstr "Polish" + +msgid "enum.TranscribeLanguageEnum.PORTUGUESE" +msgstr "Portuguese" + +msgid "enum.TranscribeLanguageEnum.PUNJABI" +msgstr "Punjabi" + +msgid "enum.TranscribeLanguageEnum.ROMANIAN" +msgstr "Romanian" + +msgid "enum.TranscribeLanguageEnum.RUSSIAN" +msgstr "Russian" + +msgid "enum.TranscribeLanguageEnum.SANSKRIT" +msgstr "Sanskrit" + +msgid "enum.TranscribeLanguageEnum.SERBIAN" +msgstr "Serbian" + +msgid "enum.TranscribeLanguageEnum.SHONA" +msgstr "Shona" + +msgid "enum.TranscribeLanguageEnum.SINDHI" +msgstr "Sindhi" + +msgid "enum.TranscribeLanguageEnum.SINHALA" +msgstr "Sinhala" + +msgid "enum.TranscribeLanguageEnum.SLOVAK" +msgstr "Slovak" + +msgid "enum.TranscribeLanguageEnum.SLOVENIAN" +msgstr "Slovenian" + +msgid "enum.TranscribeLanguageEnum.SOMALI" +msgstr "Somali" + +msgid "enum.TranscribeLanguageEnum.SPANISH" +msgstr "Spanish" + +msgid "enum.TranscribeLanguageEnum.SUNDANESE" +msgstr "Sundanese" + +msgid "enum.TranscribeLanguageEnum.SWAHILI" +msgstr "Swahili" + +msgid "enum.TranscribeLanguageEnum.SWEDISH" +msgstr "Swedish" + +msgid "enum.TranscribeLanguageEnum.TAGALOG" +msgstr "Tagalog" + +msgid "enum.TranscribeLanguageEnum.TAJIK" +msgstr "Tajik" + +msgid "enum.TranscribeLanguageEnum.TAMIL" +msgstr "Tamil" + +msgid "enum.TranscribeLanguageEnum.TATAR" +msgstr "Tatar" + +msgid "enum.TranscribeLanguageEnum.TELUGU" +msgstr "Telugu" + +msgid "enum.TranscribeLanguageEnum.THAI" +msgstr "Thai" + +msgid "enum.TranscribeLanguageEnum.TIBETAN" +msgstr "Tibetan" + +msgid "enum.TranscribeLanguageEnum.TURKISH" +msgstr "Turkish" + +msgid "enum.TranscribeLanguageEnum.TURKMEN" +msgstr "Turkmen" + +msgid "enum.TranscribeLanguageEnum.UKRAINIAN" +msgstr "Ukrainian" + +msgid "enum.TranscribeLanguageEnum.URDU" +msgstr "Urdu" + +msgid "enum.TranscribeLanguageEnum.UZBEK" +msgstr "Uzbek" + +msgid "enum.TranscribeLanguageEnum.VIETNAMESE" +msgstr "Vietnamese" + +msgid "enum.TranscribeLanguageEnum.WELSH" +msgstr "Welsh" + +msgid "enum.TranscribeLanguageEnum.YIDDISH" +msgstr "Yiddish" + +msgid "enum.TranscribeLanguageEnum.YORUBA" +msgstr "Yoruba" + +msgid "enum.TranscribeLanguageEnum.YUE" +msgstr "Cantonese" + +msgid "enum.TranscribeModelEnum.BAILIAN_FUN_ASR" +msgstr "Bailian Fun-ASR" + +msgid "enum.TranscribeModelEnum.BIJIAN" +msgstr "B Interface" + +msgid "enum.TranscribeModelEnum.FASTER_WHISPER" +msgstr "FasterWhisper" + +msgid "enum.TranscribeModelEnum.JIANYING" +msgstr "J Interface" + +msgid "enum.TranscribeModelEnum.WHISPER_API" +msgstr "Whisper [API]" + +msgid "enum.TranscribeModelEnum.WHISPER_CPP" +msgstr "WhisperCpp" + +msgid "enum.TranscribeOutputFormatEnum.ALL" +msgstr "All" + +msgid "enum.TranscribeOutputFormatEnum.ASS" +msgstr "ASS" + +msgid "enum.TranscribeOutputFormatEnum.SRT" +msgstr "SRT" + +msgid "enum.TranscribeOutputFormatEnum.TXT" +msgstr "TXT" + +msgid "enum.TranscribeOutputFormatEnum.VTT" +msgstr "VTT" + +msgid "enum.TranslatorServiceEnum.BING" +msgstr "Microsoft Translate" + +msgid "enum.TranslatorServiceEnum.DEEPLX" +msgstr "DeepLx Translate" + +msgid "enum.TranslatorServiceEnum.GOOGLE" +msgstr "Google Translate" + +msgid "enum.TranslatorServiceEnum.OPENAI" +msgstr "LLM Translation" + +msgid "enum.VadMethodEnum.AUDITOK" +msgstr "auditok" + +msgid "enum.VadMethodEnum.PYANNOTE_ONNX_V3" +msgstr "pyannote_onnx_v3" + +msgid "enum.VadMethodEnum.PYANNOTE_V3" +msgstr "pyannote_v3" + +msgid "enum.VadMethodEnum.SILERO_V3" +msgstr "silero_v3" + +msgid "enum.VadMethodEnum.SILERO_V4" +msgstr "silero_v4" + +msgid "enum.VadMethodEnum.SILERO_V4_FW" +msgstr "silero_v4_fw" + +msgid "enum.VadMethodEnum.SILERO_V5" +msgstr "silero_v5" + +msgid "enum.VadMethodEnum.WEBRTC" +msgstr "webrtc" + +msgid "enum.VideoQualityEnum.HIGH" +msgstr "High Quality" + +msgid "enum.VideoQualityEnum.LOW" +msgstr "Low Quality" + +msgid "enum.VideoQualityEnum.MEDIUM" +msgstr "Medium Quality" + +msgid "enum.VideoQualityEnum.ULTRA_HIGH" +msgstr "Very High Quality" + +msgid "enum.WhisperModelEnum.BASE" +msgstr "base" + +msgid "enum.WhisperModelEnum.LARGE_V1" +msgstr "large-v1" + +msgid "enum.WhisperModelEnum.LARGE_V2" +msgstr "large-v2" + +msgid "enum.WhisperModelEnum.MEDIUM" +msgstr "medium" + +msgid "enum.WhisperModelEnum.SMALL" +msgstr "small" + +msgid "enum.WhisperModelEnum.TINY" +msgstr "tiny" + +msgid "feedback.add_image" +msgstr "Add image" + +msgid "feedback.attach_count" +msgstr "{count}/{max}" + +msgid "feedback.category.bug" +msgstr "Bug" + +msgid "feedback.category.feature" +msgstr "Feature request" + +msgid "feedback.category.label" +msgstr "Type" + +msgid "feedback.category.other" +msgstr "Other" + +msgid "feedback.category.question" +msgstr "Question" + +msgid "feedback.contact.placeholder" +msgstr "Email / WeChat / QQ (optional, so we can reply)" + +msgid "feedback.done" +msgstr "Done" + +msgid "feedback.err.category_invalid" +msgstr "Invalid type" + +msgid "feedback.err.contact_too_long" +msgstr "Contact is too long" + +msgid "feedback.err.file_too_large" +msgstr "Each screenshot must be ≤ 5 MB" + +msgid "feedback.err.file_type" +msgstr "PNG / JPEG only" + +msgid "feedback.err.invalid_request" +msgstr "Invalid request" + +msgid "feedback.err.message_required" +msgstr "Please describe the issue first" + +msgid "feedback.err.message_too_long" +msgstr "Description too long (max 5000)" + +msgid "feedback.err.network" +msgstr "Network error, please retry" + +msgid "feedback.err.server" +msgstr "Submit failed, please retry" + +msgid "feedback.err.too_large" +msgstr "Image or request too large" + +msgid "feedback.err.too_many_files" +msgstr "Up to 3 screenshots" + +msgid "feedback.err.total_too_large" +msgstr "Total size must be ≤ 12 MB" + +msgid "feedback.err.unauthorized" +msgstr "Unauthorized" + +msgid "feedback.intro" +msgstr "" +"Your feedback goes straight to the developer — we read every one and will" +" look into it." + +msgid "feedback.message.placeholder" +msgstr "Describe the issue or suggestion — the more detail, the better…" + +msgid "feedback.screenshot.hint" +msgstr "Paste with Ctrl+V, drag in, or click + to add" + +msgid "feedback.section.contact" +msgstr "Contact (optional)" + +msgid "feedback.section.message" +msgstr "Description" + +msgid "feedback.section.screenshot" +msgstr "Screenshots (optional)" + +msgid "feedback.submit" +msgstr "Submit" + +msgid "feedback.submitting" +msgstr "Submitting…" + +msgid "feedback.success" +msgstr "Got it — we'll look into this soon. Thanks for your feedback!" + +msgid "feedback.title" +msgstr "Feedback" + +msgid "hardsub.btn.auto_region" +msgstr "Auto-detect Region" + +msgid "hardsub.btn.export" +msgstr "Export Subtitles" + +msgid "hardsub.btn.redo" +msgstr "Extract Again" + +msgid "hardsub.btn.replace_video" +msgstr "Change Video" + +msgid "hardsub.btn.send_optimize" +msgstr "Send to Subtitle Optimization" + +msgid "hardsub.btn.start" +msgstr "Start Extraction" + +msgid "hardsub.busy.detecting_region" +msgstr "Detecting subtitle region…" + +msgid "hardsub.busy.detecting_region_pct" +msgstr "Detecting subtitle region… {percent}%" + +msgid "hardsub.busy.loading_video" +msgstr "Loading video…" + +msgid "hardsub.col.end" +msgstr "End" + +msgid "hardsub.col.start" +msgstr "Start" + +msgid "hardsub.col.text" +msgstr "Text" + +msgid "hardsub.count.recognized" +msgstr "{n} recognized" + +msgid "hardsub.count.total" +msgstr "{n} total" + +msgid "hardsub.dialog.export" +msgstr "Export Subtitles" + +msgid "hardsub.dialog.pick_video" +msgstr "Select Video" + +msgid "hardsub.drop.pick" +msgstr "Select Video" + +msgid "hardsub.drop.title" +msgstr "Drag in a video with hardcoded subtitles" + +msgid "hardsub.engine.card_title" +msgstr "Engine not ready" + +msgid "hardsub.engine.desc" +msgstr "" +"Hardcoded subtitle extraction requires OCR engine dependencies (rapidocr " +"/ onnxruntime)." + +msgid "hardsub.engine.title" +msgstr "OCR engine not ready" + +msgid "hardsub.error.read_video" +msgstr "Unable to read this video: {msg}" + +msgid "hardsub.menu.delete_row" +msgstr "Delete this row" + +msgid "hardsub.menu.locate" +msgstr "Go to this frame" + +msgid "hardsub.note.done" +msgstr "" +"Double-click to edit text/time; right-click to delete a row. Can be sent " +"for subtitle optimization or exported." + +msgid "hardsub.note.empty" +msgstr "Automatically detect subtitle area after selecting a video" + +msgid "hardsub.note.engine_missing" +msgstr "OCR engine dependencies are not installed" + +msgid "hardsub.note.no_subtitle" +msgstr "If no stable area is found, manually select an area before recognizing." + +msgid "hardsub.note.processing" +msgstr "" +"Recognized subtitles will be continuously written to the table on the " +"right." + +msgid "hardsub.note.region" +msgstr "If the area is inaccurate, drag directly on the video to select an area." + +msgid "hardsub.ph.empty.sub" +msgstr "Subtitle results will appear here." + +msgid "hardsub.ph.empty.title" +msgstr "Waiting for video" + +msgid "hardsub.ph.engine_missing.sub" +msgstr "Please install the recognition engine dependencies first." + +msgid "hardsub.ph.engine_missing.title" +msgstr "OCR engine not ready" + +msgid "hardsub.ph.no_subtitle.sub" +msgstr "You can manually select an area on the video on the left and try again." + +msgid "hardsub.ph.no_subtitle.title" +msgstr "No subtitles detected" + +msgid "hardsub.ph.region.sub" +msgstr "Confirm the area to start extracting." + +msgid "hardsub.ph.region.title" +msgstr "Subtitle area found" + +msgid "hardsub.progress.title" +msgstr "Subtitle recognition" + +msgid "hardsub.result.title" +msgstr "Subtitle results" + +msgid "hardsub.stage.video_preview" +msgstr "Video preview" + +msgid "hardsub.status.done" +msgstr "Completed" + +msgid "hardsub.status.engine_missing" +msgstr "Engine not ready" + +msgid "hardsub.status.need_action" +msgstr "Needs processing" + +msgid "hardsub.status.processing" +msgstr "Extracting" + +msgid "hardsub.status.region_detected" +msgstr "Recognized area" + +msgid "hardsub.status.waiting_video" +msgstr "Waiting for video" + +msgid "hardsub.subtitle" +msgstr "" +"Recognize hard subtitles from video frames. Select an area, then export " +"as editable subtitles." + +msgid "hardsub.title" +msgstr "Hard subtitle extraction" + +msgid "hardsub.toast.exported" +msgstr "Exported: {name}" + +msgid "hardsub.toast.no_auto_region" +msgstr "" +"No subtitle area was detected automatically. Please manually select the " +"subtitle location in the frame." + +msgid "home.btn.browse" +msgstr "Select file" + +msgid "home.btn.start" +msgstr "Start processing" + +msgid "home.confirm.has_subtitle" +msgstr "With subtitles" + +msgid "home.confirm.quality" +msgstr "Clarity" + +msgid "home.confirm.quality.best" +msgstr "Best" + +msgid "home.confirm.untitled" +msgstr "Untitled Video" + +msgid "home.dialog.filter.audio" +msgstr "Audio File" + +msgid "home.dialog.filter.media" +msgstr "Media File" + +msgid "home.dialog.filter.video" +msgstr "Video File" + +msgid "home.dialog.select_media" +msgstr "Select Media File" + +msgid "home.download.done.body" +msgstr "Starting automatic processing..." + +msgid "home.download.done.title" +msgstr "Download complete" + +msgid "home.download.parsing" +msgstr "Parsing video info…" + +msgid "home.download.start" +msgstr "Start Download" + +msgid "home.error.invalid_input" +msgstr "Please enter a valid local audio/video file or a full http / https link." + +msgid "home.error.unsupported_drop" +msgstr "The dropped file is not a supported audio/video format" + +msgid "home.footer.donate" +msgstr "Donate" + +msgid "home.footer.logs" +msgstr "View Logs" + +msgid "home.hero.title" +msgstr "Import video, generate subtitles and dubbing" + +msgid "home.input.placeholder" +msgstr "Paste a video link, or drag in a local audio/video file" + +msgid "home.media.kind.audio" +msgstr "Audio" + +msgid "home.media.kind.video" +msgstr "Video" + +msgid "home.media.ready" +msgstr "Ready" + +msgid "home.quick.confirm" +msgstr "Parsing complete · Confirm resolution to start download" + +msgid "home.quick.downloading" +msgstr "Online video link · Automatically proceed to the next step after download" + +msgid "home.quick.empty" +msgstr "Drag files here · Supports local audio/video and online video links" + +msgid "home.quick.file" +msgstr "Local media file · Automatically start speech transcription after starting" + +msgid "home.quick.invalid" +msgstr "Unable to recognize input content" + +msgid "home.quick.parsing" +msgstr "Parsing video information, confirm quality later" + +msgid "home.quick.url" +msgstr "Online video link · Will download to the working directory first" + +msgid "home.status.invalid" +msgstr "Invalid input" + +msgid "home.status.parsing" +msgstr "Parsing" + +msgid "home.status.pending" +msgstr "Pending confirmation" + +msgid "home.status.ready" +msgstr "Ready to start" + +msgid "home.status.waiting" +msgstr "Waiting for input" + +msgid "home.tip.downloading" +msgstr "Downloading" + +msgid "home.tip.parsing" +msgstr "Parsing" + +msgid "homeflow.tab.subtitle_optimize" +msgstr "Subtitle Optimization and Translation" + +msgid "homeflow.tab.task_creation" +msgstr "Task Creation" + +msgid "homeflow.tab.transcription" +msgstr "Speech Transcription" + +msgid "homeflow.tab.video_synthesis" +msgstr "Subtitle Video Composition" + +msgid "inspector.color.pick" +msgstr "Select Color" + +msgid "inspector.color.pick_prefix" +msgstr "Select" + +msgid "inspector.style.rename" +msgstr "Rename" + +msgid "inspector.style.source.builtin" +msgstr "Built-in" + +msgid "inspector.style.source.mine" +msgstr "Mine" + +msgid "lclang.ar" +msgstr "Arabic" + +msgid "lclang.auto" +msgstr "Auto Detect" + +msgid "lclang.cs" +msgstr "Czech" + +msgid "lclang.da" +msgstr "Danish" + +msgid "lclang.de" +msgstr "German" + +msgid "lclang.en" +msgstr "English" + +msgid "lclang.es" +msgstr "Spanish" + +msgid "lclang.fi" +msgstr "Finnish" + +msgid "lclang.fil" +msgstr "Filipino" + +msgid "lclang.fr" +msgstr "French" + +msgid "lclang.hi" +msgstr "Hindi" + +msgid "lclang.id" +msgstr "Indonesian" + +msgid "lclang.is" +msgstr "Icelandic" + +msgid "lclang.it" +msgstr "Italian" + +msgid "lclang.ja" +msgstr "Japanese" + +msgid "lclang.ko" +msgstr "Korean" + +msgid "lclang.ms" +msgstr "Malay" + +msgid "lclang.no" +msgstr "Norwegian" + +msgid "lclang.pl" +msgstr "Polish" + +msgid "lclang.pt" +msgstr "Portuguese" + +msgid "lclang.ru" +msgstr "Russian" + +msgid "lclang.sv" +msgstr "Swedish" + +msgid "lclang.th" +msgstr "Thai" + +msgid "lclang.tr" +msgstr "Turkish" + +msgid "lclang.uk" +msgstr "Ukrainian" + +msgid "lclang.vi" +msgstr "Vietnamese" + +msgid "lclang.yue" +msgstr "Cantonese" + +msgid "lclang.zh" +msgstr "Chinese" + +msgid "live.delete.confirm" +msgstr "" +"Delete “{name}”? The entire record (including audio) will be removed from" +" disk. This action cannot be undone." + +msgid "live.delete.title" +msgstr "Delete Record" + +msgid "live.device.default_input" +msgstr "Default Input" + +msgid "live.device.default_tag" +msgstr "(Default)" + +msgid "live.device.system_audio" +msgstr "System Sound" + +msgid "live.error.concurrency_full" +msgstr "" +"Backend transcription service concurrency is full (shared interface " +"limited to 5 streams). Please try again later." + +msgid "live.error.start_failed" +msgstr "Startup Failed" + +msgid "live.error.start_failed_desc" +msgstr "Failed to start live captions" + +msgid "live.error.transcribe_failed" +msgstr "Transcription pipeline error" + +msgid "live.export.dialog_title" +msgstr "Export" + +msgid "live.export.failed" +msgstr "Export failed" + +msgid "live.export.success" +msgstr "Export successful" + +msgid "live.ready.desc" +msgstr "Select an audio source to start" + +msgid "live.ready.title" +msgstr "Start new live subtitles" + +msgid "live.rename.placeholder" +msgstr "Record name" + +msgid "live.rename.title" +msgstr "Rename record" + +msgid "live.status.connecting" +msgstr "Connecting…" + +msgid "live.status.connecting_with_time" +msgstr "00:00 · Connecting…" + +msgid "live.status.paused" +msgstr "Paused" + +msgid "live.status.progress" +msgstr "{time} · {count} sentences generated" + +msgid "live.status.recording" +msgstr "Recording" + +msgid "live.status.saved" +msgstr "Saved" + +msgid "live.status.waiting" +msgstr "Waiting to start" + +msgid "live.title" +msgstr "Live subtitles" + +msgid "livetx.menu.copy_all" +msgstr "Copy all" + +msgid "livetx.menu.copy_sentence" +msgstr "Copy this sentence" + +msgid "liveview.action.back" +msgstr "Back" + +msgid "liveview.action.export" +msgstr "Export" + +msgid "liveview.action.folder" +msgstr "Directory" + +msgid "liveview.action.home" +msgstr "Home" + +msgid "liveview.action.open" +msgstr "Open" + +msgid "liveview.action.refresh" +msgstr "Refresh" + +msgid "liveview.action.rename" +msgstr "Rename" + +msgid "liveview.btn.new_session" +msgstr "New Session" + +msgid "liveview.btn.pause" +msgstr "Pause" + +msgid "liveview.btn.resume" +msgstr "Resume" + +msgid "liveview.btn.saving" +msgstr "Saving…" + +msgid "liveview.btn.start" +msgstr "Start Live Captions" + +msgid "liveview.btn.stop" +msgstr "Finish and Save" + +msgid "liveview.btn.view_record" +msgstr "View History" + +msgid "liveview.detail.display" +msgstr "Show" + +msgid "liveview.detail.export" +msgstr "Export" + +msgid "liveview.display.bilingual" +msgstr "Bilingual" + +msgid "liveview.display.source" +msgstr "Original" + +msgid "liveview.display.target" +msgstr "Translation" + +msgid "liveview.error.detail" +msgstr "Failed to capture audio. Switch the source on the right and try again." + +msgid "liveview.error.title" +msgstr "Failed to start" + +msgid "liveview.export.srt" +msgstr "SRT Subtitles" + +msgid "liveview.export.txt" +msgstr "TXT Text" + +msgid "liveview.history.empty.detail" +msgstr "Saved live captions will appear here." + +msgid "liveview.history.empty.title" +msgstr "No records yet" + +msgid "liveview.history.search_placeholder" +msgstr "Search record name or text" + +msgid "liveview.option.audio_source" +msgstr "Audio source" + +msgid "liveview.option.live_translate" +msgstr "Real-time translation" + +msgid "liveview.option.overlay" +msgstr "Desktop floating window" + +msgid "liveview.option.source_language" +msgstr "Recognition language" + +msgid "liveview.option.target_language" +msgstr "Translation language" + +msgid "liveview.ready.detail" +msgstr "Select an audio source to get started" + +msgid "liveview.ready.title" +msgstr "Start new real-time subtitles" + +msgid "liveview.recent.empty.detail" +msgstr "" +"Start a real-time subtitle session. It will be saved here automatically " +"when finished." + +msgid "liveview.recent.empty.title" +msgstr "No records yet" + +msgid "liveview.recent.title" +msgstr "Recent records" + +msgid "liveview.recent.view_all" +msgstr "View all" + +msgid "liveview.settings.config" +msgstr "Configuration" + +msgid "liveview.settings.title" +msgstr "Current settings" + +msgid "liveview.timer.saving" +msgstr "Saving…" + +msgid "liveview.timer.waiting" +msgstr "Waiting to start" + +msgid "liveview.title.detail" +msgstr "Record details" + +msgid "liveview.title.history" +msgstr "Real-time subtitle history" + +msgid "liveview.title.session" +msgstr "Real-time subtitles" + +msgid "llmlog.btn.clear" +msgstr "Clear Logs" + +msgid "llmlog.btn.refresh" +msgstr "Refresh" + +msgid "llmlog.clear.confirm_body" +msgstr "Are you sure you want to clear all logs? This action cannot be undone." + +msgid "llmlog.clear.confirm_btn" +msgstr "Clear" + +msgid "llmlog.clear.confirm_title" +msgstr "Confirm Clear" + +msgid "llmlog.clear.success" +msgstr "Logs cleared" + +msgid "llmlog.col.duration" +msgstr "Duration" + +msgid "llmlog.col.file" +msgstr "File" + +msgid "llmlog.col.model" +msgstr "Model" + +msgid "llmlog.col.stage" +msgstr "Stage" + +msgid "llmlog.col.time" +msgstr "Time" + +msgid "llmlog.copied" +msgstr "Copied" + +msgid "llmlog.detail.foot_hint" +msgstr "" +"The copy button only copies the corresponding JSON; press Esc or close " +"from the top right." + +msgid "llmlog.detail.title" +msgstr "Request Details" + +msgid "llmlog.empty.hint" +msgstr "" +"After enabling subtitle correction, smart sentence splitting, or LLM " +"translation, the page will automatically record requests and responses." + +msgid "llmlog.empty.no_match.hint" +msgstr "Try another task ID, file name, model, or stage keyword." + +msgid "llmlog.empty.no_match.title" +msgstr "No matching logs" + +msgid "llmlog.empty.title" +msgstr "No LLM request logs yet" + +msgid "llmlog.empty_payload" +msgstr "(empty)" + +msgid "llmlog.meta.duration" +msgstr "Duration" + +msgid "llmlog.meta.result" +msgstr "Result" + +msgid "llmlog.meta.stage" +msgstr "Stage" + +msgid "llmlog.meta.time" +msgstr "Time" + +msgid "llmlog.no_records" +msgstr "No records" + +msgid "llmlog.panel.error" +msgstr "Error Response" + +msgid "llmlog.panel.error.desc" +msgstr "Error JSON returned by the model or API" + +msgid "llmlog.panel.request" +msgstr "Request Body" + +msgid "llmlog.panel.request.desc" +msgstr "Full Request JSON sent to the model" + +msgid "llmlog.panel.response" +msgstr "Response Body" + +msgid "llmlog.panel.response.desc" +msgstr "Raw Response JSON returned by the model" + +msgid "llmlog.refresh.success" +msgstr "Refresh successful" + +msgid "llmlog.result.done" +msgstr "Completed" + +msgid "llmlog.result.failed" +msgstr "Failed" + +msgid "llmlog.search.placeholder" +msgstr "Search task ID, file name, model, or stage" + +msgid "llmlog.status.filtered" +msgstr "{total} total · {shown} after filtering · Double-click to view full JSON" + +msgid "llmlog.status.total" +msgstr "{total} total · Double-click to view full JSON" + +msgid "llmlog.status.total_zero" +msgstr "0 total" + +msgid "llmlog.title" +msgstr "LLM Request Logs" + +msgid "llmlog.unknown" +msgstr "Unknown" + +msgid "logwin.btn.open_folder" +msgstr "Open Log Folder" + +msgid "logwin.error.open_failed" +msgstr "Failed to open log file: {error}" + +msgid "logwin.error.read_failed" +msgstr "Failed to read log file: {error}" + +msgid "logwin.error.update_failed" +msgstr "Error reading log file: {error}" + +msgid "logwin.history_divider" +msgstr "Above are historical logs" + +msgid "logwin.title" +msgstr "Log Viewer" + +msgid "modelmgr.action.current" +msgstr "Current" + +msgid "modelmgr.action.download" +msgstr "Download" + +msgid "modelmgr.action.resume" +msgstr "Continue" + +msgid "modelmgr.col.action" +msgstr "Action" + +msgid "modelmgr.col.model" +msgstr "Model" + +msgid "modelmgr.col.size" +msgstr "Size" + +msgid "modelmgr.col.status" +msgstr "Status" + +msgid "modelmgr.command.copied_body" +msgstr "Run in the terminal, then click \"Recheck\"." + +msgid "modelmgr.command.copied_title" +msgstr "Installation command copied" + +msgid "modelmgr.command.copy" +msgstr "Copy Command" + +msgid "modelmgr.download.done_body" +msgstr "{name} download complete." + +msgid "modelmgr.download.done_title" +msgstr "Model ready" + +msgid "modelmgr.download.failed" +msgstr "Download failed" + +msgid "modelmgr.footer.model_dir" +msgstr "Local model directory" + +msgid "modelmgr.footer.open_dir" +msgstr "Open Directory" + +msgid "modelmgr.program.available" +msgstr "Available" + +msgid "modelmgr.program.found" +msgstr "Found {name}" + +msgid "modelmgr.program.missing" +msgstr "Missing" + +msgid "modelmgr.program.open_page" +msgstr "Open page" + +msgid "modelmgr.program.opened_body" +msgstr "After downloading and installing, come back and click \"Check again\"." + +msgid "modelmgr.program.opened_title" +msgstr "Opened in browser" + +msgid "modelmgr.program.ready_body" +msgstr "You can continue downloading the model." + +msgid "modelmgr.program.ready_title" +msgstr "Runtime is ready" + +msgid "modelmgr.program.recheck" +msgstr "Check again" + +msgid "modelmgr.progress.connecting" +msgstr "Connecting to mirror…" + +msgid "modelmgr.recheck.available_body" +msgstr "Found {name}." + +msgid "modelmgr.recheck.available_title" +msgstr "Runtime available" + +msgid "modelmgr.recheck.missing_title" +msgstr "Still not detected" + +msgid "modelmgr.remove.body" +msgstr "" +"This will delete {name} ({size}); you will need to download it again if " +"needed." + +msgid "modelmgr.remove.done" +msgstr "Deleted" + +msgid "modelmgr.remove.failed" +msgstr "Delete failed" + +msgid "modelmgr.remove.title" +msgstr "Delete model" + +msgid "modelmgr.section.models" +msgstr "Model file" + +msgid "modelmgr.section.programs" +msgstr "Runtime" + +msgid "modelmgr.status.downloading" +msgstr "Downloading" + +msgid "modelmgr.status.installed" +msgstr "Downloaded" + +msgid "modelmgr.status.paused" +msgstr "Paused" + +msgid "modelmgr.status.pending" +msgstr "Pending download" + +msgid "modelmgr.title" +msgstr "Local Model Management" + +msgid "overlay.listening" +msgstr "Listening…" + +msgid "overlay.paused" +msgstr "Paused" + +msgid "overlayset.bg" +msgstr "Background Style" + +msgid "overlayset.bg.black" +msgstr "Solid Black" + +msgid "overlayset.bg.outline" +msgstr "Outline Only" + +msgid "overlayset.bg.translucent" +msgstr "Semi-transparent" + +msgid "overlayset.display" +msgstr "Display Content" + +msgid "overlayset.display.bilingual" +msgstr "Bilingual" + +msgid "overlayset.display.source" +msgstr "Original Only" + +msgid "overlayset.display.target" +msgstr "Translation Only" + +msgid "overlayset.font_size" +msgstr "Font Size" + +msgid "overlayset.font_size.medium" +msgstr "Medium" + +msgid "overlayset.title" +msgstr "Subtitle Settings" + +msgid "roi.scrub.hint" +msgstr "Drag to view other frames" + +msgid "roi.tag.caption_area" +msgstr "Subtitle Area" + +msgid "settings.about.current_version" +msgstr "Current Version" + +msgid "settings.about.feedback" +msgstr "Feedback" + +msgid "settings.about.feedback.desc" +msgstr "Submit feedback when you encounter an issue." + +msgid "settings.about.feedback_button" +msgstr "Submit Feedback" + +msgid "settings.about.help" +msgstr "Help" + +msgid "settings.about.help.desc" +msgstr "View instructions and FAQs." + +msgid "settings.about.help_button" +msgstr "Open Help" + +msgid "settings.about.update_button" +msgstr "Check for Updates" + +msgid "settings.about.version" +msgstr "Version" + +msgid "settings.busy.loading" +msgstr "Loading..." + +msgid "settings.busy.testing" +msgstr "Testing..." + +msgid "settings.busy.transcribing" +msgstr "Transcribing..." + +msgid "settings.dubbing.api_key" +msgstr "Dubbing API Key" + +msgid "settings.dubbing.api_key.desc" +msgstr "Required for Gemini or SiliconFlow dubbing." + +msgid "settings.dubbing.audio_mode" +msgstr "Original Audio Processing" + +msgid "settings.dubbing.audio_mode.desc" +msgstr "How to handle the original video audio when generating a video." + +msgid "settings.dubbing.audio_mode.duck" +msgstr "Lower Original Audio" + +msgid "settings.dubbing.audio_mode.mix" +msgstr "Mix Original Audio" + +msgid "settings.dubbing.audio_mode.replace" +msgstr "Replace Original Audio" + +msgid "settings.dubbing.config_error" +msgstr "Dubbing Configuration Error" + +msgid "settings.dubbing.enabled" +msgstr "Add Dubbing by Default" + +msgid "settings.dubbing.enabled.desc" +msgstr "" +"When enabled, the full workflow generates a dubbing audio track by " +"default." + +msgid "settings.dubbing.model" +msgstr "Dubbing Model" + +msgid "settings.dubbing.model.desc" +msgstr "Text-to-speech model used by the current dubbing provider." + +msgid "settings.dubbing.need_api_key" +msgstr "The current dubbing provider requires an API Key." + +msgid "settings.dubbing.preset" +msgstr "Default Voice" + +msgid "settings.dubbing.preset.desc" +msgstr "" +"After saving, this will be used as the default voice on the dubbing page " +"and in the full workflow." + +msgid "settings.dubbing.provider" +msgstr "Dubbing Provider" + +msgid "settings.dubbing.provider.desc" +msgstr "Edge requires no Key; Gemini and SiliconFlow require an API Key." + +msgid "settings.dubbing.test" +msgstr "Dubbing Test" + +msgid "settings.dubbing.test.desc" +msgstr "Generate a sample audio clip with the current voice." + +msgid "settings.dubbing.test_button" +msgstr "Test Dubbing" + +msgid "settings.dubbing.test_failed" +msgstr "Dubbing test failed" + +msgid "settings.dubbing.test_success" +msgstr "Dubbing test succeeded" + +msgid "settings.dubbing.test_success.detail" +msgstr "{provider} generated a sample audio file: {path}" + +msgid "settings.dubbing.text_track" +msgstr "Dubbing Text Track" + +msgid "settings.dubbing.text_track.auto" +msgstr "Auto Select" + +msgid "settings.dubbing.text_track.desc" +msgstr "Choose original text, translation, or auto-detect for dubbing." + +msgid "settings.dubbing.text_track.first" +msgstr "First Line" + +msgid "settings.dubbing.text_track.second" +msgstr "Second Line" + +msgid "settings.dubbing.timing" +msgstr "Time Alignment" + +msgid "settings.dubbing.timing.balanced" +msgstr "Balanced" + +msgid "settings.dubbing.timing.desc" +msgstr "Controls how closely the voiceover speed matches the subtitle timeline." + +msgid "settings.dubbing.timing.natural" +msgstr "Natural" + +msgid "settings.dubbing.timing.none" +msgstr "No speed change" + +msgid "settings.dubbing.timing.strict" +msgstr "Strict" + +msgid "settings.dubbing.workers" +msgstr "Voiceover concurrency" + +msgid "settings.dubbing.workers.desc" +msgstr "Number of subtitle lines synthesized at the same time." + +msgid "settings.live_caption.asr_model" +msgstr "Recognition model" + +msgid "settings.live_caption.asr_model.desc" +msgstr "" +"Fun-ASR recognition model; multilingual models can automatically " +"recognize multiple languages." + +msgid "settings.live_caption.bg.black" +msgstr "Pure black" + +msgid "settings.live_caption.bg.translucent" +msgstr "Translucent" + +msgid "settings.live_caption.bg_style" +msgstr "Background style" + +msgid "settings.live_caption.bg_style.desc" +msgstr "Floating window background style" + +msgid "settings.live_caption.deps_button" +msgstr "Download / Check" + +msgid "settings.live_caption.display.bilingual" +msgstr "Bilingual" + +msgid "settings.live_caption.display.source" +msgstr "Original only" + +msgid "settings.live_caption.display.target" +msgstr "Translation only" + +msgid "settings.live_caption.display_mode" +msgstr "Display content" + +msgid "settings.live_caption.display_mode.desc" +msgstr "Content shown by default in the floating window" + +msgid "settings.live_caption.engine" +msgstr "Transcription engine" + +msgid "settings.live_caption.engine.desc" +msgstr "Recognition engine used for real-time speech-to-text." + +msgid "settings.live_caption.engine.group" +msgstr "Transcription Engine" + +msgid "settings.live_caption.font_scale" +msgstr "Font Size" + +msgid "settings.live_caption.font_scale.desc" +msgstr "Floating Window Translation Font Size" + +msgid "settings.live_caption.fun_key" +msgstr "Bailian API Key" + +msgid "settings.live_caption.fun_key.desc" +msgstr "" +"Key required by Fun-ASR / Qwen-ASR to call Alibaba Cloud Bailian, shared " +"with the transcription configuration." + +msgid "settings.live_caption.overlay.group" +msgstr "Floating Window Display" + +msgid "settings.live_caption.source_language" +msgstr "Recognition Language" + +msgid "settings.live_caption.source_language.desc" +msgstr "" +"The language you speak; the recognition engine uses it for transcription." +" Auto-detect lets the engine determine the language from the audio." + +msgid "settings.live_caption.target_language" +msgstr "Target Language" + +msgid "settings.live_caption.target_language.desc" +msgstr "The language to translate the text into." + +msgid "settings.live_caption.test.desc" +msgstr "" +"Run a real transcription with built-in short audio to verify the current " +"engine works." + +msgid "settings.live_caption.translate" +msgstr "Real-time Translation" + +msgid "settings.live_caption.translate.desc" +msgstr "" +"When enabled, transcribed text is translated instantly; when disabled, " +"only the original text is shown." + +msgid "settings.live_caption.translate.group" +msgstr "Translate" + +msgid "settings.live_caption.translator_service" +msgstr "Translation Engine" + +msgid "settings.live_caption.translator_service.desc" +msgstr "Translation service used to generate the translated text." + +msgid "settings.live_caption.voxgate" +msgstr "Transcription Program" + +msgid "settings.live_caption.voxgate.desc" +msgstr "" +"voxgate local transcription program. Click here to download or detect it " +"if not installed." + +msgid "settings.llm.api_key" +msgstr "API Key" + +msgid "settings.llm.api_key.desc" +msgstr "Used when {service} calls the large model." + +msgid "settings.llm.base_url" +msgstr "Base URL" + +msgid "settings.llm.base_url.desc" +msgstr "Only needs to be changed for OpenAI-compatible or local services." + +msgid "settings.llm.connect_error" +msgstr "LLM connection error" + +msgid "settings.llm.connect_failed" +msgstr "LLM connection failed" + +msgid "settings.llm.connect_success" +msgstr "LLM connected successfully" + +msgid "settings.llm.immersive_hint.desc" +msgstr "" +"The free community model has limited quota and may be rate-limited or " +"fail occasionally at peak times. For better reliability and quality, " +"configure your own LLM key." + +msgid "settings.llm.immersive_hint.title" +msgstr "Free · No API Key" + +msgid "settings.llm.load_models" +msgstr "Load models" + +msgid "settings.llm.model" +msgstr "Model" + +msgid "settings.llm.model.desc" +msgstr "" +"Name of the large model used for sentence splitting, correction, and " +"translation." + +msgid "settings.llm.model_service" +msgstr "Model service" + +msgid "settings.llm.model_service.desc" +msgstr "" +"Load available models first, then test connectivity with the current " +"model." + +msgid "settings.llm.models_load_failed" +msgstr "Failed to load models" + +msgid "settings.llm.models_loaded" +msgstr "Models loaded" + +msgid "settings.llm.models_loaded.desc" +msgstr "Loaded {count} models." + +msgid "settings.llm.no_models" +msgstr "No available models" + +msgid "settings.llm.no_models.desc" +msgstr "" +"No model list was retrieved from the current provider. Please check the " +"Base URL and API Key." + +msgid "settings.llm.provider" +msgstr "LLM provider" + +msgid "settings.llm.provider.desc" +msgstr "Used for subtitle sentence splitting, correction, and LLM translation." + +msgid "settings.llm.test_connection" +msgstr "Test connection" + +msgid "settings.llm.warn.need_all" +msgstr "Please enter the current provider's Base URL, API Key, and model first." + +msgid "settings.llm.warn.need_base_key" +msgstr "Please enter the current provider's Base URL and API Key first." + +msgid "settings.page.about" +msgstr "About" + +msgid "settings.page.dubbing" +msgstr "Voiceover Settings" + +msgid "settings.page.live_caption" +msgstr "Real-time Subtitle Settings" + +msgid "settings.page.llm" +msgstr "LLM Settings" + +msgid "settings.page.personal" +msgstr "Personalization" + +msgid "settings.page.save" +msgstr "Save Settings" + +msgid "settings.page.subtitle" +msgstr "Subtitle Burn-in Settings" + +msgid "settings.page.transcribe" +msgstr "Transcription Settings" + +msgid "settings.page.translate" +msgstr "Translation and Optimization" + +msgid "settings.page.translate_service" +msgstr "Translation Service" + +msgid "settings.personal.choose_theme_color" +msgstr "Choose Theme Color" + +msgid "settings.personal.follow_system" +msgstr "Follow System" + +msgid "settings.personal.language" +msgstr "Language" + +msgid "settings.personal.restart_required" +msgstr "Restart the app for changes to take effect." + +msgid "settings.personal.theme" +msgstr "App Theme" + +msgid "settings.personal.theme.dark" +msgstr "Dark" + +msgid "settings.personal.theme.desc" +msgstr "Switch between light, dark, or follow system." + +msgid "settings.personal.theme.light" +msgstr "Light" + +msgid "settings.personal.theme_color" +msgstr "Theme Color" + +msgid "settings.personal.theme_color.desc" +msgstr "Affects buttons, highlights, and selected states." + +msgid "settings.personal.theme_color.is_default" +msgstr "Already using the project default green" + +msgid "settings.personal.theme_color.pick_tip" +msgstr "Click to choose theme color: {color}" + +msgid "settings.personal.theme_color.reset" +msgstr "Restore default" + +msgid "settings.personal.theme_color.reset_tip" +msgstr "Restore to the project default green" + +msgid "settings.personal.zoom" +msgstr "Interface scaling" + +msgid "settings.placeholder.empty" +msgstr "Not filled in" + +msgid "settings.placeholder.not_selected" +msgstr "Not selected" + +msgid "settings.restart.confirm" +msgstr "Restart now" + +msgid "settings.restart.later" +msgstr "Later" + +msgid "settings.restart.message" +msgstr "" +"Language or display settings have changed and require an app restart to " +"take effect. Restart now?" + +msgid "settings.restart.title" +msgstr "Prompt" + +msgid "settings.save.cache" +msgstr "Enable cache" + +msgid "settings.save.cache.desc" +msgstr "" +"Reuse ASR, translation, and dubbing synthesis results with the same " +"configuration." + +msgid "settings.save.cache_disabled" +msgstr "Cache disabled" + +msgid "settings.save.cache_disabled.detail" +msgstr "Future tasks will regenerate results." + +msgid "settings.save.cache_enabled" +msgstr "Cache enabled" + +msgid "settings.save.cache_enabled.detail" +msgstr "Future tasks will prioritize reusing existing results." + +msgid "settings.save.choose_work_dir" +msgstr "Select working directory" + +msgid "settings.save.keep_intermediates" +msgstr "Keep intermediate files" + +msgid "settings.save.keep_intermediates.desc" +msgstr "" +"After successful processing, keep intermediate files in the task folder, " +"such as original transcriptions and styled subtitles; by default, they " +"are deleted after completion." + +msgid "settings.save.work_dir" +msgstr "Working directory" + +msgid "settings.save.work_dir.desc" +msgstr "" +"Downloaded videos and intermediate files for processing tasks will be " +"saved here." + +msgid "settings.subtitle.layout" +msgstr "Subtitle layout" + +msgid "settings.subtitle.layout.desc" +msgstr "Choose monolingual, bilingual, and source/translation positions." + +msgid "settings.subtitle.need_video" +msgstr "Compose video" + +msgid "settings.subtitle.need_video.desc" +msgstr "" +"When disabled, only subtitle files are output; no final video is " +"generated." + +msgid "settings.subtitle.open_style" +msgstr "Open style page" + +msgid "settings.subtitle.render_mode" +msgstr "Rendering mode" + +msgid "settings.subtitle.render_mode.desc" +msgstr "Choose ASS style or rounded background rendering." + +msgid "settings.subtitle.soft" +msgstr "Soft subtitles" + +msgid "settings.subtitle.soft.desc" +msgstr "When enabled, subtitles are not burned into the video." + +msgid "settings.subtitle.style" +msgstr "Subtitle style" + +msgid "settings.subtitle.style.desc" +msgstr "Adjust fonts, colors, and preview images on the style page." + +msgid "settings.subtitle.video_quality" +msgstr "Video quality" + +msgid "settings.subtitle.video_quality.desc" +msgstr "Encoding quality used for hard-subtitle composition." + +msgid "settings.test_transcribe" +msgstr "Test transcription" + +msgid "settings.title" +msgstr "Settings" + +msgid "settings.transcribe.faster_whisper.choose_dir" +msgstr "Select Faster Whisper model directory" + +msgid "settings.transcribe.faster_whisper.device" +msgstr "Run device" + +msgid "settings.transcribe.faster_whisper.device.desc" +msgstr "Model runtime device, usually keep auto." + +msgid "settings.transcribe.faster_whisper.model" +msgstr "Faster Whisper Model" + +msgid "settings.transcribe.faster_whisper.model.desc" +msgstr "Select a downloaded Faster Whisper model." + +msgid "settings.transcribe.faster_whisper.model_dir" +msgstr "Model Directory" + +msgid "settings.transcribe.faster_whisper.model_dir.desc" +msgstr "Folder containing the Faster Whisper model." + +msgid "settings.transcribe.faster_whisper.one_word" +msgstr "Word-level Timestamps" + +msgid "settings.transcribe.faster_whisper.one_word.desc" +msgstr "Generate word-level timestamps when enabled." + +msgid "settings.transcribe.faster_whisper.vad_filter" +msgstr "VAD Filtering" + +msgid "settings.transcribe.faster_whisper.vad_filter.desc" +msgstr "Filter non-speech segments to reduce recognition hallucinations." + +msgid "settings.transcribe.faster_whisper.vad_method" +msgstr "VAD Method" + +msgid "settings.transcribe.faster_whisper.vad_method.desc" +msgstr "Select the voice activity detection method." + +msgid "settings.transcribe.faster_whisper.vad_threshold" +msgstr "VAD Threshold" + +msgid "settings.transcribe.faster_whisper.vad_threshold.desc" +msgstr "Speech probability threshold; values above this are treated as speech." + +msgid "settings.transcribe.faster_whisper.voice_extraction" +msgstr "Vocal Separation" + +msgid "settings.transcribe.faster_whisper.voice_extraction.desc" +msgstr "Separate vocals from background music before processing." + +msgid "settings.transcribe.fun_asr.key" +msgstr "Bailian API Key" + +msgid "settings.transcribe.fun_asr.key.desc" +msgstr "Required for Bailian Fun-ASR transcription." + +msgid "settings.transcribe.fun_asr.model" +msgstr "Bailian ASR Model" + +msgid "settings.transcribe.fun_asr.model.desc" +msgstr "Enter the speech recognition model Code available in the Bailian console." + +msgid "settings.transcribe.local_model" +msgstr "Local Model" + +msgid "settings.transcribe.local_model.desc" +msgstr "View runtime status, download and manage model files." + +msgid "settings.transcribe.local_model.no_model" +msgstr "No models downloaded yet. Open “Manage Models” to download one." + +msgid "settings.transcribe.local_model.not_installed" +msgstr "Runtime is not installed. Install it first in “Manage Models”." + +msgid "settings.transcribe.manage_models" +msgstr "Manage Models" + +msgid "settings.transcribe.missing.fun_asr_key" +msgstr "Please enter the Bailian API Key first." + +msgid "settings.transcribe.missing.local_model" +msgstr "No local models available yet. Download one first in “Manage Models”." + +msgid "settings.transcribe.missing.whisper_api" +msgstr "Please enter the Whisper Base URL, API Key, and model first." + +msgid "settings.transcribe.model" +msgstr "Transcription Model" + +msgid "settings.transcribe.model.desc" +msgstr "Select the speech recognition service used to generate original subtitles." + +msgid "settings.transcribe.output_format" +msgstr "Output Format" + +msgid "settings.transcribe.output_format.desc" +msgstr "Subtitle file format saved after transcription is complete." + +msgid "settings.transcribe.prompt" +msgstr "Prompt" + +msgid "settings.transcribe.prompt.desc" +msgstr "Optional transcription prompt. Empty by default." + +msgid "settings.transcribe.source_language" +msgstr "Source Language" + +msgid "settings.transcribe.source_language.desc" +msgstr "Language spoken in the audio/video. Keep auto-detect if unsure." + +msgid "settings.transcribe.test.desc" +msgstr "" +"Run a real transcription on built-in short audio to verify the current " +"service works." + +msgid "settings.transcribe.test_error" +msgstr "Transcription Test Error" + +msgid "settings.transcribe.test_failed" +msgstr "Transcription Test Failed" + +msgid "settings.transcribe.test_result" +msgstr "Recognition result: {text}" + +msgid "settings.transcribe.test_success" +msgstr "Transcription Test Successful" + +msgid "settings.transcribe.whisper_api.base" +msgstr "Whisper API Base URL" + +msgid "settings.transcribe.whisper_api.base.desc" +msgstr "Service address used when calling the Whisper API." + +msgid "settings.transcribe.whisper_api.key" +msgstr "Whisper API Key" + +msgid "settings.transcribe.whisper_api.key.desc" +msgstr "Required when transcribing with the Whisper API." + +msgid "settings.transcribe.whisper_api.model" +msgstr "Whisper Model" + +msgid "settings.transcribe.whisper_api.model.desc" +msgstr "Enter the audio transcription model name supported by the provider." + +msgid "settings.transcribe.whisper_cpp.model" +msgstr "WhisperCpp Model" + +msgid "settings.transcribe.whisper_cpp.model.desc" +msgstr "Select a downloaded whisper.cpp transcription model." + +msgid "settings.translate.cjk_length" +msgstr "Chinese Subtitle Length" + +msgid "settings.translate.cjk_length.desc" +msgstr "" +"Maximum number of Chinese characters per subtitle when splitting " +"sentences." + +msgid "settings.translate.custom_prompt" +msgstr "Custom Prompt" + +msgid "settings.translate.custom_prompt.desc" +msgstr "Additional LLM prompt for subtitle correction and translation." + +msgid "settings.translate.english_length" +msgstr "English Subtitle Length" + +msgid "settings.translate.english_length.desc" +msgstr "Maximum number of English words per subtitle when splitting sentences." + +msgid "settings.translate.optimize" +msgstr "Subtitle Correction" + +msgid "settings.translate.optimize.desc" +msgstr "Fix recognition errors and proper nouns when processing subtitles." + +msgid "settings.translate.split" +msgstr "Subtitle Sentence Splitting" + +msgid "settings.translate.split.desc" +msgstr "Re-split long subtitles by length and semantics." + +msgid "settings.translate.target_language" +msgstr "Target Language" + +msgid "settings.translate.target_language.desc" +msgstr "Target language for translated subtitle output." + +msgid "settings.translate.translate" +msgstr "Subtitle Translation" + +msgid "settings.translate.translate.desc" +msgstr "Generate target-language translations when processing subtitles." + +msgid "settings.translate_service.batch_size" +msgstr "Batch Size" + +msgid "settings.translate_service.batch_size.desc" +msgstr "Number of subtitles processed per batch by LLM translation." + +msgid "settings.translate_service.deeplx_endpoint" +msgstr "DeepLx Backend" + +msgid "settings.translate_service.deeplx_endpoint.desc" +msgstr "Required when selecting DeepLx translation." + +msgid "settings.translate_service.reflect" +msgstr "Reflective Translation" + +msgid "settings.translate_service.reflect.desc" +msgstr "Used only for LLM translation; increases model calls." + +msgid "settings.translate_service.service" +msgstr "Translation Service" + +msgid "settings.translate_service.service.desc" +msgstr "Select the service used for subtitle translation." + +msgid "settings.translate_service.thread_num" +msgstr "Concurrency" + +msgid "settings.translate_service.thread_num.desc" +msgstr "Increase this if allowed by the model service." + +msgid "settings.warn.incomplete" +msgstr "Incomplete Configuration" + +msgid "substyle.align.center" +msgstr "Center" + +msgid "substyle.align.left" +msgstr "Left" + +msgid "substyle.align.right" +msgstr "Right" + +msgid "substyle.content.bilingual" +msgstr "Bilingual" + +msgid "substyle.content.source" +msgstr "Original" + +msgid "substyle.content.target" +msgstr "Translation" + +msgid "substyle.copy_suffix" +msgstr "Copy" + +msgid "substyle.custom_suffix" +msgstr "Custom" + +msgid "substyle.dialog.delete_body" +msgstr "Delete style \"{name}\"? This action cannot be undone." + +msgid "substyle.dialog.delete_title" +msgstr "Delete Style" + +msgid "substyle.dialog.name_placeholder" +msgstr "Enter style name" + +msgid "substyle.dialog.new_style" +msgstr "New Style" + +msgid "substyle.dialog.pick_bg" +msgstr "Select Background Image" + +msgid "substyle.dialog.rename_style" +msgstr "Rename Style" + +msgid "substyle.dock.count" +msgstr "{n} sets total" + +msgid "substyle.dock.folder" +msgstr "Directory" + +msgid "substyle.dock.new" +msgstr "New" + +msgid "substyle.field.align" +msgstr "Alignment" + +msgid "substyle.field.bg_color" +msgstr "Background Color" + +msgid "substyle.field.bold" +msgstr "Bold" + +msgid "substyle.field.font" +msgstr "Font" + +msgid "substyle.field.letter_spacing" +msgstr "Letter Spacing" + +msgid "substyle.field.margin_bottom" +msgstr "Bottom Margin" + +msgid "substyle.field.max_width" +msgstr "Max Width" + +msgid "substyle.field.outline_color" +msgstr "Stroke Color" + +msgid "substyle.field.outline_width" +msgstr "Stroke Width" + +msgid "substyle.field.pad_h" +msgstr "Horizontal Padding" + +msgid "substyle.field.pad_v" +msgstr "Vertical Padding" + +msgid "substyle.field.radius" +msgstr "Corner Radius" + +msgid "substyle.field.size" +msgstr "Font Size" + +msgid "substyle.field.text_color" +msgstr "Text Color" + +msgid "substyle.filter.image" +msgstr "Image File" + +msgid "substyle.group.background" +msgstr "Background" + +msgid "substyle.group.layout" +msgstr "Subtitle Layout" + +msgid "substyle.group.padding" +msgstr "Padding" + +msgid "substyle.group.position" +msgstr "Position" + +msgid "substyle.group.primary" +msgstr "Primary Subtitle" + +msgid "substyle.group.secondary" +msgstr "Secondary Subtitle" + +msgid "substyle.group.text" +msgstr "Text" + +msgid "substyle.group.text.sub" +msgstr "Primary and Secondary Subtitles" + +msgid "substyle.inspector.autosave" +msgstr "Auto Save" + +msgid "substyle.inspector.title" +msgstr "Parameters" + +msgid "substyle.mode.ass" +msgstr "ASS Stroke" + +msgid "substyle.mode.rounded" +msgstr "Rounded Background" + +msgid "substyle.order.source_top" +msgstr "Original on Top" + +msgid "substyle.order.target_top" +msgstr "Translation on Top" + +msgid "substyle.preview.background" +msgstr "Change Background" + +msgid "substyle.preview.landscape" +msgstr "Landscape Preview" + +msgid "substyle.preview.portrait" +msgstr "Portrait Preview" + +msgid "substyle.preview.source_placeholder" +msgstr "Original text example for preview" + +msgid "substyle.preview.target_placeholder" +msgstr "Translation example for preview" + +msgid "substyle.preview.text" +msgstr "Preview Text" + +msgid "substyle.preview.title" +msgstr "Preview" + +msgid "substyle.row.content" +msgstr "Display Content" + +msgid "substyle.row.gap" +msgstr "Main/Sub Spacing" + +msgid "substyle.row.order" +msgstr "Bilingual Order" + +msgid "substyle.title" +msgstr "Subtitle Style Settings" + +msgid "substyle.toast.bg_set" +msgstr "Preview background set:" + +msgid "substyle.toast.created" +msgstr "Created style \"{name}\"" + +msgid "substyle.toast.deleted" +msgstr "Style deleted" + +msgid "substyle.toast.duplicated" +msgstr "Copied as \"{name}\"" + +msgid "substyle.toast.renamed" +msgstr "Renamed to \"{name}\"" + +msgid "subtitle.bottom.can_synthesize" +msgstr "Ready for composition" + +msgid "subtitle.bottom.count" +msgstr "{count} total" + +msgid "subtitle.bottom.hint" +msgstr "Right-click to merge, delete, or retranslate" + +msgid "subtitle.bottom.loaded" +msgstr "Loaded" + +msgid "subtitle.bottom.output" +msgstr "Output: {name}" + +msgid "subtitle.bottom.progress" +msgstr "Item {current} / {total}" + +msgid "subtitle.btn.clear" +msgstr "Clear" + +msgid "subtitle.btn.enter_synthesis" +msgstr "Go to Synthesis" + +msgid "subtitle.btn.folder" +msgstr "Directory" + +msgid "subtitle.btn.open_config" +msgstr "Open Processing Settings" + +msgid "subtitle.btn.processing" +msgstr "Processing" + +msgid "subtitle.btn.replace" +msgstr "Replace" + +msgid "subtitle.btn.start" +msgstr "Start Processing" + +msgid "subtitle.btn.wait_subtitle" +msgstr "Waiting for Subtitles" + +msgid "subtitle.clear.body" +msgstr "" +"This will clear loaded subtitles and unsaved edits, returning to the " +"initial state. Continue?" + +msgid "subtitle.clear.title" +msgstr "Clear Current Subtitles" + +msgid "subtitle.col.end" +msgstr "End" + +msgid "subtitle.col.original" +msgstr "Original" + +msgid "subtitle.col.start" +msgstr "Start" + +msgid "subtitle.col.translated" +msgstr "Translation" + +msgid "subtitle.dialog.choose_file" +msgstr "Select Subtitle File" + +msgid "subtitle.dialog.save_file" +msgstr "Save Subtitle File" + +msgid "subtitle.drop.pick" +msgstr "Click to Select Subtitles" + +msgid "subtitle.drop.title" +msgstr "Drag in a subtitle file" + +msgid "subtitle.error.need_llm" +msgstr "You need to configure an available LLM API Key, API URL, and model first." + +msgid "subtitle.error.no_config" +msgstr "Unable to build processing configuration" + +msgid "subtitle.fail.config_missing" +msgstr "Configuration missing" + +msgid "subtitle.fail.process" +msgstr "Processing failed" + +msgid "subtitle.file_filter" +msgstr "Subtitle file" + +msgid "subtitle.menu.merge" +msgstr "Merge" + +msgid "subtitle.menu.retranslate" +msgstr "Retranslate" + +msgid "subtitle.no_file" +msgstr "No subtitle file selected" + +msgid "subtitle.opt.language" +msgstr "Translation language" + +msgid "subtitle.opt.layout" +msgstr "Translation layout" + +msgid "subtitle.opt.optimize" +msgstr "Subtitle correction" + +msgid "subtitle.opt.prompt" +msgstr "Transcript prompt" + +msgid "subtitle.opt.split" +msgstr "Sentence splitting" + +msgid "subtitle.opt.translate" +msgstr "Subtitle translation" + +msgid "subtitle.process_settings" +msgstr "Processing settings" + +msgid "subtitle.prompt.placeholder" +msgstr "" +"Enter a transcript prompt (to help correct subtitles and translation)\n" +"\n" +"Supports the following:\n" +"1. Glossary - a correction mapping for technical terms, names, and " +"specific words\n" +"Example:\n" +"机器学习->Machine Learning\n" +"马斯克->Elon Musk\n" +"\n" +"2. Original subtitle transcript - the video's existing transcript or " +"related content\n" +"3. Correction requirements - unify personal pronouns, standardize " +"technical terms, etc.\n" +"\n" +"Note: When using a small LLM model, keep the transcript within 1,000 " +"Chinese characters." + +msgid "subtitle.prompt.set" +msgstr "Set" + +msgid "subtitle.prompt.unset" +msgstr "Not set" + +msgid "subtitle.status.preparing" +msgstr "Ready to process" + +msgid "subtitle.status.retranslating" +msgstr "Retranslate selected rows" + +msgid "subtitle.tip.collapse_settings" +msgstr "Collapse settings panel" + +msgid "subtitle.tip.expand_settings" +msgstr "Expand Processing Settings" + +msgid "subtitle.toast.format_error" +msgstr "Format error" + +msgid "subtitle.toast.load_failed" +msgstr "Failed to load" + +msgid "subtitle.toast.load_first" +msgstr "Please load a subtitle file first" + +msgid "subtitle.toast.no_video" +msgstr "" +"No associated video. Please select a video file on the “Subtitle-Video " +"Merge” page" + +msgid "subtitle.toast.processing_wait" +msgstr "Processing. Please wait for the current task to finish" + +msgid "subtitle.toast.save_done" +msgstr "Saved successfully" + +msgid "subtitle.toast.save_failed" +msgstr "Failed to save" + +msgid "subtitle.toast.saved_to" +msgstr "Subtitles saved to: " + +msgid "subtitle.toast.supported_formats" +msgstr "Supported subtitle formats: " + +msgid "subtitle.toast.translate_done" +msgstr "Translation complete" + +msgid "subtitle.toast.translate_done_body" +msgstr "Updated the translation for the selected row" + +msgid "subtitle.toast.translate_failed" +msgstr "Translation failed" + +msgid "synth.audio_mode.duck" +msgstr "Lower original audio" + +msgid "synth.audio_mode.mix" +msgstr "Mix with original audio" + +msgid "synth.audio_mode.replace" +msgstr "Replace original audio" + +msgid "synth.blocker.ffmpeg_missing" +msgstr "FFmpeg not found" + +msgid "synth.blocker.ffmpeg_missing_detail" +msgstr "Please install FFmpeg and ensure ffmpeg is in PATH." + +msgid "synth.blocker.ffprobe_missing" +msgstr "ffprobe missing — dubbing unavailable" + +msgid "synth.blocker.ffprobe_missing_detail" +msgstr "" +"Dubbing needs ffprobe for audio processing. Install the full FFmpeg suite" +" (with ffprobe) and retry." + +msgid "synth.blocker.key_required" +msgstr "Current voice requires API Key" + +msgid "synth.blocker.key_required_detail" +msgstr "" +"Current voice is missing an API Key. Check the dubbing settings or switch" +" to a free Edge voice." + +msgid "synth.btn.generate_audio" +msgstr "Generate dubbing audio" + +msgid "synth.btn.generate_full" +msgstr "Generate final video" + +msgid "synth.btn.generate_subtitle_video" +msgstr "Generate subtitled video" + +msgid "synth.btn.optional_video" +msgstr "Optional video" + +msgid "synth.btn.pick_output" +msgstr "Select output content" + +msgid "synth.btn.pick_subtitle" +msgstr "Select subtitles" + +msgid "synth.btn.pick_video" +msgstr "Select video" + +msgid "synth.btn.regenerate" +msgstr "Regenerate" + +msgid "synth.btn.wait_files" +msgstr "Waiting for file" + +msgid "synth.btn.wait_video" +msgstr "Waiting for video" + +msgid "synth.dialog.pick_media" +msgstr "Select subtitle and video files" + +msgid "synth.dialog.pick_subtitle" +msgstr "Select subtitle file" + +msgid "synth.dialog.pick_video" +msgstr "Select video file" + +msgid "synth.drop.formats" +msgstr "Subtitles: srt / ass / vtt Video: mp4 / mov / mkv" + +msgid "synth.drop.pick" +msgstr "Click to select file" + +msgid "synth.drop.title" +msgstr "Drag in subtitle and video files" + +msgid "synth.error.ass_unsupported" +msgstr "" +"FFmpeg does not support ASS hard subtitles. Please install a full FFmpeg " +"with libass, or switch to rounded background rendering." + +msgid "synth.error.dubbed_video_path_empty" +msgstr "Dubbing video output path is empty" + +msgid "synth.file.media" +msgstr "Media File" + +msgid "synth.file.reference_video" +msgstr "Reference Video" + +msgid "synth.file.subtitle" +msgstr "Subtitle File" + +msgid "synth.file.video" +msgstr "Video File" + +msgid "synth.hint.dub_only" +msgstr "Generate dubbing audio only; video file is optional" + +msgid "synth.hint.dub_then_synthesize" +msgstr "Dubbing will be generated first, then combined into a subtitled video" + +msgid "synth.hint.need_subtitle" +msgstr "Subtitle file required" + +msgid "synth.hint.need_subtitle_and_video" +msgstr "Subtitle file and video file required" + +msgid "synth.hint.need_video" +msgstr "Video file still required" + +msgid "synth.hint.pick_output" +msgstr "Please enable at least one output option on the right" + +msgid "synth.hint.synthesize_only" +msgstr "Subtitles will be embedded into the video" + +msgid "synth.link.style_page" +msgstr "Stylesheet" + +msgid "synth.link.voice_library" +msgstr "Voice Library" + +msgid "synth.opt.audio_mode" +msgstr "Audio Processing" + +msgid "synth.opt.render_mode" +msgstr "Rendering Mode" + +msgid "synth.opt.style" +msgstr "Style" + +msgid "synth.opt.subtitle_mode" +msgstr "Subtitle Mode" + +msgid "synth.opt.subtitle_style" +msgstr "Subtitle Style" + +msgid "synth.opt.text_track" +msgstr "Text Track" + +msgid "synth.opt.timing" +msgstr "Timing Alignment" + +msgid "synth.opt.video_quality" +msgstr "Video Quality" + +msgid "synth.opt.voice" +msgstr "Voice" + +msgid "synth.output.dubbing" +msgstr "Dubbing Track" + +msgid "synth.output.dubbing_desc" +msgstr "Generate Dubbing from Subtitles" + +msgid "synth.output.subtitle_video" +msgstr "Subtitled Video" + +msgid "synth.output.subtitle_video_desc" +msgstr "Burn Subtitles into Video" + +msgid "synth.panel.title" +msgstr "Generated This Time" + +msgid "synth.pill.completed" +msgstr "Completed" + +msgid "synth.pill.failed" +msgstr "Failed" + +msgid "synth.pill.missing_ffmpeg" +msgstr "Missing FFmpeg" + +msgid "synth.pill.missing_ffprobe" +msgstr "No ffprobe" + +msgid "synth.pill.missing_key" +msgstr "Missing Key" + +msgid "synth.pill.missing_video" +msgstr "Missing Video" + +msgid "synth.pill.no_output" +msgstr "No Output Selected" + +msgid "synth.pill.ready" +msgstr "Ready to Generate" + +msgid "synth.plan.collect" +msgstr "Organize Result Files" + +msgid "synth.plan.dubbing" +msgstr "Generate Dubbing Track" + +msgid "synth.plan.pending" +msgstr "Pending Generation" + +msgid "synth.plan.save" +msgstr "Save Result File" + +msgid "synth.plan.synthesize" +msgstr "Create Subtitled Video" + +msgid "synth.result.dubbed_audio" +msgstr "Dubbing Audio" + +msgid "synth.result.dubbed_video" +msgstr "Dubbing Video" + +msgid "synth.result.subtitled_video" +msgstr "Subtitled Video" + +msgid "synth.row.subtitle_required" +msgstr "Required: Select SRT / ASS / VTT subtitles" + +msgid "synth.row.video_optional" +msgstr "Optional: Also generate a dubbed video after selection" + +msgid "synth.row.video_required" +msgstr "Required: Select an MP4 / MOV / MKV video" + +msgid "synth.section.dubbing_params" +msgstr "Dubbing Settings" + +msgid "synth.section.output" +msgstr "Output Content" + +msgid "synth.section.subtitle_params" +msgstr "Subtitle Video Settings" + +msgid "synth.status.completed" +msgstr "Generation Complete" + +msgid "synth.status.done" +msgstr "Complete" + +msgid "synth.status.failed" +msgstr "Generation Failed" + +msgid "synth.status.generating" +msgstr "Generating result file" + +msgid "synth.status.missing" +msgstr "Missing" + +msgid "synth.status.optional" +msgstr "Optional" + +msgid "synth.status.processing" +msgstr "Generating" + +msgid "synth.status.ready" +msgstr "Ready" + +msgid "synth.status.waiting" +msgstr "Waiting" + +msgid "synth.subtitle_mode.hard" +msgstr "Hard Subtitles" + +msgid "synth.subtitle_mode.soft" +msgstr "Soft Subtitles" + +msgid "synth.text_track.auto" +msgstr "Auto Select" + +msgid "synth.text_track.first" +msgstr "First Line" + +msgid "synth.text_track.second" +msgstr "Second Line" + +msgid "synth.timing.balanced" +msgstr "Balanced" + +msgid "synth.timing.natural" +msgstr "Natural" + +msgid "synth.timing.strict" +msgstr "Strict Fit" + +msgid "synth.tip.collapse_panel" +msgstr "Collapse This Panel" + +msgid "synth.tip.expand_panel" +msgstr "Expand Generation Panel" + +msgid "synth.tip.open_config" +msgstr "Open Synthesis Settings" + +msgid "synth.title.config_check" +msgstr "Configuration Check" + +msgid "synth.title.confirm" +msgstr "Confirm Before Generation" + +msgid "synth.title.input" +msgstr "Input File" + +msgid "synth.title.results" +msgstr "Output File" + +msgid "synth.title.running" +msgstr "Generating" + +msgid "t_dubbing.error.config_empty" +msgstr "Dubbing configuration is empty" + +msgid "t_dubbing.error.output_audio_path_empty" +msgstr "Output audio path is empty" + +msgid "t_dubbing.error.subtitle_path_empty" +msgstr "Subtitle path is empty" + +msgid "t_dubbing.error.task_dir_empty" +msgstr "Task directory is empty" + +msgid "t_dubbing.status.done" +msgstr "Dubbing complete" + +msgid "t_dubbing.status.preparing" +msgstr "Preparing dubbing" + +msgid "t_hardsub.error.read_video_failed" +msgstr "Unable to read this video" + +msgid "t_hardsub.status.detecting_region" +msgstr "Detect subtitle area" + +msgid "t_hardsub.status.recognizing" +msgstr "Recognizing {count} items" + +msgid "t_media.error.no_video_file" +msgstr "Download completed but no video file was found. Try another link." + +msgid "t_media.status.completed" +msgstr "Download completed" + +msgid "t_subtitle.error.llm_model_not_configured" +msgstr "LLM model not configured" + +msgid "t_subtitle.error.llm_not_configured" +msgstr "LLM API not configured. Please check LLM settings." + +msgid "t_subtitle.error.llm_test_failed" +msgstr "LLM API test failed: {message}" + +msgid "t_subtitle.error.no_config" +msgstr "Subtitle settings are empty" + +msgid "t_subtitle.error.no_subtitle_path" +msgstr "Subtitle file path is empty" + +msgid "t_subtitle.error.no_target_language" +msgstr "Target language not configured" + +msgid "t_subtitle.error.unsupported_service" +msgstr "Unsupported translation service: {service}" + +msgid "t_subtitle.status.done" +msgstr "Processing completed" + +msgid "t_subtitle.status.optimizing" +msgstr "Optimizing subtitles..." + +msgid "t_subtitle.status.processing_percent" +msgstr "Processing subtitles {percent}%" + +msgid "t_subtitle.status.splitting" +msgstr "Splitting subtitle sentences..." + +msgid "t_subtitle.status.translating" +msgstr "Translating subtitles..." + +msgid "t_subtitle.status.translating_percent" +msgstr "Translating {percent}%" + +msgid "t_subtitle.status.verifying_llm" +msgstr "Starting LLM configuration validation..." + +msgid "t_synth.error.no_output_path" +msgstr "Output path is empty" + +msgid "t_synth.error.no_subtitle_path" +msgstr "Subtitle path is empty" + +msgid "t_synth.error.no_video_path" +msgstr "Video path is empty" + +msgid "t_synth.status.done" +msgstr "Synthesis complete" + +msgid "t_synth.status.synthesizing" +msgstr "Synthesizing" + +msgid "t_transcript.error.audio_extract_failed" +msgstr "Audio conversion failed" + +msgid "t_transcript.error.file_not_found" +msgstr "Media file does not exist" + +msgid "t_transcript.error.no_config" +msgstr "Transcription config is empty" + +msgid "t_transcript.error.no_file_path" +msgstr "File path is empty" + +msgid "t_transcript.error.no_output_path" +msgstr "Output path is empty" + +msgid "t_transcript.status.done" +msgstr "Transcription complete" + +msgid "t_transcript.status.extracting_audio" +msgstr "Converting audio" + +msgid "t_transcript.status.transcribing" +msgstr "Transcribing speech" + +msgid "t_voice.error.need_api_key" +msgstr "" +"To preview {provider}, please enter the voiceover API Key in Settings " +"first" + +msgid "t_voice.sample_text" +msgstr "Hello, this is a voiceover preview from Kaka Subtitle Assistant." + +msgid "transcribe.action.cancel" +msgstr "Cancel transcription" + +msgid "transcribe.action.replace_file" +msgstr "Replace file" + +msgid "transcribe.btn.open_subtitle" +msgstr "Go to subtitle optimization" + +msgid "transcribe.btn.retranscribe" +msgstr "Transcribe again" + +msgid "transcribe.btn.running" +msgstr "Transcribing" + +msgid "transcribe.btn.start" +msgstr "Start transcription" + +msgid "transcribe.btn.waiting_file" +msgstr "Waiting for file" + +msgid "transcribe.col.content" +msgstr "Subtitle content" + +msgid "transcribe.col.end" +msgstr "End time" + +msgid "transcribe.col.start" +msgstr "Start time" + +msgid "transcribe.config.open_tip" +msgstr "Open transcription settings" + +msgid "transcribe.dialog.media_filter" +msgstr "Media file" + +msgid "transcribe.dialog.pick_media" +msgstr "Select media file" + +msgid "transcribe.drop.pick" +msgstr "Click to select file" + +msgid "transcribe.drop.title" +msgstr "Drag in an audio or video file" + +msgid "transcribe.empty.title" +msgstr "No media file selected" + +msgid "transcribe.error.title" +msgstr "Failure reason" + +msgid "transcribe.file.title" +msgstr "Current file" + +msgid "transcribe.format.txt" +msgstr "TXT" + +msgid "transcribe.opt.language" +msgstr "Language" + +msgid "transcribe.opt.model" +msgstr "Model" + +msgid "transcribe.opt.output" +msgstr "Output" + +msgid "transcribe.opt.service" +msgstr "Service" + +msgid "transcribe.opt.track" +msgstr "Audio track" + +msgid "transcribe.opt.word_timestamp" +msgstr "Word timestamps" + +msgid "transcribe.params.collapse_tip" +msgstr "Collapse parameter panel" + +msgid "transcribe.params.expand_tip" +msgstr "Expand parameters panel" + +msgid "transcribe.params.title" +msgstr "Parameters" + +msgid "transcribe.pending.not_started" +msgstr "Transcription not started yet" + +msgid "transcribe.preview.count" +msgstr "{n} items" + +msgid "transcribe.preview.title" +msgstr "Original subtitle preview" + +msgid "transcribe.progress.phase" +msgstr "Speech recognition" + +msgid "transcribe.result.collapse_tip" +msgstr "Collapse action bar" + +msgid "transcribe.result.file" +msgstr "Result file" + +msgid "transcribe.result.open_file_tip" +msgstr "Click to open result file" + +msgid "transcribe.result.title" +msgstr "Result actions" + +msgid "transcribe.stage.done" +msgstr "Completed" + +msgid "transcribe.stage.read_audio" +msgstr "Read audio" + +msgid "transcribe.stage.recognize" +msgstr "Recognize speech" + +msgid "transcribe.stage.running" +msgstr "In progress" + +msgid "transcribe.stage.waiting" +msgstr "Waiting" + +msgid "transcribe.stage.write_subtitle" +msgstr "Generate subtitle file" + +msgid "transcribe.status.done" +msgstr "Completed" + +msgid "transcribe.status.failed" +msgstr "Transcription failed" + +msgid "transcribe.status.pending" +msgstr "Not started" + +msgid "transcribe.status.running" +msgstr "Transcribing" + +msgid "transcribe.status.unreachable" +msgstr "Not connected" + +msgid "transcribe.text_preview.done" +msgstr "Transcription complete" + +msgid "transcribe.text_preview.title" +msgstr "Plain text preview" + +msgid "transcribe.toast.bad_format" +msgstr "Invalid format {suffix}" + +msgid "transcribe.toast.drop_media" +msgstr "Please drag in an audio or video file" + +msgid "transcribe.toast.no_subtitle" +msgstr "No subtitle file available for subtitle optimization" + +msgid "transcribe.toast.processing" +msgstr "Processing, please wait for the current task to complete" + +msgid "transcribe.track.first" +msgstr "Audio Track 1" + +msgid "workbench.drop.or" +msgstr "or" + diff --git a/resource/i18n/videocaptioner.pot b/resource/i18n/videocaptioner.pot new file mode 100644 index 00000000..ed7dc132 --- /dev/null +++ b/resource/i18n/videocaptioner.pot @@ -0,0 +1,4419 @@ +msgid "app.announcement.got_it" +msgstr "" + +msgid "app.announcement.title" +msgstr "" + +msgid "app.ffmpeg.missing_body" +msgstr "" + +msgid "app.ffmpeg.missing_title" +msgstr "" + +msgid "app.github.body" +msgstr "" + +msgid "app.github.open" +msgstr "" + +msgid "app.github.support_author" +msgstr "" + +msgid "app.github.title" +msgstr "" + +msgid "app.nav.batch" +msgstr "" + +msgid "app.nav.doctor" +msgstr "" + +msgid "app.nav.dubbing" +msgstr "" + +msgid "app.nav.hardsub" +msgstr "" + +msgid "app.nav.home" +msgstr "" + +msgid "app.nav.live_caption" +msgstr "" + +msgid "app.nav.request_logs" +msgstr "" + +msgid "app.nav.settings" +msgstr "" + +msgid "app.nav.subtitle_style" +msgstr "" + +msgid "app.sidebar.collapse" +msgstr "" + +msgid "app.sidebar.expand" +msgstr "" + +msgid "app.update.available" +msgstr "" + +msgid "app.update.cancel" +msgstr "" + +msgid "app.update.check_failed" +msgstr "" + +msgid "app.update.checking" +msgstr "" + +msgid "app.update.download" +msgstr "" + +msgid "app.update.downloading" +msgstr "" + +msgid "app.update.failed" +msgstr "" + +msgid "app.update.go_download" +msgstr "" + +msgid "app.update.install" +msgstr "" + +msgid "app.update.install_failed" +msgstr "" + +msgid "app.update.mandatory" +msgstr "" + +msgid "app.update.ready" +msgstr "" + +msgid "app.update.retry" +msgstr "" + +msgid "app.update.title" +msgstr "" + +msgid "app.update.up_to_date" +msgstr "" + +msgid "app.window_title" +msgstr "" + +msgid "batch.added.count" +msgstr "" + +msgid "batch.added.duplicated" +msgstr "" + +msgid "batch.added.ignored" +msgstr "" + +msgid "batch.added.title" +msgstr "" + +msgid "batch.btn.add_file" +msgstr "" + +msgid "batch.btn.add_folder" +msgstr "" + +msgid "batch.btn.clear" +msgstr "" + +msgid "batch.btn.pause" +msgstr "" + +msgid "batch.btn.resume" +msgstr "" + +msgid "batch.btn.start" +msgstr "" + +msgid "batch.cannot_retry" +msgstr "" + +msgid "batch.cannot_start" +msgstr "" + +msgid "batch.concurrency" +msgstr "" + +msgid "batch.count" +msgstr "" + +msgid "batch.detail.error" +msgstr "" + +msgid "batch.detail.file" +msgstr "" + +msgid "batch.detail.got_it" +msgstr "" + +msgid "batch.detail.outputs" +msgstr "" + +msgid "batch.detail.progress" +msgstr "" + +msgid "batch.detail.status" +msgstr "" + +msgid "batch.detail.title" +msgstr "" + +msgid "batch.dialog.pick_files" +msgstr "" + +msgid "batch.dialog.pick_folder" +msgstr "" + +msgid "batch.done.body" +msgstr "" + +msgid "batch.done.title" +msgstr "" + +msgid "batch.drop.format" +msgstr "" + +msgid "batch.drop.media_kinds" +msgstr "" + +msgid "batch.drop.subtitle_kinds" +msgstr "" + +msgid "batch.drop.title" +msgstr "" + +msgid "batch.empty.duplicated" +msgstr "" + +msgid "batch.empty.no_media" +msgstr "" + +msgid "batch.empty.no_subtitle" +msgstr "" + +msgid "batch.empty.title" +msgstr "" + +msgid "batch.error.empty_output" +msgstr "" + +msgid "batch.error.empty_video_output" +msgstr "" + +msgid "batch.filter.all" +msgstr "" + +msgid "batch.filter.empty" +msgstr "" + +msgid "batch.filter.media" +msgstr "" + +msgid "batch.filter.subtitle" +msgstr "" + +msgid "batch.filtered.body" +msgstr "" + +msgid "batch.filtered.title" +msgstr "" + +msgid "batch.finished.body" +msgstr "" + +msgid "batch.finished.title" +msgstr "" + +msgid "batch.metric.concurrency" +msgstr "" + +msgid "batch.metric.progress" +msgstr "" + +msgid "batch.metric.queue" +msgstr "" + +msgid "batch.metric.success_rate" +msgstr "" + +msgid "batch.mode.full.desc" +msgstr "" + +msgid "batch.mode.full.title" +msgstr "" + +msgid "batch.mode.subtitle.desc" +msgstr "" + +msgid "batch.mode.subtitle.title" +msgstr "" + +msgid "batch.mode.trans_sub.desc" +msgstr "" + +msgid "batch.mode.trans_sub.title" +msgstr "" + +msgid "batch.mode.transcribe.desc" +msgstr "" + +msgid "batch.mode.transcribe.title" +msgstr "" + +msgid "batch.note.completed" +msgstr "" + +msgid "batch.note.failed" +msgstr "" + +msgid "batch.note.output" +msgstr "" + +msgid "batch.panel.ended" +msgstr "" + +msgid "batch.panel.not_started" +msgstr "" + +msgid "batch.panel.paused" +msgstr "" + +msgid "batch.panel.ready" +msgstr "" + +msgid "batch.panel.running" +msgstr "" + +msgid "batch.panel.title" +msgstr "" + +msgid "batch.preflight.dubbing_key" +msgstr "" + +msgid "batch.preflight.ffmpeg" +msgstr "" + +msgid "batch.preflight.ffprobe" +msgstr "" + +msgid "batch.preflight.llm" +msgstr "" + +msgid "batch.row.details_tip" +msgstr "" + +msgid "batch.row.open_output" +msgstr "" + +msgid "batch.row.remove" +msgstr "" + +msgid "batch.row.retry" +msgstr "" + +msgid "batch.stage.dubbing.desc" +msgstr "" + +msgid "batch.stage.dubbing.title" +msgstr "" + +msgid "batch.stage.settings_tip" +msgstr "" + +msgid "batch.stage.subtitle.desc" +msgstr "" + +msgid "batch.stage.subtitle.title" +msgstr "" + +msgid "batch.stage.synthesis.desc" +msgstr "" + +msgid "batch.stage.synthesis.title" +msgstr "" + +msgid "batch.stage.transcribe.desc" +msgstr "" + +msgid "batch.stage.transcribe.title" +msgstr "" + +msgid "batch.status.completed" +msgstr "" + +msgid "batch.status.failed" +msgstr "" + +msgid "batch.status.preparing" +msgstr "" + +msgid "batch.status.running" +msgstr "" + +msgid "batch.status.waiting" +msgstr "" + +msgid "batch.subtitle.done" +msgstr "" + +msgid "batch.subtitle.done_failed" +msgstr "" + +msgid "batch.subtitle.empty" +msgstr "" + +msgid "batch.subtitle.paused" +msgstr "" + +msgid "batch.subtitle.ready" +msgstr "" + +msgid "batch.subtitle.running" +msgstr "" + +msgid "batch.switch.busy_body" +msgstr "" + +msgid "batch.switch.busy_title" +msgstr "" + +msgid "batch.switched.body" +msgstr "" + +msgid "batch.switched.title" +msgstr "" + +msgid "batch.title" +msgstr "" + +msgid "colorpicker.clear" +msgstr "" + +msgid "colorpicker.section.presets" +msgstr "" + +msgid "colorpicker.section.recent" +msgstr "" + +msgid "colorpicker.title" +msgstr "" + +msgid "common.cancel" +msgstr "" + +msgid "common.close" +msgstr "" + +msgid "common.copy" +msgstr "" + +msgid "common.delete" +msgstr "" + +msgid "common.error" +msgstr "" + +msgid "common.ok" +msgstr "" + +msgid "common.open_folder" +msgstr "" + +msgid "common.retry" +msgstr "" + +msgid "common.save" +msgstr "" + +msgid "common.tip" +msgstr "" + +msgid "common.warning" +msgstr "" + +msgid "ctrl.change" +msgstr "" + +msgid "ctrl.open" +msgstr "" + +msgid "ctrl.settings_title" +msgstr "" + +msgid "depdl.body.intro" +msgstr "" + +msgid "depdl.btn.done" +msgstr "" + +msgid "depdl.btn.download" +msgstr "" + +msgid "depdl.btn.install_all_missing" +msgstr "" + +msgid "depdl.btn.open_install_dir" +msgstr "" + +msgid "depdl.error.download_failed" +msgstr "" + +msgid "depdl.info.done_body" +msgstr "" + +msgid "depdl.info.nothing_body" +msgstr "" + +msgid "depdl.info.nothing_title" +msgstr "" + +msgid "depdl.status.connecting" +msgstr "" + +msgid "depdl.status.failed" +msgstr "" + +msgid "depdl.status.installed" +msgstr "" + +msgid "depdl.status.unsupported" +msgstr "" + +msgid "depdl.title" +msgstr "" + +msgid "doctor.btn.download_install" +msgstr "" + +msgid "doctor.btn.download_voxgate" +msgstr "" + +msgid "doctor.btn.dubbing_settings" +msgstr "" + +msgid "doctor.btn.how_to_handle" +msgstr "" + +msgid "doctor.btn.install_tool" +msgstr "" + +msgid "doctor.btn.live_caption_settings" +msgstr "" + +msgid "doctor.btn.llm_settings" +msgstr "" + +msgid "doctor.btn.rerun" +msgstr "" + +msgid "doctor.btn.run" +msgstr "" + +msgid "doctor.btn.running" +msgstr "" + +msgid "doctor.btn.transcribe_settings" +msgstr "" + +msgid "doctor.btn.translate_settings" +msgstr "" + +msgid "doctor.btn.usage" +msgstr "" + +msgid "doctor.checklist" +msgstr "" + +msgid "doctor.chip.dubbing" +msgstr "" + +msgid "doctor.chip.export" +msgstr "" + +msgid "doctor.chip.subtitle" +msgstr "" + +msgid "doctor.chip.transcribe" +msgstr "" + +msgid "doctor.chip.translate" +msgstr "" + +msgid "doctor.done.all_passed" +msgstr "" + +msgid "doctor.done.errors" +msgstr "" + +msgid "doctor.done.title" +msgstr "" + +msgid "doctor.done.warnings" +msgstr "" + +msgid "doctor.download" +msgstr "" + +msgid "doctor.download.desc" +msgstr "" + +msgid "doctor.download.ok" +msgstr "" + +msgid "doctor.download.ok_login" +msgstr "" + +msgid "doctor.download.unavailable" +msgstr "" + +msgid "doctor.dubbing.desc" +msgstr "" + +msgid "doctor.dubbing.fail.desc" +msgstr "" + +msgid "doctor.dubbing.ok.desc" +msgstr "" + +msgid "doctor.dubbing.title" +msgstr "" + +msgid "doctor.failed.title" +msgstr "" + +msgid "doctor.ffmpeg.desc" +msgstr "" + +msgid "doctor.ffmpeg.missing.desc" +msgstr "" + +msgid "doctor.ffmpeg.missing.title" +msgstr "" + +msgid "doctor.ffmpeg.no_ass.desc" +msgstr "" + +msgid "doctor.ffmpeg.no_ass.title" +msgstr "" + +msgid "doctor.ffmpeg.ok.desc" +msgstr "" + +msgid "doctor.help.download" +msgstr "" + +msgid "doctor.help.ffmpeg" +msgstr "" + +msgid "doctor.jump_failed.body" +msgstr "" + +msgid "doctor.jump_failed.title" +msgstr "" + +msgid "doctor.label.disabled" +msgstr "" + +msgid "doctor.label.dubbing" +msgstr "" + +msgid "doctor.label.optimize" +msgstr "" + +msgid "doctor.label.split" +msgstr "" + +msgid "doctor.label.subtitle" +msgstr "" + +msgid "doctor.label.subtitle_file" +msgstr "" + +msgid "doctor.label.video" +msgstr "" + +msgid "doctor.live_caption.desc" +msgstr "" + +msgid "doctor.live_caption.funasr_no_key.desc" +msgstr "" + +msgid "doctor.live_caption.not_found.desc" +msgstr "" + +msgid "doctor.live_caption.ok.desc" +msgstr "" + +msgid "doctor.live_caption.title" +msgstr "" + +msgid "doctor.llm.desc.default" +msgstr "" + +msgid "doctor.llm.desc.translate" +msgstr "" + +msgid "doctor.llm.fail.desc" +msgstr "" + +msgid "doctor.llm.ok.desc" +msgstr "" + +msgid "doctor.llm.title.optimize" +msgstr "" + +msgid "doctor.llm.title.optimize_split" +msgstr "" + +msgid "doctor.llm.title.split" +msgstr "" + +msgid "doctor.llm.title.translate" +msgstr "" + +msgid "doctor.llm.unavailable.title" +msgstr "" + +msgid "doctor.status.checking" +msgstr "" + +msgid "doctor.status.error" +msgstr "" + +msgid "doctor.status.ok" +msgstr "" + +msgid "doctor.status.pending" +msgstr "" + +msgid "doctor.status.warning" +msgstr "" + +msgid "doctor.subtitle" +msgstr "" + +msgid "doctor.summary.all_passed" +msgstr "" + +msgid "doctor.summary.errors" +msgstr "" + +msgid "doctor.summary.pending" +msgstr "" + +msgid "doctor.summary.warnings" +msgstr "" + +msgid "doctor.title" +msgstr "" + +msgid "doctor.transcribe.desc.free" +msgstr "" + +msgid "doctor.transcribe.desc.local" +msgstr "" + +msgid "doctor.transcribe.desc.whisper" +msgstr "" + +msgid "doctor.transcribe.fail.desc" +msgstr "" + +msgid "doctor.transcribe.ok.desc" +msgstr "" + +msgid "doctor.transcribe.title" +msgstr "" + +msgid "doctor.translate.desc.default" +msgstr "" + +msgid "doctor.translate.desc.uses_llm" +msgstr "" + +msgid "doctor.translate.ok.desc" +msgstr "" + +msgid "doctor.translate.title" +msgstr "" + +msgid "doctor.translate.uses_llm.desc" +msgstr "" + +msgid "donate.alipay" +msgstr "" + +msgid "donate.desc" +msgstr "" + +msgid "donate.title" +msgstr "" + +msgid "donate.wechat" +msgstr "" + +msgid "dubbing.badge.clone" +msgstr "" + +msgid "dubbing.badge.need_key" +msgstr "" + +msgid "dubbing.badge.no_key" +msgstr "" + +msgid "dubbing.btn.audition" +msgstr "" + +msgid "dubbing.btn.config" +msgstr "" + +msgid "dubbing.btn.generate_clone" +msgstr "" + +msgid "dubbing.btn.generate_preview" +msgstr "" + +msgid "dubbing.btn.stop" +msgstr "" + +msgid "dubbing.btn.synthesizing" +msgstr "" + +msgid "dubbing.clone.clear" +msgstr "" + +msgid "dubbing.clone.hint" +msgstr "" + +msgid "dubbing.clone.missing_file" +msgstr "" + +msgid "dubbing.clone.no_audio" +msgstr "" + +msgid "dubbing.clone.record" +msgstr "" + +msgid "dubbing.clone.ref_text" +msgstr "" + +msgid "dubbing.clone.ref_text_placeholder" +msgstr "" + +msgid "dubbing.clone.title" +msgstr "" + +msgid "dubbing.clone.upload" +msgstr "" + +msgid "dubbing.clone.uploaded" +msgstr "" + +msgid "dubbing.dialog.audio_filter" +msgstr "" + +msgid "dubbing.dialog.choose_audio" +msgstr "" + +msgid "dubbing.filter.all" +msgstr "" + +msgid "dubbing.filter.clone" +msgstr "" + +msgid "dubbing.filter.female" +msgstr "" + +msgid "dubbing.filter.male" +msgstr "" + +msgid "dubbing.form.current_voice" +msgstr "" + +msgid "dubbing.form.gen_type" +msgstr "" + +msgid "dubbing.form.not_selected" +msgstr "" + +msgid "dubbing.form.type_preview" +msgstr "" + +msgid "dubbing.preview.char_count" +msgstr "" + +msgid "dubbing.preview.desc" +msgstr "" + +msgid "dubbing.preview.desc_clone" +msgstr "" + +msgid "dubbing.preview.input_placeholder" +msgstr "" + +msgid "dubbing.preview.sample_text" +msgstr "" + +msgid "dubbing.preview.title" +msgstr "" + +msgid "dubbing.provider.edge.desc" +msgstr "" + +msgid "dubbing.provider.edge.title" +msgstr "" + +msgid "dubbing.provider.gemini.desc" +msgstr "" + +msgid "dubbing.provider.gemini.title" +msgstr "" + +msgid "dubbing.provider.siliconflow.desc" +msgstr "" + +msgid "dubbing.provider.siliconflow.title" +msgstr "" + +msgid "dubbing.ready.clone" +msgstr "" + +msgid "dubbing.ready.default" +msgstr "" + +msgid "dubbing.ready.need_key" +msgstr "" + +msgid "dubbing.ready.no_key" +msgstr "" + +msgid "dubbing.subtitle" +msgstr "" + +msgid "dubbing.tag.breathy" +msgstr "" + +msgid "dubbing.tag.bright" +msgstr "" + +msgid "dubbing.tag.casual" +msgstr "" + +msgid "dubbing.tag.clear" +msgstr "" + +msgid "dubbing.tag.clone" +msgstr "" + +msgid "dubbing.tag.easy_going" +msgstr "" + +msgid "dubbing.tag.en" +msgstr "" + +msgid "dubbing.tag.even" +msgstr "" + +msgid "dubbing.tag.female" +msgstr "" + +msgid "dubbing.tag.firm" +msgstr "" + +msgid "dubbing.tag.forward" +msgstr "" + +msgid "dubbing.tag.free" +msgstr "" + +msgid "dubbing.tag.gentle" +msgstr "" + +msgid "dubbing.tag.gravelly" +msgstr "" + +msgid "dubbing.tag.informative" +msgstr "" + +msgid "dubbing.tag.knowledgeable" +msgstr "" + +msgid "dubbing.tag.lively" +msgstr "" + +msgid "dubbing.tag.male" +msgstr "" + +msgid "dubbing.tag.mature" +msgstr "" + +msgid "dubbing.tag.need_key" +msgstr "" + +msgid "dubbing.tag.recommended" +msgstr "" + +msgid "dubbing.tag.smooth" +msgstr "" + +msgid "dubbing.tag.soft" +msgstr "" + +msgid "dubbing.tag.upbeat" +msgstr "" + +msgid "dubbing.tag.warm" +msgstr "" + +msgid "dubbing.tag.yue" +msgstr "" + +msgid "dubbing.tag.zh" +msgstr "" + +msgid "dubbing.title" +msgstr "" + +msgid "dubbing.toast.audio_missing" +msgstr "" + +msgid "dubbing.toast.audio_missing_body" +msgstr "" + +msgid "dubbing.toast.empty_text" +msgstr "" + +msgid "dubbing.toast.empty_text_body" +msgstr "" + +msgid "dubbing.toast.missing_ref_text" +msgstr "" + +msgid "dubbing.toast.missing_ref_text_body" +msgstr "" + +msgid "dubbing.toast.need_api_key" +msgstr "" + +msgid "dubbing.toast.need_api_key_body" +msgstr "" + +msgid "dubbing.toast.play_failed" +msgstr "" + +msgid "dubbing.toast.play_failed_body" +msgstr "" + +msgid "dubbing.toast.please_wait" +msgstr "" + +msgid "dubbing.toast.please_wait_body" +msgstr "" + +msgid "dubbing.toast.preview_failed" +msgstr "" + +msgid "dubbing.toast.record_done" +msgstr "" + +msgid "dubbing.toast.record_done_body" +msgstr "" + +msgid "dubbing.voice.edge-cn-female.desc" +msgstr "" + +msgid "dubbing.voice.edge-cn-male.desc" +msgstr "" + +msgid "dubbing.voice.edge-cn-xiaoyi.desc" +msgstr "" + +msgid "dubbing.voice.edge-cn-yunjian.desc" +msgstr "" + +msgid "dubbing.voice.edge-cn-yunyang.desc" +msgstr "" + +msgid "dubbing.voice.edge-en-andrew.desc" +msgstr "" + +msgid "dubbing.voice.edge-en-ava.desc" +msgstr "" + +msgid "dubbing.voice.edge-en-brian.desc" +msgstr "" + +msgid "dubbing.voice.edge-en-emma.desc" +msgstr "" + +msgid "dubbing.voice.edge-en-female.desc" +msgstr "" + +msgid "dubbing.voice.edge-en-libby.desc" +msgstr "" + +msgid "dubbing.voice.edge-en-male.desc" +msgstr "" + +msgid "dubbing.voice.edge-en-ryan.desc" +msgstr "" + +msgid "dubbing.voice.edge-hk-hiugaai.desc" +msgstr "" + +msgid "dubbing.voice.edge-hk-wanlung.desc" +msgstr "" + +msgid "dubbing.voice.edge-tw-hsiaoyu.desc" +msgstr "" + +msgid "dubbing.voice.edge-tw-yunjhe.desc" +msgstr "" + +msgid "dubbing.voice.gemini-achernar.desc" +msgstr "" + +msgid "dubbing.voice.gemini-algenib.desc" +msgstr "" + +msgid "dubbing.voice.gemini-algieba.desc" +msgstr "" + +msgid "dubbing.voice.gemini-alnilam.desc" +msgstr "" + +msgid "dubbing.voice.gemini-aoede.desc" +msgstr "" + +msgid "dubbing.voice.gemini-autonoe.desc" +msgstr "" + +msgid "dubbing.voice.gemini-callirrhoe.desc" +msgstr "" + +msgid "dubbing.voice.gemini-charon.desc" +msgstr "" + +msgid "dubbing.voice.gemini-despina.desc" +msgstr "" + +msgid "dubbing.voice.gemini-en-friendly.desc" +msgstr "" + +msgid "dubbing.voice.gemini-en-neutral.desc" +msgstr "" + +msgid "dubbing.voice.gemini-en-upbeat.desc" +msgstr "" + +msgid "dubbing.voice.gemini-enceladus.desc" +msgstr "" + +msgid "dubbing.voice.gemini-erinome.desc" +msgstr "" + +msgid "dubbing.voice.gemini-fenrir.desc" +msgstr "" + +msgid "dubbing.voice.gemini-gacrux.desc" +msgstr "" + +msgid "dubbing.voice.gemini-iapetus.desc" +msgstr "" + +msgid "dubbing.voice.gemini-laomedeia.desc" +msgstr "" + +msgid "dubbing.voice.gemini-leda.desc" +msgstr "" + +msgid "dubbing.voice.gemini-orus.desc" +msgstr "" + +msgid "dubbing.voice.gemini-pulcherrima.desc" +msgstr "" + +msgid "dubbing.voice.gemini-rasalgethi.desc" +msgstr "" + +msgid "dubbing.voice.gemini-sadachbia.desc" +msgstr "" + +msgid "dubbing.voice.gemini-sadaltager.desc" +msgstr "" + +msgid "dubbing.voice.gemini-schedar.desc" +msgstr "" + +msgid "dubbing.voice.gemini-sulafat.desc" +msgstr "" + +msgid "dubbing.voice.gemini-umbriel.desc" +msgstr "" + +msgid "dubbing.voice.gemini-vindemiatrix.desc" +msgstr "" + +msgid "dubbing.voice.gemini-zephyr.desc" +msgstr "" + +msgid "dubbing.voice.gemini-zubenelgenubi.desc" +msgstr "" + +msgid "dubbing.voice.library" +msgstr "" + +msgid "dubbing.voice.library_zh" +msgstr "" + +msgid "dubbing.voice.siliconflow-cn-bella.desc" +msgstr "" + +msgid "dubbing.voice.siliconflow-cn-charles.desc" +msgstr "" + +msgid "dubbing.voice.siliconflow-cn-claire.desc" +msgstr "" + +msgid "dubbing.voice.siliconflow-cn-david.desc" +msgstr "" + +msgid "dubbing.voice.siliconflow-cn-deep-male.desc" +msgstr "" + +msgid "dubbing.voice.siliconflow-cn-diana.desc" +msgstr "" + +msgid "dubbing.voice.siliconflow-cn-female.desc" +msgstr "" + +msgid "dubbing.voice.siliconflow-cn-male.desc" +msgstr "" + +msgid "enum.FasterWhisperModelEnum.BASE" +msgstr "" + +msgid "enum.FasterWhisperModelEnum.LARGE_V1" +msgstr "" + +msgid "enum.FasterWhisperModelEnum.LARGE_V2" +msgstr "" + +msgid "enum.FasterWhisperModelEnum.LARGE_V3" +msgstr "" + +msgid "enum.FasterWhisperModelEnum.LARGE_V3_TURBO" +msgstr "" + +msgid "enum.FasterWhisperModelEnum.MEDIUM" +msgstr "" + +msgid "enum.FasterWhisperModelEnum.SMALL" +msgstr "" + +msgid "enum.FasterWhisperModelEnum.TINY" +msgstr "" + +msgid "enum.LLMServiceEnum.CHATGLM" +msgstr "" + +msgid "enum.LLMServiceEnum.DEEPSEEK" +msgstr "" + +msgid "enum.LLMServiceEnum.GEMINI" +msgstr "" + +msgid "enum.LLMServiceEnum.IMMERSIVE" +msgstr "" + +msgid "enum.LLMServiceEnum.LM_STUDIO" +msgstr "" + +msgid "enum.LLMServiceEnum.OLLAMA" +msgstr "" + +msgid "enum.LLMServiceEnum.OPENAI" +msgstr "" + +msgid "enum.LLMServiceEnum.SILICON_CLOUD" +msgstr "" + +msgid "enum.SubtitleLayoutEnum.ONLY_ORIGINAL" +msgstr "" + +msgid "enum.SubtitleLayoutEnum.ONLY_TRANSLATE" +msgstr "" + +msgid "enum.SubtitleLayoutEnum.ORIGINAL_ON_TOP" +msgstr "" + +msgid "enum.SubtitleLayoutEnum.TRANSLATE_ON_TOP" +msgstr "" + +msgid "enum.SubtitleRenderModeEnum.ASS_STYLE" +msgstr "" + +msgid "enum.SubtitleRenderModeEnum.ROUNDED_BG" +msgstr "" + +msgid "enum.TargetLanguage.ARABIC" +msgstr "" + +msgid "enum.TargetLanguage.BULGARIAN" +msgstr "" + +msgid "enum.TargetLanguage.CANTONESE" +msgstr "" + +msgid "enum.TargetLanguage.CZECH" +msgstr "" + +msgid "enum.TargetLanguage.DANISH" +msgstr "" + +msgid "enum.TargetLanguage.DUTCH" +msgstr "" + +msgid "enum.TargetLanguage.ENGLISH" +msgstr "" + +msgid "enum.TargetLanguage.ENGLISH_UK" +msgstr "" + +msgid "enum.TargetLanguage.ENGLISH_US" +msgstr "" + +msgid "enum.TargetLanguage.FINNISH" +msgstr "" + +msgid "enum.TargetLanguage.FRENCH" +msgstr "" + +msgid "enum.TargetLanguage.GERMAN" +msgstr "" + +msgid "enum.TargetLanguage.GREEK" +msgstr "" + +msgid "enum.TargetLanguage.HEBREW" +msgstr "" + +msgid "enum.TargetLanguage.HUNGARIAN" +msgstr "" + +msgid "enum.TargetLanguage.INDONESIAN" +msgstr "" + +msgid "enum.TargetLanguage.ITALIAN" +msgstr "" + +msgid "enum.TargetLanguage.JAPANESE" +msgstr "" + +msgid "enum.TargetLanguage.KOREAN" +msgstr "" + +msgid "enum.TargetLanguage.MALAY" +msgstr "" + +msgid "enum.TargetLanguage.NORWEGIAN" +msgstr "" + +msgid "enum.TargetLanguage.PERSIAN" +msgstr "" + +msgid "enum.TargetLanguage.POLISH" +msgstr "" + +msgid "enum.TargetLanguage.PORTUGUESE" +msgstr "" + +msgid "enum.TargetLanguage.PORTUGUESE_BR" +msgstr "" + +msgid "enum.TargetLanguage.PORTUGUESE_PT" +msgstr "" + +msgid "enum.TargetLanguage.ROMANIAN" +msgstr "" + +msgid "enum.TargetLanguage.RUSSIAN" +msgstr "" + +msgid "enum.TargetLanguage.SIMPLIFIED_CHINESE" +msgstr "" + +msgid "enum.TargetLanguage.SPANISH" +msgstr "" + +msgid "enum.TargetLanguage.SPANISH_LATAM" +msgstr "" + +msgid "enum.TargetLanguage.SWEDISH" +msgstr "" + +msgid "enum.TargetLanguage.TAGALOG" +msgstr "" + +msgid "enum.TargetLanguage.THAI" +msgstr "" + +msgid "enum.TargetLanguage.TRADITIONAL_CHINESE" +msgstr "" + +msgid "enum.TargetLanguage.TURKISH" +msgstr "" + +msgid "enum.TargetLanguage.UKRAINIAN" +msgstr "" + +msgid "enum.TargetLanguage.VIETNAMESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.AFRIKAANS" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ALBANIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.AMHARIC" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ARABIC" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ARMENIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ASSAMESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.AUTO" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.AZERBAIJANI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.BASHKIR" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.BASQUE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.BELARUSIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.BENGALI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.BOSNIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.BRETON" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.BULGARIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.CANTONESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.CATALAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.CHINESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.CROATIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.CZECH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.DANISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.DUTCH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ENGLISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ESTONIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.FAROESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.FINNISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.FRENCH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.GALICIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.GEORGIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.GERMAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.GREEK" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.GUJARATI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.HAITIAN_CREOLE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.HAUSA" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.HAWAIIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.HEBREW" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.HINDI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.HUNGARIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ICELANDIC" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.INDONESIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ITALIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.JAPANESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.JAVANESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.KANNADA" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.KAZAKH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.KHMER" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.KOREAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.LAO" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.LATIN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.LATVIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.LINGALA" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.LITHUANIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.LUXEMBOURGISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MACEDONIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MALAGASY" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MALAY" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MALAYALAM" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MALTESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MAORI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MARATHI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MONGOLIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.MYANMAR" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.NEPALI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.NORWEGIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.NYNORSK" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.OCCITAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.PASHTO" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.PERSIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.POLISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.PORTUGUESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.PUNJABI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.ROMANIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.RUSSIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SANSKRIT" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SERBIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SHONA" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SINDHI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SINHALA" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SLOVAK" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SLOVENIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SOMALI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SPANISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SUNDANESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SWAHILI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.SWEDISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.TAGALOG" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.TAJIK" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.TAMIL" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.TATAR" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.TELUGU" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.THAI" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.TIBETAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.TURKISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.TURKMEN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.UKRAINIAN" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.URDU" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.UZBEK" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.VIETNAMESE" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.WELSH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.YIDDISH" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.YORUBA" +msgstr "" + +msgid "enum.TranscribeLanguageEnum.YUE" +msgstr "" + +msgid "enum.TranscribeModelEnum.BAILIAN_FUN_ASR" +msgstr "" + +msgid "enum.TranscribeModelEnum.BIJIAN" +msgstr "" + +msgid "enum.TranscribeModelEnum.FASTER_WHISPER" +msgstr "" + +msgid "enum.TranscribeModelEnum.JIANYING" +msgstr "" + +msgid "enum.TranscribeModelEnum.WHISPER_API" +msgstr "" + +msgid "enum.TranscribeModelEnum.WHISPER_CPP" +msgstr "" + +msgid "enum.TranscribeOutputFormatEnum.ALL" +msgstr "" + +msgid "enum.TranscribeOutputFormatEnum.ASS" +msgstr "" + +msgid "enum.TranscribeOutputFormatEnum.SRT" +msgstr "" + +msgid "enum.TranscribeOutputFormatEnum.TXT" +msgstr "" + +msgid "enum.TranscribeOutputFormatEnum.VTT" +msgstr "" + +msgid "enum.TranslatorServiceEnum.BING" +msgstr "" + +msgid "enum.TranslatorServiceEnum.DEEPLX" +msgstr "" + +msgid "enum.TranslatorServiceEnum.GOOGLE" +msgstr "" + +msgid "enum.TranslatorServiceEnum.OPENAI" +msgstr "" + +msgid "enum.VadMethodEnum.AUDITOK" +msgstr "" + +msgid "enum.VadMethodEnum.PYANNOTE_ONNX_V3" +msgstr "" + +msgid "enum.VadMethodEnum.PYANNOTE_V3" +msgstr "" + +msgid "enum.VadMethodEnum.SILERO_V3" +msgstr "" + +msgid "enum.VadMethodEnum.SILERO_V4" +msgstr "" + +msgid "enum.VadMethodEnum.SILERO_V4_FW" +msgstr "" + +msgid "enum.VadMethodEnum.SILERO_V5" +msgstr "" + +msgid "enum.VadMethodEnum.WEBRTC" +msgstr "" + +msgid "enum.VideoQualityEnum.HIGH" +msgstr "" + +msgid "enum.VideoQualityEnum.LOW" +msgstr "" + +msgid "enum.VideoQualityEnum.MEDIUM" +msgstr "" + +msgid "enum.VideoQualityEnum.ULTRA_HIGH" +msgstr "" + +msgid "enum.WhisperModelEnum.BASE" +msgstr "" + +msgid "enum.WhisperModelEnum.LARGE_V1" +msgstr "" + +msgid "enum.WhisperModelEnum.LARGE_V2" +msgstr "" + +msgid "enum.WhisperModelEnum.MEDIUM" +msgstr "" + +msgid "enum.WhisperModelEnum.SMALL" +msgstr "" + +msgid "enum.WhisperModelEnum.TINY" +msgstr "" + +msgid "feedback.add_image" +msgstr "" + +msgid "feedback.attach_count" +msgstr "" + +msgid "feedback.category.bug" +msgstr "" + +msgid "feedback.category.feature" +msgstr "" + +msgid "feedback.category.label" +msgstr "" + +msgid "feedback.category.other" +msgstr "" + +msgid "feedback.category.question" +msgstr "" + +msgid "feedback.contact.placeholder" +msgstr "" + +msgid "feedback.done" +msgstr "" + +msgid "feedback.err.category_invalid" +msgstr "" + +msgid "feedback.err.contact_too_long" +msgstr "" + +msgid "feedback.err.file_too_large" +msgstr "" + +msgid "feedback.err.file_type" +msgstr "" + +msgid "feedback.err.invalid_request" +msgstr "" + +msgid "feedback.err.message_required" +msgstr "" + +msgid "feedback.err.message_too_long" +msgstr "" + +msgid "feedback.err.network" +msgstr "" + +msgid "feedback.err.server" +msgstr "" + +msgid "feedback.err.too_large" +msgstr "" + +msgid "feedback.err.too_many_files" +msgstr "" + +msgid "feedback.err.total_too_large" +msgstr "" + +msgid "feedback.err.unauthorized" +msgstr "" + +msgid "feedback.intro" +msgstr "" + +msgid "feedback.message.placeholder" +msgstr "" + +msgid "feedback.screenshot.hint" +msgstr "" + +msgid "feedback.section.contact" +msgstr "" + +msgid "feedback.section.message" +msgstr "" + +msgid "feedback.section.screenshot" +msgstr "" + +msgid "feedback.submit" +msgstr "" + +msgid "feedback.submitting" +msgstr "" + +msgid "feedback.success" +msgstr "" + +msgid "feedback.title" +msgstr "" + +msgid "hardsub.btn.auto_region" +msgstr "" + +msgid "hardsub.btn.export" +msgstr "" + +msgid "hardsub.btn.redo" +msgstr "" + +msgid "hardsub.btn.replace_video" +msgstr "" + +msgid "hardsub.btn.send_optimize" +msgstr "" + +msgid "hardsub.btn.start" +msgstr "" + +msgid "hardsub.busy.detecting_region" +msgstr "" + +msgid "hardsub.busy.detecting_region_pct" +msgstr "" + +msgid "hardsub.busy.loading_video" +msgstr "" + +msgid "hardsub.col.end" +msgstr "" + +msgid "hardsub.col.start" +msgstr "" + +msgid "hardsub.col.text" +msgstr "" + +msgid "hardsub.count.recognized" +msgstr "" + +msgid "hardsub.count.total" +msgstr "" + +msgid "hardsub.dialog.export" +msgstr "" + +msgid "hardsub.dialog.pick_video" +msgstr "" + +msgid "hardsub.drop.pick" +msgstr "" + +msgid "hardsub.drop.title" +msgstr "" + +msgid "hardsub.engine.card_title" +msgstr "" + +msgid "hardsub.engine.desc" +msgstr "" + +msgid "hardsub.engine.title" +msgstr "" + +msgid "hardsub.error.read_video" +msgstr "" + +msgid "hardsub.menu.delete_row" +msgstr "" + +msgid "hardsub.menu.locate" +msgstr "" + +msgid "hardsub.note.done" +msgstr "" + +msgid "hardsub.note.empty" +msgstr "" + +msgid "hardsub.note.engine_missing" +msgstr "" + +msgid "hardsub.note.no_subtitle" +msgstr "" + +msgid "hardsub.note.processing" +msgstr "" + +msgid "hardsub.note.region" +msgstr "" + +msgid "hardsub.ph.empty.sub" +msgstr "" + +msgid "hardsub.ph.empty.title" +msgstr "" + +msgid "hardsub.ph.engine_missing.sub" +msgstr "" + +msgid "hardsub.ph.engine_missing.title" +msgstr "" + +msgid "hardsub.ph.no_subtitle.sub" +msgstr "" + +msgid "hardsub.ph.no_subtitle.title" +msgstr "" + +msgid "hardsub.ph.region.sub" +msgstr "" + +msgid "hardsub.ph.region.title" +msgstr "" + +msgid "hardsub.progress.title" +msgstr "" + +msgid "hardsub.result.title" +msgstr "" + +msgid "hardsub.stage.video_preview" +msgstr "" + +msgid "hardsub.status.done" +msgstr "" + +msgid "hardsub.status.engine_missing" +msgstr "" + +msgid "hardsub.status.need_action" +msgstr "" + +msgid "hardsub.status.processing" +msgstr "" + +msgid "hardsub.status.region_detected" +msgstr "" + +msgid "hardsub.status.waiting_video" +msgstr "" + +msgid "hardsub.subtitle" +msgstr "" + +msgid "hardsub.title" +msgstr "" + +msgid "hardsub.toast.exported" +msgstr "" + +msgid "hardsub.toast.no_auto_region" +msgstr "" + +msgid "home.btn.browse" +msgstr "" + +msgid "home.btn.start" +msgstr "" + +msgid "home.confirm.has_subtitle" +msgstr "" + +msgid "home.confirm.quality" +msgstr "" + +msgid "home.confirm.quality.best" +msgstr "" + +msgid "home.confirm.untitled" +msgstr "" + +msgid "home.dialog.filter.audio" +msgstr "" + +msgid "home.dialog.filter.media" +msgstr "" + +msgid "home.dialog.filter.video" +msgstr "" + +msgid "home.dialog.select_media" +msgstr "" + +msgid "home.download.done.body" +msgstr "" + +msgid "home.download.done.title" +msgstr "" + +msgid "home.download.parsing" +msgstr "" + +msgid "home.download.start" +msgstr "" + +msgid "home.error.invalid_input" +msgstr "" + +msgid "home.error.unsupported_drop" +msgstr "" + +msgid "home.footer.donate" +msgstr "" + +msgid "home.footer.logs" +msgstr "" + +msgid "home.hero.title" +msgstr "" + +msgid "home.input.placeholder" +msgstr "" + +msgid "home.media.kind.audio" +msgstr "" + +msgid "home.media.kind.video" +msgstr "" + +msgid "home.media.ready" +msgstr "" + +msgid "home.quick.confirm" +msgstr "" + +msgid "home.quick.downloading" +msgstr "" + +msgid "home.quick.empty" +msgstr "" + +msgid "home.quick.file" +msgstr "" + +msgid "home.quick.invalid" +msgstr "" + +msgid "home.quick.parsing" +msgstr "" + +msgid "home.quick.url" +msgstr "" + +msgid "home.status.invalid" +msgstr "" + +msgid "home.status.parsing" +msgstr "" + +msgid "home.status.pending" +msgstr "" + +msgid "home.status.ready" +msgstr "" + +msgid "home.status.waiting" +msgstr "" + +msgid "home.tip.downloading" +msgstr "" + +msgid "home.tip.parsing" +msgstr "" + +msgid "homeflow.tab.subtitle_optimize" +msgstr "" + +msgid "homeflow.tab.task_creation" +msgstr "" + +msgid "homeflow.tab.transcription" +msgstr "" + +msgid "homeflow.tab.video_synthesis" +msgstr "" + +msgid "inspector.color.pick" +msgstr "" + +msgid "inspector.color.pick_prefix" +msgstr "" + +msgid "inspector.style.rename" +msgstr "" + +msgid "inspector.style.source.builtin" +msgstr "" + +msgid "inspector.style.source.mine" +msgstr "" + +msgid "lclang.ar" +msgstr "" + +msgid "lclang.auto" +msgstr "" + +msgid "lclang.cs" +msgstr "" + +msgid "lclang.da" +msgstr "" + +msgid "lclang.de" +msgstr "" + +msgid "lclang.en" +msgstr "" + +msgid "lclang.es" +msgstr "" + +msgid "lclang.fi" +msgstr "" + +msgid "lclang.fil" +msgstr "" + +msgid "lclang.fr" +msgstr "" + +msgid "lclang.hi" +msgstr "" + +msgid "lclang.id" +msgstr "" + +msgid "lclang.is" +msgstr "" + +msgid "lclang.it" +msgstr "" + +msgid "lclang.ja" +msgstr "" + +msgid "lclang.ko" +msgstr "" + +msgid "lclang.ms" +msgstr "" + +msgid "lclang.no" +msgstr "" + +msgid "lclang.pl" +msgstr "" + +msgid "lclang.pt" +msgstr "" + +msgid "lclang.ru" +msgstr "" + +msgid "lclang.sv" +msgstr "" + +msgid "lclang.th" +msgstr "" + +msgid "lclang.tr" +msgstr "" + +msgid "lclang.uk" +msgstr "" + +msgid "lclang.vi" +msgstr "" + +msgid "lclang.yue" +msgstr "" + +msgid "lclang.zh" +msgstr "" + +msgid "live.delete.confirm" +msgstr "" + +msgid "live.delete.title" +msgstr "" + +msgid "live.device.default_input" +msgstr "" + +msgid "live.device.default_tag" +msgstr "" + +msgid "live.device.system_audio" +msgstr "" + +msgid "live.error.concurrency_full" +msgstr "" + +msgid "live.error.start_failed" +msgstr "" + +msgid "live.error.start_failed_desc" +msgstr "" + +msgid "live.error.transcribe_failed" +msgstr "" + +msgid "live.export.dialog_title" +msgstr "" + +msgid "live.export.failed" +msgstr "" + +msgid "live.export.success" +msgstr "" + +msgid "live.ready.desc" +msgstr "" + +msgid "live.ready.title" +msgstr "" + +msgid "live.rename.placeholder" +msgstr "" + +msgid "live.rename.title" +msgstr "" + +msgid "live.status.connecting" +msgstr "" + +msgid "live.status.connecting_with_time" +msgstr "" + +msgid "live.status.paused" +msgstr "" + +msgid "live.status.progress" +msgstr "" + +msgid "live.status.recording" +msgstr "" + +msgid "live.status.saved" +msgstr "" + +msgid "live.status.waiting" +msgstr "" + +msgid "live.title" +msgstr "" + +msgid "livetx.menu.copy_all" +msgstr "" + +msgid "livetx.menu.copy_sentence" +msgstr "" + +msgid "liveview.action.back" +msgstr "" + +msgid "liveview.action.export" +msgstr "" + +msgid "liveview.action.folder" +msgstr "" + +msgid "liveview.action.home" +msgstr "" + +msgid "liveview.action.open" +msgstr "" + +msgid "liveview.action.refresh" +msgstr "" + +msgid "liveview.action.rename" +msgstr "" + +msgid "liveview.btn.new_session" +msgstr "" + +msgid "liveview.btn.pause" +msgstr "" + +msgid "liveview.btn.resume" +msgstr "" + +msgid "liveview.btn.saving" +msgstr "" + +msgid "liveview.btn.start" +msgstr "" + +msgid "liveview.btn.stop" +msgstr "" + +msgid "liveview.btn.view_record" +msgstr "" + +msgid "liveview.detail.display" +msgstr "" + +msgid "liveview.detail.export" +msgstr "" + +msgid "liveview.display.bilingual" +msgstr "" + +msgid "liveview.display.source" +msgstr "" + +msgid "liveview.display.target" +msgstr "" + +msgid "liveview.error.detail" +msgstr "" + +msgid "liveview.error.title" +msgstr "" + +msgid "liveview.export.srt" +msgstr "" + +msgid "liveview.export.txt" +msgstr "" + +msgid "liveview.history.empty.detail" +msgstr "" + +msgid "liveview.history.empty.title" +msgstr "" + +msgid "liveview.history.search_placeholder" +msgstr "" + +msgid "liveview.option.audio_source" +msgstr "" + +msgid "liveview.option.live_translate" +msgstr "" + +msgid "liveview.option.overlay" +msgstr "" + +msgid "liveview.option.source_language" +msgstr "" + +msgid "liveview.option.target_language" +msgstr "" + +msgid "liveview.ready.detail" +msgstr "" + +msgid "liveview.ready.title" +msgstr "" + +msgid "liveview.recent.empty.detail" +msgstr "" + +msgid "liveview.recent.empty.title" +msgstr "" + +msgid "liveview.recent.title" +msgstr "" + +msgid "liveview.recent.view_all" +msgstr "" + +msgid "liveview.settings.config" +msgstr "" + +msgid "liveview.settings.title" +msgstr "" + +msgid "liveview.timer.saving" +msgstr "" + +msgid "liveview.timer.waiting" +msgstr "" + +msgid "liveview.title.detail" +msgstr "" + +msgid "liveview.title.history" +msgstr "" + +msgid "liveview.title.session" +msgstr "" + +msgid "llmlog.btn.clear" +msgstr "" + +msgid "llmlog.btn.refresh" +msgstr "" + +msgid "llmlog.clear.confirm_body" +msgstr "" + +msgid "llmlog.clear.confirm_btn" +msgstr "" + +msgid "llmlog.clear.confirm_title" +msgstr "" + +msgid "llmlog.clear.success" +msgstr "" + +msgid "llmlog.col.duration" +msgstr "" + +msgid "llmlog.col.file" +msgstr "" + +msgid "llmlog.col.model" +msgstr "" + +msgid "llmlog.col.stage" +msgstr "" + +msgid "llmlog.col.time" +msgstr "" + +msgid "llmlog.copied" +msgstr "" + +msgid "llmlog.detail.foot_hint" +msgstr "" + +msgid "llmlog.detail.title" +msgstr "" + +msgid "llmlog.empty.hint" +msgstr "" + +msgid "llmlog.empty.no_match.hint" +msgstr "" + +msgid "llmlog.empty.no_match.title" +msgstr "" + +msgid "llmlog.empty.title" +msgstr "" + +msgid "llmlog.empty_payload" +msgstr "" + +msgid "llmlog.meta.duration" +msgstr "" + +msgid "llmlog.meta.result" +msgstr "" + +msgid "llmlog.meta.stage" +msgstr "" + +msgid "llmlog.meta.time" +msgstr "" + +msgid "llmlog.no_records" +msgstr "" + +msgid "llmlog.panel.error" +msgstr "" + +msgid "llmlog.panel.error.desc" +msgstr "" + +msgid "llmlog.panel.request" +msgstr "" + +msgid "llmlog.panel.request.desc" +msgstr "" + +msgid "llmlog.panel.response" +msgstr "" + +msgid "llmlog.panel.response.desc" +msgstr "" + +msgid "llmlog.refresh.success" +msgstr "" + +msgid "llmlog.result.done" +msgstr "" + +msgid "llmlog.result.failed" +msgstr "" + +msgid "llmlog.search.placeholder" +msgstr "" + +msgid "llmlog.status.filtered" +msgstr "" + +msgid "llmlog.status.total" +msgstr "" + +msgid "llmlog.status.total_zero" +msgstr "" + +msgid "llmlog.title" +msgstr "" + +msgid "llmlog.unknown" +msgstr "" + +msgid "logwin.btn.open_folder" +msgstr "" + +msgid "logwin.error.open_failed" +msgstr "" + +msgid "logwin.error.read_failed" +msgstr "" + +msgid "logwin.error.update_failed" +msgstr "" + +msgid "logwin.history_divider" +msgstr "" + +msgid "logwin.title" +msgstr "" + +msgid "modelmgr.action.current" +msgstr "" + +msgid "modelmgr.action.download" +msgstr "" + +msgid "modelmgr.action.resume" +msgstr "" + +msgid "modelmgr.col.action" +msgstr "" + +msgid "modelmgr.col.model" +msgstr "" + +msgid "modelmgr.col.size" +msgstr "" + +msgid "modelmgr.col.status" +msgstr "" + +msgid "modelmgr.command.copied_body" +msgstr "" + +msgid "modelmgr.command.copied_title" +msgstr "" + +msgid "modelmgr.command.copy" +msgstr "" + +msgid "modelmgr.download.done_body" +msgstr "" + +msgid "modelmgr.download.done_title" +msgstr "" + +msgid "modelmgr.download.failed" +msgstr "" + +msgid "modelmgr.footer.model_dir" +msgstr "" + +msgid "modelmgr.footer.open_dir" +msgstr "" + +msgid "modelmgr.program.available" +msgstr "" + +msgid "modelmgr.program.found" +msgstr "" + +msgid "modelmgr.program.missing" +msgstr "" + +msgid "modelmgr.program.open_page" +msgstr "" + +msgid "modelmgr.program.opened_body" +msgstr "" + +msgid "modelmgr.program.opened_title" +msgstr "" + +msgid "modelmgr.program.ready_body" +msgstr "" + +msgid "modelmgr.program.ready_title" +msgstr "" + +msgid "modelmgr.program.recheck" +msgstr "" + +msgid "modelmgr.progress.connecting" +msgstr "" + +msgid "modelmgr.recheck.available_body" +msgstr "" + +msgid "modelmgr.recheck.available_title" +msgstr "" + +msgid "modelmgr.recheck.missing_title" +msgstr "" + +msgid "modelmgr.remove.body" +msgstr "" + +msgid "modelmgr.remove.done" +msgstr "" + +msgid "modelmgr.remove.failed" +msgstr "" + +msgid "modelmgr.remove.title" +msgstr "" + +msgid "modelmgr.section.models" +msgstr "" + +msgid "modelmgr.section.programs" +msgstr "" + +msgid "modelmgr.status.downloading" +msgstr "" + +msgid "modelmgr.status.installed" +msgstr "" + +msgid "modelmgr.status.paused" +msgstr "" + +msgid "modelmgr.status.pending" +msgstr "" + +msgid "modelmgr.title" +msgstr "" + +msgid "overlay.listening" +msgstr "" + +msgid "overlay.paused" +msgstr "" + +msgid "overlayset.bg" +msgstr "" + +msgid "overlayset.bg.black" +msgstr "" + +msgid "overlayset.bg.outline" +msgstr "" + +msgid "overlayset.bg.translucent" +msgstr "" + +msgid "overlayset.display" +msgstr "" + +msgid "overlayset.display.bilingual" +msgstr "" + +msgid "overlayset.display.source" +msgstr "" + +msgid "overlayset.display.target" +msgstr "" + +msgid "overlayset.font_size" +msgstr "" + +msgid "overlayset.font_size.medium" +msgstr "" + +msgid "overlayset.title" +msgstr "" + +msgid "roi.scrub.hint" +msgstr "" + +msgid "roi.tag.caption_area" +msgstr "" + +msgid "settings.about.current_version" +msgstr "" + +msgid "settings.about.feedback" +msgstr "" + +msgid "settings.about.feedback.desc" +msgstr "" + +msgid "settings.about.feedback_button" +msgstr "" + +msgid "settings.about.help" +msgstr "" + +msgid "settings.about.help.desc" +msgstr "" + +msgid "settings.about.help_button" +msgstr "" + +msgid "settings.about.update_button" +msgstr "" + +msgid "settings.about.version" +msgstr "" + +msgid "settings.busy.loading" +msgstr "" + +msgid "settings.busy.testing" +msgstr "" + +msgid "settings.busy.transcribing" +msgstr "" + +msgid "settings.dubbing.api_key" +msgstr "" + +msgid "settings.dubbing.api_key.desc" +msgstr "" + +msgid "settings.dubbing.audio_mode" +msgstr "" + +msgid "settings.dubbing.audio_mode.desc" +msgstr "" + +msgid "settings.dubbing.audio_mode.duck" +msgstr "" + +msgid "settings.dubbing.audio_mode.mix" +msgstr "" + +msgid "settings.dubbing.audio_mode.replace" +msgstr "" + +msgid "settings.dubbing.config_error" +msgstr "" + +msgid "settings.dubbing.enabled" +msgstr "" + +msgid "settings.dubbing.enabled.desc" +msgstr "" + +msgid "settings.dubbing.model" +msgstr "" + +msgid "settings.dubbing.model.desc" +msgstr "" + +msgid "settings.dubbing.need_api_key" +msgstr "" + +msgid "settings.dubbing.preset" +msgstr "" + +msgid "settings.dubbing.preset.desc" +msgstr "" + +msgid "settings.dubbing.provider" +msgstr "" + +msgid "settings.dubbing.provider.desc" +msgstr "" + +msgid "settings.dubbing.test" +msgstr "" + +msgid "settings.dubbing.test.desc" +msgstr "" + +msgid "settings.dubbing.test_button" +msgstr "" + +msgid "settings.dubbing.test_failed" +msgstr "" + +msgid "settings.dubbing.test_success" +msgstr "" + +msgid "settings.dubbing.test_success.detail" +msgstr "" + +msgid "settings.dubbing.text_track" +msgstr "" + +msgid "settings.dubbing.text_track.auto" +msgstr "" + +msgid "settings.dubbing.text_track.desc" +msgstr "" + +msgid "settings.dubbing.text_track.first" +msgstr "" + +msgid "settings.dubbing.text_track.second" +msgstr "" + +msgid "settings.dubbing.timing" +msgstr "" + +msgid "settings.dubbing.timing.balanced" +msgstr "" + +msgid "settings.dubbing.timing.desc" +msgstr "" + +msgid "settings.dubbing.timing.natural" +msgstr "" + +msgid "settings.dubbing.timing.none" +msgstr "" + +msgid "settings.dubbing.timing.strict" +msgstr "" + +msgid "settings.dubbing.workers" +msgstr "" + +msgid "settings.dubbing.workers.desc" +msgstr "" + +msgid "settings.live_caption.asr_model" +msgstr "" + +msgid "settings.live_caption.asr_model.desc" +msgstr "" + +msgid "settings.live_caption.bg.black" +msgstr "" + +msgid "settings.live_caption.bg.translucent" +msgstr "" + +msgid "settings.live_caption.bg_style" +msgstr "" + +msgid "settings.live_caption.bg_style.desc" +msgstr "" + +msgid "settings.live_caption.deps_button" +msgstr "" + +msgid "settings.live_caption.display.bilingual" +msgstr "" + +msgid "settings.live_caption.display.source" +msgstr "" + +msgid "settings.live_caption.display.target" +msgstr "" + +msgid "settings.live_caption.display_mode" +msgstr "" + +msgid "settings.live_caption.display_mode.desc" +msgstr "" + +msgid "settings.live_caption.engine" +msgstr "" + +msgid "settings.live_caption.engine.desc" +msgstr "" + +msgid "settings.live_caption.engine.group" +msgstr "" + +msgid "settings.live_caption.font_scale" +msgstr "" + +msgid "settings.live_caption.font_scale.desc" +msgstr "" + +msgid "settings.live_caption.fun_key" +msgstr "" + +msgid "settings.live_caption.fun_key.desc" +msgstr "" + +msgid "settings.live_caption.overlay.group" +msgstr "" + +msgid "settings.live_caption.source_language" +msgstr "" + +msgid "settings.live_caption.source_language.desc" +msgstr "" + +msgid "settings.live_caption.target_language" +msgstr "" + +msgid "settings.live_caption.target_language.desc" +msgstr "" + +msgid "settings.live_caption.test.desc" +msgstr "" + +msgid "settings.live_caption.translate" +msgstr "" + +msgid "settings.live_caption.translate.desc" +msgstr "" + +msgid "settings.live_caption.translate.group" +msgstr "" + +msgid "settings.live_caption.translator_service" +msgstr "" + +msgid "settings.live_caption.translator_service.desc" +msgstr "" + +msgid "settings.live_caption.voxgate" +msgstr "" + +msgid "settings.live_caption.voxgate.desc" +msgstr "" + +msgid "settings.llm.api_key" +msgstr "" + +msgid "settings.llm.api_key.desc" +msgstr "" + +msgid "settings.llm.base_url" +msgstr "" + +msgid "settings.llm.base_url.desc" +msgstr "" + +msgid "settings.llm.connect_error" +msgstr "" + +msgid "settings.llm.connect_failed" +msgstr "" + +msgid "settings.llm.connect_success" +msgstr "" + +msgid "settings.llm.immersive_hint.desc" +msgstr "" + +msgid "settings.llm.immersive_hint.title" +msgstr "" + +msgid "settings.llm.load_models" +msgstr "" + +msgid "settings.llm.model" +msgstr "" + +msgid "settings.llm.model.desc" +msgstr "" + +msgid "settings.llm.model_service" +msgstr "" + +msgid "settings.llm.model_service.desc" +msgstr "" + +msgid "settings.llm.models_load_failed" +msgstr "" + +msgid "settings.llm.models_loaded" +msgstr "" + +msgid "settings.llm.models_loaded.desc" +msgstr "" + +msgid "settings.llm.no_models" +msgstr "" + +msgid "settings.llm.no_models.desc" +msgstr "" + +msgid "settings.llm.provider" +msgstr "" + +msgid "settings.llm.provider.desc" +msgstr "" + +msgid "settings.llm.test_connection" +msgstr "" + +msgid "settings.llm.warn.need_all" +msgstr "" + +msgid "settings.llm.warn.need_base_key" +msgstr "" + +msgid "settings.page.about" +msgstr "" + +msgid "settings.page.dubbing" +msgstr "" + +msgid "settings.page.live_caption" +msgstr "" + +msgid "settings.page.llm" +msgstr "" + +msgid "settings.page.personal" +msgstr "" + +msgid "settings.page.save" +msgstr "" + +msgid "settings.page.subtitle" +msgstr "" + +msgid "settings.page.transcribe" +msgstr "" + +msgid "settings.page.translate" +msgstr "" + +msgid "settings.page.translate_service" +msgstr "" + +msgid "settings.personal.choose_theme_color" +msgstr "" + +msgid "settings.personal.follow_system" +msgstr "" + +msgid "settings.personal.language" +msgstr "" + +msgid "settings.personal.restart_required" +msgstr "" + +msgid "settings.personal.theme" +msgstr "" + +msgid "settings.personal.theme.dark" +msgstr "" + +msgid "settings.personal.theme.desc" +msgstr "" + +msgid "settings.personal.theme.light" +msgstr "" + +msgid "settings.personal.theme_color" +msgstr "" + +msgid "settings.personal.theme_color.desc" +msgstr "" + +msgid "settings.personal.theme_color.is_default" +msgstr "" + +msgid "settings.personal.theme_color.pick_tip" +msgstr "" + +msgid "settings.personal.theme_color.reset" +msgstr "" + +msgid "settings.personal.theme_color.reset_tip" +msgstr "" + +msgid "settings.personal.zoom" +msgstr "" + +msgid "settings.placeholder.empty" +msgstr "" + +msgid "settings.placeholder.not_selected" +msgstr "" + +msgid "settings.restart.confirm" +msgstr "" + +msgid "settings.restart.later" +msgstr "" + +msgid "settings.restart.message" +msgstr "" + +msgid "settings.restart.title" +msgstr "" + +msgid "settings.save.cache" +msgstr "" + +msgid "settings.save.cache.desc" +msgstr "" + +msgid "settings.save.cache_disabled" +msgstr "" + +msgid "settings.save.cache_disabled.detail" +msgstr "" + +msgid "settings.save.cache_enabled" +msgstr "" + +msgid "settings.save.cache_enabled.detail" +msgstr "" + +msgid "settings.save.choose_work_dir" +msgstr "" + +msgid "settings.save.keep_intermediates" +msgstr "" + +msgid "settings.save.keep_intermediates.desc" +msgstr "" + +msgid "settings.save.work_dir" +msgstr "" + +msgid "settings.save.work_dir.desc" +msgstr "" + +msgid "settings.subtitle.layout" +msgstr "" + +msgid "settings.subtitle.layout.desc" +msgstr "" + +msgid "settings.subtitle.need_video" +msgstr "" + +msgid "settings.subtitle.need_video.desc" +msgstr "" + +msgid "settings.subtitle.open_style" +msgstr "" + +msgid "settings.subtitle.render_mode" +msgstr "" + +msgid "settings.subtitle.render_mode.desc" +msgstr "" + +msgid "settings.subtitle.soft" +msgstr "" + +msgid "settings.subtitle.soft.desc" +msgstr "" + +msgid "settings.subtitle.style" +msgstr "" + +msgid "settings.subtitle.style.desc" +msgstr "" + +msgid "settings.subtitle.video_quality" +msgstr "" + +msgid "settings.subtitle.video_quality.desc" +msgstr "" + +msgid "settings.test_transcribe" +msgstr "" + +msgid "settings.title" +msgstr "" + +msgid "settings.transcribe.faster_whisper.choose_dir" +msgstr "" + +msgid "settings.transcribe.faster_whisper.device" +msgstr "" + +msgid "settings.transcribe.faster_whisper.device.desc" +msgstr "" + +msgid "settings.transcribe.faster_whisper.model" +msgstr "" + +msgid "settings.transcribe.faster_whisper.model.desc" +msgstr "" + +msgid "settings.transcribe.faster_whisper.model_dir" +msgstr "" + +msgid "settings.transcribe.faster_whisper.model_dir.desc" +msgstr "" + +msgid "settings.transcribe.faster_whisper.one_word" +msgstr "" + +msgid "settings.transcribe.faster_whisper.one_word.desc" +msgstr "" + +msgid "settings.transcribe.faster_whisper.vad_filter" +msgstr "" + +msgid "settings.transcribe.faster_whisper.vad_filter.desc" +msgstr "" + +msgid "settings.transcribe.faster_whisper.vad_method" +msgstr "" + +msgid "settings.transcribe.faster_whisper.vad_method.desc" +msgstr "" + +msgid "settings.transcribe.faster_whisper.vad_threshold" +msgstr "" + +msgid "settings.transcribe.faster_whisper.vad_threshold.desc" +msgstr "" + +msgid "settings.transcribe.faster_whisper.voice_extraction" +msgstr "" + +msgid "settings.transcribe.faster_whisper.voice_extraction.desc" +msgstr "" + +msgid "settings.transcribe.fun_asr.key" +msgstr "" + +msgid "settings.transcribe.fun_asr.key.desc" +msgstr "" + +msgid "settings.transcribe.fun_asr.model" +msgstr "" + +msgid "settings.transcribe.fun_asr.model.desc" +msgstr "" + +msgid "settings.transcribe.local_model" +msgstr "" + +msgid "settings.transcribe.local_model.desc" +msgstr "" + +msgid "settings.transcribe.local_model.no_model" +msgstr "" + +msgid "settings.transcribe.local_model.not_installed" +msgstr "" + +msgid "settings.transcribe.manage_models" +msgstr "" + +msgid "settings.transcribe.missing.fun_asr_key" +msgstr "" + +msgid "settings.transcribe.missing.local_model" +msgstr "" + +msgid "settings.transcribe.missing.whisper_api" +msgstr "" + +msgid "settings.transcribe.model" +msgstr "" + +msgid "settings.transcribe.model.desc" +msgstr "" + +msgid "settings.transcribe.output_format" +msgstr "" + +msgid "settings.transcribe.output_format.desc" +msgstr "" + +msgid "settings.transcribe.prompt" +msgstr "" + +msgid "settings.transcribe.prompt.desc" +msgstr "" + +msgid "settings.transcribe.source_language" +msgstr "" + +msgid "settings.transcribe.source_language.desc" +msgstr "" + +msgid "settings.transcribe.test.desc" +msgstr "" + +msgid "settings.transcribe.test_error" +msgstr "" + +msgid "settings.transcribe.test_failed" +msgstr "" + +msgid "settings.transcribe.test_result" +msgstr "" + +msgid "settings.transcribe.test_success" +msgstr "" + +msgid "settings.transcribe.whisper_api.base" +msgstr "" + +msgid "settings.transcribe.whisper_api.base.desc" +msgstr "" + +msgid "settings.transcribe.whisper_api.key" +msgstr "" + +msgid "settings.transcribe.whisper_api.key.desc" +msgstr "" + +msgid "settings.transcribe.whisper_api.model" +msgstr "" + +msgid "settings.transcribe.whisper_api.model.desc" +msgstr "" + +msgid "settings.transcribe.whisper_cpp.model" +msgstr "" + +msgid "settings.transcribe.whisper_cpp.model.desc" +msgstr "" + +msgid "settings.translate.cjk_length" +msgstr "" + +msgid "settings.translate.cjk_length.desc" +msgstr "" + +msgid "settings.translate.custom_prompt" +msgstr "" + +msgid "settings.translate.custom_prompt.desc" +msgstr "" + +msgid "settings.translate.english_length" +msgstr "" + +msgid "settings.translate.english_length.desc" +msgstr "" + +msgid "settings.translate.optimize" +msgstr "" + +msgid "settings.translate.optimize.desc" +msgstr "" + +msgid "settings.translate.split" +msgstr "" + +msgid "settings.translate.split.desc" +msgstr "" + +msgid "settings.translate.target_language" +msgstr "" + +msgid "settings.translate.target_language.desc" +msgstr "" + +msgid "settings.translate.translate" +msgstr "" + +msgid "settings.translate.translate.desc" +msgstr "" + +msgid "settings.translate_service.batch_size" +msgstr "" + +msgid "settings.translate_service.batch_size.desc" +msgstr "" + +msgid "settings.translate_service.deeplx_endpoint" +msgstr "" + +msgid "settings.translate_service.deeplx_endpoint.desc" +msgstr "" + +msgid "settings.translate_service.reflect" +msgstr "" + +msgid "settings.translate_service.reflect.desc" +msgstr "" + +msgid "settings.translate_service.service" +msgstr "" + +msgid "settings.translate_service.service.desc" +msgstr "" + +msgid "settings.translate_service.thread_num" +msgstr "" + +msgid "settings.translate_service.thread_num.desc" +msgstr "" + +msgid "settings.warn.incomplete" +msgstr "" + +msgid "substyle.align.center" +msgstr "" + +msgid "substyle.align.left" +msgstr "" + +msgid "substyle.align.right" +msgstr "" + +msgid "substyle.content.bilingual" +msgstr "" + +msgid "substyle.content.source" +msgstr "" + +msgid "substyle.content.target" +msgstr "" + +msgid "substyle.copy_suffix" +msgstr "" + +msgid "substyle.custom_suffix" +msgstr "" + +msgid "substyle.dialog.delete_body" +msgstr "" + +msgid "substyle.dialog.delete_title" +msgstr "" + +msgid "substyle.dialog.name_placeholder" +msgstr "" + +msgid "substyle.dialog.new_style" +msgstr "" + +msgid "substyle.dialog.pick_bg" +msgstr "" + +msgid "substyle.dialog.rename_style" +msgstr "" + +msgid "substyle.dock.count" +msgstr "" + +msgid "substyle.dock.folder" +msgstr "" + +msgid "substyle.dock.new" +msgstr "" + +msgid "substyle.field.align" +msgstr "" + +msgid "substyle.field.bg_color" +msgstr "" + +msgid "substyle.field.bold" +msgstr "" + +msgid "substyle.field.font" +msgstr "" + +msgid "substyle.field.letter_spacing" +msgstr "" + +msgid "substyle.field.margin_bottom" +msgstr "" + +msgid "substyle.field.max_width" +msgstr "" + +msgid "substyle.field.outline_color" +msgstr "" + +msgid "substyle.field.outline_width" +msgstr "" + +msgid "substyle.field.pad_h" +msgstr "" + +msgid "substyle.field.pad_v" +msgstr "" + +msgid "substyle.field.radius" +msgstr "" + +msgid "substyle.field.size" +msgstr "" + +msgid "substyle.field.text_color" +msgstr "" + +msgid "substyle.filter.image" +msgstr "" + +msgid "substyle.group.background" +msgstr "" + +msgid "substyle.group.layout" +msgstr "" + +msgid "substyle.group.padding" +msgstr "" + +msgid "substyle.group.position" +msgstr "" + +msgid "substyle.group.primary" +msgstr "" + +msgid "substyle.group.secondary" +msgstr "" + +msgid "substyle.group.text" +msgstr "" + +msgid "substyle.group.text.sub" +msgstr "" + +msgid "substyle.inspector.autosave" +msgstr "" + +msgid "substyle.inspector.title" +msgstr "" + +msgid "substyle.mode.ass" +msgstr "" + +msgid "substyle.mode.rounded" +msgstr "" + +msgid "substyle.order.source_top" +msgstr "" + +msgid "substyle.order.target_top" +msgstr "" + +msgid "substyle.preview.background" +msgstr "" + +msgid "substyle.preview.landscape" +msgstr "" + +msgid "substyle.preview.portrait" +msgstr "" + +msgid "substyle.preview.source_placeholder" +msgstr "" + +msgid "substyle.preview.target_placeholder" +msgstr "" + +msgid "substyle.preview.text" +msgstr "" + +msgid "substyle.preview.title" +msgstr "" + +msgid "substyle.row.content" +msgstr "" + +msgid "substyle.row.gap" +msgstr "" + +msgid "substyle.row.order" +msgstr "" + +msgid "substyle.title" +msgstr "" + +msgid "substyle.toast.bg_set" +msgstr "" + +msgid "substyle.toast.created" +msgstr "" + +msgid "substyle.toast.deleted" +msgstr "" + +msgid "substyle.toast.duplicated" +msgstr "" + +msgid "substyle.toast.renamed" +msgstr "" + +msgid "subtitle.bottom.can_synthesize" +msgstr "" + +msgid "subtitle.bottom.count" +msgstr "" + +msgid "subtitle.bottom.hint" +msgstr "" + +msgid "subtitle.bottom.loaded" +msgstr "" + +msgid "subtitle.bottom.output" +msgstr "" + +msgid "subtitle.bottom.progress" +msgstr "" + +msgid "subtitle.btn.clear" +msgstr "" + +msgid "subtitle.btn.enter_synthesis" +msgstr "" + +msgid "subtitle.btn.folder" +msgstr "" + +msgid "subtitle.btn.open_config" +msgstr "" + +msgid "subtitle.btn.processing" +msgstr "" + +msgid "subtitle.btn.replace" +msgstr "" + +msgid "subtitle.btn.start" +msgstr "" + +msgid "subtitle.btn.wait_subtitle" +msgstr "" + +msgid "subtitle.clear.body" +msgstr "" + +msgid "subtitle.clear.title" +msgstr "" + +msgid "subtitle.col.end" +msgstr "" + +msgid "subtitle.col.original" +msgstr "" + +msgid "subtitle.col.start" +msgstr "" + +msgid "subtitle.col.translated" +msgstr "" + +msgid "subtitle.dialog.choose_file" +msgstr "" + +msgid "subtitle.dialog.save_file" +msgstr "" + +msgid "subtitle.drop.pick" +msgstr "" + +msgid "subtitle.drop.title" +msgstr "" + +msgid "subtitle.error.need_llm" +msgstr "" + +msgid "subtitle.error.no_config" +msgstr "" + +msgid "subtitle.fail.config_missing" +msgstr "" + +msgid "subtitle.fail.process" +msgstr "" + +msgid "subtitle.file_filter" +msgstr "" + +msgid "subtitle.menu.merge" +msgstr "" + +msgid "subtitle.menu.retranslate" +msgstr "" + +msgid "subtitle.no_file" +msgstr "" + +msgid "subtitle.opt.language" +msgstr "" + +msgid "subtitle.opt.layout" +msgstr "" + +msgid "subtitle.opt.optimize" +msgstr "" + +msgid "subtitle.opt.prompt" +msgstr "" + +msgid "subtitle.opt.split" +msgstr "" + +msgid "subtitle.opt.translate" +msgstr "" + +msgid "subtitle.process_settings" +msgstr "" + +msgid "subtitle.prompt.placeholder" +msgstr "" + +msgid "subtitle.prompt.set" +msgstr "" + +msgid "subtitle.prompt.unset" +msgstr "" + +msgid "subtitle.status.preparing" +msgstr "" + +msgid "subtitle.status.retranslating" +msgstr "" + +msgid "subtitle.tip.collapse_settings" +msgstr "" + +msgid "subtitle.tip.expand_settings" +msgstr "" + +msgid "subtitle.toast.format_error" +msgstr "" + +msgid "subtitle.toast.load_failed" +msgstr "" + +msgid "subtitle.toast.load_first" +msgstr "" + +msgid "subtitle.toast.no_video" +msgstr "" + +msgid "subtitle.toast.processing_wait" +msgstr "" + +msgid "subtitle.toast.save_done" +msgstr "" + +msgid "subtitle.toast.save_failed" +msgstr "" + +msgid "subtitle.toast.saved_to" +msgstr "" + +msgid "subtitle.toast.supported_formats" +msgstr "" + +msgid "subtitle.toast.translate_done" +msgstr "" + +msgid "subtitle.toast.translate_done_body" +msgstr "" + +msgid "subtitle.toast.translate_failed" +msgstr "" + +msgid "synth.audio_mode.duck" +msgstr "" + +msgid "synth.audio_mode.mix" +msgstr "" + +msgid "synth.audio_mode.replace" +msgstr "" + +msgid "synth.blocker.ffmpeg_missing" +msgstr "" + +msgid "synth.blocker.ffmpeg_missing_detail" +msgstr "" + +msgid "synth.blocker.ffprobe_missing" +msgstr "" + +msgid "synth.blocker.ffprobe_missing_detail" +msgstr "" + +msgid "synth.blocker.key_required" +msgstr "" + +msgid "synth.blocker.key_required_detail" +msgstr "" + +msgid "synth.btn.generate_audio" +msgstr "" + +msgid "synth.btn.generate_full" +msgstr "" + +msgid "synth.btn.generate_subtitle_video" +msgstr "" + +msgid "synth.btn.optional_video" +msgstr "" + +msgid "synth.btn.pick_output" +msgstr "" + +msgid "synth.btn.pick_subtitle" +msgstr "" + +msgid "synth.btn.pick_video" +msgstr "" + +msgid "synth.btn.regenerate" +msgstr "" + +msgid "synth.btn.wait_files" +msgstr "" + +msgid "synth.btn.wait_video" +msgstr "" + +msgid "synth.dialog.pick_media" +msgstr "" + +msgid "synth.dialog.pick_subtitle" +msgstr "" + +msgid "synth.dialog.pick_video" +msgstr "" + +msgid "synth.drop.formats" +msgstr "" + +msgid "synth.drop.pick" +msgstr "" + +msgid "synth.drop.title" +msgstr "" + +msgid "synth.error.ass_unsupported" +msgstr "" + +msgid "synth.error.dubbed_video_path_empty" +msgstr "" + +msgid "synth.file.media" +msgstr "" + +msgid "synth.file.reference_video" +msgstr "" + +msgid "synth.file.subtitle" +msgstr "" + +msgid "synth.file.video" +msgstr "" + +msgid "synth.hint.dub_only" +msgstr "" + +msgid "synth.hint.dub_then_synthesize" +msgstr "" + +msgid "synth.hint.need_subtitle" +msgstr "" + +msgid "synth.hint.need_subtitle_and_video" +msgstr "" + +msgid "synth.hint.need_video" +msgstr "" + +msgid "synth.hint.pick_output" +msgstr "" + +msgid "synth.hint.synthesize_only" +msgstr "" + +msgid "synth.link.style_page" +msgstr "" + +msgid "synth.link.voice_library" +msgstr "" + +msgid "synth.opt.audio_mode" +msgstr "" + +msgid "synth.opt.render_mode" +msgstr "" + +msgid "synth.opt.style" +msgstr "" + +msgid "synth.opt.subtitle_mode" +msgstr "" + +msgid "synth.opt.subtitle_style" +msgstr "" + +msgid "synth.opt.text_track" +msgstr "" + +msgid "synth.opt.timing" +msgstr "" + +msgid "synth.opt.video_quality" +msgstr "" + +msgid "synth.opt.voice" +msgstr "" + +msgid "synth.output.dubbing" +msgstr "" + +msgid "synth.output.dubbing_desc" +msgstr "" + +msgid "synth.output.subtitle_video" +msgstr "" + +msgid "synth.output.subtitle_video_desc" +msgstr "" + +msgid "synth.panel.title" +msgstr "" + +msgid "synth.pill.completed" +msgstr "" + +msgid "synth.pill.failed" +msgstr "" + +msgid "synth.pill.missing_ffmpeg" +msgstr "" + +msgid "synth.pill.missing_ffprobe" +msgstr "" + +msgid "synth.pill.missing_key" +msgstr "" + +msgid "synth.pill.missing_video" +msgstr "" + +msgid "synth.pill.no_output" +msgstr "" + +msgid "synth.pill.ready" +msgstr "" + +msgid "synth.plan.collect" +msgstr "" + +msgid "synth.plan.dubbing" +msgstr "" + +msgid "synth.plan.pending" +msgstr "" + +msgid "synth.plan.save" +msgstr "" + +msgid "synth.plan.synthesize" +msgstr "" + +msgid "synth.result.dubbed_audio" +msgstr "" + +msgid "synth.result.dubbed_video" +msgstr "" + +msgid "synth.result.subtitled_video" +msgstr "" + +msgid "synth.row.subtitle_required" +msgstr "" + +msgid "synth.row.video_optional" +msgstr "" + +msgid "synth.row.video_required" +msgstr "" + +msgid "synth.section.dubbing_params" +msgstr "" + +msgid "synth.section.output" +msgstr "" + +msgid "synth.section.subtitle_params" +msgstr "" + +msgid "synth.status.completed" +msgstr "" + +msgid "synth.status.done" +msgstr "" + +msgid "synth.status.failed" +msgstr "" + +msgid "synth.status.generating" +msgstr "" + +msgid "synth.status.missing" +msgstr "" + +msgid "synth.status.optional" +msgstr "" + +msgid "synth.status.processing" +msgstr "" + +msgid "synth.status.ready" +msgstr "" + +msgid "synth.status.waiting" +msgstr "" + +msgid "synth.subtitle_mode.hard" +msgstr "" + +msgid "synth.subtitle_mode.soft" +msgstr "" + +msgid "synth.text_track.auto" +msgstr "" + +msgid "synth.text_track.first" +msgstr "" + +msgid "synth.text_track.second" +msgstr "" + +msgid "synth.timing.balanced" +msgstr "" + +msgid "synth.timing.natural" +msgstr "" + +msgid "synth.timing.strict" +msgstr "" + +msgid "synth.tip.collapse_panel" +msgstr "" + +msgid "synth.tip.expand_panel" +msgstr "" + +msgid "synth.tip.open_config" +msgstr "" + +msgid "synth.title.config_check" +msgstr "" + +msgid "synth.title.confirm" +msgstr "" + +msgid "synth.title.input" +msgstr "" + +msgid "synth.title.results" +msgstr "" + +msgid "synth.title.running" +msgstr "" + +msgid "t_dubbing.error.config_empty" +msgstr "" + +msgid "t_dubbing.error.output_audio_path_empty" +msgstr "" + +msgid "t_dubbing.error.subtitle_path_empty" +msgstr "" + +msgid "t_dubbing.error.task_dir_empty" +msgstr "" + +msgid "t_dubbing.status.done" +msgstr "" + +msgid "t_dubbing.status.preparing" +msgstr "" + +msgid "t_hardsub.error.read_video_failed" +msgstr "" + +msgid "t_hardsub.status.detecting_region" +msgstr "" + +msgid "t_hardsub.status.recognizing" +msgstr "" + +msgid "t_media.error.no_video_file" +msgstr "" + +msgid "t_media.status.completed" +msgstr "" + +msgid "t_subtitle.error.llm_model_not_configured" +msgstr "" + +msgid "t_subtitle.error.llm_not_configured" +msgstr "" + +msgid "t_subtitle.error.llm_test_failed" +msgstr "" + +msgid "t_subtitle.error.no_config" +msgstr "" + +msgid "t_subtitle.error.no_subtitle_path" +msgstr "" + +msgid "t_subtitle.error.no_target_language" +msgstr "" + +msgid "t_subtitle.error.unsupported_service" +msgstr "" + +msgid "t_subtitle.status.done" +msgstr "" + +msgid "t_subtitle.status.optimizing" +msgstr "" + +msgid "t_subtitle.status.processing_percent" +msgstr "" + +msgid "t_subtitle.status.splitting" +msgstr "" + +msgid "t_subtitle.status.translating" +msgstr "" + +msgid "t_subtitle.status.translating_percent" +msgstr "" + +msgid "t_subtitle.status.verifying_llm" +msgstr "" + +msgid "t_synth.error.no_output_path" +msgstr "" + +msgid "t_synth.error.no_subtitle_path" +msgstr "" + +msgid "t_synth.error.no_video_path" +msgstr "" + +msgid "t_synth.status.done" +msgstr "" + +msgid "t_synth.status.synthesizing" +msgstr "" + +msgid "t_transcript.error.audio_extract_failed" +msgstr "" + +msgid "t_transcript.error.file_not_found" +msgstr "" + +msgid "t_transcript.error.no_config" +msgstr "" + +msgid "t_transcript.error.no_file_path" +msgstr "" + +msgid "t_transcript.error.no_output_path" +msgstr "" + +msgid "t_transcript.status.done" +msgstr "" + +msgid "t_transcript.status.extracting_audio" +msgstr "" + +msgid "t_transcript.status.transcribing" +msgstr "" + +msgid "t_voice.error.need_api_key" +msgstr "" + +msgid "t_voice.sample_text" +msgstr "" + +msgid "transcribe.action.cancel" +msgstr "" + +msgid "transcribe.action.replace_file" +msgstr "" + +msgid "transcribe.btn.open_subtitle" +msgstr "" + +msgid "transcribe.btn.retranscribe" +msgstr "" + +msgid "transcribe.btn.running" +msgstr "" + +msgid "transcribe.btn.start" +msgstr "" + +msgid "transcribe.btn.waiting_file" +msgstr "" + +msgid "transcribe.col.content" +msgstr "" + +msgid "transcribe.col.end" +msgstr "" + +msgid "transcribe.col.start" +msgstr "" + +msgid "transcribe.config.open_tip" +msgstr "" + +msgid "transcribe.dialog.media_filter" +msgstr "" + +msgid "transcribe.dialog.pick_media" +msgstr "" + +msgid "transcribe.drop.pick" +msgstr "" + +msgid "transcribe.drop.title" +msgstr "" + +msgid "transcribe.empty.title" +msgstr "" + +msgid "transcribe.error.title" +msgstr "" + +msgid "transcribe.file.title" +msgstr "" + +msgid "transcribe.format.txt" +msgstr "" + +msgid "transcribe.opt.language" +msgstr "" + +msgid "transcribe.opt.model" +msgstr "" + +msgid "transcribe.opt.output" +msgstr "" + +msgid "transcribe.opt.service" +msgstr "" + +msgid "transcribe.opt.track" +msgstr "" + +msgid "transcribe.opt.word_timestamp" +msgstr "" + +msgid "transcribe.params.collapse_tip" +msgstr "" + +msgid "transcribe.params.expand_tip" +msgstr "" + +msgid "transcribe.params.title" +msgstr "" + +msgid "transcribe.pending.not_started" +msgstr "" + +msgid "transcribe.preview.count" +msgstr "" + +msgid "transcribe.preview.title" +msgstr "" + +msgid "transcribe.progress.phase" +msgstr "" + +msgid "transcribe.result.collapse_tip" +msgstr "" + +msgid "transcribe.result.file" +msgstr "" + +msgid "transcribe.result.open_file_tip" +msgstr "" + +msgid "transcribe.result.title" +msgstr "" + +msgid "transcribe.stage.done" +msgstr "" + +msgid "transcribe.stage.read_audio" +msgstr "" + +msgid "transcribe.stage.recognize" +msgstr "" + +msgid "transcribe.stage.running" +msgstr "" + +msgid "transcribe.stage.waiting" +msgstr "" + +msgid "transcribe.stage.write_subtitle" +msgstr "" + +msgid "transcribe.status.done" +msgstr "" + +msgid "transcribe.status.failed" +msgstr "" + +msgid "transcribe.status.pending" +msgstr "" + +msgid "transcribe.status.running" +msgstr "" + +msgid "transcribe.status.unreachable" +msgstr "" + +msgid "transcribe.text_preview.done" +msgstr "" + +msgid "transcribe.text_preview.title" +msgstr "" + +msgid "transcribe.toast.bad_format" +msgstr "" + +msgid "transcribe.toast.drop_media" +msgstr "" + +msgid "transcribe.toast.no_subtitle" +msgstr "" + +msgid "transcribe.toast.processing" +msgstr "" + +msgid "transcribe.track.first" +msgstr "" + +msgid "workbench.drop.or" +msgstr "" + diff --git a/resource/i18n/zh_Hans/LC_MESSAGES/videocaptioner.mo b/resource/i18n/zh_Hans/LC_MESSAGES/videocaptioner.mo new file mode 100644 index 00000000..d1d47440 Binary files /dev/null and b/resource/i18n/zh_Hans/LC_MESSAGES/videocaptioner.mo differ diff --git a/resource/i18n/zh_Hans/LC_MESSAGES/videocaptioner.po b/resource/i18n/zh_Hans/LC_MESSAGES/videocaptioner.po new file mode 100644 index 00000000..21fe8e48 --- /dev/null +++ b/resource/i18n/zh_Hans/LC_MESSAGES/videocaptioner.po @@ -0,0 +1,4462 @@ +# Chinese (Simplified) translations for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-06-25 20:45+0800\n" +"PO-Revision-Date: 2026-06-21 23:08+0800\n" +"Last-Translator: FULL NAME \n" +"Language: zh_Hans\n" +"Language-Team: zh_Hans \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +msgid "app.announcement.got_it" +msgstr "知道了" + +msgid "app.announcement.title" +msgstr "公告" + +msgid "app.ffmpeg.missing_body" +msgstr "软件处理音视频文件时需要 FFmpeg,请先安装" + +msgid "app.ffmpeg.missing_title" +msgstr "FFmpeg 未安装" + +msgid "app.github.body" +msgstr "" +"VideoCaptioner " +"由本人在课余时间独立开发完成,目前托管在GitHub上,欢迎Star和Fork。项目诚然还有很多地方需要完善,遇到软件的问题或者BUG欢迎提交Issue。" +"\n" +"\n" +" https://github.com/WEIFENG2333/VideoCaptioner" + +msgid "app.github.open" +msgstr "打开 GitHub" + +msgid "app.github.support_author" +msgstr "支持作者" + +msgid "app.github.title" +msgstr "GitHub信息" + +msgid "app.nav.batch" +msgstr "批量处理" + +msgid "app.nav.doctor" +msgstr "诊断" + +msgid "app.nav.dubbing" +msgstr "配音" + +msgid "app.nav.hardsub" +msgstr "硬字幕提取" + +msgid "app.nav.home" +msgstr "主页" + +msgid "app.nav.live_caption" +msgstr "实时字幕" + +msgid "app.nav.request_logs" +msgstr "请求日志" + +msgid "app.nav.settings" +msgstr "设置" + +msgid "app.nav.subtitle_style" +msgstr "字幕样式" + +msgid "app.sidebar.collapse" +msgstr "收起" + +msgid "app.sidebar.expand" +msgstr "展开" + +msgid "app.update.available" +msgstr "有可用更新" + +msgid "app.update.cancel" +msgstr "取消" + +msgid "app.update.check_failed" +msgstr "检查更新失败" + +msgid "app.update.checking" +msgstr "正在检查更新…" + +msgid "app.update.download" +msgstr "下载更新" + +msgid "app.update.downloading" +msgstr "下载中 {percent}%" + +msgid "app.update.failed" +msgstr "下载失败:{error}" + +msgid "app.update.go_download" +msgstr "前往下载" + +msgid "app.update.install" +msgstr "重启并安装" + +msgid "app.update.install_failed" +msgstr "安装失败" + +msgid "app.update.mandatory" +msgstr "需更新到最新版本后才能继续使用" + +msgid "app.update.ready" +msgstr "下载完成" + +msgid "app.update.retry" +msgstr "重试" + +msgid "app.update.title" +msgstr "发现新版本 {version}" + +msgid "app.update.up_to_date" +msgstr "已是最新版本" + +msgid "app.window_title" +msgstr "卡卡字幕助手 -- VideoCaptioner" + +msgid "batch.added.count" +msgstr "已加入 {n} 个文件" + +msgid "batch.added.duplicated" +msgstr "{n} 个已在队列" + +msgid "batch.added.ignored" +msgstr "忽略 {n} 个不支持的文件" + +msgid "batch.added.title" +msgstr "添加完成" + +msgid "batch.btn.add_file" +msgstr "添加文件" + +msgid "batch.btn.add_folder" +msgstr "添加文件夹" + +msgid "batch.btn.clear" +msgstr "清空列表" + +msgid "batch.btn.pause" +msgstr "暂停队列" + +msgid "batch.btn.resume" +msgstr "继续处理" + +msgid "batch.btn.start" +msgstr "开始处理" + +msgid "batch.cannot_retry" +msgstr "无法重试" + +msgid "batch.cannot_start" +msgstr "无法开始" + +msgid "batch.concurrency" +msgstr "并发 {n}" + +msgid "batch.count" +msgstr "{n} 个任务" + +msgid "batch.detail.error" +msgstr "" +"错误信息:\n" +"{error}" + +msgid "batch.detail.file" +msgstr "文件:{path}" + +msgid "batch.detail.got_it" +msgstr "我知道了" + +msgid "batch.detail.outputs" +msgstr "输出文件:" + +msgid "batch.detail.progress" +msgstr "进度:{note}" + +msgid "batch.detail.status" +msgstr "状态:{status} · {progress}%" + +msgid "batch.detail.title" +msgstr "任务详情" + +msgid "batch.dialog.pick_files" +msgstr "选择文件" + +msgid "batch.dialog.pick_folder" +msgstr "选择文件夹" + +msgid "batch.done.body" +msgstr "{completed} 个任务全部完成" + +msgid "batch.done.title" +msgstr "批量处理完成" + +msgid "batch.drop.format" +msgstr "当前模式:{mode} · 支持{kinds},可拖入文件夹" + +msgid "batch.drop.media_kinds" +msgstr "音频、视频" + +msgid "batch.drop.subtitle_kinds" +msgstr "字幕文件" + +msgid "batch.drop.title" +msgstr "拖入文件或文件夹" + +msgid "batch.empty.duplicated" +msgstr "文件已在队列中" + +msgid "batch.empty.no_media" +msgstr "未找到支持的音视频文件" + +msgid "batch.empty.no_subtitle" +msgstr "未找到字幕文件" + +msgid "batch.empty.title" +msgstr "未加入任何文件" + +msgid "batch.error.empty_output" +msgstr "{stage}:输出路径为空" + +msgid "batch.error.empty_video_output" +msgstr "{stage}:输出视频路径为空" + +msgid "batch.filter.all" +msgstr "全部" + +msgid "batch.filter.empty" +msgstr "没有匹配当前筛选的任务" + +msgid "batch.filter.media" +msgstr "音视频文件 ({patterns})" + +msgid "batch.filter.subtitle" +msgstr "字幕文件 ({patterns})" + +msgid "batch.filtered.body" +msgstr "{n} 个文件与「{mode}」输入类型不匹配,已移出队列" + +msgid "batch.filtered.title" +msgstr "已过滤队列" + +msgid "batch.finished.body" +msgstr "{completed} 个完成,{failed} 个失败,可在列表中重试" + +msgid "batch.finished.title" +msgstr "批量处理结束" + +msgid "batch.metric.concurrency" +msgstr "并发数" + +msgid "batch.metric.progress" +msgstr "当前进度" + +msgid "batch.metric.queue" +msgstr "队列任务" + +msgid "batch.metric.success_rate" +msgstr "成功率" + +msgid "batch.mode.full.desc" +msgstr "转录、翻译、合成成片" + +msgid "batch.mode.full.title" +msgstr "全流程处理" + +msgid "batch.mode.subtitle.desc" +msgstr "优化、翻译已有字幕" + +msgid "batch.mode.subtitle.title" +msgstr "批量字幕翻译" + +msgid "batch.mode.trans_sub.desc" +msgstr "生成字幕并翻译优化" + +msgid "batch.mode.trans_sub.title" +msgstr "转录 + 字幕" + +msgid "batch.mode.transcribe.desc" +msgstr "从音视频生成字幕" + +msgid "batch.mode.transcribe.title" +msgstr "批量转录" + +msgid "batch.note.completed" +msgstr "处理完成" + +msgid "batch.note.failed" +msgstr "处理失败" + +msgid "batch.note.output" +msgstr "已输出 {name}" + +msgid "batch.panel.ended" +msgstr "已结束" + +msgid "batch.panel.not_started" +msgstr "未开始" + +msgid "batch.panel.paused" +msgstr "已暂停" + +msgid "batch.panel.ready" +msgstr "可开始" + +msgid "batch.panel.running" +msgstr "运行中" + +msgid "batch.panel.title" +msgstr "本批任务" + +msgid "batch.preflight.dubbing_key" +msgstr "当前配音音色需要 API Key,请检查配音配置或切换 Edge 免费音色" + +msgid "batch.preflight.ffmpeg" +msgstr "视频合成需要 FFmpeg:请先安装并确保 ffmpeg 在 PATH 中" + +msgid "batch.preflight.ffprobe" +msgstr "配音需要 ffprobe 处理音频,请确认 FFmpeg 套件完整(含 ffprobe)" + +msgid "batch.preflight.llm" +msgstr "字幕处理需要大模型:请先配置可用的 API Key、接口地址和模型" + +msgid "batch.row.details_tip" +msgstr "点击查看任务详情" + +msgid "batch.row.open_output" +msgstr "打开输出目录" + +msgid "batch.row.remove" +msgstr "移除任务" + +msgid "batch.row.retry" +msgstr "重试任务" + +msgid "batch.stage.dubbing.desc" +msgstr "按字幕生成音轨" + +msgid "batch.stage.dubbing.title" +msgstr "配音" + +msgid "batch.stage.settings_tip" +msgstr "{title}设置" + +msgid "batch.stage.subtitle.desc" +msgstr "断句、优化、翻译" + +msgid "batch.stage.subtitle.title" +msgstr "字幕处理" + +msgid "batch.stage.synthesis.desc" +msgstr "输出成片" + +msgid "batch.stage.synthesis.title" +msgstr "视频合成" + +msgid "batch.stage.transcribe.desc" +msgstr "生成原始字幕" + +msgid "batch.stage.transcribe.title" +msgstr "语音转录" + +msgid "batch.status.completed" +msgstr "已完成" + +msgid "batch.status.failed" +msgstr "失败" + +msgid "batch.status.preparing" +msgstr "准备中" + +msgid "batch.status.running" +msgstr "处理中" + +msgid "batch.status.waiting" +msgstr "等待中" + +msgid "batch.subtitle.done" +msgstr "批量任务完成" + +msgid "batch.subtitle.done_failed" +msgstr "批量任务完成,{n} 个文件需要处理" + +msgid "batch.subtitle.empty" +msgstr "拖入一批文件,选择处理类型后开始" + +msgid "batch.subtitle.paused" +msgstr "已暂停 · 处理中的任务完成后停止" + +msgid "batch.subtitle.ready" +msgstr "{n} 个文件已加入队列" + +msgid "batch.subtitle.running" +msgstr "正在按队列顺序处理" + +msgid "batch.switch.busy_body" +msgstr "请先暂停并等待当前任务结束,再切换处理类型" + +msgid "batch.switch.busy_title" +msgstr "正在处理" + +msgid "batch.switched.body" +msgstr "根据文件类型切换为「{mode}」" + +msgid "batch.switched.title" +msgstr "已切换处理类型" + +msgid "batch.title" +msgstr "批量处理" + +msgid "colorpicker.clear" +msgstr "清除" + +msgid "colorpicker.section.presets" +msgstr "常用字幕色" + +msgid "colorpicker.section.recent" +msgstr "最近使用" + +msgid "colorpicker.title" +msgstr "选择颜色" + +msgid "common.cancel" +msgstr "取消" + +msgid "common.close" +msgstr "关闭" + +msgid "common.copy" +msgstr "复制" + +msgid "common.delete" +msgstr "删除" + +msgid "common.error" +msgstr "错误" + +msgid "common.ok" +msgstr "确定" + +msgid "common.open_folder" +msgstr "打开文件夹" + +msgid "common.retry" +msgstr "重试" + +msgid "common.save" +msgstr "保存" + +msgid "common.tip" +msgstr "提示" + +msgid "common.warning" +msgstr "警告" + +msgid "ctrl.change" +msgstr "更改" + +msgid "ctrl.open" +msgstr "打开" + +msgid "ctrl.settings_title" +msgstr "设置" + +msgid "depdl.body.intro" +msgstr "按你的系统自动选择合适的版本下载到本机;国内网络会优先走加速镜像。" + +msgid "depdl.btn.done" +msgstr "完成" + +msgid "depdl.btn.download" +msgstr "下载" + +msgid "depdl.btn.install_all_missing" +msgstr "一键安装缺失" + +msgid "depdl.btn.open_install_dir" +msgstr "打开安装目录" + +msgid "depdl.error.download_failed" +msgstr "下载失败" + +msgid "depdl.info.done_body" +msgstr "{name} 下载完成。" + +msgid "depdl.info.nothing_body" +msgstr "所有依赖都已就绪。" + +msgid "depdl.info.nothing_title" +msgstr "无需安装" + +msgid "depdl.status.connecting" +msgstr "正在连接镜像…" + +msgid "depdl.status.failed" +msgstr "失败" + +msgid "depdl.status.installed" +msgstr "已安装" + +msgid "depdl.status.unsupported" +msgstr "暂不支持" + +msgid "depdl.title" +msgstr "下载运行依赖" + +msgid "doctor.btn.download_install" +msgstr "下载安装" + +msgid "doctor.btn.download_voxgate" +msgstr "下载 voxgate" + +msgid "doctor.btn.dubbing_settings" +msgstr "配音配置" + +msgid "doctor.btn.how_to_handle" +msgstr "处理方式" + +msgid "doctor.btn.install_tool" +msgstr "安装工具" + +msgid "doctor.btn.live_caption_settings" +msgstr "实时字幕配置" + +msgid "doctor.btn.llm_settings" +msgstr "大模型配置" + +msgid "doctor.btn.rerun" +msgstr "重新诊断" + +msgid "doctor.btn.run" +msgstr "运行诊断" + +msgid "doctor.btn.running" +msgstr "诊断中" + +msgid "doctor.btn.transcribe_settings" +msgstr "转录配置" + +msgid "doctor.btn.translate_settings" +msgstr "翻译配置" + +msgid "doctor.btn.usage" +msgstr "使用说明" + +msgid "doctor.checklist" +msgstr "检查清单" + +msgid "doctor.chip.dubbing" +msgstr "配音" + +msgid "doctor.chip.export" +msgstr "导出" + +msgid "doctor.chip.subtitle" +msgstr "字幕处理" + +msgid "doctor.chip.transcribe" +msgstr "转录" + +msgid "doctor.chip.translate" +msgstr "翻译" + +msgid "doctor.done.all_passed" +msgstr "当前检查项全部通过" + +msgid "doctor.done.errors" +msgstr "发现 {count} 项需要处理" + +msgid "doctor.done.title" +msgstr "诊断完成" + +msgid "doctor.done.warnings" +msgstr "有 {count} 项可选项需注意(不影响主要流程)" + +msgid "doctor.download" +msgstr "视频下载" + +msgid "doctor.download.desc" +msgstr "解析 YouTube 与哔哩哔哩链接,验证在线视频能否下载。" + +msgid "doctor.download.ok" +msgstr "YouTube 与哔哩哔哩解析正常,可直接粘贴链接下载。" + +msgid "doctor.download.ok_login" +msgstr "YouTube 与哔哩哔哩解析正常(部分站点通过浏览器登录态),可直接粘贴链接下载。" + +msgid "doctor.download.unavailable" +msgstr "{detail}" + +msgid "doctor.dubbing.desc" +msgstr "按当前提供商和音色生成配音;部分服务需要 Key。" + +msgid "doctor.dubbing.fail.desc" +msgstr "Gemini / SiliconFlow 需要配音 Key;Edge 可免 Key。" + +msgid "doctor.dubbing.ok.desc" +msgstr "当前配音配置可用,可继续生成配音。" + +msgid "doctor.dubbing.title" +msgstr "配音服务" + +msgid "doctor.failed.title" +msgstr "诊断失败" + +msgid "doctor.ffmpeg.desc" +msgstr "生成视频、压入字幕、合入配音都需要它。" + +msgid "doctor.ffmpeg.missing.desc" +msgstr "缺少后无法生成视频、压入字幕或合入配音。" + +msgid "doctor.ffmpeg.missing.title" +msgstr "缺少 FFmpeg" + +msgid "doctor.ffmpeg.no_ass.desc" +msgstr "当前 FFmpeg 缺少 ASS 字幕滤镜。请安装完整版本,或把字幕渲染模式切换为圆角背景。" + +msgid "doctor.ffmpeg.no_ass.title" +msgstr "FFmpeg 不支持 ASS 硬字幕" + +msgid "doctor.ffmpeg.ok.desc" +msgstr "工具完整,可生成视频和配音视频。" + +msgid "doctor.help.download" +msgstr "" +"YouTube 需要可用的系统代理;提示登录验证时:安装 Firefox 并登录,或用浏览器扩展导出 cookies.txt " +"放到应用数据目录(Windows 上新版 Chrome/Edge 的登录态无法读取)。哔哩哔哩风控(412)稍等几分钟会自动恢复。" + +msgid "doctor.help.ffmpeg" +msgstr "ASS 硬字幕需要带 libass 的完整 FFmpeg。macOS 可安装 ffmpeg-full;也可以先切换为圆角背景。" + +msgid "doctor.jump_failed.body" +msgstr "没有找到对应的设置页。" + +msgid "doctor.jump_failed.title" +msgstr "跳转失败" + +msgid "doctor.label.disabled" +msgstr "未启用" + +msgid "doctor.label.dubbing" +msgstr "配音" + +msgid "doctor.label.optimize" +msgstr "校正" + +msgid "doctor.label.split" +msgstr "智能断句" + +msgid "doctor.label.subtitle" +msgstr "字幕" + +msgid "doctor.label.subtitle_file" +msgstr "字幕文件" + +msgid "doctor.label.video" +msgstr "视频" + +msgid "doctor.live_caption.desc" +msgstr "实时转录需要本机 voxgate 转录程序,或一个外部实时服务。" + +msgid "doctor.live_caption.funasr_no_key.desc" +msgstr "Fun-ASR 实时缺少百炼 API Key,去设置里填写即可。" + +msgid "doctor.live_caption.not_found.desc" +msgstr "可选功能:未找到 voxgate 转录程序或外部实时服务,需要时再配置即可。" + +msgid "doctor.live_caption.ok.desc" +msgstr "实时字幕后端就绪,可开始实时转录与翻译。" + +msgid "doctor.live_caption.title" +msgstr "实时字幕" + +msgid "doctor.llm.desc.default" +msgstr "校正、术语修正、智能断句需要可用 Key。" + +msgid "doctor.llm.desc.translate" +msgstr "当前翻译会调用大模型,需要可用 Key。" + +msgid "doctor.llm.fail.desc" +msgstr "字幕校正、术语修正和智能断句需要可用 Key。" + +msgid "doctor.llm.ok.desc" +msgstr "大模型配置可用,可用于字幕增强。" + +msgid "doctor.llm.title.optimize" +msgstr "字幕校正" + +msgid "doctor.llm.title.optimize_split" +msgstr "字幕校正与智能断句" + +msgid "doctor.llm.title.split" +msgstr "智能断句" + +msgid "doctor.llm.title.translate" +msgstr "大模型翻译" + +msgid "doctor.llm.unavailable.title" +msgstr "大模型配置不可用" + +msgid "doctor.status.checking" +msgstr "检查中" + +msgid "doctor.status.error" +msgstr "未通过" + +msgid "doctor.status.ok" +msgstr "正常" + +msgid "doctor.status.pending" +msgstr "待检查" + +msgid "doctor.status.warning" +msgstr "需注意" + +msgid "doctor.subtitle" +msgstr "检查当前任务会用到的服务和工具。未启用的功能不会出现在清单里。" + +msgid "doctor.summary.all_passed" +msgstr "全部通过" + +msgid "doctor.summary.errors" +msgstr "{count} 项未通过" + +msgid "doctor.summary.pending" +msgstr "{count} 项待检查" + +msgid "doctor.summary.warnings" +msgstr "{count} 项需注意" + +msgid "doctor.title" +msgstr "诊断" + +msgid "doctor.transcribe.desc.free" +msgstr "把视频或音频转成原文字幕,免费接口需要网络。" + +msgid "doctor.transcribe.desc.local" +msgstr "把视频或音频转成原文字幕,需要本地模型。" + +msgid "doctor.transcribe.desc.whisper" +msgstr "把视频或音频转成原文字幕,需要 Whisper Key。" + +msgid "doctor.transcribe.fail.desc" +msgstr "当前转录方式不可用,请检查网络、Key 或本地模型。" + +msgid "doctor.transcribe.ok.desc" +msgstr "当前转录方式可用,可生成原文字幕。" + +msgid "doctor.transcribe.title" +msgstr "转录服务" + +msgid "doctor.translate.desc.default" +msgstr "生成目标语言字幕,失败只影响译文。" + +msgid "doctor.translate.desc.uses_llm" +msgstr "生成目标语言字幕,大模型翻译会复用 LLM Key。" + +msgid "doctor.translate.ok.desc" +msgstr "翻译服务可用,可生成目标语言字幕。" + +msgid "doctor.translate.title" +msgstr "翻译服务" + +msgid "doctor.translate.uses_llm.desc" +msgstr "大模型翻译会复用 LLM Key。" + +msgid "donate.alipay" +msgstr "支付宝" + +msgid "donate.desc" +msgstr "" +"目前本人精力有限,您的支持让我有动力继续折腾这个项目!\n" +"感谢您对开源事业的热爱与支持!" + +msgid "donate.title" +msgstr "支持作者" + +msgid "donate.wechat" +msgstr "微信" + +msgid "dubbing.badge.clone" +msgstr "可克隆" + +msgid "dubbing.badge.need_key" +msgstr "需 Key" + +msgid "dubbing.badge.no_key" +msgstr "免 Key" + +msgid "dubbing.btn.audition" +msgstr "试听" + +msgid "dubbing.btn.config" +msgstr "配音配置" + +msgid "dubbing.btn.generate_clone" +msgstr "生成克隆音频" + +msgid "dubbing.btn.generate_preview" +msgstr "生成试听音频" + +msgid "dubbing.btn.stop" +msgstr "停止" + +msgid "dubbing.btn.synthesizing" +msgstr "合成中…" + +msgid "dubbing.clone.clear" +msgstr "清除" + +msgid "dubbing.clone.hint" +msgstr "未上传参考音频时,会直接用上方文案试听当前音色。" + +msgid "dubbing.clone.missing_file" +msgstr "参考音频文件不存在,请重新选择或清除。" + +msgid "dubbing.clone.no_audio" +msgstr "未选择参考音频" + +msgid "dubbing.clone.record" +msgstr "录制" + +msgid "dubbing.clone.ref_text" +msgstr "参考文本" + +msgid "dubbing.clone.ref_text_placeholder" +msgstr "输入参考音频里实际朗读的文字" + +msgid "dubbing.clone.title" +msgstr "声音克隆" + +msgid "dubbing.clone.upload" +msgstr "上传" + +msgid "dubbing.clone.uploaded" +msgstr "已上传" + +msgid "dubbing.dialog.audio_filter" +msgstr "音频文件 (*.wav *.mp3 *.m4a *.aac *.flac *.ogg *.opus);;所有文件 (*.*)" + +msgid "dubbing.dialog.choose_audio" +msgstr "选择参考音频" + +msgid "dubbing.filter.all" +msgstr "全部" + +msgid "dubbing.filter.clone" +msgstr "克隆" + +msgid "dubbing.filter.female" +msgstr "女声" + +msgid "dubbing.filter.male" +msgstr "男声" + +msgid "dubbing.form.current_voice" +msgstr "当前音色" + +msgid "dubbing.form.gen_type" +msgstr "生成类型" + +msgid "dubbing.form.not_selected" +msgstr "未选择" + +msgid "dubbing.form.type_preview" +msgstr "试听音频" + +msgid "dubbing.preview.char_count" +msgstr "{count} 字" + +msgid "dubbing.preview.desc" +msgstr "填写测试文案,生成音频后确认声音和语气。" + +msgid "dubbing.preview.desc_clone" +msgstr "可直接试听预置音色,或加参考音频做声音克隆。" + +msgid "dubbing.preview.input_placeholder" +msgstr "输入一句话,试听选中的音色" + +msgid "dubbing.preview.sample_text" +msgstr "你好,这是我想用于测试的配音文案。请用自然清晰的语气朗读这一句话。" + +msgid "dubbing.preview.title" +msgstr "配音文案" + +msgid "dubbing.provider.edge.desc" +msgstr "免 API Key,适合默认快速生成中文或英文配音。" + +msgid "dubbing.provider.edge.title" +msgstr "Edge 免费配音" + +msgid "dubbing.provider.gemini.desc" +msgstr "Google Gemini 语音模型,适合英文自然表达。" + +msgid "dubbing.provider.gemini.title" +msgstr "Gemini TTS" + +msgid "dubbing.provider.siliconflow.desc" +msgstr "CosyVoice 中文表现稳定,并支持参考音频克隆。" + +msgid "dubbing.provider.siliconflow.title" +msgstr "SiliconFlow CosyVoice" + +msgid "dubbing.ready.clone" +msgstr "支持声音克隆" + +msgid "dubbing.ready.default" +msgstr "就绪" + +msgid "dubbing.ready.need_key" +msgstr "需 API Key" + +msgid "dubbing.ready.no_key" +msgstr "免 Key 即用" + +msgid "dubbing.subtitle" +msgstr "选择提供商和音色,输入一句自己的试听文案。" + +msgid "dubbing.tag.breathy" +msgstr "Breathy" + +msgid "dubbing.tag.bright" +msgstr "Bright" + +msgid "dubbing.tag.casual" +msgstr "Casual" + +msgid "dubbing.tag.clear" +msgstr "Clear" + +msgid "dubbing.tag.clone" +msgstr "克隆" + +msgid "dubbing.tag.easy_going" +msgstr "Easy-going" + +msgid "dubbing.tag.en" +msgstr "英文" + +msgid "dubbing.tag.even" +msgstr "Even" + +msgid "dubbing.tag.female" +msgstr "女声" + +msgid "dubbing.tag.firm" +msgstr "Firm" + +msgid "dubbing.tag.forward" +msgstr "Forward" + +msgid "dubbing.tag.free" +msgstr "免费" + +msgid "dubbing.tag.gentle" +msgstr "Gentle" + +msgid "dubbing.tag.gravelly" +msgstr "Gravelly" + +msgid "dubbing.tag.informative" +msgstr "Informative" + +msgid "dubbing.tag.knowledgeable" +msgstr "Knowledgeable" + +msgid "dubbing.tag.lively" +msgstr "Lively" + +msgid "dubbing.tag.male" +msgstr "男声" + +msgid "dubbing.tag.mature" +msgstr "Mature" + +msgid "dubbing.tag.need_key" +msgstr "需 Key" + +msgid "dubbing.tag.recommended" +msgstr "推荐" + +msgid "dubbing.tag.smooth" +msgstr "Smooth" + +msgid "dubbing.tag.soft" +msgstr "Soft" + +msgid "dubbing.tag.upbeat" +msgstr "Upbeat" + +msgid "dubbing.tag.warm" +msgstr "Warm" + +msgid "dubbing.tag.yue" +msgstr "粤语" + +msgid "dubbing.tag.zh" +msgstr "中文" + +msgid "dubbing.title" +msgstr "配音" + +msgid "dubbing.toast.audio_missing" +msgstr "参考音频不存在" + +msgid "dubbing.toast.audio_missing_body" +msgstr "请重新上传或录制参考音频。" + +msgid "dubbing.toast.empty_text" +msgstr "请输入试听文本" + +msgid "dubbing.toast.empty_text_body" +msgstr "文本试听会使用你输入的内容实时生成音频。" + +msgid "dubbing.toast.missing_ref_text" +msgstr "缺少参考文本" + +msgid "dubbing.toast.missing_ref_text_body" +msgstr "请填写参考音频里实际朗读的文字,或清除参考音频后普通试听。" + +msgid "dubbing.toast.need_api_key" +msgstr "需要 API Key" + +msgid "dubbing.toast.need_api_key_body" +msgstr "自定义文本试听需要真实请求,请先填写当前配音服务的 API Key。" + +msgid "dubbing.toast.play_failed" +msgstr "播放失败" + +msgid "dubbing.toast.play_failed_body" +msgstr "当前系统缺少音频解码组件,且未找到可用的外部播放器。" + +msgid "dubbing.toast.please_wait" +msgstr "请稍候" + +msgid "dubbing.toast.please_wait_body" +msgstr "正在合成另一段试听。" + +msgid "dubbing.toast.preview_failed" +msgstr "试听失败" + +msgid "dubbing.toast.record_done" +msgstr "录制完成" + +msgid "dubbing.toast.record_done_body" +msgstr "已保存为参考音频" + +msgid "dubbing.voice.edge-cn-female.desc" +msgstr "清晰自然的普通话女声" + +msgid "dubbing.voice.edge-cn-male.desc" +msgstr "年轻自然的普通话男声" + +msgid "dubbing.voice.edge-cn-xiaoyi.desc" +msgstr "温和明亮的普通话女声" + +msgid "dubbing.voice.edge-cn-yunjian.desc" +msgstr "更适合演讲和旁白的男声" + +msgid "dubbing.voice.edge-cn-yunyang.desc" +msgstr "播报感更强的普通话男声" + +msgid "dubbing.voice.edge-en-andrew.desc" +msgstr "清晰稳重的美式英语男声" + +msgid "dubbing.voice.edge-en-ava.desc" +msgstr "清爽自然的美式英语女声" + +msgid "dubbing.voice.edge-en-brian.desc" +msgstr "自然稳健的美式英语男声" + +msgid "dubbing.voice.edge-en-emma.desc" +msgstr "柔和自然的美式英语女声" + +msgid "dubbing.voice.edge-en-female.desc" +msgstr "美式英语女声" + +msgid "dubbing.voice.edge-en-libby.desc" +msgstr "英式英语女声" + +msgid "dubbing.voice.edge-en-male.desc" +msgstr "美式英语男声" + +msgid "dubbing.voice.edge-en-ryan.desc" +msgstr "英式英语男声" + +msgid "dubbing.voice.edge-hk-hiugaai.desc" +msgstr "粤语女声" + +msgid "dubbing.voice.edge-hk-wanlung.desc" +msgstr "粤语男声" + +msgid "dubbing.voice.edge-tw-hsiaoyu.desc" +msgstr "台湾国语女声" + +msgid "dubbing.voice.edge-tw-yunjhe.desc" +msgstr "台湾国语男声" + +msgid "dubbing.voice.gemini-achernar.desc" +msgstr "柔和的英文声音" + +msgid "dubbing.voice.gemini-algenib.desc" +msgstr "颗粒感更强的英文声音" + +msgid "dubbing.voice.gemini-algieba.desc" +msgstr "平滑的英文声音" + +msgid "dubbing.voice.gemini-alnilam.desc" +msgstr "坚定清晰的英文声音" + +msgid "dubbing.voice.gemini-aoede.desc" +msgstr "明亮自然的英文声音" + +msgid "dubbing.voice.gemini-autonoe.desc" +msgstr "均衡清晰的英文声音" + +msgid "dubbing.voice.gemini-callirrhoe.desc" +msgstr "轻松自然的英文声音" + +msgid "dubbing.voice.gemini-charon.desc" +msgstr "更沉稳的英文声音" + +msgid "dubbing.voice.gemini-despina.desc" +msgstr "平滑自然的英文声音" + +msgid "dubbing.voice.gemini-en-friendly.desc" +msgstr "友好自然的英文表达" + +msgid "dubbing.voice.gemini-en-neutral.desc" +msgstr "清晰稳定的自然英文" + +msgid "dubbing.voice.gemini-en-upbeat.desc" +msgstr "更有能量的英文表达" + +msgid "dubbing.voice.gemini-enceladus.desc" +msgstr "气声感更明显的英文声音" + +msgid "dubbing.voice.gemini-erinome.desc" +msgstr "清晰直给的英文声音" + +msgid "dubbing.voice.gemini-fenrir.desc" +msgstr "低沉有力的英文声音" + +msgid "dubbing.voice.gemini-gacrux.desc" +msgstr "成熟稳重的英文声音" + +msgid "dubbing.voice.gemini-iapetus.desc" +msgstr "清澈稳定的英文声音" + +msgid "dubbing.voice.gemini-laomedeia.desc" +msgstr "轻快活泼的英文声音" + +msgid "dubbing.voice.gemini-leda.desc" +msgstr "轻快明亮的英文声音" + +msgid "dubbing.voice.gemini-orus.desc" +msgstr "旁白感更强的英文声音" + +msgid "dubbing.voice.gemini-pulcherrima.desc" +msgstr "前置感更强的英文声音" + +msgid "dubbing.voice.gemini-rasalgethi.desc" +msgstr "信息感更强的英文声音" + +msgid "dubbing.voice.gemini-sadachbia.desc" +msgstr "生动轻快的英文声音" + +msgid "dubbing.voice.gemini-sadaltager.desc" +msgstr "知识型表达的英文声音" + +msgid "dubbing.voice.gemini-schedar.desc" +msgstr "平稳均衡的英文声音" + +msgid "dubbing.voice.gemini-sulafat.desc" +msgstr "温暖自然的英文声音" + +msgid "dubbing.voice.gemini-umbriel.desc" +msgstr "轻松自然的英文声音" + +msgid "dubbing.voice.gemini-vindemiatrix.desc" +msgstr "温和柔顺的英文声音" + +msgid "dubbing.voice.gemini-zephyr.desc" +msgstr "明亮清爽的英文声音" + +msgid "dubbing.voice.gemini-zubenelgenubi.desc" +msgstr "休闲自然的英文声音" + +msgid "dubbing.voice.library" +msgstr "音色库" + +msgid "dubbing.voice.library_zh" +msgstr "中文音色" + +msgid "dubbing.voice.siliconflow-cn-bella.desc" +msgstr "热情中文女声,可克隆" + +msgid "dubbing.voice.siliconflow-cn-charles.desc" +msgstr "磁性中文男声,可克隆" + +msgid "dubbing.voice.siliconflow-cn-claire.desc" +msgstr "温柔中文女声,可克隆" + +msgid "dubbing.voice.siliconflow-cn-david.desc" +msgstr "欢快中文男声,可克隆" + +msgid "dubbing.voice.siliconflow-cn-deep-male.desc" +msgstr "沉稳低沉的中文男声,可克隆" + +msgid "dubbing.voice.siliconflow-cn-diana.desc" +msgstr "欢快中文女声,可克隆" + +msgid "dubbing.voice.siliconflow-cn-female.desc" +msgstr "自然中文女声,可配合参考音频克隆" + +msgid "dubbing.voice.siliconflow-cn-male.desc" +msgstr "自然中文男声,可配合参考音频克隆" + +msgid "enum.FasterWhisperModelEnum.BASE" +msgstr "base" + +msgid "enum.FasterWhisperModelEnum.LARGE_V1" +msgstr "large-v1" + +msgid "enum.FasterWhisperModelEnum.LARGE_V2" +msgstr "large-v2" + +msgid "enum.FasterWhisperModelEnum.LARGE_V3" +msgstr "large-v3" + +msgid "enum.FasterWhisperModelEnum.LARGE_V3_TURBO" +msgstr "large-v3-turbo" + +msgid "enum.FasterWhisperModelEnum.MEDIUM" +msgstr "medium" + +msgid "enum.FasterWhisperModelEnum.SMALL" +msgstr "small" + +msgid "enum.FasterWhisperModelEnum.TINY" +msgstr "tiny" + +msgid "enum.LLMServiceEnum.CHATGLM" +msgstr "ChatGLM" + +msgid "enum.LLMServiceEnum.DEEPSEEK" +msgstr "DeepSeek" + +msgid "enum.LLMServiceEnum.GEMINI" +msgstr "Gemini" + +msgid "enum.LLMServiceEnum.IMMERSIVE" +msgstr "公益大模型" + +msgid "enum.LLMServiceEnum.LM_STUDIO" +msgstr "LM Studio" + +msgid "enum.LLMServiceEnum.OLLAMA" +msgstr "Ollama" + +msgid "enum.LLMServiceEnum.OPENAI" +msgstr "OpenAI 兼容" + +msgid "enum.LLMServiceEnum.SILICON_CLOUD" +msgstr "SiliconCloud" + +msgid "enum.SubtitleLayoutEnum.ONLY_ORIGINAL" +msgstr "仅原文" + +msgid "enum.SubtitleLayoutEnum.ONLY_TRANSLATE" +msgstr "仅译文" + +msgid "enum.SubtitleLayoutEnum.ORIGINAL_ON_TOP" +msgstr "原文在上" + +msgid "enum.SubtitleLayoutEnum.TRANSLATE_ON_TOP" +msgstr "译文在上" + +msgid "enum.SubtitleRenderModeEnum.ASS_STYLE" +msgstr "ASS 样式" + +msgid "enum.SubtitleRenderModeEnum.ROUNDED_BG" +msgstr "圆角背景" + +msgid "enum.TargetLanguage.ARABIC" +msgstr "阿拉伯语" + +msgid "enum.TargetLanguage.BULGARIAN" +msgstr "保加利亚语" + +msgid "enum.TargetLanguage.CANTONESE" +msgstr "粤语" + +msgid "enum.TargetLanguage.CZECH" +msgstr "捷克语" + +msgid "enum.TargetLanguage.DANISH" +msgstr "丹麦语" + +msgid "enum.TargetLanguage.DUTCH" +msgstr "荷兰语" + +msgid "enum.TargetLanguage.ENGLISH" +msgstr "英语" + +msgid "enum.TargetLanguage.ENGLISH_UK" +msgstr "英语(英国)" + +msgid "enum.TargetLanguage.ENGLISH_US" +msgstr "英语(美国)" + +msgid "enum.TargetLanguage.FINNISH" +msgstr "芬兰语" + +msgid "enum.TargetLanguage.FRENCH" +msgstr "法语" + +msgid "enum.TargetLanguage.GERMAN" +msgstr "德语" + +msgid "enum.TargetLanguage.GREEK" +msgstr "希腊语" + +msgid "enum.TargetLanguage.HEBREW" +msgstr "希伯来语" + +msgid "enum.TargetLanguage.HUNGARIAN" +msgstr "匈牙利语" + +msgid "enum.TargetLanguage.INDONESIAN" +msgstr "印尼语" + +msgid "enum.TargetLanguage.ITALIAN" +msgstr "意大利语" + +msgid "enum.TargetLanguage.JAPANESE" +msgstr "日本語" + +msgid "enum.TargetLanguage.KOREAN" +msgstr "韩语" + +msgid "enum.TargetLanguage.MALAY" +msgstr "马来语" + +msgid "enum.TargetLanguage.NORWEGIAN" +msgstr "挪威语" + +msgid "enum.TargetLanguage.PERSIAN" +msgstr "波斯语" + +msgid "enum.TargetLanguage.POLISH" +msgstr "波兰语" + +msgid "enum.TargetLanguage.PORTUGUESE" +msgstr "葡萄牙语" + +msgid "enum.TargetLanguage.PORTUGUESE_BR" +msgstr "葡萄牙语(巴西)" + +msgid "enum.TargetLanguage.PORTUGUESE_PT" +msgstr "葡萄牙语(葡萄牙)" + +msgid "enum.TargetLanguage.ROMANIAN" +msgstr "罗马尼亚语" + +msgid "enum.TargetLanguage.RUSSIAN" +msgstr "俄语" + +msgid "enum.TargetLanguage.SIMPLIFIED_CHINESE" +msgstr "简体中文" + +msgid "enum.TargetLanguage.SPANISH" +msgstr "西班牙语" + +msgid "enum.TargetLanguage.SPANISH_LATAM" +msgstr "西班牙语(拉丁美洲)" + +msgid "enum.TargetLanguage.SWEDISH" +msgstr "瑞典语" + +msgid "enum.TargetLanguage.TAGALOG" +msgstr "菲律宾语" + +msgid "enum.TargetLanguage.THAI" +msgstr "泰语" + +msgid "enum.TargetLanguage.TRADITIONAL_CHINESE" +msgstr "繁体中文" + +msgid "enum.TargetLanguage.TURKISH" +msgstr "土耳其语" + +msgid "enum.TargetLanguage.UKRAINIAN" +msgstr "乌克兰语" + +msgid "enum.TargetLanguage.VIETNAMESE" +msgstr "越南语" + +msgid "enum.TranscribeLanguageEnum.AFRIKAANS" +msgstr "Afrikaans" + +msgid "enum.TranscribeLanguageEnum.ALBANIAN" +msgstr "Albanian" + +msgid "enum.TranscribeLanguageEnum.AMHARIC" +msgstr "Amharic" + +msgid "enum.TranscribeLanguageEnum.ARABIC" +msgstr "Arabic" + +msgid "enum.TranscribeLanguageEnum.ARMENIAN" +msgstr "Armenian" + +msgid "enum.TranscribeLanguageEnum.ASSAMESE" +msgstr "Assamese" + +msgid "enum.TranscribeLanguageEnum.AUTO" +msgstr "自动检测" + +msgid "enum.TranscribeLanguageEnum.AZERBAIJANI" +msgstr "Azerbaijani" + +msgid "enum.TranscribeLanguageEnum.BASHKIR" +msgstr "Bashkir" + +msgid "enum.TranscribeLanguageEnum.BASQUE" +msgstr "Basque" + +msgid "enum.TranscribeLanguageEnum.BELARUSIAN" +msgstr "Belarusian" + +msgid "enum.TranscribeLanguageEnum.BENGALI" +msgstr "Bengali" + +msgid "enum.TranscribeLanguageEnum.BOSNIAN" +msgstr "Bosnian" + +msgid "enum.TranscribeLanguageEnum.BRETON" +msgstr "Breton" + +msgid "enum.TranscribeLanguageEnum.BULGARIAN" +msgstr "Bulgarian" + +msgid "enum.TranscribeLanguageEnum.CANTONESE" +msgstr "Cantonese" + +msgid "enum.TranscribeLanguageEnum.CATALAN" +msgstr "Catalan" + +msgid "enum.TranscribeLanguageEnum.CHINESE" +msgstr "中文" + +msgid "enum.TranscribeLanguageEnum.CROATIAN" +msgstr "Croatian" + +msgid "enum.TranscribeLanguageEnum.CZECH" +msgstr "Czech" + +msgid "enum.TranscribeLanguageEnum.DANISH" +msgstr "Danish" + +msgid "enum.TranscribeLanguageEnum.DUTCH" +msgstr "Dutch" + +msgid "enum.TranscribeLanguageEnum.ENGLISH" +msgstr "英语" + +msgid "enum.TranscribeLanguageEnum.ESTONIAN" +msgstr "Estonian" + +msgid "enum.TranscribeLanguageEnum.FAROESE" +msgstr "Faroese" + +msgid "enum.TranscribeLanguageEnum.FINNISH" +msgstr "Finnish" + +msgid "enum.TranscribeLanguageEnum.FRENCH" +msgstr "法语" + +msgid "enum.TranscribeLanguageEnum.GALICIAN" +msgstr "Galician" + +msgid "enum.TranscribeLanguageEnum.GEORGIAN" +msgstr "Georgian" + +msgid "enum.TranscribeLanguageEnum.GERMAN" +msgstr "德语" + +msgid "enum.TranscribeLanguageEnum.GREEK" +msgstr "Greek" + +msgid "enum.TranscribeLanguageEnum.GUJARATI" +msgstr "Gujarati" + +msgid "enum.TranscribeLanguageEnum.HAITIAN_CREOLE" +msgstr "Haitian Creole" + +msgid "enum.TranscribeLanguageEnum.HAUSA" +msgstr "Hausa" + +msgid "enum.TranscribeLanguageEnum.HAWAIIAN" +msgstr "Hawaiian" + +msgid "enum.TranscribeLanguageEnum.HEBREW" +msgstr "Hebrew" + +msgid "enum.TranscribeLanguageEnum.HINDI" +msgstr "Hindi" + +msgid "enum.TranscribeLanguageEnum.HUNGARIAN" +msgstr "Hungarian" + +msgid "enum.TranscribeLanguageEnum.ICELANDIC" +msgstr "Icelandic" + +msgid "enum.TranscribeLanguageEnum.INDONESIAN" +msgstr "Indonesian" + +msgid "enum.TranscribeLanguageEnum.ITALIAN" +msgstr "Italian" + +msgid "enum.TranscribeLanguageEnum.JAPANESE" +msgstr "日本語" + +msgid "enum.TranscribeLanguageEnum.JAVANESE" +msgstr "Javanese" + +msgid "enum.TranscribeLanguageEnum.KANNADA" +msgstr "Kannada" + +msgid "enum.TranscribeLanguageEnum.KAZAKH" +msgstr "Kazakh" + +msgid "enum.TranscribeLanguageEnum.KHMER" +msgstr "Khmer" + +msgid "enum.TranscribeLanguageEnum.KOREAN" +msgstr "韩语" + +msgid "enum.TranscribeLanguageEnum.LAO" +msgstr "Lao" + +msgid "enum.TranscribeLanguageEnum.LATIN" +msgstr "Latin" + +msgid "enum.TranscribeLanguageEnum.LATVIAN" +msgstr "Latvian" + +msgid "enum.TranscribeLanguageEnum.LINGALA" +msgstr "Lingala" + +msgid "enum.TranscribeLanguageEnum.LITHUANIAN" +msgstr "Lithuanian" + +msgid "enum.TranscribeLanguageEnum.LUXEMBOURGISH" +msgstr "Luxembourgish" + +msgid "enum.TranscribeLanguageEnum.MACEDONIAN" +msgstr "Macedonian" + +msgid "enum.TranscribeLanguageEnum.MALAGASY" +msgstr "Malagasy" + +msgid "enum.TranscribeLanguageEnum.MALAY" +msgstr "Malay" + +msgid "enum.TranscribeLanguageEnum.MALAYALAM" +msgstr "Malayalam" + +msgid "enum.TranscribeLanguageEnum.MALTESE" +msgstr "Maltese" + +msgid "enum.TranscribeLanguageEnum.MAORI" +msgstr "Maori" + +msgid "enum.TranscribeLanguageEnum.MARATHI" +msgstr "Marathi" + +msgid "enum.TranscribeLanguageEnum.MONGOLIAN" +msgstr "Mongolian" + +msgid "enum.TranscribeLanguageEnum.MYANMAR" +msgstr "Myanmar" + +msgid "enum.TranscribeLanguageEnum.NEPALI" +msgstr "Nepali" + +msgid "enum.TranscribeLanguageEnum.NORWEGIAN" +msgstr "Norwegian" + +msgid "enum.TranscribeLanguageEnum.NYNORSK" +msgstr "Nynorsk" + +msgid "enum.TranscribeLanguageEnum.OCCITAN" +msgstr "Occitan" + +msgid "enum.TranscribeLanguageEnum.PASHTO" +msgstr "Pashto" + +msgid "enum.TranscribeLanguageEnum.PERSIAN" +msgstr "Persian" + +msgid "enum.TranscribeLanguageEnum.POLISH" +msgstr "Polish" + +msgid "enum.TranscribeLanguageEnum.PORTUGUESE" +msgstr "葡萄牙语" + +msgid "enum.TranscribeLanguageEnum.PUNJABI" +msgstr "Punjabi" + +msgid "enum.TranscribeLanguageEnum.ROMANIAN" +msgstr "Romanian" + +msgid "enum.TranscribeLanguageEnum.RUSSIAN" +msgstr "俄语" + +msgid "enum.TranscribeLanguageEnum.SANSKRIT" +msgstr "Sanskrit" + +msgid "enum.TranscribeLanguageEnum.SERBIAN" +msgstr "Serbian" + +msgid "enum.TranscribeLanguageEnum.SHONA" +msgstr "Shona" + +msgid "enum.TranscribeLanguageEnum.SINDHI" +msgstr "Sindhi" + +msgid "enum.TranscribeLanguageEnum.SINHALA" +msgstr "Sinhala" + +msgid "enum.TranscribeLanguageEnum.SLOVAK" +msgstr "Slovak" + +msgid "enum.TranscribeLanguageEnum.SLOVENIAN" +msgstr "Slovenian" + +msgid "enum.TranscribeLanguageEnum.SOMALI" +msgstr "Somali" + +msgid "enum.TranscribeLanguageEnum.SPANISH" +msgstr "西班牙语" + +msgid "enum.TranscribeLanguageEnum.SUNDANESE" +msgstr "Sundanese" + +msgid "enum.TranscribeLanguageEnum.SWAHILI" +msgstr "Swahili" + +msgid "enum.TranscribeLanguageEnum.SWEDISH" +msgstr "Swedish" + +msgid "enum.TranscribeLanguageEnum.TAGALOG" +msgstr "Tagalog" + +msgid "enum.TranscribeLanguageEnum.TAJIK" +msgstr "Tajik" + +msgid "enum.TranscribeLanguageEnum.TAMIL" +msgstr "Tamil" + +msgid "enum.TranscribeLanguageEnum.TATAR" +msgstr "Tatar" + +msgid "enum.TranscribeLanguageEnum.TELUGU" +msgstr "Telugu" + +msgid "enum.TranscribeLanguageEnum.THAI" +msgstr "Thai" + +msgid "enum.TranscribeLanguageEnum.TIBETAN" +msgstr "Tibetan" + +msgid "enum.TranscribeLanguageEnum.TURKISH" +msgstr "土耳其语" + +msgid "enum.TranscribeLanguageEnum.TURKMEN" +msgstr "Turkmen" + +msgid "enum.TranscribeLanguageEnum.UKRAINIAN" +msgstr "Ukrainian" + +msgid "enum.TranscribeLanguageEnum.URDU" +msgstr "Urdu" + +msgid "enum.TranscribeLanguageEnum.UZBEK" +msgstr "Uzbek" + +msgid "enum.TranscribeLanguageEnum.VIETNAMESE" +msgstr "Vietnamese" + +msgid "enum.TranscribeLanguageEnum.WELSH" +msgstr "Welsh" + +msgid "enum.TranscribeLanguageEnum.YIDDISH" +msgstr "Yiddish" + +msgid "enum.TranscribeLanguageEnum.YORUBA" +msgstr "Yoruba" + +msgid "enum.TranscribeLanguageEnum.YUE" +msgstr "粤语" + +msgid "enum.TranscribeModelEnum.BAILIAN_FUN_ASR" +msgstr "百炼 Fun-ASR" + +msgid "enum.TranscribeModelEnum.BIJIAN" +msgstr "B 接口" + +msgid "enum.TranscribeModelEnum.FASTER_WHISPER" +msgstr "FasterWhisper" + +msgid "enum.TranscribeModelEnum.JIANYING" +msgstr "J 接口" + +msgid "enum.TranscribeModelEnum.WHISPER_API" +msgstr "Whisper [API]" + +msgid "enum.TranscribeModelEnum.WHISPER_CPP" +msgstr "WhisperCpp" + +msgid "enum.TranscribeOutputFormatEnum.ALL" +msgstr "All" + +msgid "enum.TranscribeOutputFormatEnum.ASS" +msgstr "ASS" + +msgid "enum.TranscribeOutputFormatEnum.SRT" +msgstr "SRT" + +msgid "enum.TranscribeOutputFormatEnum.TXT" +msgstr "TXT" + +msgid "enum.TranscribeOutputFormatEnum.VTT" +msgstr "VTT" + +msgid "enum.TranslatorServiceEnum.BING" +msgstr "微软翻译" + +msgid "enum.TranslatorServiceEnum.DEEPLX" +msgstr "DeepLx 翻译" + +msgid "enum.TranslatorServiceEnum.GOOGLE" +msgstr "谷歌翻译" + +msgid "enum.TranslatorServiceEnum.OPENAI" +msgstr "LLM 大模型翻译" + +msgid "enum.VadMethodEnum.AUDITOK" +msgstr "auditok" + +msgid "enum.VadMethodEnum.PYANNOTE_ONNX_V3" +msgstr "pyannote_onnx_v3" + +msgid "enum.VadMethodEnum.PYANNOTE_V3" +msgstr "pyannote_v3" + +msgid "enum.VadMethodEnum.SILERO_V3" +msgstr "silero_v3" + +msgid "enum.VadMethodEnum.SILERO_V4" +msgstr "silero_v4" + +msgid "enum.VadMethodEnum.SILERO_V4_FW" +msgstr "silero_v4_fw" + +msgid "enum.VadMethodEnum.SILERO_V5" +msgstr "silero_v5" + +msgid "enum.VadMethodEnum.WEBRTC" +msgstr "webrtc" + +msgid "enum.VideoQualityEnum.HIGH" +msgstr "高质量" + +msgid "enum.VideoQualityEnum.LOW" +msgstr "低质量" + +msgid "enum.VideoQualityEnum.MEDIUM" +msgstr "中等质量" + +msgid "enum.VideoQualityEnum.ULTRA_HIGH" +msgstr "极高质量" + +msgid "enum.WhisperModelEnum.BASE" +msgstr "base" + +msgid "enum.WhisperModelEnum.LARGE_V1" +msgstr "large-v1" + +msgid "enum.WhisperModelEnum.LARGE_V2" +msgstr "large-v2" + +msgid "enum.WhisperModelEnum.MEDIUM" +msgstr "medium" + +msgid "enum.WhisperModelEnum.SMALL" +msgstr "small" + +msgid "enum.WhisperModelEnum.TINY" +msgstr "tiny" + +msgid "feedback.add_image" +msgstr "添加图片" + +msgid "feedback.attach_count" +msgstr "{count}/{max}" + +msgid "feedback.category.bug" +msgstr "问题报告" + +msgid "feedback.category.feature" +msgstr "功能建议" + +msgid "feedback.category.label" +msgstr "类型" + +msgid "feedback.category.other" +msgstr "其他" + +msgid "feedback.category.question" +msgstr "使用问题" + +msgid "feedback.contact.placeholder" +msgstr "邮箱 / 微信 / QQ(选填,方便回复你)" + +msgid "feedback.done" +msgstr "完成" + +msgid "feedback.err.category_invalid" +msgstr "反馈类型无效" + +msgid "feedback.err.contact_too_long" +msgstr "联系方式过长" + +msgid "feedback.err.file_too_large" +msgstr "单张截图需 ≤ 5 MB" + +msgid "feedback.err.file_type" +msgstr "仅支持 PNG / JPEG" + +msgid "feedback.err.invalid_request" +msgstr "请求参数有误" + +msgid "feedback.err.message_required" +msgstr "请先填写问题描述" + +msgid "feedback.err.message_too_long" +msgstr "问题描述过长(最多 5000 字)" + +msgid "feedback.err.network" +msgstr "网络错误,请稍后重试" + +msgid "feedback.err.server" +msgstr "提交失败,请稍后重试" + +msgid "feedback.err.too_large" +msgstr "图片或请求过大" + +msgid "feedback.err.too_many_files" +msgstr "最多 3 张截图" + +msgid "feedback.err.total_too_large" +msgstr "截图总大小需 ≤ 12 MB" + +msgid "feedback.err.unauthorized" +msgstr "鉴权失败" + +msgid "feedback.intro" +msgstr "你的反馈会直接发送给开发者,我们会认真查看每一条并尽快处理 🙏" + +msgid "feedback.message.placeholder" +msgstr "请描述你遇到的问题或建议,越具体我们越好定位…" + +msgid "feedback.screenshot.hint" +msgstr "可 Ctrl+V 粘贴、拖入图片,或点 + 添加" + +msgid "feedback.section.contact" +msgstr "联系方式(选填)" + +msgid "feedback.section.message" +msgstr "问题描述" + +msgid "feedback.section.screenshot" +msgstr "截图(选填)" + +msgid "feedback.submit" +msgstr "提交反馈" + +msgid "feedback.submitting" +msgstr "提交中…" + +msgid "feedback.success" +msgstr "已收到,我们会尽快查看处理,感谢你的反馈!" + +msgid "feedback.title" +msgstr "意见反馈" + +msgid "hardsub.btn.auto_region" +msgstr "自动识别区域" + +msgid "hardsub.btn.export" +msgstr "导出字幕" + +msgid "hardsub.btn.redo" +msgstr "重新提取" + +msgid "hardsub.btn.replace_video" +msgstr "更换视频" + +msgid "hardsub.btn.send_optimize" +msgstr "送入字幕优化" + +msgid "hardsub.btn.start" +msgstr "开始提取" + +msgid "hardsub.busy.detecting_region" +msgstr "正在识别字幕区域…" + +msgid "hardsub.busy.detecting_region_pct" +msgstr "正在识别字幕区域… {percent}%" + +msgid "hardsub.busy.loading_video" +msgstr "正在载入视频…" + +msgid "hardsub.col.end" +msgstr "结束" + +msgid "hardsub.col.start" +msgstr "开始" + +msgid "hardsub.col.text" +msgstr "文本" + +msgid "hardsub.count.recognized" +msgstr "已识别 {n} 条" + +msgid "hardsub.count.total" +msgstr "共 {n} 条" + +msgid "hardsub.dialog.export" +msgstr "导出字幕" + +msgid "hardsub.dialog.pick_video" +msgstr "选择视频" + +msgid "hardsub.drop.pick" +msgstr "选择视频" + +msgid "hardsub.drop.title" +msgstr "拖入带硬字幕的视频" + +msgid "hardsub.engine.card_title" +msgstr "引擎未就绪" + +msgid "hardsub.engine.desc" +msgstr "硬字幕提取需要 OCR 引擎依赖(rapidocr / onnxruntime)。" + +msgid "hardsub.engine.title" +msgstr "OCR 引擎未就绪" + +msgid "hardsub.error.read_video" +msgstr "无法读取该视频:{msg}" + +msgid "hardsub.menu.delete_row" +msgstr "删除该行" + +msgid "hardsub.menu.locate" +msgstr "定位到此画面" + +msgid "hardsub.note.done" +msgstr "双击改文本 / 时间,右键删除行;可送入字幕优化或导出。" + +msgid "hardsub.note.empty" +msgstr "选择视频后自动识别字幕区域" + +msgid "hardsub.note.engine_missing" +msgstr "OCR 引擎依赖未安装" + +msgid "hardsub.note.no_subtitle" +msgstr "没有稳定区域时,手动框选后再识别。" + +msgid "hardsub.note.processing" +msgstr "识别到的字幕会持续写入右侧表格。" + +msgid "hardsub.note.region" +msgstr "区域不准时,直接在视频画面上拖动框选。" + +msgid "hardsub.ph.empty.sub" +msgstr "字幕结果会显示在这里。" + +msgid "hardsub.ph.empty.title" +msgstr "等待视频" + +msgid "hardsub.ph.engine_missing.sub" +msgstr "请先安装识别引擎依赖。" + +msgid "hardsub.ph.engine_missing.title" +msgstr "OCR 引擎未就绪" + +msgid "hardsub.ph.no_subtitle.sub" +msgstr "可在左侧视频上手动框选后重试。" + +msgid "hardsub.ph.no_subtitle.title" +msgstr "没有识别到字幕" + +msgid "hardsub.ph.region.sub" +msgstr "确认区域后开始提取。" + +msgid "hardsub.ph.region.title" +msgstr "已找到字幕区域" + +msgid "hardsub.progress.title" +msgstr "字幕识别" + +msgid "hardsub.result.title" +msgstr "字幕结果" + +msgid "hardsub.stage.video_preview" +msgstr "视频预览" + +msgid "hardsub.status.done" +msgstr "已完成" + +msgid "hardsub.status.engine_missing" +msgstr "引擎未就绪" + +msgid "hardsub.status.need_action" +msgstr "需要处理" + +msgid "hardsub.status.processing" +msgstr "提取中" + +msgid "hardsub.status.region_detected" +msgstr "已识别区域" + +msgid "hardsub.status.waiting_video" +msgstr "等待视频" + +msgid "hardsub.subtitle" +msgstr "从视频画面识别硬字幕,框选区域后导出为可编辑字幕。" + +msgid "hardsub.title" +msgstr "硬字幕提取" + +msgid "hardsub.toast.exported" +msgstr "已导出:{name}" + +msgid "hardsub.toast.no_auto_region" +msgstr "未自动检测到字幕区域,请在画面上手动框选字幕所在的位置。" + +msgid "home.btn.browse" +msgstr "选择文件" + +msgid "home.btn.start" +msgstr "开始处理" + +msgid "home.confirm.has_subtitle" +msgstr "含字幕" + +msgid "home.confirm.quality" +msgstr "清晰度" + +msgid "home.confirm.quality.best" +msgstr "最佳" + +msgid "home.confirm.untitled" +msgstr "未命名视频" + +msgid "home.dialog.filter.audio" +msgstr "音频文件" + +msgid "home.dialog.filter.media" +msgstr "媒体文件" + +msgid "home.dialog.filter.video" +msgstr "视频文件" + +msgid "home.dialog.select_media" +msgstr "选择媒体文件" + +msgid "home.download.done.body" +msgstr "开始自动处理..." + +msgid "home.download.done.title" +msgstr "下载完成" + +msgid "home.download.parsing" +msgstr "正在解析视频信息…" + +msgid "home.download.start" +msgstr "开始下载" + +msgid "home.error.invalid_input" +msgstr "请输入有效的本地音视频文件,或完整的 http / https 链接。" + +msgid "home.error.unsupported_drop" +msgstr "拖入的文件不是受支持的音视频格式" + +msgid "home.footer.donate" +msgstr "捐助" + +msgid "home.footer.logs" +msgstr "查看日志" + +msgid "home.hero.title" +msgstr "导入视频,生成字幕与配音" + +msgid "home.input.placeholder" +msgstr "粘贴视频链接,或拖入本地音视频文件" + +msgid "home.media.kind.audio" +msgstr "音频" + +msgid "home.media.kind.video" +msgstr "视频" + +msgid "home.media.ready" +msgstr "已就绪" + +msgid "home.quick.confirm" +msgstr "解析完成 · 确认清晰度后开始下载" + +msgid "home.quick.downloading" +msgstr "在线视频链接 · 下载完成后自动进入下一步" + +msgid "home.quick.empty" +msgstr "可直接拖入文件 · 支持本地音视频与在线视频链接" + +msgid "home.quick.file" +msgstr "本地媒体文件 · 开始后自动进入语音转录" + +msgid "home.quick.invalid" +msgstr "无法识别输入内容" + +msgid "home.quick.parsing" +msgstr "正在解析视频信息,稍后确认清晰度" + +msgid "home.quick.url" +msgstr "在线视频链接 · 将先下载到工作目录" + +msgid "home.status.invalid" +msgstr "输入无效" + +msgid "home.status.parsing" +msgstr "解析中" + +msgid "home.status.pending" +msgstr "待确认" + +msgid "home.status.ready" +msgstr "可开始" + +msgid "home.status.waiting" +msgstr "等待输入" + +msgid "home.tip.downloading" +msgstr "下载中" + +msgid "home.tip.parsing" +msgstr "解析中" + +msgid "homeflow.tab.subtitle_optimize" +msgstr "字幕优化与翻译" + +msgid "homeflow.tab.task_creation" +msgstr "任务创建" + +msgid "homeflow.tab.transcription" +msgstr "语音转录" + +msgid "homeflow.tab.video_synthesis" +msgstr "字幕视频合成" + +msgid "inspector.color.pick" +msgstr "选择颜色" + +msgid "inspector.color.pick_prefix" +msgstr "选择" + +msgid "inspector.style.rename" +msgstr "重命名" + +msgid "inspector.style.source.builtin" +msgstr "内置" + +msgid "inspector.style.source.mine" +msgstr "我的" + +msgid "lclang.ar" +msgstr "阿拉伯语" + +msgid "lclang.auto" +msgstr "自动识别" + +msgid "lclang.cs" +msgstr "捷克语" + +msgid "lclang.da" +msgstr "丹麦语" + +msgid "lclang.de" +msgstr "德语" + +msgid "lclang.en" +msgstr "英语" + +msgid "lclang.es" +msgstr "西班牙语" + +msgid "lclang.fi" +msgstr "芬兰语" + +msgid "lclang.fil" +msgstr "菲律宾语" + +msgid "lclang.fr" +msgstr "法语" + +msgid "lclang.hi" +msgstr "印地语" + +msgid "lclang.id" +msgstr "印尼语" + +msgid "lclang.is" +msgstr "冰岛语" + +msgid "lclang.it" +msgstr "意大利语" + +msgid "lclang.ja" +msgstr "日语" + +msgid "lclang.ko" +msgstr "韩语" + +msgid "lclang.ms" +msgstr "马来语" + +msgid "lclang.no" +msgstr "挪威语" + +msgid "lclang.pl" +msgstr "波兰语" + +msgid "lclang.pt" +msgstr "葡萄牙语" + +msgid "lclang.ru" +msgstr "俄语" + +msgid "lclang.sv" +msgstr "瑞典语" + +msgid "lclang.th" +msgstr "泰语" + +msgid "lclang.tr" +msgstr "土耳其语" + +msgid "lclang.uk" +msgstr "乌克兰语" + +msgid "lclang.vi" +msgstr "越南语" + +msgid "lclang.yue" +msgstr "粤语" + +msgid "lclang.zh" +msgstr "中文" + +msgid "live.delete.confirm" +msgstr "确定删除「{name}」?整条记录(含音频)将从磁盘移除,此操作不可撤销。" + +msgid "live.delete.title" +msgstr "删除记录" + +msgid "live.device.default_input" +msgstr "默认输入" + +msgid "live.device.default_tag" +msgstr "(默认)" + +msgid "live.device.system_audio" +msgstr "系统声音" + +msgid "live.error.concurrency_full" +msgstr "后端转录服务并发已满(共享接口限 5 路),请稍后重试。" + +msgid "live.error.start_failed" +msgstr "启动失败" + +msgid "live.error.start_failed_desc" +msgstr "未能开始实时字幕" + +msgid "live.error.transcribe_failed" +msgstr "转录链路出错" + +msgid "live.export.dialog_title" +msgstr "导出" + +msgid "live.export.failed" +msgstr "导出失败" + +msgid "live.export.success" +msgstr "导出成功" + +msgid "live.ready.desc" +msgstr "选择声音来源后即可开始" + +msgid "live.ready.title" +msgstr "开始新的实时字幕" + +msgid "live.rename.placeholder" +msgstr "记录名称" + +msgid "live.rename.title" +msgstr "重命名记录" + +msgid "live.status.connecting" +msgstr "连接中…" + +msgid "live.status.connecting_with_time" +msgstr "00:00 · 连接中…" + +msgid "live.status.paused" +msgstr "已暂停" + +msgid "live.status.progress" +msgstr "{time} · 已生成 {count} 句" + +msgid "live.status.recording" +msgstr "录制中" + +msgid "live.status.saved" +msgstr "已保存" + +msgid "live.status.waiting" +msgstr "等待开始" + +msgid "live.title" +msgstr "实时字幕" + +msgid "livetx.menu.copy_all" +msgstr "复制全部" + +msgid "livetx.menu.copy_sentence" +msgstr "复制本句" + +msgid "liveview.action.back" +msgstr "返回" + +msgid "liveview.action.export" +msgstr "导出" + +msgid "liveview.action.folder" +msgstr "目录" + +msgid "liveview.action.home" +msgstr "主页" + +msgid "liveview.action.open" +msgstr "打开" + +msgid "liveview.action.refresh" +msgstr "刷新" + +msgid "liveview.action.rename" +msgstr "重命名" + +msgid "liveview.btn.new_session" +msgstr "新建会话" + +msgid "liveview.btn.pause" +msgstr "暂停" + +msgid "liveview.btn.resume" +msgstr "继续" + +msgid "liveview.btn.saving" +msgstr "保存中…" + +msgid "liveview.btn.start" +msgstr "开始实时字幕" + +msgid "liveview.btn.stop" +msgstr "结束保存" + +msgid "liveview.btn.view_record" +msgstr "查看记录" + +msgid "liveview.detail.display" +msgstr "显示" + +msgid "liveview.detail.export" +msgstr "导出" + +msgid "liveview.display.bilingual" +msgstr "双语" + +msgid "liveview.display.source" +msgstr "原文" + +msgid "liveview.display.target" +msgstr "译文" + +msgid "liveview.error.detail" +msgstr "未能捕获音频,请在右侧切换来源后重试" + +msgid "liveview.error.title" +msgstr "启动失败" + +msgid "liveview.export.srt" +msgstr "SRT 字幕" + +msgid "liveview.export.txt" +msgstr "TXT 文本" + +msgid "liveview.history.empty.detail" +msgstr "保存后的实时字幕会显示在这里。" + +msgid "liveview.history.empty.title" +msgstr "暂无记录" + +msgid "liveview.history.search_placeholder" +msgstr "搜索记录名称或正文" + +msgid "liveview.option.audio_source" +msgstr "音频来源" + +msgid "liveview.option.live_translate" +msgstr "实时翻译" + +msgid "liveview.option.overlay" +msgstr "桌面浮窗" + +msgid "liveview.option.source_language" +msgstr "识别语言" + +msgid "liveview.option.target_language" +msgstr "翻译语言" + +msgid "liveview.ready.detail" +msgstr "选择声音来源后即可开始" + +msgid "liveview.ready.title" +msgstr "开始新的实时字幕" + +msgid "liveview.recent.empty.detail" +msgstr "开始一段实时字幕,结束后会自动保存到这里。" + +msgid "liveview.recent.empty.title" +msgstr "还没有记录" + +msgid "liveview.recent.title" +msgstr "最近记录" + +msgid "liveview.recent.view_all" +msgstr "查看全部" + +msgid "liveview.settings.config" +msgstr "配置" + +msgid "liveview.settings.title" +msgstr "本次设置" + +msgid "liveview.timer.saving" +msgstr "正在保存…" + +msgid "liveview.timer.waiting" +msgstr "等待开始" + +msgid "liveview.title.detail" +msgstr "记录详情" + +msgid "liveview.title.history" +msgstr "实时字幕历史" + +msgid "liveview.title.session" +msgstr "实时字幕" + +msgid "llmlog.btn.clear" +msgstr "清空日志" + +msgid "llmlog.btn.refresh" +msgstr "刷新" + +msgid "llmlog.clear.confirm_body" +msgstr "确定要清空所有日志吗?此操作不可恢复。" + +msgid "llmlog.clear.confirm_btn" +msgstr "清空" + +msgid "llmlog.clear.confirm_title" +msgstr "确认清空" + +msgid "llmlog.clear.success" +msgstr "日志已清空" + +msgid "llmlog.col.duration" +msgstr "耗时" + +msgid "llmlog.col.file" +msgstr "文件" + +msgid "llmlog.col.model" +msgstr "模型" + +msgid "llmlog.col.stage" +msgstr "阶段" + +msgid "llmlog.col.time" +msgstr "时间" + +msgid "llmlog.copied" +msgstr "已复制" + +msgid "llmlog.detail.foot_hint" +msgstr "复制按钮只复制对应 JSON;Esc 或右上角关闭。" + +msgid "llmlog.detail.title" +msgstr "请求详情" + +msgid "llmlog.empty.hint" +msgstr "启用字幕校正、智能断句或 LLM 翻译后,页面会自动记录请求与响应。" + +msgid "llmlog.empty.no_match.hint" +msgstr "换个任务 ID、文件名、模型或阶段关键词再试。" + +msgid "llmlog.empty.no_match.title" +msgstr "没有匹配的日志" + +msgid "llmlog.empty.title" +msgstr "暂无 LLM 请求日志" + +msgid "llmlog.empty_payload" +msgstr "(空)" + +msgid "llmlog.meta.duration" +msgstr "耗时" + +msgid "llmlog.meta.result" +msgstr "结果" + +msgid "llmlog.meta.stage" +msgstr "阶段" + +msgid "llmlog.meta.time" +msgstr "时间" + +msgid "llmlog.no_records" +msgstr "暂无记录" + +msgid "llmlog.panel.error" +msgstr "错误响应" + +msgid "llmlog.panel.error.desc" +msgstr "模型或接口返回的 Error JSON" + +msgid "llmlog.panel.request" +msgstr "请求体" + +msgid "llmlog.panel.request.desc" +msgstr "发送给模型的完整 Request JSON" + +msgid "llmlog.panel.response" +msgstr "响应体" + +msgid "llmlog.panel.response.desc" +msgstr "模型返回的原始 Response JSON" + +msgid "llmlog.refresh.success" +msgstr "刷新成功" + +msgid "llmlog.result.done" +msgstr "完成" + +msgid "llmlog.result.failed" +msgstr "失败" + +msgid "llmlog.search.placeholder" +msgstr "搜索任务 ID、文件名、模型或阶段" + +msgid "llmlog.status.filtered" +msgstr "共 {total} 条 · 筛选后 {shown} 条 · 双击查看完整 JSON" + +msgid "llmlog.status.total" +msgstr "共 {total} 条 · 双击查看完整 JSON" + +msgid "llmlog.status.total_zero" +msgstr "共 0 条" + +msgid "llmlog.title" +msgstr "LLM 请求日志" + +msgid "llmlog.unknown" +msgstr "未知" + +msgid "logwin.btn.open_folder" +msgstr "打开日志文件夹" + +msgid "logwin.error.open_failed" +msgstr "打开日志文件失败: {error}" + +msgid "logwin.error.read_failed" +msgstr "读取日志文件失败: {error}" + +msgid "logwin.error.update_failed" +msgstr "读取日志文件出错: {error}" + +msgid "logwin.history_divider" +msgstr "以上是历史日志" + +msgid "logwin.title" +msgstr "日志查看器" + +msgid "modelmgr.action.current" +msgstr "当前" + +msgid "modelmgr.action.download" +msgstr "下载" + +msgid "modelmgr.action.resume" +msgstr "继续" + +msgid "modelmgr.col.action" +msgstr "操作" + +msgid "modelmgr.col.model" +msgstr "模型" + +msgid "modelmgr.col.size" +msgstr "大小" + +msgid "modelmgr.col.status" +msgstr "状态" + +msgid "modelmgr.command.copied_body" +msgstr "在终端执行后点「重新检测」。" + +msgid "modelmgr.command.copied_title" +msgstr "已复制安装命令" + +msgid "modelmgr.command.copy" +msgstr "复制命令" + +msgid "modelmgr.download.done_body" +msgstr "{name} 下载完成。" + +msgid "modelmgr.download.done_title" +msgstr "模型已就绪" + +msgid "modelmgr.download.failed" +msgstr "下载失败" + +msgid "modelmgr.footer.model_dir" +msgstr "本地模型目录" + +msgid "modelmgr.footer.open_dir" +msgstr "打开目录" + +msgid "modelmgr.program.available" +msgstr "可用" + +msgid "modelmgr.program.found" +msgstr "已找到 {name}" + +msgid "modelmgr.program.missing" +msgstr "缺失" + +msgid "modelmgr.program.open_page" +msgstr "打开页面" + +msgid "modelmgr.program.opened_body" +msgstr "下载安装后回来点「重新检测」。" + +msgid "modelmgr.program.opened_title" +msgstr "已在浏览器打开" + +msgid "modelmgr.program.ready_body" +msgstr "可以继续下载模型。" + +msgid "modelmgr.program.ready_title" +msgstr "运行程序已就绪" + +msgid "modelmgr.program.recheck" +msgstr "重新检测" + +msgid "modelmgr.progress.connecting" +msgstr "正在连接镜像…" + +msgid "modelmgr.recheck.available_body" +msgstr "已找到 {name}。" + +msgid "modelmgr.recheck.available_title" +msgstr "运行程序可用" + +msgid "modelmgr.recheck.missing_title" +msgstr "仍未检测到" + +msgid "modelmgr.remove.body" +msgstr "将删除 {name}({size}),需要时须重新下载。" + +msgid "modelmgr.remove.done" +msgstr "已删除" + +msgid "modelmgr.remove.failed" +msgstr "删除失败" + +msgid "modelmgr.remove.title" +msgstr "删除模型" + +msgid "modelmgr.section.models" +msgstr "模型文件" + +msgid "modelmgr.section.programs" +msgstr "运行程序" + +msgid "modelmgr.status.downloading" +msgstr "下载中" + +msgid "modelmgr.status.installed" +msgstr "已下载" + +msgid "modelmgr.status.paused" +msgstr "已暂停" + +msgid "modelmgr.status.pending" +msgstr "待下载" + +msgid "modelmgr.title" +msgstr "本地模型管理" + +msgid "overlay.listening" +msgstr "监听中…" + +msgid "overlay.paused" +msgstr "已暂停" + +msgid "overlayset.bg" +msgstr "底色样式" + +msgid "overlayset.bg.black" +msgstr "纯黑" + +msgid "overlayset.bg.outline" +msgstr "纯描边" + +msgid "overlayset.bg.translucent" +msgstr "半透明" + +msgid "overlayset.display" +msgstr "显示内容" + +msgid "overlayset.display.bilingual" +msgstr "双语" + +msgid "overlayset.display.source" +msgstr "仅原文" + +msgid "overlayset.display.target" +msgstr "仅译文" + +msgid "overlayset.font_size" +msgstr "字号" + +msgid "overlayset.font_size.medium" +msgstr "中" + +msgid "overlayset.title" +msgstr "字幕设置" + +msgid "roi.scrub.hint" +msgstr "拖动查看其他帧" + +msgid "roi.tag.caption_area" +msgstr "字幕区域" + +msgid "settings.about.current_version" +msgstr "当前版本" + +msgid "settings.about.feedback" +msgstr "反馈" + +msgid "settings.about.feedback.desc" +msgstr "遇到问题时提交反馈。" + +msgid "settings.about.feedback_button" +msgstr "提交反馈" + +msgid "settings.about.help" +msgstr "帮助" + +msgid "settings.about.help.desc" +msgstr "查看使用说明和常见问题。" + +msgid "settings.about.help_button" +msgstr "打开帮助" + +msgid "settings.about.update_button" +msgstr "检查更新" + +msgid "settings.about.version" +msgstr "版本" + +msgid "settings.busy.loading" +msgstr "正在加载..." + +msgid "settings.busy.testing" +msgstr "正在测试..." + +msgid "settings.busy.transcribing" +msgstr "正在转录..." + +msgid "settings.dubbing.api_key" +msgstr "配音 API Key" + +msgid "settings.dubbing.api_key.desc" +msgstr "Gemini 或 SiliconFlow 配音需要填写。" + +msgid "settings.dubbing.audio_mode" +msgstr "原声处理" + +msgid "settings.dubbing.audio_mode.desc" +msgstr "生成视频时如何处理原视频声音。" + +msgid "settings.dubbing.audio_mode.duck" +msgstr "压低原声" + +msgid "settings.dubbing.audio_mode.mix" +msgstr "混合原声" + +msgid "settings.dubbing.audio_mode.replace" +msgstr "替换原声" + +msgid "settings.dubbing.config_error" +msgstr "配音配置错误" + +msgid "settings.dubbing.enabled" +msgstr "默认添加配音" + +msgid "settings.dubbing.enabled.desc" +msgstr "开启后,全流程处理默认生成配音音轨。" + +msgid "settings.dubbing.model" +msgstr "配音模型" + +msgid "settings.dubbing.model.desc" +msgstr "当前配音提供商使用的文字转语音模型。" + +msgid "settings.dubbing.need_api_key" +msgstr "当前配音提供商需要 API Key。" + +msgid "settings.dubbing.preset" +msgstr "默认音色" + +msgid "settings.dubbing.preset.desc" +msgstr "保存后会作为配音页和全流程的默认音色。" + +msgid "settings.dubbing.provider" +msgstr "配音提供商" + +msgid "settings.dubbing.provider.desc" +msgstr "Edge 免 Key;Gemini 和 SiliconFlow 需要 API Key。" + +msgid "settings.dubbing.test" +msgstr "配音测试" + +msgid "settings.dubbing.test.desc" +msgstr "用当前音色合成一句试听音频。" + +msgid "settings.dubbing.test_button" +msgstr "测试配音" + +msgid "settings.dubbing.test_failed" +msgstr "配音测试失败" + +msgid "settings.dubbing.test_success" +msgstr "配音测试成功" + +msgid "settings.dubbing.test_success.detail" +msgstr "{provider} 已生成试听音频:{path}" + +msgid "settings.dubbing.text_track" +msgstr "配音文本轨道" + +msgid "settings.dubbing.text_track.auto" +msgstr "自动选择" + +msgid "settings.dubbing.text_track.desc" +msgstr "选择用原文、译文,或自动判断生成配音。" + +msgid "settings.dubbing.text_track.first" +msgstr "第一行" + +msgid "settings.dubbing.text_track.second" +msgstr "第二行" + +msgid "settings.dubbing.timing" +msgstr "时间对齐" + +msgid "settings.dubbing.timing.balanced" +msgstr "均衡" + +msgid "settings.dubbing.timing.desc" +msgstr "控制配音语速与字幕时间轴的贴合程度。" + +msgid "settings.dubbing.timing.natural" +msgstr "自然" + +msgid "settings.dubbing.timing.none" +msgstr "不变速" + +msgid "settings.dubbing.timing.strict" +msgstr "严格" + +msgid "settings.dubbing.workers" +msgstr "配音并发" + +msgid "settings.dubbing.workers.desc" +msgstr "同时合成的字幕行数。" + +msgid "settings.live_caption.asr_model" +msgstr "识别模型" + +msgid "settings.live_caption.asr_model.desc" +msgstr "Fun-ASR 识别模型;多语种模型可自动识别多种语言。" + +msgid "settings.live_caption.bg.black" +msgstr "纯黑" + +msgid "settings.live_caption.bg.translucent" +msgstr "半透明" + +msgid "settings.live_caption.bg_style" +msgstr "底色样式" + +msgid "settings.live_caption.bg_style.desc" +msgstr "浮窗底色风格" + +msgid "settings.live_caption.deps_button" +msgstr "下载 / 检测" + +msgid "settings.live_caption.display.bilingual" +msgstr "双语" + +msgid "settings.live_caption.display.source" +msgstr "仅原文" + +msgid "settings.live_caption.display.target" +msgstr "仅译文" + +msgid "settings.live_caption.display_mode" +msgstr "显示内容" + +msgid "settings.live_caption.display_mode.desc" +msgstr "浮窗默认显示的内容" + +msgid "settings.live_caption.engine" +msgstr "转录引擎" + +msgid "settings.live_caption.engine.desc" +msgstr "实时语音转文字所用的识别引擎。" + +msgid "settings.live_caption.engine.group" +msgstr "转录引擎" + +msgid "settings.live_caption.font_scale" +msgstr "字号" + +msgid "settings.live_caption.font_scale.desc" +msgstr "浮窗译文字号" + +msgid "settings.live_caption.fun_key" +msgstr "百炼 API Key" + +msgid "settings.live_caption.fun_key.desc" +msgstr "Fun-ASR / Qwen-ASR 调用阿里云百炼所需的密钥,与转录配置共用同一个。" + +msgid "settings.live_caption.overlay.group" +msgstr "浮窗显示" + +msgid "settings.live_caption.source_language" +msgstr "识别语言" + +msgid "settings.live_caption.source_language.desc" +msgstr "你说话所用的语言;识别引擎据此进行转写。自动识别可让引擎按音频判定语言。" + +msgid "settings.live_caption.target_language" +msgstr "目标语言" + +msgid "settings.live_caption.target_language.desc" +msgstr "译文翻译成的语言。" + +msgid "settings.live_caption.test.desc" +msgstr "用内置短音频真实转录一次,验证当前引擎能跑通。" + +msgid "settings.live_caption.translate" +msgstr "实时翻译" + +msgid "settings.live_caption.translate.desc" +msgstr "开启后对转录文本即时翻译;关闭则只显示原文。" + +msgid "settings.live_caption.translate.group" +msgstr "翻译" + +msgid "settings.live_caption.translator_service" +msgstr "翻译引擎" + +msgid "settings.live_caption.translator_service.desc" +msgstr "生成译文所用的翻译服务。" + +msgid "settings.live_caption.voxgate" +msgstr "转录程序" + +msgid "settings.live_caption.voxgate.desc" +msgstr "voxgate 本地转录程序,未安装时点此下载或检测。" + +msgid "settings.llm.api_key" +msgstr "API Key" + +msgid "settings.llm.api_key.desc" +msgstr "{service} 调用大模型时使用。" + +msgid "settings.llm.base_url" +msgstr "Base URL" + +msgid "settings.llm.base_url.desc" +msgstr "仅 OpenAI 兼容或本地服务需要修改。" + +msgid "settings.llm.connect_error" +msgstr "LLM 连接错误" + +msgid "settings.llm.connect_failed" +msgstr "LLM 连接失败" + +msgid "settings.llm.connect_success" +msgstr "LLM 连接成功" + +msgid "settings.llm.immersive_hint.desc" +msgstr "公益模型额度有限,高峰期可能限流或偶发失败,追求稳定和更好的效果可配置自己的 LLM Key。" + +msgid "settings.llm.immersive_hint.title" +msgstr "免费 · 无需 API Key" + +msgid "settings.llm.load_models" +msgstr "加载模型" + +msgid "settings.llm.model" +msgstr "模型" + +msgid "settings.llm.model.desc" +msgstr "用于断句、校正、翻译的大模型名称。" + +msgid "settings.llm.model_service" +msgstr "模型服务" + +msgid "settings.llm.model_service.desc" +msgstr "先加载可用模型,再用当前模型测试连通性。" + +msgid "settings.llm.models_load_failed" +msgstr "模型加载失败" + +msgid "settings.llm.models_loaded" +msgstr "模型已加载" + +msgid "settings.llm.models_loaded.desc" +msgstr "已加载 {count} 个模型。" + +msgid "settings.llm.no_models" +msgstr "没有可用模型" + +msgid "settings.llm.no_models.desc" +msgstr "没有从当前提供商获取到模型列表,请检查 Base URL 和 API Key。" + +msgid "settings.llm.provider" +msgstr "LLM 提供商" + +msgid "settings.llm.provider.desc" +msgstr "用于字幕断句、校正和 LLM 翻译。" + +msgid "settings.llm.test_connection" +msgstr "测试连接" + +msgid "settings.llm.warn.need_all" +msgstr "请先填写当前提供商的 Base URL、API Key 和模型。" + +msgid "settings.llm.warn.need_base_key" +msgstr "请先填写当前提供商的 Base URL 和 API Key。" + +msgid "settings.page.about" +msgstr "关于" + +msgid "settings.page.dubbing" +msgstr "配音配置" + +msgid "settings.page.live_caption" +msgstr "实时字幕配置" + +msgid "settings.page.llm" +msgstr "LLM 配置" + +msgid "settings.page.personal" +msgstr "个性化" + +msgid "settings.page.save" +msgstr "保存配置" + +msgid "settings.page.subtitle" +msgstr "字幕合成配置" + +msgid "settings.page.transcribe" +msgstr "转录配置" + +msgid "settings.page.translate" +msgstr "翻译与优化" + +msgid "settings.page.translate_service" +msgstr "翻译服务" + +msgid "settings.personal.choose_theme_color" +msgstr "选择主题颜色" + +msgid "settings.personal.follow_system" +msgstr "跟随系统" + +msgid "settings.personal.language" +msgstr "语言" + +msgid "settings.personal.restart_required" +msgstr "修改后需要重启应用。" + +msgid "settings.personal.theme" +msgstr "应用主题" + +msgid "settings.personal.theme.dark" +msgstr "深色" + +msgid "settings.personal.theme.desc" +msgstr "切换浅色、深色或跟随系统。" + +msgid "settings.personal.theme.light" +msgstr "浅色" + +msgid "settings.personal.theme_color" +msgstr "主题颜色" + +msgid "settings.personal.theme_color.desc" +msgstr "影响按钮、高亮和选中状态。" + +msgid "settings.personal.theme_color.is_default" +msgstr "当前已经是项目默认绿色" + +msgid "settings.personal.theme_color.pick_tip" +msgstr "点击选择主题颜色:{color}" + +msgid "settings.personal.theme_color.reset" +msgstr "恢复默认" + +msgid "settings.personal.theme_color.reset_tip" +msgstr "恢复为项目默认绿色" + +msgid "settings.personal.zoom" +msgstr "界面缩放" + +msgid "settings.placeholder.empty" +msgstr "未填写" + +msgid "settings.placeholder.not_selected" +msgstr "未选择" + +msgid "settings.restart.confirm" +msgstr "立即重启" + +msgid "settings.restart.later" +msgstr "稍后" + +msgid "settings.restart.message" +msgstr "语言或显示设置已更改,需要重启应用后生效。是否立即重启?" + +msgid "settings.restart.title" +msgstr "提示" + +msgid "settings.save.cache" +msgstr "启用缓存" + +msgid "settings.save.cache.desc" +msgstr "相同配置下复用 ASR、翻译和配音合成结果。" + +msgid "settings.save.cache_disabled" +msgstr "缓存已禁用" + +msgid "settings.save.cache_disabled.detail" +msgstr "后续任务会重新生成结果。" + +msgid "settings.save.cache_enabled" +msgstr "缓存已启用" + +msgid "settings.save.cache_enabled.detail" +msgstr "后续任务会优先复用已有结果。" + +msgid "settings.save.choose_work_dir" +msgstr "选择工作目录" + +msgid "settings.save.keep_intermediates" +msgstr "保留中间文件" + +msgid "settings.save.keep_intermediates.desc" +msgstr "处理成功后保留任务目录里的原始转录、样式字幕等中间产物;默认跑完即清。" + +msgid "settings.save.work_dir" +msgstr "工作目录" + +msgid "settings.save.work_dir.desc" +msgstr "下载视频与处理任务的中间文件会写入这里。" + +msgid "settings.subtitle.layout" +msgstr "字幕布局" + +msgid "settings.subtitle.layout.desc" +msgstr "选择单语、双语以及原文译文位置。" + +msgid "settings.subtitle.need_video" +msgstr "合成视频" + +msgid "settings.subtitle.need_video.desc" +msgstr "关闭后只输出字幕文件,不生成成片。" + +msgid "settings.subtitle.open_style" +msgstr "打开样式页" + +msgid "settings.subtitle.render_mode" +msgstr "渲染模式" + +msgid "settings.subtitle.render_mode.desc" +msgstr "选择 ASS 样式或圆角背景渲染。" + +msgid "settings.subtitle.soft" +msgstr "软字幕" + +msgid "settings.subtitle.soft.desc" +msgstr "开启后字幕不烧录进画面。" + +msgid "settings.subtitle.style" +msgstr "字幕样式" + +msgid "settings.subtitle.style.desc" +msgstr "字体、颜色和预览图在样式页调整。" + +msgid "settings.subtitle.video_quality" +msgstr "视频质量" + +msgid "settings.subtitle.video_quality.desc" +msgstr "硬字幕合成时使用的编码质量。" + +msgid "settings.test_transcribe" +msgstr "测试转录" + +msgid "settings.title" +msgstr "设置" + +msgid "settings.transcribe.faster_whisper.choose_dir" +msgstr "选择 Faster Whisper 模型目录" + +msgid "settings.transcribe.faster_whisper.device" +msgstr "运行设备" + +msgid "settings.transcribe.faster_whisper.device.desc" +msgstr "模型运行设备,通常保持 auto。" + +msgid "settings.transcribe.faster_whisper.model" +msgstr "Faster Whisper 模型" + +msgid "settings.transcribe.faster_whisper.model.desc" +msgstr "选择已下载的 Faster Whisper 模型。" + +msgid "settings.transcribe.faster_whisper.model_dir" +msgstr "模型目录" + +msgid "settings.transcribe.faster_whisper.model_dir.desc" +msgstr "Faster Whisper 模型所在文件夹。" + +msgid "settings.transcribe.faster_whisper.one_word" +msgstr "单字时间戳" + +msgid "settings.transcribe.faster_whisper.one_word.desc" +msgstr "开启后生成单字级时间戳。" + +msgid "settings.transcribe.faster_whisper.vad_filter" +msgstr "VAD 过滤" + +msgid "settings.transcribe.faster_whisper.vad_filter.desc" +msgstr "过滤无人声片段,减少识别幻觉。" + +msgid "settings.transcribe.faster_whisper.vad_method" +msgstr "VAD 方法" + +msgid "settings.transcribe.faster_whisper.vad_method.desc" +msgstr "选择语音活动检测方法。" + +msgid "settings.transcribe.faster_whisper.vad_threshold" +msgstr "VAD 阈值" + +msgid "settings.transcribe.faster_whisper.vad_threshold.desc" +msgstr "语音概率阈值,高于此值视为语音。" + +msgid "settings.transcribe.faster_whisper.voice_extraction" +msgstr "人声分离" + +msgid "settings.transcribe.faster_whisper.voice_extraction.desc" +msgstr "处理前分离人声和背景音乐。" + +msgid "settings.transcribe.fun_asr.key" +msgstr "百炼 API Key" + +msgid "settings.transcribe.fun_asr.key.desc" +msgstr "百炼 Fun-ASR 转录需要填写。" + +msgid "settings.transcribe.fun_asr.model" +msgstr "百炼 ASR 模型" + +msgid "settings.transcribe.fun_asr.model.desc" +msgstr "填写百炼控制台里可用的语音识别模型 Code。" + +msgid "settings.transcribe.local_model" +msgstr "本地模型" + +msgid "settings.transcribe.local_model.desc" +msgstr "查看运行程序状态、下载和管理模型文件。" + +msgid "settings.transcribe.local_model.no_model" +msgstr "还没有下载模型,打开「管理模型」选择下载。" + +msgid "settings.transcribe.local_model.not_installed" +msgstr "运行程序未安装,先在「管理模型」里完成安装。" + +msgid "settings.transcribe.manage_models" +msgstr "管理模型" + +msgid "settings.transcribe.missing.fun_asr_key" +msgstr "请先填写百炼 API Key。" + +msgid "settings.transcribe.missing.local_model" +msgstr "还没有可用的本地模型,请先在「管理模型」中下载。" + +msgid "settings.transcribe.missing.whisper_api" +msgstr "请先填写 Whisper Base URL、API Key 和模型。" + +msgid "settings.transcribe.model" +msgstr "转录模型" + +msgid "settings.transcribe.model.desc" +msgstr "选择生成原始字幕时使用的语音识别服务。" + +msgid "settings.transcribe.output_format" +msgstr "输出格式" + +msgid "settings.transcribe.output_format.desc" +msgstr "转录完成后保存的字幕文件格式。" + +msgid "settings.transcribe.prompt" +msgstr "提示词" + +msgid "settings.transcribe.prompt.desc" +msgstr "可选的转录提示词,默认空。" + +msgid "settings.transcribe.source_language" +msgstr "源语言" + +msgid "settings.transcribe.source_language.desc" +msgstr "音视频中说话的语言,不确定时保持自动检测。" + +msgid "settings.transcribe.test.desc" +msgstr "用内置短音频真实转录一次,验证当前服务能跑通。" + +msgid "settings.transcribe.test_error" +msgstr "转录测试错误" + +msgid "settings.transcribe.test_failed" +msgstr "转录测试失败" + +msgid "settings.transcribe.test_result" +msgstr "识别结果:{text}" + +msgid "settings.transcribe.test_success" +msgstr "转录测试成功" + +msgid "settings.transcribe.whisper_api.base" +msgstr "Whisper API Base URL" + +msgid "settings.transcribe.whisper_api.base.desc" +msgstr "使用 Whisper API 时请求的服务地址。" + +msgid "settings.transcribe.whisper_api.key" +msgstr "Whisper API Key" + +msgid "settings.transcribe.whisper_api.key.desc" +msgstr "使用 Whisper API 转录时需要填写。" + +msgid "settings.transcribe.whisper_api.model" +msgstr "Whisper 模型" + +msgid "settings.transcribe.whisper_api.model.desc" +msgstr "填写服务商支持的音频转录模型名。" + +msgid "settings.transcribe.whisper_cpp.model" +msgstr "WhisperCpp 模型" + +msgid "settings.transcribe.whisper_cpp.model.desc" +msgstr "选择已下载的 whisper.cpp 转录模型。" + +msgid "settings.translate.cjk_length" +msgstr "中文字幕长度" + +msgid "settings.translate.cjk_length.desc" +msgstr "断句时每条字幕的中文最大字数。" + +msgid "settings.translate.custom_prompt" +msgstr "自定义提示词" + +msgid "settings.translate.custom_prompt.desc" +msgstr "补充给字幕校正和翻译的大模型提示。" + +msgid "settings.translate.english_length" +msgstr "英文字幕长度" + +msgid "settings.translate.english_length.desc" +msgstr "断句时每条字幕的英文最大词数。" + +msgid "settings.translate.optimize" +msgstr "字幕校正" + +msgid "settings.translate.optimize.desc" +msgstr "处理字幕时修正识别错误和专有名词。" + +msgid "settings.translate.split" +msgstr "字幕断句" + +msgid "settings.translate.split.desc" +msgstr "按字数和语义重新切分长字幕。" + +msgid "settings.translate.target_language" +msgstr "目标语言" + +msgid "settings.translate.target_language.desc" +msgstr "翻译字幕输出的目标语言。" + +msgid "settings.translate.translate" +msgstr "字幕翻译" + +msgid "settings.translate.translate.desc" +msgstr "处理字幕时生成目标语言译文。" + +msgid "settings.translate_service.batch_size" +msgstr "批处理大小" + +msgid "settings.translate_service.batch_size.desc" +msgstr "LLM 翻译每批处理的字幕数量。" + +msgid "settings.translate_service.deeplx_endpoint" +msgstr "DeepLx 后端" + +msgid "settings.translate_service.deeplx_endpoint.desc" +msgstr "选择 DeepLx 翻译时需要填写。" + +msgid "settings.translate_service.reflect" +msgstr "反思翻译" + +msgid "settings.translate_service.reflect.desc" +msgstr "仅 LLM 翻译时使用,会增加模型调用量。" + +msgid "settings.translate_service.service" +msgstr "翻译服务" + +msgid "settings.translate_service.service.desc" +msgstr "选择字幕翻译使用的服务。" + +msgid "settings.translate_service.thread_num" +msgstr "并发数" + +msgid "settings.translate_service.thread_num.desc" +msgstr "模型服务允许的情况下可以调高。" + +msgid "settings.warn.incomplete" +msgstr "配置不完整" + +msgid "substyle.align.center" +msgstr "居中" + +msgid "substyle.align.left" +msgstr "居左" + +msgid "substyle.align.right" +msgstr "居右" + +msgid "substyle.content.bilingual" +msgstr "双语" + +msgid "substyle.content.source" +msgstr "原文" + +msgid "substyle.content.target" +msgstr "译文" + +msgid "substyle.copy_suffix" +msgstr "副本" + +msgid "substyle.custom_suffix" +msgstr "自定义" + +msgid "substyle.dialog.delete_body" +msgstr "确定删除样式「{name}」吗?此操作不可恢复。" + +msgid "substyle.dialog.delete_title" +msgstr "删除样式" + +msgid "substyle.dialog.name_placeholder" +msgstr "输入样式名称" + +msgid "substyle.dialog.new_style" +msgstr "新建样式" + +msgid "substyle.dialog.pick_bg" +msgstr "选择背景图片" + +msgid "substyle.dialog.rename_style" +msgstr "重命名样式" + +msgid "substyle.dock.count" +msgstr "共 {n} 套" + +msgid "substyle.dock.folder" +msgstr "目录" + +msgid "substyle.dock.new" +msgstr "新建" + +msgid "substyle.field.align" +msgstr "对齐方式" + +msgid "substyle.field.bg_color" +msgstr "背景颜色" + +msgid "substyle.field.bold" +msgstr "加粗" + +msgid "substyle.field.font" +msgstr "字体" + +msgid "substyle.field.letter_spacing" +msgstr "字间距" + +msgid "substyle.field.margin_bottom" +msgstr "底部边距" + +msgid "substyle.field.max_width" +msgstr "最大宽度" + +msgid "substyle.field.outline_color" +msgstr "描边颜色" + +msgid "substyle.field.outline_width" +msgstr "描边宽度" + +msgid "substyle.field.pad_h" +msgstr "水平内边距" + +msgid "substyle.field.pad_v" +msgstr "垂直内边距" + +msgid "substyle.field.radius" +msgstr "圆角半径" + +msgid "substyle.field.size" +msgstr "字号" + +msgid "substyle.field.text_color" +msgstr "文字颜色" + +msgid "substyle.filter.image" +msgstr "图片文件" + +msgid "substyle.group.background" +msgstr "背景" + +msgid "substyle.group.layout" +msgstr "字幕排布" + +msgid "substyle.group.padding" +msgstr "内边距" + +msgid "substyle.group.position" +msgstr "位置" + +msgid "substyle.group.primary" +msgstr "主字幕" + +msgid "substyle.group.secondary" +msgstr "副字幕" + +msgid "substyle.group.text" +msgstr "文字" + +msgid "substyle.group.text.sub" +msgstr "主副字幕" + +msgid "substyle.inspector.autosave" +msgstr "自动保存" + +msgid "substyle.inspector.title" +msgstr "参数" + +msgid "substyle.mode.ass" +msgstr "ASS 描边" + +msgid "substyle.mode.rounded" +msgstr "圆角背景" + +msgid "substyle.order.source_top" +msgstr "原文在上" + +msgid "substyle.order.target_top" +msgstr "译文在上" + +msgid "substyle.preview.background" +msgstr "更换背景" + +msgid "substyle.preview.landscape" +msgstr "横屏预览" + +msgid "substyle.preview.portrait" +msgstr "竖屏预览" + +msgid "substyle.preview.source_placeholder" +msgstr "用于预览的原文示例" + +msgid "substyle.preview.target_placeholder" +msgstr "用于预览的译文示例" + +msgid "substyle.preview.text" +msgstr "预览文字" + +msgid "substyle.preview.title" +msgstr "预览" + +msgid "substyle.row.content" +msgstr "显示内容" + +msgid "substyle.row.gap" +msgstr "主副间距" + +msgid "substyle.row.order" +msgstr "双语顺序" + +msgid "substyle.title" +msgstr "字幕样式配置" + +msgid "substyle.toast.bg_set" +msgstr "已设置预览背景:" + +msgid "substyle.toast.created" +msgstr "已创建样式「{name}」" + +msgid "substyle.toast.deleted" +msgstr "样式已删除" + +msgid "substyle.toast.duplicated" +msgstr "已复制为「{name}」" + +msgid "substyle.toast.renamed" +msgstr "已重命名为「{name}」" + +msgid "subtitle.bottom.can_synthesize" +msgstr "可进入合成" + +msgid "subtitle.bottom.count" +msgstr "共 {count} 条" + +msgid "subtitle.bottom.hint" +msgstr "右键可合并、删除、重新翻译" + +msgid "subtitle.bottom.loaded" +msgstr "已加载" + +msgid "subtitle.bottom.output" +msgstr "输出:{name}" + +msgid "subtitle.bottom.progress" +msgstr "第 {current} / {total} 条" + +msgid "subtitle.btn.clear" +msgstr "清空" + +msgid "subtitle.btn.enter_synthesis" +msgstr "进入合成" + +msgid "subtitle.btn.folder" +msgstr "目录" + +msgid "subtitle.btn.open_config" +msgstr "打开处理配置" + +msgid "subtitle.btn.processing" +msgstr "处理中" + +msgid "subtitle.btn.replace" +msgstr "更换" + +msgid "subtitle.btn.start" +msgstr "开始处理" + +msgid "subtitle.btn.wait_subtitle" +msgstr "等待字幕" + +msgid "subtitle.clear.body" +msgstr "将清除已载入的字幕与未保存的编辑,回到初始状态。确定继续?" + +msgid "subtitle.clear.title" +msgstr "清空当前字幕" + +msgid "subtitle.col.end" +msgstr "结束" + +msgid "subtitle.col.original" +msgstr "原文" + +msgid "subtitle.col.start" +msgstr "开始" + +msgid "subtitle.col.translated" +msgstr "译文" + +msgid "subtitle.dialog.choose_file" +msgstr "选择字幕文件" + +msgid "subtitle.dialog.save_file" +msgstr "保存字幕文件" + +msgid "subtitle.drop.pick" +msgstr "点击选择字幕" + +msgid "subtitle.drop.title" +msgstr "拖入一个字幕文件" + +msgid "subtitle.error.need_llm" +msgstr "需要先配置可用的大模型 API Key、接口地址和模型。" + +msgid "subtitle.error.no_config" +msgstr "无法构建处理配置" + +msgid "subtitle.fail.config_missing" +msgstr "配置缺失" + +msgid "subtitle.fail.process" +msgstr "处理失败" + +msgid "subtitle.file_filter" +msgstr "字幕文件" + +msgid "subtitle.menu.merge" +msgstr "合并" + +msgid "subtitle.menu.retranslate" +msgstr "重新翻译" + +msgid "subtitle.no_file" +msgstr "未选择字幕文件" + +msgid "subtitle.opt.language" +msgstr "翻译语言" + +msgid "subtitle.opt.layout" +msgstr "译文排布" + +msgid "subtitle.opt.optimize" +msgstr "字幕校正" + +msgid "subtitle.opt.prompt" +msgstr "文稿提示" + +msgid "subtitle.opt.split" +msgstr "断句" + +msgid "subtitle.opt.translate" +msgstr "字幕翻译" + +msgid "subtitle.process_settings" +msgstr "处理设置" + +msgid "subtitle.prompt.placeholder" +msgstr "" +"请输入文稿提示(辅助校正字幕和翻译)\n" +"\n" +"支持以下内容:\n" +"1. 术语表 - 专业术语、人名、特定词语的修正对照表\n" +"示例:\n" +"机器学习->Machine Learning\n" +"马斯克->Elon Musk\n" +"\n" +"2. 原字幕文稿 - 视频的原有文稿或相关内容\n" +"3. 修正要求 - 统一人称代词、规范专业术语等\n" +"\n" +"注意: 使用小型 LLM 模型时建议控制文稿在 1 千字内。" + +msgid "subtitle.prompt.set" +msgstr "已设置" + +msgid "subtitle.prompt.unset" +msgstr "未设置" + +msgid "subtitle.status.preparing" +msgstr "准备处理" + +msgid "subtitle.status.retranslating" +msgstr "重新翻译选中行" + +msgid "subtitle.tip.collapse_settings" +msgstr "收起设置栏" + +msgid "subtitle.tip.expand_settings" +msgstr "展开处理设置" + +msgid "subtitle.toast.format_error" +msgstr "格式错误 " + +msgid "subtitle.toast.load_failed" +msgstr "加载失败" + +msgid "subtitle.toast.load_first" +msgstr "请先加载字幕文件" + +msgid "subtitle.toast.no_video" +msgstr "没有关联视频,请到「字幕视频合成」页选择视频文件" + +msgid "subtitle.toast.processing_wait" +msgstr "正在处理中,请等待当前任务完成" + +msgid "subtitle.toast.save_done" +msgstr "保存成功" + +msgid "subtitle.toast.save_failed" +msgstr "保存失败" + +msgid "subtitle.toast.saved_to" +msgstr "字幕已保存至: " + +msgid "subtitle.toast.supported_formats" +msgstr "支持的字幕格式: " + +msgid "subtitle.toast.translate_done" +msgstr "翻译完成" + +msgid "subtitle.toast.translate_done_body" +msgstr "已更新选中行的翻译" + +msgid "subtitle.toast.translate_failed" +msgstr "翻译失败" + +msgid "synth.audio_mode.duck" +msgstr "压低原声" + +msgid "synth.audio_mode.mix" +msgstr "混合原声" + +msgid "synth.audio_mode.replace" +msgstr "替换原声" + +msgid "synth.blocker.ffmpeg_missing" +msgstr "未找到 FFmpeg" + +msgid "synth.blocker.ffmpeg_missing_detail" +msgstr "请先安装 FFmpeg 并确保 ffmpeg 在 PATH 中。" + +msgid "synth.blocker.ffprobe_missing" +msgstr "缺少 ffprobe,无法配音" + +msgid "synth.blocker.ffprobe_missing_detail" +msgstr "配音需要 ffprobe 处理音频,请安装完整的 FFmpeg 套件(含 ffprobe)后重试。" + +msgid "synth.blocker.key_required" +msgstr "当前音色需要 API Key" + +msgid "synth.blocker.key_required_detail" +msgstr "当前音色缺少 API Key,请检查配音配置,或切换到 Edge 免费音色。" + +msgid "synth.btn.generate_audio" +msgstr "生成配音音频" + +msgid "synth.btn.generate_full" +msgstr "生成成片" + +msgid "synth.btn.generate_subtitle_video" +msgstr "生成字幕视频" + +msgid "synth.btn.optional_video" +msgstr "可选视频" + +msgid "synth.btn.pick_output" +msgstr "选择输出内容" + +msgid "synth.btn.pick_subtitle" +msgstr "选择字幕" + +msgid "synth.btn.pick_video" +msgstr "选择视频" + +msgid "synth.btn.regenerate" +msgstr "重新生成" + +msgid "synth.btn.wait_files" +msgstr "等待文件" + +msgid "synth.btn.wait_video" +msgstr "等待视频" + +msgid "synth.dialog.pick_media" +msgstr "选择字幕和视频文件" + +msgid "synth.dialog.pick_subtitle" +msgstr "选择字幕文件" + +msgid "synth.dialog.pick_video" +msgstr "选择视频文件" + +msgid "synth.drop.formats" +msgstr "字幕:srt / ass / vtt 视频:mp4 / mov / mkv" + +msgid "synth.drop.pick" +msgstr "点击选择文件" + +msgid "synth.drop.title" +msgstr "拖入字幕和视频文件" + +msgid "synth.error.ass_unsupported" +msgstr "FFmpeg 不支持 ASS 硬字幕,请安装带 libass 的完整 FFmpeg,或切换为圆角背景渲染。" + +msgid "synth.error.dubbed_video_path_empty" +msgstr "配音视频输出路径为空" + +msgid "synth.file.media" +msgstr "媒体文件" + +msgid "synth.file.reference_video" +msgstr "参考视频" + +msgid "synth.file.subtitle" +msgstr "字幕文件" + +msgid "synth.file.video" +msgstr "视频文件" + +msgid "synth.hint.dub_only" +msgstr "仅生成配音音频,视频文件可不选" + +msgid "synth.hint.dub_then_synthesize" +msgstr "将先生成配音,再合成字幕视频" + +msgid "synth.hint.need_subtitle" +msgstr "需要字幕文件" + +msgid "synth.hint.need_subtitle_and_video" +msgstr "需要字幕文件和视频文件" + +msgid "synth.hint.need_video" +msgstr "还需要视频文件" + +msgid "synth.hint.pick_output" +msgstr "请在右侧至少打开一种输出内容" + +msgid "synth.hint.synthesize_only" +msgstr "将把字幕合成进视频" + +msgid "synth.link.style_page" +msgstr "样式页" + +msgid "synth.link.voice_library" +msgstr "音色库" + +msgid "synth.opt.audio_mode" +msgstr "音频处理" + +msgid "synth.opt.render_mode" +msgstr "渲染模式" + +msgid "synth.opt.style" +msgstr "样式" + +msgid "synth.opt.subtitle_mode" +msgstr "字幕方式" + +msgid "synth.opt.subtitle_style" +msgstr "字幕样式" + +msgid "synth.opt.text_track" +msgstr "文本轨道" + +msgid "synth.opt.timing" +msgstr "时间贴合" + +msgid "synth.opt.video_quality" +msgstr "视频质量" + +msgid "synth.opt.voice" +msgstr "音色" + +msgid "synth.output.dubbing" +msgstr "配音音轨" + +msgid "synth.output.dubbing_desc" +msgstr "按字幕生成配音" + +msgid "synth.output.subtitle_video" +msgstr "字幕视频" + +msgid "synth.output.subtitle_video_desc" +msgstr "把字幕合成到视频里" + +msgid "synth.panel.title" +msgstr "本次生成" + +msgid "synth.pill.completed" +msgstr "已完成" + +msgid "synth.pill.failed" +msgstr "失败" + +msgid "synth.pill.missing_ffmpeg" +msgstr "缺少 FFmpeg" + +msgid "synth.pill.missing_ffprobe" +msgstr "缺 ffprobe" + +msgid "synth.pill.missing_key" +msgstr "缺少 Key" + +msgid "synth.pill.missing_video" +msgstr "缺少视频" + +msgid "synth.pill.no_output" +msgstr "未选择输出" + +msgid "synth.pill.ready" +msgstr "可以生成" + +msgid "synth.plan.collect" +msgstr "整理结果文件" + +msgid "synth.plan.dubbing" +msgstr "生成配音音轨" + +msgid "synth.plan.pending" +msgstr "待生成" + +msgid "synth.plan.save" +msgstr "保存结果文件" + +msgid "synth.plan.synthesize" +msgstr "合成字幕视频" + +msgid "synth.result.dubbed_audio" +msgstr "配音音频" + +msgid "synth.result.dubbed_video" +msgstr "配音视频" + +msgid "synth.result.subtitled_video" +msgstr "字幕视频" + +msgid "synth.row.subtitle_required" +msgstr "必填:选择 SRT / ASS / VTT 字幕" + +msgid "synth.row.video_optional" +msgstr "可选:选择后额外生成配音视频" + +msgid "synth.row.video_required" +msgstr "必填:选择 MP4 / MOV / MKV 视频" + +msgid "synth.section.dubbing_params" +msgstr "配音参数" + +msgid "synth.section.output" +msgstr "输出内容" + +msgid "synth.section.subtitle_params" +msgstr "字幕视频参数" + +msgid "synth.status.completed" +msgstr "生成完成" + +msgid "synth.status.done" +msgstr "完成" + +msgid "synth.status.failed" +msgstr "生成失败" + +msgid "synth.status.generating" +msgstr "正在生成结果文件" + +msgid "synth.status.missing" +msgstr "缺少" + +msgid "synth.status.optional" +msgstr "可选" + +msgid "synth.status.processing" +msgstr "生成中" + +msgid "synth.status.ready" +msgstr "已就绪" + +msgid "synth.status.waiting" +msgstr "等待" + +msgid "synth.subtitle_mode.hard" +msgstr "硬字幕" + +msgid "synth.subtitle_mode.soft" +msgstr "软字幕" + +msgid "synth.text_track.auto" +msgstr "自动选择" + +msgid "synth.text_track.first" +msgstr "第一行" + +msgid "synth.text_track.second" +msgstr "第二行" + +msgid "synth.timing.balanced" +msgstr "平衡" + +msgid "synth.timing.natural" +msgstr "自然" + +msgid "synth.timing.strict" +msgstr "严格贴合" + +msgid "synth.tip.collapse_panel" +msgstr "收起本栏" + +msgid "synth.tip.expand_panel" +msgstr "展开生成栏" + +msgid "synth.tip.open_config" +msgstr "打开合成配置" + +msgid "synth.title.config_check" +msgstr "配置检查" + +msgid "synth.title.confirm" +msgstr "生成前确认" + +msgid "synth.title.input" +msgstr "输入文件" + +msgid "synth.title.results" +msgstr "结果文件" + +msgid "synth.title.running" +msgstr "生成中" + +msgid "t_dubbing.error.config_empty" +msgstr "配音配置为空" + +msgid "t_dubbing.error.output_audio_path_empty" +msgstr "输出音频路径为空" + +msgid "t_dubbing.error.subtitle_path_empty" +msgstr "字幕路径为空" + +msgid "t_dubbing.error.task_dir_empty" +msgstr "任务目录为空" + +msgid "t_dubbing.status.done" +msgstr "配音完成" + +msgid "t_dubbing.status.preparing" +msgstr "准备配音" + +msgid "t_hardsub.error.read_video_failed" +msgstr "无法读取该视频" + +msgid "t_hardsub.status.detecting_region" +msgstr "识别字幕区域" + +msgid "t_hardsub.status.recognizing" +msgstr "识别中 {count} 条" + +msgid "t_media.error.no_video_file" +msgstr "下载完成但未找到视频文件,请换一个链接重试" + +msgid "t_media.status.completed" +msgstr "下载完成" + +msgid "t_subtitle.error.llm_model_not_configured" +msgstr "LLM 模型未配置" + +msgid "t_subtitle.error.llm_not_configured" +msgstr "LLM API 未配置, 请检查LLM配置" + +msgid "t_subtitle.error.llm_test_failed" +msgstr "LLM API 测试失败: {message}" + +msgid "t_subtitle.error.no_config" +msgstr "字幕配置为空" + +msgid "t_subtitle.error.no_subtitle_path" +msgstr "字幕文件路径为空" + +msgid "t_subtitle.error.no_target_language" +msgstr "目标语言未配置" + +msgid "t_subtitle.error.unsupported_service" +msgstr "不支持的翻译服务: {service}" + +msgid "t_subtitle.status.done" +msgstr "处理完成" + +msgid "t_subtitle.status.optimizing" +msgstr "优化字幕..." + +msgid "t_subtitle.status.processing_percent" +msgstr "{percent}% 处理字幕" + +msgid "t_subtitle.status.splitting" +msgstr "字幕断句..." + +msgid "t_subtitle.status.translating" +msgstr "翻译字幕..." + +msgid "t_subtitle.status.translating_percent" +msgstr "{percent}% 翻译中" + +msgid "t_subtitle.status.verifying_llm" +msgstr "开始验证 LLM 配置..." + +msgid "t_synth.error.no_output_path" +msgstr "输出路径为空" + +msgid "t_synth.error.no_subtitle_path" +msgstr "字幕路径为空" + +msgid "t_synth.error.no_video_path" +msgstr "视频路径为空" + +msgid "t_synth.status.done" +msgstr "合成完成" + +msgid "t_synth.status.synthesizing" +msgstr "正在合成" + +msgid "t_transcript.error.audio_extract_failed" +msgstr "音频转换失败" + +msgid "t_transcript.error.file_not_found" +msgstr "媒体文件不存在" + +msgid "t_transcript.error.no_config" +msgstr "转录配置为空" + +msgid "t_transcript.error.no_file_path" +msgstr "文件路径为空" + +msgid "t_transcript.error.no_output_path" +msgstr "输出路径为空" + +msgid "t_transcript.status.done" +msgstr "转录完成" + +msgid "t_transcript.status.extracting_audio" +msgstr "转换音频中" + +msgid "t_transcript.status.transcribing" +msgstr "语音转录中" + +msgid "t_voice.error.need_api_key" +msgstr "{provider} 试听需要先在设置里填写配音 API Key" + +msgid "t_voice.sample_text" +msgstr "你好,这是卡卡字幕助手的配音试听。" + +msgid "transcribe.action.cancel" +msgstr "取消转录" + +msgid "transcribe.action.replace_file" +msgstr "更换文件" + +msgid "transcribe.btn.open_subtitle" +msgstr "进入字幕优化" + +msgid "transcribe.btn.retranscribe" +msgstr "重新转录" + +msgid "transcribe.btn.running" +msgstr "转录中" + +msgid "transcribe.btn.start" +msgstr "开始转录" + +msgid "transcribe.btn.waiting_file" +msgstr "等待文件" + +msgid "transcribe.col.content" +msgstr "字幕内容" + +msgid "transcribe.col.end" +msgstr "结束时间" + +msgid "transcribe.col.start" +msgstr "开始时间" + +msgid "transcribe.config.open_tip" +msgstr "打开转录配置" + +msgid "transcribe.dialog.media_filter" +msgstr "媒体文件" + +msgid "transcribe.dialog.pick_media" +msgstr "选择媒体文件" + +msgid "transcribe.drop.pick" +msgstr "点击选择文件" + +msgid "transcribe.drop.title" +msgstr "拖入一个音频或视频文件" + +msgid "transcribe.empty.title" +msgstr "未选择媒体文件" + +msgid "transcribe.error.title" +msgstr "失败原因" + +msgid "transcribe.file.title" +msgstr "当前文件" + +msgid "transcribe.format.txt" +msgstr "TXT" + +msgid "transcribe.opt.language" +msgstr "语言" + +msgid "transcribe.opt.model" +msgstr "模型" + +msgid "transcribe.opt.output" +msgstr "输出" + +msgid "transcribe.opt.service" +msgstr "服务" + +msgid "transcribe.opt.track" +msgstr "音轨" + +msgid "transcribe.opt.word_timestamp" +msgstr "词时间戳" + +msgid "transcribe.params.collapse_tip" +msgstr "收起参数栏" + +msgid "transcribe.params.expand_tip" +msgstr "展开参数栏" + +msgid "transcribe.params.title" +msgstr "参数" + +msgid "transcribe.pending.not_started" +msgstr "尚未开始转录" + +msgid "transcribe.preview.count" +msgstr "{n} 条" + +msgid "transcribe.preview.title" +msgstr "原始字幕预览" + +msgid "transcribe.progress.phase" +msgstr "语音识别" + +msgid "transcribe.result.collapse_tip" +msgstr "收起操作栏" + +msgid "transcribe.result.file" +msgstr "结果文件" + +msgid "transcribe.result.open_file_tip" +msgstr "点击打开结果文件" + +msgid "transcribe.result.title" +msgstr "结果操作" + +msgid "transcribe.stage.done" +msgstr "完成" + +msgid "transcribe.stage.read_audio" +msgstr "读取音频" + +msgid "transcribe.stage.recognize" +msgstr "识别语音" + +msgid "transcribe.stage.running" +msgstr "进行中" + +msgid "transcribe.stage.waiting" +msgstr "等待" + +msgid "transcribe.stage.write_subtitle" +msgstr "生成字幕文件" + +msgid "transcribe.status.done" +msgstr "完成" + +msgid "transcribe.status.failed" +msgstr "转录失败" + +msgid "transcribe.status.pending" +msgstr "待开始" + +msgid "transcribe.status.running" +msgstr "转录中" + +msgid "transcribe.status.unreachable" +msgstr "未连通" + +msgid "transcribe.text_preview.done" +msgstr "转录完成" + +msgid "transcribe.text_preview.title" +msgstr "纯文本预览" + +msgid "transcribe.toast.bad_format" +msgstr "格式错误 {suffix}" + +msgid "transcribe.toast.drop_media" +msgstr "请拖入音频或视频文件" + +msgid "transcribe.toast.no_subtitle" +msgstr "没有可用于字幕优化的字幕文件" + +msgid "transcribe.toast.processing" +msgstr "正在处理中,请等待当前任务完成" + +msgid "transcribe.track.first" +msgstr "音轨 1" + +msgid "workbench.drop.or" +msgstr "或" + diff --git a/resource/i18n/zh_Hant/LC_MESSAGES/videocaptioner.mo b/resource/i18n/zh_Hant/LC_MESSAGES/videocaptioner.mo new file mode 100644 index 00000000..7b001102 Binary files /dev/null and b/resource/i18n/zh_Hant/LC_MESSAGES/videocaptioner.mo differ diff --git a/resource/i18n/zh_Hant/LC_MESSAGES/videocaptioner.po b/resource/i18n/zh_Hant/LC_MESSAGES/videocaptioner.po new file mode 100644 index 00000000..64b837bb --- /dev/null +++ b/resource/i18n/zh_Hant/LC_MESSAGES/videocaptioner.po @@ -0,0 +1,4461 @@ +# Chinese (Traditional) translations for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-06-25 20:45+0800\n" +"PO-Revision-Date: 2026-06-21 23:08+0800\n" +"Last-Translator: FULL NAME \n" +"Language: zh_Hant\n" +"Language-Team: zh_Hant \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +msgid "app.announcement.got_it" +msgstr "知道了" + +msgid "app.announcement.title" +msgstr "公告" + +msgid "app.ffmpeg.missing_body" +msgstr "軟件處理音訊及影片檔案時需要 FFmpeg,請先安裝" + +msgid "app.ffmpeg.missing_title" +msgstr "尚未安裝 FFmpeg" + +msgid "app.github.body" +msgstr "" +"VideoCaptioner 由本人在課餘時間獨立開發完成,目前託管於 GitHub,歡迎 Star 和 " +"Fork。項目仍有不少地方需要完善,如遇到軟件問題或 BUG,歡迎提交 Issue。\n" +"\n" +" https://github.com/WEIFENG2333/VideoCaptioner" + +msgid "app.github.open" +msgstr "開啟 GitHub" + +msgid "app.github.support_author" +msgstr "支持作者" + +msgid "app.github.title" +msgstr "GitHub 資訊" + +msgid "app.nav.batch" +msgstr "批量處理" + +msgid "app.nav.doctor" +msgstr "診斷" + +msgid "app.nav.dubbing" +msgstr "配音" + +msgid "app.nav.hardsub" +msgstr "硬字幕擷取" + +msgid "app.nav.home" +msgstr "主頁" + +msgid "app.nav.live_caption" +msgstr "即時字幕" + +msgid "app.nav.request_logs" +msgstr "請求日誌" + +msgid "app.nav.settings" +msgstr "設定" + +msgid "app.nav.subtitle_style" +msgstr "字幕樣式" + +msgid "app.sidebar.collapse" +msgstr "收起" + +msgid "app.sidebar.expand" +msgstr "展開" + +msgid "app.update.available" +msgstr "有可用更新" + +msgid "app.update.cancel" +msgstr "取消" + +msgid "app.update.check_failed" +msgstr "檢查更新失敗" + +msgid "app.update.checking" +msgstr "正在檢查更新…" + +msgid "app.update.download" +msgstr "下載更新" + +msgid "app.update.downloading" +msgstr "下載中 {percent}%" + +msgid "app.update.failed" +msgstr "下載失敗:{error}" + +msgid "app.update.go_download" +msgstr "前往下載" + +msgid "app.update.install" +msgstr "重啟並安裝" + +msgid "app.update.install_failed" +msgstr "安裝失敗" + +msgid "app.update.mandatory" +msgstr "需更新到最新版本後才能繼續使用" + +msgid "app.update.ready" +msgstr "下載完成" + +msgid "app.update.retry" +msgstr "重試" + +msgid "app.update.title" +msgstr "發現新版本 {version}" + +msgid "app.update.up_to_date" +msgstr "已是最新版本" + +msgid "app.window_title" +msgstr "卡卡字幕助手 -- VideoCaptioner" + +msgid "batch.added.count" +msgstr "已加入 {n} 個檔案" + +msgid "batch.added.duplicated" +msgstr "{n} 個已在隊列" + +msgid "batch.added.ignored" +msgstr "已忽略 {n} 個不支援的檔案" + +msgid "batch.added.title" +msgstr "加入完成" + +msgid "batch.btn.add_file" +msgstr "加入檔案" + +msgid "batch.btn.add_folder" +msgstr "加入資料夾" + +msgid "batch.btn.clear" +msgstr "清空列表" + +msgid "batch.btn.pause" +msgstr "暫停隊列" + +msgid "batch.btn.resume" +msgstr "繼續處理" + +msgid "batch.btn.start" +msgstr "開始處理" + +msgid "batch.cannot_retry" +msgstr "無法重試" + +msgid "batch.cannot_start" +msgstr "無法開始" + +msgid "batch.concurrency" +msgstr "並發 {n}" + +msgid "batch.count" +msgstr "{n} 個任務" + +msgid "batch.detail.error" +msgstr "" +"錯誤資訊:\n" +"{error}" + +msgid "batch.detail.file" +msgstr "檔案:{path}" + +msgid "batch.detail.got_it" +msgstr "我知道了" + +msgid "batch.detail.outputs" +msgstr "輸出檔案:" + +msgid "batch.detail.progress" +msgstr "進度:{note}" + +msgid "batch.detail.status" +msgstr "狀態:{status} · {progress}%" + +msgid "batch.detail.title" +msgstr "任務詳情" + +msgid "batch.dialog.pick_files" +msgstr "選擇檔案" + +msgid "batch.dialog.pick_folder" +msgstr "選擇資料夾" + +msgid "batch.done.body" +msgstr "{completed} 個任務全部完成" + +msgid "batch.done.title" +msgstr "批量處理完成" + +msgid "batch.drop.format" +msgstr "目前模式:{mode} · 支援{kinds},可拖入資料夾" + +msgid "batch.drop.media_kinds" +msgstr "音訊、影片" + +msgid "batch.drop.subtitle_kinds" +msgstr "字幕檔案" + +msgid "batch.drop.title" +msgstr "拖入檔案或資料夾" + +msgid "batch.empty.duplicated" +msgstr "檔案已在佇列中" + +msgid "batch.empty.no_media" +msgstr "找不到支援的音訊或影片檔案" + +msgid "batch.empty.no_subtitle" +msgstr "找不到字幕檔案" + +msgid "batch.empty.title" +msgstr "未加入任何檔案" + +msgid "batch.error.empty_output" +msgstr "{stage}:輸出路徑為空" + +msgid "batch.error.empty_video_output" +msgstr "{stage}:輸出影片路徑為空" + +msgid "batch.filter.all" +msgstr "全部" + +msgid "batch.filter.empty" +msgstr "沒有符合目前篩選的任務" + +msgid "batch.filter.media" +msgstr "音影片檔案 ({patterns})" + +msgid "batch.filter.subtitle" +msgstr "字幕檔案 ({patterns})" + +msgid "batch.filtered.body" +msgstr "{n} 個檔案與「{mode}」輸入類型不符,已移出佇列" + +msgid "batch.filtered.title" +msgstr "已篩選佇列" + +msgid "batch.finished.body" +msgstr "{completed} 個完成,{failed} 個失敗,可在清單中重試" + +msgid "batch.finished.title" +msgstr "批量處理結束" + +msgid "batch.metric.concurrency" +msgstr "並行數" + +msgid "batch.metric.progress" +msgstr "目前進度" + +msgid "batch.metric.queue" +msgstr "佇列任務" + +msgid "batch.metric.success_rate" +msgstr "成功率" + +msgid "batch.mode.full.desc" +msgstr "轉錄、翻譯、合成影片" + +msgid "batch.mode.full.title" +msgstr "全流程處理" + +msgid "batch.mode.subtitle.desc" +msgstr "優化、翻譯現有字幕" + +msgid "batch.mode.subtitle.title" +msgstr "批量字幕翻譯" + +msgid "batch.mode.trans_sub.desc" +msgstr "生成字幕並翻譯優化" + +msgid "batch.mode.trans_sub.title" +msgstr "轉錄 + 字幕" + +msgid "batch.mode.transcribe.desc" +msgstr "從音影片生成字幕" + +msgid "batch.mode.transcribe.title" +msgstr "批量轉錄" + +msgid "batch.note.completed" +msgstr "處理完成" + +msgid "batch.note.failed" +msgstr "處理失敗" + +msgid "batch.note.output" +msgstr "已輸出 {name}" + +msgid "batch.panel.ended" +msgstr "已結束" + +msgid "batch.panel.not_started" +msgstr "未開始" + +msgid "batch.panel.paused" +msgstr "已暫停" + +msgid "batch.panel.ready" +msgstr "可開始" + +msgid "batch.panel.running" +msgstr "運行中" + +msgid "batch.panel.title" +msgstr "本批任務" + +msgid "batch.preflight.dubbing_key" +msgstr "目前配音音色需要 API Key,請檢查配音設定或切換至 Edge 免費音色" + +msgid "batch.preflight.ffmpeg" +msgstr "影片合成需要 FFmpeg:請先安裝並確保 ffmpeg 在 PATH 中" + +msgid "batch.preflight.ffprobe" +msgstr "配音需要 ffprobe 處理音訊,請確認 FFmpeg 套件完整(含 ffprobe)" + +msgid "batch.preflight.llm" +msgstr "字幕處理需要大模型:請先設定可用的 API Key、介面地址和模型" + +msgid "batch.row.details_tip" +msgstr "點擊查看任務詳情" + +msgid "batch.row.open_output" +msgstr "開啟輸出目錄" + +msgid "batch.row.remove" +msgstr "移除任務" + +msgid "batch.row.retry" +msgstr "重試任務" + +msgid "batch.stage.dubbing.desc" +msgstr "按字幕生成音軌" + +msgid "batch.stage.dubbing.title" +msgstr "配音" + +msgid "batch.stage.settings_tip" +msgstr "{title}設定" + +msgid "batch.stage.subtitle.desc" +msgstr "斷句、優化、翻譯" + +msgid "batch.stage.subtitle.title" +msgstr "字幕處理" + +msgid "batch.stage.synthesis.desc" +msgstr "輸出影片" + +msgid "batch.stage.synthesis.title" +msgstr "影片合成" + +msgid "batch.stage.transcribe.desc" +msgstr "生成原始字幕" + +msgid "batch.stage.transcribe.title" +msgstr "語音轉錄" + +msgid "batch.status.completed" +msgstr "已完成" + +msgid "batch.status.failed" +msgstr "失敗" + +msgid "batch.status.preparing" +msgstr "準備中" + +msgid "batch.status.running" +msgstr "處理中" + +msgid "batch.status.waiting" +msgstr "等待中" + +msgid "batch.subtitle.done" +msgstr "批次任務完成" + +msgid "batch.subtitle.done_failed" +msgstr "批次任務完成,{n} 個檔案需要處理" + +msgid "batch.subtitle.empty" +msgstr "拖入一批檔案,選擇處理類型後開始" + +msgid "batch.subtitle.paused" +msgstr "已暫停 · 處理中的任務完成後停止" + +msgid "batch.subtitle.ready" +msgstr "{n} 個檔案已加入佇列" + +msgid "batch.subtitle.running" +msgstr "正按佇列順序處理" + +msgid "batch.switch.busy_body" +msgstr "請先暫停並等待目前任務結束,再切換處理類型" + +msgid "batch.switch.busy_title" +msgstr "處理中" + +msgid "batch.switched.body" +msgstr "根據檔案類型切換為「{mode}」" + +msgid "batch.switched.title" +msgstr "已切換處理類型" + +msgid "batch.title" +msgstr "批量處理" + +msgid "colorpicker.clear" +msgstr "清除" + +msgid "colorpicker.section.presets" +msgstr "常用字幕顏色" + +msgid "colorpicker.section.recent" +msgstr "最近使用" + +msgid "colorpicker.title" +msgstr "選擇顏色" + +msgid "common.cancel" +msgstr "取消" + +msgid "common.close" +msgstr "關閉" + +msgid "common.copy" +msgstr "複製" + +msgid "common.delete" +msgstr "刪除" + +msgid "common.error" +msgstr "錯誤" + +msgid "common.ok" +msgstr "確定" + +msgid "common.open_folder" +msgstr "開啟資料夾" + +msgid "common.retry" +msgstr "重試" + +msgid "common.save" +msgstr "儲存" + +msgid "common.tip" +msgstr "提示" + +msgid "common.warning" +msgstr "警告" + +msgid "ctrl.change" +msgstr "更改" + +msgid "ctrl.open" +msgstr "開啟" + +msgid "ctrl.settings_title" +msgstr "設定" + +msgid "depdl.body.intro" +msgstr "會按你的系統自動選擇合適版本下載到本機;內地網絡會優先使用加速鏡像。" + +msgid "depdl.btn.done" +msgstr "完成" + +msgid "depdl.btn.download" +msgstr "下載" + +msgid "depdl.btn.install_all_missing" +msgstr "一鍵安裝缺失項目" + +msgid "depdl.btn.open_install_dir" +msgstr "開啟安裝目錄" + +msgid "depdl.error.download_failed" +msgstr "下載失敗" + +msgid "depdl.info.done_body" +msgstr "{name} 下載完成。" + +msgid "depdl.info.nothing_body" +msgstr "所有依賴都已就緒。" + +msgid "depdl.info.nothing_title" +msgstr "無需安裝" + +msgid "depdl.status.connecting" +msgstr "正在連接鏡像…" + +msgid "depdl.status.failed" +msgstr "失敗" + +msgid "depdl.status.installed" +msgstr "已安裝" + +msgid "depdl.status.unsupported" +msgstr "暫不支援" + +msgid "depdl.title" +msgstr "下載運行依賴" + +msgid "doctor.btn.download_install" +msgstr "下載安裝" + +msgid "doctor.btn.download_voxgate" +msgstr "下載 voxgate" + +msgid "doctor.btn.dubbing_settings" +msgstr "配音配置" + +msgid "doctor.btn.how_to_handle" +msgstr "處理方式" + +msgid "doctor.btn.install_tool" +msgstr "安裝工具" + +msgid "doctor.btn.live_caption_settings" +msgstr "實時字幕配置" + +msgid "doctor.btn.llm_settings" +msgstr "大模型配置" + +msgid "doctor.btn.rerun" +msgstr "重新診斷" + +msgid "doctor.btn.run" +msgstr "執行診斷" + +msgid "doctor.btn.running" +msgstr "診斷中" + +msgid "doctor.btn.transcribe_settings" +msgstr "轉錄配置" + +msgid "doctor.btn.translate_settings" +msgstr "翻譯配置" + +msgid "doctor.btn.usage" +msgstr "使用說明" + +msgid "doctor.checklist" +msgstr "檢查清單" + +msgid "doctor.chip.dubbing" +msgstr "配音" + +msgid "doctor.chip.export" +msgstr "匯出" + +msgid "doctor.chip.subtitle" +msgstr "字幕處理" + +msgid "doctor.chip.transcribe" +msgstr "轉錄" + +msgid "doctor.chip.translate" +msgstr "翻譯" + +msgid "doctor.done.all_passed" +msgstr "目前檢查項目全部通過" + +msgid "doctor.done.errors" +msgstr "發現 {count} 項需要處理" + +msgid "doctor.done.title" +msgstr "診斷完成" + +msgid "doctor.done.warnings" +msgstr "有 {count} 項可選項需注意(不影響主要流程)" + +msgid "doctor.download" +msgstr "影片下載" + +msgid "doctor.download.desc" +msgstr "解析 YouTube 與哔哩哔哩連結,驗證線上影片能否下載。" + +msgid "doctor.download.ok" +msgstr "YouTube 與哔哩哔哩解析正常,可直接貼上連結下載。" + +msgid "doctor.download.ok_login" +msgstr "YouTube 與哔哩哔哩解析正常(部分網站透過瀏覽器登入狀態),可直接貼上連結下載。" + +msgid "doctor.download.unavailable" +msgstr "{detail}" + +msgid "doctor.dubbing.desc" +msgstr "按目前提供商及音色生成配音;部分服務需要 Key。" + +msgid "doctor.dubbing.fail.desc" +msgstr "Gemini / SiliconFlow 需要配音 Key;Edge 可免 Key。" + +msgid "doctor.dubbing.ok.desc" +msgstr "目前配音設定可用,可繼續生成配音。" + +msgid "doctor.dubbing.title" +msgstr "配音服務" + +msgid "doctor.failed.title" +msgstr "診斷失敗" + +msgid "doctor.ffmpeg.desc" +msgstr "生成影片、壓入字幕、合入配音都需要它。" + +msgid "doctor.ffmpeg.missing.desc" +msgstr "缺少後將無法生成影片、壓入字幕或合入配音。" + +msgid "doctor.ffmpeg.missing.title" +msgstr "缺少 FFmpeg" + +msgid "doctor.ffmpeg.no_ass.desc" +msgstr "目前 FFmpeg 缺少 ASS 字幕濾鏡。請安裝完整版本,或將字幕渲染模式切換為圓角背景。" + +msgid "doctor.ffmpeg.no_ass.title" +msgstr "FFmpeg 不支援 ASS 硬字幕" + +msgid "doctor.ffmpeg.ok.desc" +msgstr "工具完整,可生成影片和配音影片。" + +msgid "doctor.help.download" +msgstr "" +"YouTube 需要可用的系統代理;提示登入驗證時:安裝 Firefox 並登入,或用瀏覽器擴充功能匯出 cookies.txt " +"放到應用資料目錄(Windows 上新版 Chrome/Edge 的登入態無法讀取)。哔哩哔哩風控(412)稍等幾分鐘會自動恢復。" + +msgid "doctor.help.ffmpeg" +msgstr "ASS 硬字幕需要帶 libass 的完整 FFmpeg。macOS 可安裝 ffmpeg-full;亦可先切換為圓角背景。" + +msgid "doctor.jump_failed.body" +msgstr "找不到對應的設定頁。" + +msgid "doctor.jump_failed.title" +msgstr "跳轉失敗" + +msgid "doctor.label.disabled" +msgstr "未啟用" + +msgid "doctor.label.dubbing" +msgstr "配音" + +msgid "doctor.label.optimize" +msgstr "校正" + +msgid "doctor.label.split" +msgstr "智能斷句" + +msgid "doctor.label.subtitle" +msgstr "字幕" + +msgid "doctor.label.subtitle_file" +msgstr "字幕檔案" + +msgid "doctor.label.video" +msgstr "影片" + +msgid "doctor.live_caption.desc" +msgstr "實時轉錄需要本機 voxgate 轉錄程式,或外部實時服務。" + +msgid "doctor.live_caption.funasr_no_key.desc" +msgstr "Fun-ASR 實時缺少百煉 API Key,請到設定填寫。" + +msgid "doctor.live_caption.not_found.desc" +msgstr "可選功能:未找到 voxgate 轉錄程式或外部實時服務,需要時再配置即可。" + +msgid "doctor.live_caption.ok.desc" +msgstr "實時字幕後端已就緒,可開始實時轉錄與翻譯。" + +msgid "doctor.live_caption.title" +msgstr "實時字幕" + +msgid "doctor.llm.desc.default" +msgstr "校正、術語修正、智能斷句需要可用 Key。" + +msgid "doctor.llm.desc.translate" +msgstr "目前翻譯會調用大模型,需要可用 Key。" + +msgid "doctor.llm.fail.desc" +msgstr "字幕校正、術語修正和智能斷句需要可用 Key。" + +msgid "doctor.llm.ok.desc" +msgstr "大模型配置可用,可用於字幕增強。" + +msgid "doctor.llm.title.optimize" +msgstr "字幕校正" + +msgid "doctor.llm.title.optimize_split" +msgstr "字幕校正與智能斷句" + +msgid "doctor.llm.title.split" +msgstr "智能斷句" + +msgid "doctor.llm.title.translate" +msgstr "大模型翻譯" + +msgid "doctor.llm.unavailable.title" +msgstr "大模型配置不可用" + +msgid "doctor.status.checking" +msgstr "檢查中" + +msgid "doctor.status.error" +msgstr "未通過" + +msgid "doctor.status.ok" +msgstr "正常" + +msgid "doctor.status.pending" +msgstr "待檢查" + +msgid "doctor.status.warning" +msgstr "需注意" + +msgid "doctor.subtitle" +msgstr "檢查目前任務會用到的服務和工具。未啟用的功能不會出現在清單中。" + +msgid "doctor.summary.all_passed" +msgstr "全部通過" + +msgid "doctor.summary.errors" +msgstr "{count} 項未通過" + +msgid "doctor.summary.pending" +msgstr "{count} 項待檢查" + +msgid "doctor.summary.warnings" +msgstr "{count} 項需注意" + +msgid "doctor.title" +msgstr "診斷" + +msgid "doctor.transcribe.desc.free" +msgstr "將影片或音訊轉成原文字幕,免費接口需要網絡。" + +msgid "doctor.transcribe.desc.local" +msgstr "將影片或音訊轉成原文字幕,需要本地模型。" + +msgid "doctor.transcribe.desc.whisper" +msgstr "將影片或音訊轉成原文字幕,需要 Whisper Key。" + +msgid "doctor.transcribe.fail.desc" +msgstr "目前轉錄方式不可用,請檢查網絡、Key 或本地模型。" + +msgid "doctor.transcribe.ok.desc" +msgstr "目前轉錄方式可用,可產生原文字幕。" + +msgid "doctor.transcribe.title" +msgstr "轉錄服務" + +msgid "doctor.translate.desc.default" +msgstr "產生目標語言字幕,失敗只會影響譯文。" + +msgid "doctor.translate.desc.uses_llm" +msgstr "產生目標語言字幕,大模型翻譯會重用 LLM Key。" + +msgid "doctor.translate.ok.desc" +msgstr "翻譯服務可用,可產生目標語言字幕。" + +msgid "doctor.translate.title" +msgstr "翻譯服務" + +msgid "doctor.translate.uses_llm.desc" +msgstr "大模型翻譯會重用 LLM Key。" + +msgid "donate.alipay" +msgstr "支付寶" + +msgid "donate.desc" +msgstr "" +"目前我精力有限,您的支持讓我有動力繼續鑽研這個項目!\n" +"感謝您對開源事業的熱愛與支持!" + +msgid "donate.title" +msgstr "支持作者" + +msgid "donate.wechat" +msgstr "微信" + +msgid "dubbing.badge.clone" +msgstr "可複製" + +msgid "dubbing.badge.need_key" +msgstr "需 Key" + +msgid "dubbing.badge.no_key" +msgstr "免 Key" + +msgid "dubbing.btn.audition" +msgstr "試聽" + +msgid "dubbing.btn.config" +msgstr "配音設定" + +msgid "dubbing.btn.generate_clone" +msgstr "生成複製音訊" + +msgid "dubbing.btn.generate_preview" +msgstr "生成試聽音訊" + +msgid "dubbing.btn.stop" +msgstr "停止" + +msgid "dubbing.btn.synthesizing" +msgstr "合成中…" + +msgid "dubbing.clone.clear" +msgstr "清除" + +msgid "dubbing.clone.hint" +msgstr "未上載參考音訊時,會直接用上方文案試聽當前音色。" + +msgid "dubbing.clone.missing_file" +msgstr "參考音訊檔案不存在,請重新選擇或清除。" + +msgid "dubbing.clone.no_audio" +msgstr "未選擇參考音訊" + +msgid "dubbing.clone.record" +msgstr "錄製" + +msgid "dubbing.clone.ref_text" +msgstr "參考文字" + +msgid "dubbing.clone.ref_text_placeholder" +msgstr "輸入參考音訊中實際朗讀的文字" + +msgid "dubbing.clone.title" +msgstr "聲音克隆" + +msgid "dubbing.clone.upload" +msgstr "上載" + +msgid "dubbing.clone.uploaded" +msgstr "已上載" + +msgid "dubbing.dialog.audio_filter" +msgstr "音訊檔案 (*.wav *.mp3 *.m4a *.aac *.flac *.ogg *.opus);;所有檔案 (*.*)" + +msgid "dubbing.dialog.choose_audio" +msgstr "選擇參考音訊" + +msgid "dubbing.filter.all" +msgstr "全部" + +msgid "dubbing.filter.clone" +msgstr "克隆" + +msgid "dubbing.filter.female" +msgstr "女聲" + +msgid "dubbing.filter.male" +msgstr "男聲" + +msgid "dubbing.form.current_voice" +msgstr "目前音色" + +msgid "dubbing.form.gen_type" +msgstr "生成類型" + +msgid "dubbing.form.not_selected" +msgstr "未選擇" + +msgid "dubbing.form.type_preview" +msgstr "試聽音訊" + +msgid "dubbing.preview.char_count" +msgstr "{count} 字" + +msgid "dubbing.preview.desc" +msgstr "填寫測試文案,生成音訊後確認聲音和語氣。" + +msgid "dubbing.preview.desc_clone" +msgstr "可直接試聽預設音色,或加入參考音訊進行聲音克隆。" + +msgid "dubbing.preview.input_placeholder" +msgstr "輸入一句話,試聽所選音色" + +msgid "dubbing.preview.sample_text" +msgstr "你好,這是我想用作測試的配音文案。請用自然清晰的語氣朗讀這句話。" + +msgid "dubbing.preview.title" +msgstr "配音文案" + +msgid "dubbing.provider.edge.desc" +msgstr "免 API Key,適合預設快速生成中文或英文配音。" + +msgid "dubbing.provider.edge.title" +msgstr "Edge 免費配音" + +msgid "dubbing.provider.gemini.desc" +msgstr "Google Gemini 語音模型,適合英文自然表達。" + +msgid "dubbing.provider.gemini.title" +msgstr "Gemini TTS" + +msgid "dubbing.provider.siliconflow.desc" +msgstr "CosyVoice 中文表現穩定,並支援參考音訊克隆。" + +msgid "dubbing.provider.siliconflow.title" +msgstr "SiliconFlow CosyVoice" + +msgid "dubbing.ready.clone" +msgstr "支援聲音克隆" + +msgid "dubbing.ready.default" +msgstr "就緒" + +msgid "dubbing.ready.need_key" +msgstr "需要 API Key" + +msgid "dubbing.ready.no_key" +msgstr "免 Key 即用" + +msgid "dubbing.subtitle" +msgstr "選擇提供商和音色,輸入一句自己的試聽文案。" + +msgid "dubbing.tag.breathy" +msgstr "Breathy" + +msgid "dubbing.tag.bright" +msgstr "Bright" + +msgid "dubbing.tag.casual" +msgstr "Casual" + +msgid "dubbing.tag.clear" +msgstr "Clear" + +msgid "dubbing.tag.clone" +msgstr "克隆" + +msgid "dubbing.tag.easy_going" +msgstr "Easy-going" + +msgid "dubbing.tag.en" +msgstr "英文" + +msgid "dubbing.tag.even" +msgstr "Even" + +msgid "dubbing.tag.female" +msgstr "女聲" + +msgid "dubbing.tag.firm" +msgstr "Firm" + +msgid "dubbing.tag.forward" +msgstr "Forward" + +msgid "dubbing.tag.free" +msgstr "免費" + +msgid "dubbing.tag.gentle" +msgstr "Gentle" + +msgid "dubbing.tag.gravelly" +msgstr "Gravelly" + +msgid "dubbing.tag.informative" +msgstr "Informative" + +msgid "dubbing.tag.knowledgeable" +msgstr "Knowledgeable" + +msgid "dubbing.tag.lively" +msgstr "Lively" + +msgid "dubbing.tag.male" +msgstr "男聲" + +msgid "dubbing.tag.mature" +msgstr "Mature" + +msgid "dubbing.tag.need_key" +msgstr "需 Key" + +msgid "dubbing.tag.recommended" +msgstr "推薦" + +msgid "dubbing.tag.smooth" +msgstr "Smooth" + +msgid "dubbing.tag.soft" +msgstr "Soft" + +msgid "dubbing.tag.upbeat" +msgstr "Upbeat" + +msgid "dubbing.tag.warm" +msgstr "Warm" + +msgid "dubbing.tag.yue" +msgstr "粵語" + +msgid "dubbing.tag.zh" +msgstr "中文" + +msgid "dubbing.title" +msgstr "配音" + +msgid "dubbing.toast.audio_missing" +msgstr "參考音訊不存在" + +msgid "dubbing.toast.audio_missing_body" +msgstr "請重新上載或錄製參考音訊。" + +msgid "dubbing.toast.empty_text" +msgstr "請輸入試聽文字" + +msgid "dubbing.toast.empty_text_body" +msgstr "文字試聽會使用你輸入的內容即時生成音訊。" + +msgid "dubbing.toast.missing_ref_text" +msgstr "缺少參考文字" + +msgid "dubbing.toast.missing_ref_text_body" +msgstr "請填寫參考音訊中實際朗讀的文字,或清除參考音訊後進行普通試聽。" + +msgid "dubbing.toast.need_api_key" +msgstr "需要 API Key" + +msgid "dubbing.toast.need_api_key_body" +msgstr "自訂文字試聽需要發出真實請求,請先填寫目前配音服務的 API Key。" + +msgid "dubbing.toast.play_failed" +msgstr "播放失敗" + +msgid "dubbing.toast.play_failed_body" +msgstr "目前系統缺少音訊解碼組件,且找不到可用的外部播放器。" + +msgid "dubbing.toast.please_wait" +msgstr "請稍候" + +msgid "dubbing.toast.please_wait_body" +msgstr "正在合成另一段試聽。" + +msgid "dubbing.toast.preview_failed" +msgstr "試聽失敗" + +msgid "dubbing.toast.record_done" +msgstr "錄製完成" + +msgid "dubbing.toast.record_done_body" +msgstr "已儲存為參考音訊" + +msgid "dubbing.voice.edge-cn-female.desc" +msgstr "清晰自然的普通話女聲" + +msgid "dubbing.voice.edge-cn-male.desc" +msgstr "年輕自然的普通話男聲" + +msgid "dubbing.voice.edge-cn-xiaoyi.desc" +msgstr "溫和明亮的普通話女聲" + +msgid "dubbing.voice.edge-cn-yunjian.desc" +msgstr "更適合演講和旁白的男聲" + +msgid "dubbing.voice.edge-cn-yunyang.desc" +msgstr "播報感更強的普通話男聲" + +msgid "dubbing.voice.edge-en-andrew.desc" +msgstr "清晰穩重的美式英語男聲" + +msgid "dubbing.voice.edge-en-ava.desc" +msgstr "清爽自然的美式英語女聲" + +msgid "dubbing.voice.edge-en-brian.desc" +msgstr "自然穩健的美式英語男聲" + +msgid "dubbing.voice.edge-en-emma.desc" +msgstr "柔和自然的美式英語女聲" + +msgid "dubbing.voice.edge-en-female.desc" +msgstr "美式英語女聲" + +msgid "dubbing.voice.edge-en-libby.desc" +msgstr "英式英語女聲" + +msgid "dubbing.voice.edge-en-male.desc" +msgstr "美式英語男聲" + +msgid "dubbing.voice.edge-en-ryan.desc" +msgstr "英式英語男聲" + +msgid "dubbing.voice.edge-hk-hiugaai.desc" +msgstr "廣東話女聲" + +msgid "dubbing.voice.edge-hk-wanlung.desc" +msgstr "廣東話男聲" + +msgid "dubbing.voice.edge-tw-hsiaoyu.desc" +msgstr "台灣國語女聲" + +msgid "dubbing.voice.edge-tw-yunjhe.desc" +msgstr "台灣國語男聲" + +msgid "dubbing.voice.gemini-achernar.desc" +msgstr "柔和的英文聲音" + +msgid "dubbing.voice.gemini-algenib.desc" +msgstr "顆粒感較強的英文聲音" + +msgid "dubbing.voice.gemini-algieba.desc" +msgstr "平滑的英文聲音" + +msgid "dubbing.voice.gemini-alnilam.desc" +msgstr "堅定清晰的英文聲音" + +msgid "dubbing.voice.gemini-aoede.desc" +msgstr "明亮自然的英文聲音" + +msgid "dubbing.voice.gemini-autonoe.desc" +msgstr "均衡清晰的英文聲音" + +msgid "dubbing.voice.gemini-callirrhoe.desc" +msgstr "輕鬆自然的英文聲音" + +msgid "dubbing.voice.gemini-charon.desc" +msgstr "較沉穩的英文聲音" + +msgid "dubbing.voice.gemini-despina.desc" +msgstr "平滑自然的英文聲音" + +msgid "dubbing.voice.gemini-en-friendly.desc" +msgstr "友善自然的英文表達" + +msgid "dubbing.voice.gemini-en-neutral.desc" +msgstr "清晰穩定的自然英文" + +msgid "dubbing.voice.gemini-en-upbeat.desc" +msgstr "更有活力的英文表達" + +msgid "dubbing.voice.gemini-enceladus.desc" +msgstr "氣聲感更明顯的英文聲音" + +msgid "dubbing.voice.gemini-erinome.desc" +msgstr "清晰直接的英文聲音" + +msgid "dubbing.voice.gemini-fenrir.desc" +msgstr "低沉有力的英文聲音" + +msgid "dubbing.voice.gemini-gacrux.desc" +msgstr "成熟穩重的英文聲音" + +msgid "dubbing.voice.gemini-iapetus.desc" +msgstr "清澈穩定的英文聲音" + +msgid "dubbing.voice.gemini-laomedeia.desc" +msgstr "輕快活潑的英文聲音" + +msgid "dubbing.voice.gemini-leda.desc" +msgstr "輕快明亮的英文聲音" + +msgid "dubbing.voice.gemini-orus.desc" +msgstr "旁白感更強的英文聲音" + +msgid "dubbing.voice.gemini-pulcherrima.desc" +msgstr "前置感更強的英文聲音" + +msgid "dubbing.voice.gemini-rasalgethi.desc" +msgstr "資訊感更強的英文聲音" + +msgid "dubbing.voice.gemini-sadachbia.desc" +msgstr "生動輕快的英文聲音" + +msgid "dubbing.voice.gemini-sadaltager.desc" +msgstr "知識型表達的英文聲音" + +msgid "dubbing.voice.gemini-schedar.desc" +msgstr "平穩均衡的英文聲音" + +msgid "dubbing.voice.gemini-sulafat.desc" +msgstr "溫暖自然的英文聲音" + +msgid "dubbing.voice.gemini-umbriel.desc" +msgstr "輕鬆自然的英文聲音" + +msgid "dubbing.voice.gemini-vindemiatrix.desc" +msgstr "溫和柔順的英文聲音" + +msgid "dubbing.voice.gemini-zephyr.desc" +msgstr "明亮清爽的英文聲音" + +msgid "dubbing.voice.gemini-zubenelgenubi.desc" +msgstr "休閒自然的英文聲音" + +msgid "dubbing.voice.library" +msgstr "音色庫" + +msgid "dubbing.voice.library_zh" +msgstr "中文音色" + +msgid "dubbing.voice.siliconflow-cn-bella.desc" +msgstr "熱情中文女聲,可克隆" + +msgid "dubbing.voice.siliconflow-cn-charles.desc" +msgstr "磁性中文男聲,可克隆" + +msgid "dubbing.voice.siliconflow-cn-claire.desc" +msgstr "溫柔中文女聲,可克隆" + +msgid "dubbing.voice.siliconflow-cn-david.desc" +msgstr "歡快中文男聲,可克隆" + +msgid "dubbing.voice.siliconflow-cn-deep-male.desc" +msgstr "沉穩低沉的中文男聲,可克隆" + +msgid "dubbing.voice.siliconflow-cn-diana.desc" +msgstr "歡快中文女聲,可克隆" + +msgid "dubbing.voice.siliconflow-cn-female.desc" +msgstr "自然中文女聲,可配合參考音訊克隆" + +msgid "dubbing.voice.siliconflow-cn-male.desc" +msgstr "自然中文男聲,可配合參考音訊克隆" + +msgid "enum.FasterWhisperModelEnum.BASE" +msgstr "base" + +msgid "enum.FasterWhisperModelEnum.LARGE_V1" +msgstr "large-v1" + +msgid "enum.FasterWhisperModelEnum.LARGE_V2" +msgstr "large-v2" + +msgid "enum.FasterWhisperModelEnum.LARGE_V3" +msgstr "large-v3" + +msgid "enum.FasterWhisperModelEnum.LARGE_V3_TURBO" +msgstr "large-v3-turbo" + +msgid "enum.FasterWhisperModelEnum.MEDIUM" +msgstr "medium" + +msgid "enum.FasterWhisperModelEnum.SMALL" +msgstr "small" + +msgid "enum.FasterWhisperModelEnum.TINY" +msgstr "tiny" + +msgid "enum.LLMServiceEnum.CHATGLM" +msgstr "ChatGLM" + +msgid "enum.LLMServiceEnum.DEEPSEEK" +msgstr "DeepSeek" + +msgid "enum.LLMServiceEnum.GEMINI" +msgstr "Gemini" + +msgid "enum.LLMServiceEnum.IMMERSIVE" +msgstr "公益大模型" + +msgid "enum.LLMServiceEnum.LM_STUDIO" +msgstr "LM Studio" + +msgid "enum.LLMServiceEnum.OLLAMA" +msgstr "Ollama" + +msgid "enum.LLMServiceEnum.OPENAI" +msgstr "OpenAI 兼容" + +msgid "enum.LLMServiceEnum.SILICON_CLOUD" +msgstr "SiliconCloud" + +msgid "enum.SubtitleLayoutEnum.ONLY_ORIGINAL" +msgstr "只顯示原文" + +msgid "enum.SubtitleLayoutEnum.ONLY_TRANSLATE" +msgstr "只顯示譯文" + +msgid "enum.SubtitleLayoutEnum.ORIGINAL_ON_TOP" +msgstr "原文在上" + +msgid "enum.SubtitleLayoutEnum.TRANSLATE_ON_TOP" +msgstr "譯文在上" + +msgid "enum.SubtitleRenderModeEnum.ASS_STYLE" +msgstr "ASS 樣式" + +msgid "enum.SubtitleRenderModeEnum.ROUNDED_BG" +msgstr "圓角背景" + +msgid "enum.TargetLanguage.ARABIC" +msgstr "阿拉伯文" + +msgid "enum.TargetLanguage.BULGARIAN" +msgstr "保加利亞文" + +msgid "enum.TargetLanguage.CANTONESE" +msgstr "粵語" + +msgid "enum.TargetLanguage.CZECH" +msgstr "捷克文" + +msgid "enum.TargetLanguage.DANISH" +msgstr "丹麥文" + +msgid "enum.TargetLanguage.DUTCH" +msgstr "荷蘭文" + +msgid "enum.TargetLanguage.ENGLISH" +msgstr "英文" + +msgid "enum.TargetLanguage.ENGLISH_UK" +msgstr "英文(英國)" + +msgid "enum.TargetLanguage.ENGLISH_US" +msgstr "英文(美國)" + +msgid "enum.TargetLanguage.FINNISH" +msgstr "芬蘭文" + +msgid "enum.TargetLanguage.FRENCH" +msgstr "法文" + +msgid "enum.TargetLanguage.GERMAN" +msgstr "德文" + +msgid "enum.TargetLanguage.GREEK" +msgstr "希臘文" + +msgid "enum.TargetLanguage.HEBREW" +msgstr "希伯來文" + +msgid "enum.TargetLanguage.HUNGARIAN" +msgstr "匈牙利文" + +msgid "enum.TargetLanguage.INDONESIAN" +msgstr "印尼文" + +msgid "enum.TargetLanguage.ITALIAN" +msgstr "意大利文" + +msgid "enum.TargetLanguage.JAPANESE" +msgstr "日文" + +msgid "enum.TargetLanguage.KOREAN" +msgstr "韓文" + +msgid "enum.TargetLanguage.MALAY" +msgstr "馬來文" + +msgid "enum.TargetLanguage.NORWEGIAN" +msgstr "挪威文" + +msgid "enum.TargetLanguage.PERSIAN" +msgstr "波斯文" + +msgid "enum.TargetLanguage.POLISH" +msgstr "波蘭文" + +msgid "enum.TargetLanguage.PORTUGUESE" +msgstr "葡萄牙文" + +msgid "enum.TargetLanguage.PORTUGUESE_BR" +msgstr "葡萄牙文(巴西)" + +msgid "enum.TargetLanguage.PORTUGUESE_PT" +msgstr "葡萄牙文(葡萄牙)" + +msgid "enum.TargetLanguage.ROMANIAN" +msgstr "羅馬尼亞文" + +msgid "enum.TargetLanguage.RUSSIAN" +msgstr "俄文" + +msgid "enum.TargetLanguage.SIMPLIFIED_CHINESE" +msgstr "簡體中文" + +msgid "enum.TargetLanguage.SPANISH" +msgstr "西班牙文" + +msgid "enum.TargetLanguage.SPANISH_LATAM" +msgstr "西班牙文(拉丁美洲)" + +msgid "enum.TargetLanguage.SWEDISH" +msgstr "瑞典文" + +msgid "enum.TargetLanguage.TAGALOG" +msgstr "菲律賓文" + +msgid "enum.TargetLanguage.THAI" +msgstr "泰文" + +msgid "enum.TargetLanguage.TRADITIONAL_CHINESE" +msgstr "繁體中文" + +msgid "enum.TargetLanguage.TURKISH" +msgstr "土耳其文" + +msgid "enum.TargetLanguage.UKRAINIAN" +msgstr "烏克蘭文" + +msgid "enum.TargetLanguage.VIETNAMESE" +msgstr "越南文" + +msgid "enum.TranscribeLanguageEnum.AFRIKAANS" +msgstr "Afrikaans" + +msgid "enum.TranscribeLanguageEnum.ALBANIAN" +msgstr "Albanian" + +msgid "enum.TranscribeLanguageEnum.AMHARIC" +msgstr "Amharic" + +msgid "enum.TranscribeLanguageEnum.ARABIC" +msgstr "Arabic" + +msgid "enum.TranscribeLanguageEnum.ARMENIAN" +msgstr "Armenian" + +msgid "enum.TranscribeLanguageEnum.ASSAMESE" +msgstr "Assamese" + +msgid "enum.TranscribeLanguageEnum.AUTO" +msgstr "自動偵測" + +msgid "enum.TranscribeLanguageEnum.AZERBAIJANI" +msgstr "Azerbaijani" + +msgid "enum.TranscribeLanguageEnum.BASHKIR" +msgstr "Bashkir" + +msgid "enum.TranscribeLanguageEnum.BASQUE" +msgstr "Basque" + +msgid "enum.TranscribeLanguageEnum.BELARUSIAN" +msgstr "白俄羅斯文" + +msgid "enum.TranscribeLanguageEnum.BENGALI" +msgstr "孟加拉文" + +msgid "enum.TranscribeLanguageEnum.BOSNIAN" +msgstr "波斯尼亞文" + +msgid "enum.TranscribeLanguageEnum.BRETON" +msgstr "布列塔尼文" + +msgid "enum.TranscribeLanguageEnum.BULGARIAN" +msgstr "保加利亞文" + +msgid "enum.TranscribeLanguageEnum.CANTONESE" +msgstr "廣東話" + +msgid "enum.TranscribeLanguageEnum.CATALAN" +msgstr "加泰羅尼亞文" + +msgid "enum.TranscribeLanguageEnum.CHINESE" +msgstr "中文" + +msgid "enum.TranscribeLanguageEnum.CROATIAN" +msgstr "克羅地亞文" + +msgid "enum.TranscribeLanguageEnum.CZECH" +msgstr "捷克文" + +msgid "enum.TranscribeLanguageEnum.DANISH" +msgstr "丹麥文" + +msgid "enum.TranscribeLanguageEnum.DUTCH" +msgstr "荷蘭文" + +msgid "enum.TranscribeLanguageEnum.ENGLISH" +msgstr "英文" + +msgid "enum.TranscribeLanguageEnum.ESTONIAN" +msgstr "愛沙尼亞文" + +msgid "enum.TranscribeLanguageEnum.FAROESE" +msgstr "法羅文" + +msgid "enum.TranscribeLanguageEnum.FINNISH" +msgstr "芬蘭文" + +msgid "enum.TranscribeLanguageEnum.FRENCH" +msgstr "法文" + +msgid "enum.TranscribeLanguageEnum.GALICIAN" +msgstr "加利西亞文" + +msgid "enum.TranscribeLanguageEnum.GEORGIAN" +msgstr "格魯吉亞文" + +msgid "enum.TranscribeLanguageEnum.GERMAN" +msgstr "德文" + +msgid "enum.TranscribeLanguageEnum.GREEK" +msgstr "希臘文" + +msgid "enum.TranscribeLanguageEnum.GUJARATI" +msgstr "古吉拉特文" + +msgid "enum.TranscribeLanguageEnum.HAITIAN_CREOLE" +msgstr "海地克里奧爾文" + +msgid "enum.TranscribeLanguageEnum.HAUSA" +msgstr "豪薩文" + +msgid "enum.TranscribeLanguageEnum.HAWAIIAN" +msgstr "夏威夷文" + +msgid "enum.TranscribeLanguageEnum.HEBREW" +msgstr "希伯來文" + +msgid "enum.TranscribeLanguageEnum.HINDI" +msgstr "印地文" + +msgid "enum.TranscribeLanguageEnum.HUNGARIAN" +msgstr "匈牙利文" + +msgid "enum.TranscribeLanguageEnum.ICELANDIC" +msgstr "冰島文" + +msgid "enum.TranscribeLanguageEnum.INDONESIAN" +msgstr "印尼文" + +msgid "enum.TranscribeLanguageEnum.ITALIAN" +msgstr "意大利文" + +msgid "enum.TranscribeLanguageEnum.JAPANESE" +msgstr "日文" + +msgid "enum.TranscribeLanguageEnum.JAVANESE" +msgstr "爪哇文" + +msgid "enum.TranscribeLanguageEnum.KANNADA" +msgstr "卡納達文" + +msgid "enum.TranscribeLanguageEnum.KAZAKH" +msgstr "哈薩克文" + +msgid "enum.TranscribeLanguageEnum.KHMER" +msgstr "高棉文" + +msgid "enum.TranscribeLanguageEnum.KOREAN" +msgstr "韓文" + +msgid "enum.TranscribeLanguageEnum.LAO" +msgstr "老撾文" + +msgid "enum.TranscribeLanguageEnum.LATIN" +msgstr "拉丁文" + +msgid "enum.TranscribeLanguageEnum.LATVIAN" +msgstr "拉脫維亞文" + +msgid "enum.TranscribeLanguageEnum.LINGALA" +msgstr "林格拉文" + +msgid "enum.TranscribeLanguageEnum.LITHUANIAN" +msgstr "立陶宛文" + +msgid "enum.TranscribeLanguageEnum.LUXEMBOURGISH" +msgstr "盧森堡文" + +msgid "enum.TranscribeLanguageEnum.MACEDONIAN" +msgstr "馬其頓文" + +msgid "enum.TranscribeLanguageEnum.MALAGASY" +msgstr "馬達加斯加文" + +msgid "enum.TranscribeLanguageEnum.MALAY" +msgstr "馬來文" + +msgid "enum.TranscribeLanguageEnum.MALAYALAM" +msgstr "馬拉雅拉姆文" + +msgid "enum.TranscribeLanguageEnum.MALTESE" +msgstr "馬耳他文" + +msgid "enum.TranscribeLanguageEnum.MAORI" +msgstr "毛利文" + +msgid "enum.TranscribeLanguageEnum.MARATHI" +msgstr "馬拉地文" + +msgid "enum.TranscribeLanguageEnum.MONGOLIAN" +msgstr "蒙古文" + +msgid "enum.TranscribeLanguageEnum.MYANMAR" +msgstr "緬甸文" + +msgid "enum.TranscribeLanguageEnum.NEPALI" +msgstr "尼泊爾文" + +msgid "enum.TranscribeLanguageEnum.NORWEGIAN" +msgstr "挪威文" + +msgid "enum.TranscribeLanguageEnum.NYNORSK" +msgstr "新挪威文" + +msgid "enum.TranscribeLanguageEnum.OCCITAN" +msgstr "奧克文" + +msgid "enum.TranscribeLanguageEnum.PASHTO" +msgstr "普什圖文" + +msgid "enum.TranscribeLanguageEnum.PERSIAN" +msgstr "波斯文" + +msgid "enum.TranscribeLanguageEnum.POLISH" +msgstr "波蘭文" + +msgid "enum.TranscribeLanguageEnum.PORTUGUESE" +msgstr "葡萄牙文" + +msgid "enum.TranscribeLanguageEnum.PUNJABI" +msgstr "旁遮普語" + +msgid "enum.TranscribeLanguageEnum.ROMANIAN" +msgstr "羅馬尼亞語" + +msgid "enum.TranscribeLanguageEnum.RUSSIAN" +msgstr "俄語" + +msgid "enum.TranscribeLanguageEnum.SANSKRIT" +msgstr "梵語" + +msgid "enum.TranscribeLanguageEnum.SERBIAN" +msgstr "塞爾維亞語" + +msgid "enum.TranscribeLanguageEnum.SHONA" +msgstr "紹納語" + +msgid "enum.TranscribeLanguageEnum.SINDHI" +msgstr "信德語" + +msgid "enum.TranscribeLanguageEnum.SINHALA" +msgstr "僧伽羅語" + +msgid "enum.TranscribeLanguageEnum.SLOVAK" +msgstr "斯洛伐克語" + +msgid "enum.TranscribeLanguageEnum.SLOVENIAN" +msgstr "斯洛文尼亞語" + +msgid "enum.TranscribeLanguageEnum.SOMALI" +msgstr "索馬里語" + +msgid "enum.TranscribeLanguageEnum.SPANISH" +msgstr "西班牙語" + +msgid "enum.TranscribeLanguageEnum.SUNDANESE" +msgstr "巽他語" + +msgid "enum.TranscribeLanguageEnum.SWAHILI" +msgstr "斯瓦希里語" + +msgid "enum.TranscribeLanguageEnum.SWEDISH" +msgstr "瑞典語" + +msgid "enum.TranscribeLanguageEnum.TAGALOG" +msgstr "他加祿語" + +msgid "enum.TranscribeLanguageEnum.TAJIK" +msgstr "塔吉克語" + +msgid "enum.TranscribeLanguageEnum.TAMIL" +msgstr "泰米爾語" + +msgid "enum.TranscribeLanguageEnum.TATAR" +msgstr "韃靼語" + +msgid "enum.TranscribeLanguageEnum.TELUGU" +msgstr "泰盧固語" + +msgid "enum.TranscribeLanguageEnum.THAI" +msgstr "Thai" + +msgid "enum.TranscribeLanguageEnum.TIBETAN" +msgstr "Tibetan" + +msgid "enum.TranscribeLanguageEnum.TURKISH" +msgstr "土耳其文" + +msgid "enum.TranscribeLanguageEnum.TURKMEN" +msgstr "Turkmen" + +msgid "enum.TranscribeLanguageEnum.UKRAINIAN" +msgstr "Ukrainian" + +msgid "enum.TranscribeLanguageEnum.URDU" +msgstr "Urdu" + +msgid "enum.TranscribeLanguageEnum.UZBEK" +msgstr "Uzbek" + +msgid "enum.TranscribeLanguageEnum.VIETNAMESE" +msgstr "Vietnamese" + +msgid "enum.TranscribeLanguageEnum.WELSH" +msgstr "Welsh" + +msgid "enum.TranscribeLanguageEnum.YIDDISH" +msgstr "Yiddish" + +msgid "enum.TranscribeLanguageEnum.YORUBA" +msgstr "Yoruba" + +msgid "enum.TranscribeLanguageEnum.YUE" +msgstr "廣東話" + +msgid "enum.TranscribeModelEnum.BAILIAN_FUN_ASR" +msgstr "百煉 Fun-ASR" + +msgid "enum.TranscribeModelEnum.BIJIAN" +msgstr "B 接口" + +msgid "enum.TranscribeModelEnum.FASTER_WHISPER" +msgstr "FasterWhisper" + +msgid "enum.TranscribeModelEnum.JIANYING" +msgstr "J 接口" + +msgid "enum.TranscribeModelEnum.WHISPER_API" +msgstr "Whisper [API]" + +msgid "enum.TranscribeModelEnum.WHISPER_CPP" +msgstr "WhisperCpp" + +msgid "enum.TranscribeOutputFormatEnum.ALL" +msgstr "全部" + +msgid "enum.TranscribeOutputFormatEnum.ASS" +msgstr "ASS" + +msgid "enum.TranscribeOutputFormatEnum.SRT" +msgstr "SRT" + +msgid "enum.TranscribeOutputFormatEnum.TXT" +msgstr "TXT" + +msgid "enum.TranscribeOutputFormatEnum.VTT" +msgstr "VTT" + +msgid "enum.TranslatorServiceEnum.BING" +msgstr "微軟翻譯" + +msgid "enum.TranslatorServiceEnum.DEEPLX" +msgstr "DeepLx 翻譯" + +msgid "enum.TranslatorServiceEnum.GOOGLE" +msgstr "Google 翻譯" + +msgid "enum.TranslatorServiceEnum.OPENAI" +msgstr "LLM 大模型翻譯" + +msgid "enum.VadMethodEnum.AUDITOK" +msgstr "auditok" + +msgid "enum.VadMethodEnum.PYANNOTE_ONNX_V3" +msgstr "pyannote_onnx_v3" + +msgid "enum.VadMethodEnum.PYANNOTE_V3" +msgstr "pyannote_v3" + +msgid "enum.VadMethodEnum.SILERO_V3" +msgstr "silero_v3" + +msgid "enum.VadMethodEnum.SILERO_V4" +msgstr "silero_v4" + +msgid "enum.VadMethodEnum.SILERO_V4_FW" +msgstr "silero_v4_fw" + +msgid "enum.VadMethodEnum.SILERO_V5" +msgstr "silero_v5" + +msgid "enum.VadMethodEnum.WEBRTC" +msgstr "webrtc" + +msgid "enum.VideoQualityEnum.HIGH" +msgstr "高品質" + +msgid "enum.VideoQualityEnum.LOW" +msgstr "低品質" + +msgid "enum.VideoQualityEnum.MEDIUM" +msgstr "中等品質" + +msgid "enum.VideoQualityEnum.ULTRA_HIGH" +msgstr "極高品質" + +msgid "enum.WhisperModelEnum.BASE" +msgstr "base" + +msgid "enum.WhisperModelEnum.LARGE_V1" +msgstr "large-v1" + +msgid "enum.WhisperModelEnum.LARGE_V2" +msgstr "large-v2" + +msgid "enum.WhisperModelEnum.MEDIUM" +msgstr "medium" + +msgid "enum.WhisperModelEnum.SMALL" +msgstr "small" + +msgid "enum.WhisperModelEnum.TINY" +msgstr "tiny" + +msgid "feedback.add_image" +msgstr "添加圖片" + +msgid "feedback.attach_count" +msgstr "{count}/{max}" + +msgid "feedback.category.bug" +msgstr "問題報告" + +msgid "feedback.category.feature" +msgstr "功能建議" + +msgid "feedback.category.label" +msgstr "類型" + +msgid "feedback.category.other" +msgstr "其他" + +msgid "feedback.category.question" +msgstr "使用問題" + +msgid "feedback.contact.placeholder" +msgstr "郵箱 / 微信 / QQ(選填,方便回覆你)" + +msgid "feedback.done" +msgstr "完成" + +msgid "feedback.err.category_invalid" +msgstr "反饋類型無效" + +msgid "feedback.err.contact_too_long" +msgstr "聯絡方式過長" + +msgid "feedback.err.file_too_large" +msgstr "單張截圖需 ≤ 5 MB" + +msgid "feedback.err.file_type" +msgstr "僅支援 PNG / JPEG" + +msgid "feedback.err.invalid_request" +msgstr "請求參數有誤" + +msgid "feedback.err.message_required" +msgstr "請先填寫問題描述" + +msgid "feedback.err.message_too_long" +msgstr "問題描述過長(最多 5000 字)" + +msgid "feedback.err.network" +msgstr "網路錯誤,請稍後重試" + +msgid "feedback.err.server" +msgstr "提交失敗,請稍後重試" + +msgid "feedback.err.too_large" +msgstr "圖片或請求過大" + +msgid "feedback.err.too_many_files" +msgstr "最多 3 張截圖" + +msgid "feedback.err.total_too_large" +msgstr "截圖總大小需 ≤ 12 MB" + +msgid "feedback.err.unauthorized" +msgstr "鑑權失敗" + +msgid "feedback.intro" +msgstr "你的反饋會直接發送給開發者,我們會認真查看每一條並盡快處理 🙏" + +msgid "feedback.message.placeholder" +msgstr "請描述你遇到的問題或建議,越具體我們越好定位…" + +msgid "feedback.screenshot.hint" +msgstr "可 Ctrl+V 貼上、拖入圖片,或點 + 添加" + +msgid "feedback.section.contact" +msgstr "聯絡方式(選填)" + +msgid "feedback.section.message" +msgstr "問題描述" + +msgid "feedback.section.screenshot" +msgstr "截圖(選填)" + +msgid "feedback.submit" +msgstr "提交反饋" + +msgid "feedback.submitting" +msgstr "提交中…" + +msgid "feedback.success" +msgstr "已收到,我們會盡快查看處理,感謝你的反饋!" + +msgid "feedback.title" +msgstr "意見反饋" + +msgid "hardsub.btn.auto_region" +msgstr "自動識別區域" + +msgid "hardsub.btn.export" +msgstr "匯出字幕" + +msgid "hardsub.btn.redo" +msgstr "重新擷取" + +msgid "hardsub.btn.replace_video" +msgstr "更換影片" + +msgid "hardsub.btn.send_optimize" +msgstr "送入字幕優化" + +msgid "hardsub.btn.start" +msgstr "開始擷取" + +msgid "hardsub.busy.detecting_region" +msgstr "正在識別字幕區域…" + +msgid "hardsub.busy.detecting_region_pct" +msgstr "正在識別字幕區域… {percent}%" + +msgid "hardsub.busy.loading_video" +msgstr "正在載入影片…" + +msgid "hardsub.col.end" +msgstr "結束" + +msgid "hardsub.col.start" +msgstr "開始" + +msgid "hardsub.col.text" +msgstr "文字" + +msgid "hardsub.count.recognized" +msgstr "已識別 {n} 條" + +msgid "hardsub.count.total" +msgstr "共 {n} 條" + +msgid "hardsub.dialog.export" +msgstr "匯出字幕" + +msgid "hardsub.dialog.pick_video" +msgstr "選擇影片" + +msgid "hardsub.drop.pick" +msgstr "選擇影片" + +msgid "hardsub.drop.title" +msgstr "拖入帶硬字幕的影片" + +msgid "hardsub.engine.card_title" +msgstr "引擎未就緒" + +msgid "hardsub.engine.desc" +msgstr "提取硬字幕需要 OCR 引擎依賴(rapidocr / onnxruntime)。" + +msgid "hardsub.engine.title" +msgstr "OCR 引擎未就緒" + +msgid "hardsub.error.read_video" +msgstr "無法讀取此影片:{msg}" + +msgid "hardsub.menu.delete_row" +msgstr "刪除此行" + +msgid "hardsub.menu.locate" +msgstr "定位到此畫面" + +msgid "hardsub.note.done" +msgstr "雙擊修改文字 / 時間,右鍵刪除行;可送入字幕優化或匯出。" + +msgid "hardsub.note.empty" +msgstr "選擇影片後自動識別字幕區域" + +msgid "hardsub.note.engine_missing" +msgstr "OCR 引擎依賴未安裝" + +msgid "hardsub.note.no_subtitle" +msgstr "沒有穩定區域時,手動框選後再識別。" + +msgid "hardsub.note.processing" +msgstr "識別到的字幕會持續寫入右側表格。" + +msgid "hardsub.note.region" +msgstr "區域不準時,直接在影片畫面上拖動框選。" + +msgid "hardsub.ph.empty.sub" +msgstr "字幕結果會顯示在這裏。" + +msgid "hardsub.ph.empty.title" +msgstr "等待影片" + +msgid "hardsub.ph.engine_missing.sub" +msgstr "請先安裝識別引擎依賴。" + +msgid "hardsub.ph.engine_missing.title" +msgstr "OCR 引擎未就緒" + +msgid "hardsub.ph.no_subtitle.sub" +msgstr "可在左側影片上手動框選後重試。" + +msgid "hardsub.ph.no_subtitle.title" +msgstr "未識別到字幕" + +msgid "hardsub.ph.region.sub" +msgstr "確認區域後開始擷取。" + +msgid "hardsub.ph.region.title" +msgstr "已找到字幕區域" + +msgid "hardsub.progress.title" +msgstr "字幕識別" + +msgid "hardsub.result.title" +msgstr "字幕結果" + +msgid "hardsub.stage.video_preview" +msgstr "影片預覽" + +msgid "hardsub.status.done" +msgstr "已完成" + +msgid "hardsub.status.engine_missing" +msgstr "引擎未就緒" + +msgid "hardsub.status.need_action" +msgstr "需要處理" + +msgid "hardsub.status.processing" +msgstr "擷取中" + +msgid "hardsub.status.region_detected" +msgstr "已識別區域" + +msgid "hardsub.status.waiting_video" +msgstr "等待影片" + +msgid "hardsub.subtitle" +msgstr "從影片畫面識別硬字幕,框選區域後匯出為可編輯字幕。" + +msgid "hardsub.title" +msgstr "硬字幕擷取" + +msgid "hardsub.toast.exported" +msgstr "已匯出:{name}" + +msgid "hardsub.toast.no_auto_region" +msgstr "未能自動偵測字幕區域,請在畫面上手動框選字幕所在位置。" + +msgid "home.btn.browse" +msgstr "選擇檔案" + +msgid "home.btn.start" +msgstr "開始處理" + +msgid "home.confirm.has_subtitle" +msgstr "含字幕" + +msgid "home.confirm.quality" +msgstr "清晰度" + +msgid "home.confirm.quality.best" +msgstr "最佳" + +msgid "home.confirm.untitled" +msgstr "未命名影片" + +msgid "home.dialog.filter.audio" +msgstr "音訊檔案" + +msgid "home.dialog.filter.media" +msgstr "媒體檔案" + +msgid "home.dialog.filter.video" +msgstr "影片檔案" + +msgid "home.dialog.select_media" +msgstr "選擇媒體檔案" + +msgid "home.download.done.body" +msgstr "開始自動處理..." + +msgid "home.download.done.title" +msgstr "下載完成" + +msgid "home.download.parsing" +msgstr "正在解析影片資訊…" + +msgid "home.download.start" +msgstr "開始下載" + +msgid "home.error.invalid_input" +msgstr "請輸入有效的本機音訊或影片檔案,或完整的 http / https 連結。" + +msgid "home.error.unsupported_drop" +msgstr "拖入的檔案並非支援的音訊或影片格式" + +msgid "home.footer.donate" +msgstr "捐助" + +msgid "home.footer.logs" +msgstr "查看日誌" + +msgid "home.hero.title" +msgstr "匯入影片,產生字幕及配音" + +msgid "home.input.placeholder" +msgstr "貼上影片連結,或拖入本機音訊或影片檔案" + +msgid "home.media.kind.audio" +msgstr "音訊" + +msgid "home.media.kind.video" +msgstr "影片" + +msgid "home.media.ready" +msgstr "準備就緒" + +msgid "home.quick.confirm" +msgstr "解析完成 · 確認清晰度後開始下載" + +msgid "home.quick.downloading" +msgstr "網上影片連結 · 下載完成後自動進入下一步" + +msgid "home.quick.empty" +msgstr "可直接拖入檔案 · 支援本機音影片及網上影片連結" + +msgid "home.quick.file" +msgstr "本機媒體檔案 · 開始後自動進入語音轉錄" + +msgid "home.quick.invalid" +msgstr "無法識別輸入內容" + +msgid "home.quick.parsing" +msgstr "正在解析影片資訊,稍後確認清晰度" + +msgid "home.quick.url" +msgstr "網上影片連結 · 將先下載到工作目錄" + +msgid "home.status.invalid" +msgstr "輸入無效" + +msgid "home.status.parsing" +msgstr "解析中" + +msgid "home.status.pending" +msgstr "待確認" + +msgid "home.status.ready" +msgstr "可開始" + +msgid "home.status.waiting" +msgstr "等待輸入" + +msgid "home.tip.downloading" +msgstr "下載中" + +msgid "home.tip.parsing" +msgstr "解析中" + +msgid "homeflow.tab.subtitle_optimize" +msgstr "字幕優化與翻譯" + +msgid "homeflow.tab.task_creation" +msgstr "建立任務" + +msgid "homeflow.tab.transcription" +msgstr "語音轉錄" + +msgid "homeflow.tab.video_synthesis" +msgstr "字幕影片合成" + +msgid "inspector.color.pick" +msgstr "選擇顏色" + +msgid "inspector.color.pick_prefix" +msgstr "選擇" + +msgid "inspector.style.rename" +msgstr "重新命名" + +msgid "inspector.style.source.builtin" +msgstr "內置" + +msgid "inspector.style.source.mine" +msgstr "我的" + +msgid "lclang.ar" +msgstr "阿拉伯文" + +msgid "lclang.auto" +msgstr "自動識別" + +msgid "lclang.cs" +msgstr "捷克文" + +msgid "lclang.da" +msgstr "丹麥文" + +msgid "lclang.de" +msgstr "德文" + +msgid "lclang.en" +msgstr "英文" + +msgid "lclang.es" +msgstr "西班牙文" + +msgid "lclang.fi" +msgstr "芬蘭文" + +msgid "lclang.fil" +msgstr "菲律賓文" + +msgid "lclang.fr" +msgstr "法文" + +msgid "lclang.hi" +msgstr "印地文" + +msgid "lclang.id" +msgstr "印尼文" + +msgid "lclang.is" +msgstr "冰島文" + +msgid "lclang.it" +msgstr "意大利文" + +msgid "lclang.ja" +msgstr "日文" + +msgid "lclang.ko" +msgstr "韓文" + +msgid "lclang.ms" +msgstr "馬來文" + +msgid "lclang.no" +msgstr "挪威文" + +msgid "lclang.pl" +msgstr "波蘭文" + +msgid "lclang.pt" +msgstr "葡萄牙文" + +msgid "lclang.ru" +msgstr "俄文" + +msgid "lclang.sv" +msgstr "瑞典文" + +msgid "lclang.th" +msgstr "泰文" + +msgid "lclang.tr" +msgstr "土耳其文" + +msgid "lclang.uk" +msgstr "烏克蘭文" + +msgid "lclang.vi" +msgstr "越南文" + +msgid "lclang.yue" +msgstr "廣東話" + +msgid "lclang.zh" +msgstr "中文" + +msgid "live.delete.confirm" +msgstr "確定刪除「{name}」?整條記錄(包括音訊)將會從磁碟移除,此操作無法復原。" + +msgid "live.delete.title" +msgstr "刪除記錄" + +msgid "live.device.default_input" +msgstr "預設輸入" + +msgid "live.device.default_tag" +msgstr "(預設)" + +msgid "live.device.system_audio" +msgstr "系統聲音" + +msgid "live.error.concurrency_full" +msgstr "後端轉錄服務並發已滿(共享介面限 5 路),請稍後再試。" + +msgid "live.error.start_failed" +msgstr "啟動失敗" + +msgid "live.error.start_failed_desc" +msgstr "未能開始即時字幕" + +msgid "live.error.transcribe_failed" +msgstr "轉錄鏈路出錯" + +msgid "live.export.dialog_title" +msgstr "匯出" + +msgid "live.export.failed" +msgstr "匯出失敗" + +msgid "live.export.success" +msgstr "匯出成功" + +msgid "live.ready.desc" +msgstr "選擇聲音來源後即可開始" + +msgid "live.ready.title" +msgstr "開始新的即時字幕" + +msgid "live.rename.placeholder" +msgstr "記錄名稱" + +msgid "live.rename.title" +msgstr "重新命名記錄" + +msgid "live.status.connecting" +msgstr "連線中…" + +msgid "live.status.connecting_with_time" +msgstr "00:00 · 連線中…" + +msgid "live.status.paused" +msgstr "已暫停" + +msgid "live.status.progress" +msgstr "{time} · 已產生 {count} 句" + +msgid "live.status.recording" +msgstr "錄製中" + +msgid "live.status.saved" +msgstr "已儲存" + +msgid "live.status.waiting" +msgstr "等待開始" + +msgid "live.title" +msgstr "即時字幕" + +msgid "livetx.menu.copy_all" +msgstr "複製全部" + +msgid "livetx.menu.copy_sentence" +msgstr "複製本句" + +msgid "liveview.action.back" +msgstr "返回" + +msgid "liveview.action.export" +msgstr "匯出" + +msgid "liveview.action.folder" +msgstr "目錄" + +msgid "liveview.action.home" +msgstr "主頁" + +msgid "liveview.action.open" +msgstr "開啟" + +msgid "liveview.action.refresh" +msgstr "重新整理" + +msgid "liveview.action.rename" +msgstr "重新命名" + +msgid "liveview.btn.new_session" +msgstr "新增會話" + +msgid "liveview.btn.pause" +msgstr "暫停" + +msgid "liveview.btn.resume" +msgstr "繼續" + +msgid "liveview.btn.saving" +msgstr "儲存中…" + +msgid "liveview.btn.start" +msgstr "開始即時字幕" + +msgid "liveview.btn.stop" +msgstr "結束並儲存" + +msgid "liveview.btn.view_record" +msgstr "查看記錄" + +msgid "liveview.detail.display" +msgstr "顯示" + +msgid "liveview.detail.export" +msgstr "匯出" + +msgid "liveview.display.bilingual" +msgstr "雙語" + +msgid "liveview.display.source" +msgstr "原文" + +msgid "liveview.display.target" +msgstr "譯文" + +msgid "liveview.error.detail" +msgstr "無法擷取音訊,請在右側切換來源後重試" + +msgid "liveview.error.title" +msgstr "啟動失敗" + +msgid "liveview.export.srt" +msgstr "SRT 字幕" + +msgid "liveview.export.txt" +msgstr "TXT 文字" + +msgid "liveview.history.empty.detail" +msgstr "儲存後的即時字幕會顯示喺呢度。" + +msgid "liveview.history.empty.title" +msgstr "暫無記錄" + +msgid "liveview.history.search_placeholder" +msgstr "搜尋記錄名稱或正文" + +msgid "liveview.option.audio_source" +msgstr "音訊來源" + +msgid "liveview.option.live_translate" +msgstr "即時翻譯" + +msgid "liveview.option.overlay" +msgstr "桌面浮動視窗" + +msgid "liveview.option.source_language" +msgstr "識別語言" + +msgid "liveview.option.target_language" +msgstr "翻譯語言" + +msgid "liveview.ready.detail" +msgstr "選擇聲音來源後即可開始" + +msgid "liveview.ready.title" +msgstr "開始新的即時字幕" + +msgid "liveview.recent.empty.detail" +msgstr "開始一段即時字幕,結束後會自動儲存到此處。" + +msgid "liveview.recent.empty.title" +msgstr "尚未有記錄" + +msgid "liveview.recent.title" +msgstr "最近記錄" + +msgid "liveview.recent.view_all" +msgstr "查看全部" + +msgid "liveview.settings.config" +msgstr "設定" + +msgid "liveview.settings.title" +msgstr "本次設定" + +msgid "liveview.timer.saving" +msgstr "正在儲存…" + +msgid "liveview.timer.waiting" +msgstr "等待開始" + +msgid "liveview.title.detail" +msgstr "記錄詳情" + +msgid "liveview.title.history" +msgstr "即時字幕歷史" + +msgid "liveview.title.session" +msgstr "即時字幕" + +msgid "llmlog.btn.clear" +msgstr "清空日誌" + +msgid "llmlog.btn.refresh" +msgstr "重新整理" + +msgid "llmlog.clear.confirm_body" +msgstr "確定要清空所有日誌嗎?此操作無法復原。" + +msgid "llmlog.clear.confirm_btn" +msgstr "清空" + +msgid "llmlog.clear.confirm_title" +msgstr "確認清空" + +msgid "llmlog.clear.success" +msgstr "日誌已清空" + +msgid "llmlog.col.duration" +msgstr "耗時" + +msgid "llmlog.col.file" +msgstr "檔案" + +msgid "llmlog.col.model" +msgstr "模型" + +msgid "llmlog.col.stage" +msgstr "階段" + +msgid "llmlog.col.time" +msgstr "時間" + +msgid "llmlog.copied" +msgstr "已複製" + +msgid "llmlog.detail.foot_hint" +msgstr "複製按鈕只會複製對應 JSON;按 Esc 或右上角關閉。" + +msgid "llmlog.detail.title" +msgstr "請求詳情" + +msgid "llmlog.empty.hint" +msgstr "啟用字幕校正、智能斷句或 LLM 翻譯後,頁面會自動記錄請求與回應。" + +msgid "llmlog.empty.no_match.hint" +msgstr "試試其他任務 ID、檔案名稱、模型或階段關鍵字。" + +msgid "llmlog.empty.no_match.title" +msgstr "沒有相符日誌" + +msgid "llmlog.empty.title" +msgstr "暫無 LLM 請求日誌" + +msgid "llmlog.empty_payload" +msgstr "(空)" + +msgid "llmlog.meta.duration" +msgstr "耗時" + +msgid "llmlog.meta.result" +msgstr "結果" + +msgid "llmlog.meta.stage" +msgstr "階段" + +msgid "llmlog.meta.time" +msgstr "時間" + +msgid "llmlog.no_records" +msgstr "暫無記錄" + +msgid "llmlog.panel.error" +msgstr "錯誤回應" + +msgid "llmlog.panel.error.desc" +msgstr "模型或介面返回的 Error JSON" + +msgid "llmlog.panel.request" +msgstr "請求內容" + +msgid "llmlog.panel.request.desc" +msgstr "傳送畀模型的完整 Request JSON" + +msgid "llmlog.panel.response" +msgstr "回應內容" + +msgid "llmlog.panel.response.desc" +msgstr "模型返回的原始 Response JSON" + +msgid "llmlog.refresh.success" +msgstr "重新整理成功" + +msgid "llmlog.result.done" +msgstr "完成" + +msgid "llmlog.result.failed" +msgstr "失敗" + +msgid "llmlog.search.placeholder" +msgstr "搜尋任務 ID、檔案名、模型或階段" + +msgid "llmlog.status.filtered" +msgstr "共 {total} 條 · 篩選後 {shown} 條 · 雙擊查看完整 JSON" + +msgid "llmlog.status.total" +msgstr "共 {total} 條 · 雙擊查看完整 JSON" + +msgid "llmlog.status.total_zero" +msgstr "共 0 條" + +msgid "llmlog.title" +msgstr "LLM 請求日誌" + +msgid "llmlog.unknown" +msgstr "未知" + +msgid "logwin.btn.open_folder" +msgstr "開啟日誌資料夾" + +msgid "logwin.error.open_failed" +msgstr "開啟日誌檔案失敗:{error}" + +msgid "logwin.error.read_failed" +msgstr "讀取日誌檔案失敗:{error}" + +msgid "logwin.error.update_failed" +msgstr "讀取日誌檔案時出錯:{error}" + +msgid "logwin.history_divider" +msgstr "以上是歷史日誌" + +msgid "logwin.title" +msgstr "日誌檢視器" + +msgid "modelmgr.action.current" +msgstr "目前" + +msgid "modelmgr.action.download" +msgstr "下載" + +msgid "modelmgr.action.resume" +msgstr "繼續" + +msgid "modelmgr.col.action" +msgstr "操作" + +msgid "modelmgr.col.model" +msgstr "模型" + +msgid "modelmgr.col.size" +msgstr "大小" + +msgid "modelmgr.col.status" +msgstr "狀態" + +msgid "modelmgr.command.copied_body" +msgstr "在終端機執行後點按「重新偵測」。" + +msgid "modelmgr.command.copied_title" +msgstr "已複製安裝指令" + +msgid "modelmgr.command.copy" +msgstr "複製指令" + +msgid "modelmgr.download.done_body" +msgstr "{name} 下載完成。" + +msgid "modelmgr.download.done_title" +msgstr "模型已就緒" + +msgid "modelmgr.download.failed" +msgstr "下載失敗" + +msgid "modelmgr.footer.model_dir" +msgstr "本機模型目錄" + +msgid "modelmgr.footer.open_dir" +msgstr "開啟目錄" + +msgid "modelmgr.program.available" +msgstr "可用" + +msgid "modelmgr.program.found" +msgstr "已找到 {name}" + +msgid "modelmgr.program.missing" +msgstr "缺失" + +msgid "modelmgr.program.open_page" +msgstr "開啟頁面" + +msgid "modelmgr.program.opened_body" +msgstr "下載並安裝後,回來點擊「重新檢測」。" + +msgid "modelmgr.program.opened_title" +msgstr "已在瀏覽器開啟" + +msgid "modelmgr.program.ready_body" +msgstr "可以繼續下載模型。" + +msgid "modelmgr.program.ready_title" +msgstr "執行程式已就緒" + +msgid "modelmgr.program.recheck" +msgstr "重新檢測" + +msgid "modelmgr.progress.connecting" +msgstr "正在連接鏡像…" + +msgid "modelmgr.recheck.available_body" +msgstr "已找到 {name}。" + +msgid "modelmgr.recheck.available_title" +msgstr "執行程式可用" + +msgid "modelmgr.recheck.missing_title" +msgstr "仍未檢測到" + +msgid "modelmgr.remove.body" +msgstr "將刪除 {name}({size}),需要時須重新下載。" + +msgid "modelmgr.remove.done" +msgstr "已刪除" + +msgid "modelmgr.remove.failed" +msgstr "刪除失敗" + +msgid "modelmgr.remove.title" +msgstr "刪除模型" + +msgid "modelmgr.section.models" +msgstr "模型檔案" + +msgid "modelmgr.section.programs" +msgstr "執行程式" + +msgid "modelmgr.status.downloading" +msgstr "下載中" + +msgid "modelmgr.status.installed" +msgstr "已下載" + +msgid "modelmgr.status.paused" +msgstr "已暫停" + +msgid "modelmgr.status.pending" +msgstr "待下載" + +msgid "modelmgr.title" +msgstr "本地模型管理" + +msgid "overlay.listening" +msgstr "監聽中…" + +msgid "overlay.paused" +msgstr "已暫停" + +msgid "overlayset.bg" +msgstr "底色樣式" + +msgid "overlayset.bg.black" +msgstr "純黑" + +msgid "overlayset.bg.outline" +msgstr "純描邊" + +msgid "overlayset.bg.translucent" +msgstr "半透明" + +msgid "overlayset.display" +msgstr "顯示內容" + +msgid "overlayset.display.bilingual" +msgstr "雙語" + +msgid "overlayset.display.source" +msgstr "僅原文" + +msgid "overlayset.display.target" +msgstr "僅譯文" + +msgid "overlayset.font_size" +msgstr "字號" + +msgid "overlayset.font_size.medium" +msgstr "中" + +msgid "overlayset.title" +msgstr "字幕設定" + +msgid "roi.scrub.hint" +msgstr "拖動查看其他幀" + +msgid "roi.tag.caption_area" +msgstr "字幕區域" + +msgid "settings.about.current_version" +msgstr "目前版本" + +msgid "settings.about.feedback" +msgstr "意見反饋" + +msgid "settings.about.feedback.desc" +msgstr "遇到問題時提交意見反饋。" + +msgid "settings.about.feedback_button" +msgstr "提交意見反饋" + +msgid "settings.about.help" +msgstr "幫助" + +msgid "settings.about.help.desc" +msgstr "查看使用說明及常見問題。" + +msgid "settings.about.help_button" +msgstr "開啟幫助" + +msgid "settings.about.update_button" +msgstr "檢查更新" + +msgid "settings.about.version" +msgstr "版本" + +msgid "settings.busy.loading" +msgstr "載入中..." + +msgid "settings.busy.testing" +msgstr "測試中..." + +msgid "settings.busy.transcribing" +msgstr "轉錄中..." + +msgid "settings.dubbing.api_key" +msgstr "配音 API Key" + +msgid "settings.dubbing.api_key.desc" +msgstr "Gemini 或 SiliconFlow 配音需要填寫。" + +msgid "settings.dubbing.audio_mode" +msgstr "原聲處理" + +msgid "settings.dubbing.audio_mode.desc" +msgstr "生成影片時如何處理原影片聲音。" + +msgid "settings.dubbing.audio_mode.duck" +msgstr "降低原聲音量" + +msgid "settings.dubbing.audio_mode.mix" +msgstr "混合原聲" + +msgid "settings.dubbing.audio_mode.replace" +msgstr "替換原聲" + +msgid "settings.dubbing.config_error" +msgstr "配音配置錯誤" + +msgid "settings.dubbing.enabled" +msgstr "預設加入配音" + +msgid "settings.dubbing.enabled.desc" +msgstr "開啟後,全流程處理預設產生配音音軌。" + +msgid "settings.dubbing.model" +msgstr "配音模型" + +msgid "settings.dubbing.model.desc" +msgstr "目前配音提供商使用的文字轉語音模型。" + +msgid "settings.dubbing.need_api_key" +msgstr "目前配音提供商需要 API Key。" + +msgid "settings.dubbing.preset" +msgstr "預設音色" + +msgid "settings.dubbing.preset.desc" +msgstr "儲存後會成為配音頁及全流程的預設音色。" + +msgid "settings.dubbing.provider" +msgstr "配音提供商" + +msgid "settings.dubbing.provider.desc" +msgstr "Edge 免 Key;Gemini 及 SiliconFlow 需要 API Key。" + +msgid "settings.dubbing.test" +msgstr "配音測試" + +msgid "settings.dubbing.test.desc" +msgstr "用目前音色合成一句試聽音訊。" + +msgid "settings.dubbing.test_button" +msgstr "測試配音" + +msgid "settings.dubbing.test_failed" +msgstr "配音測試失敗" + +msgid "settings.dubbing.test_success" +msgstr "配音測試成功" + +msgid "settings.dubbing.test_success.detail" +msgstr "{provider} 已產生試聽音訊:{path}" + +msgid "settings.dubbing.text_track" +msgstr "配音文字軌" + +msgid "settings.dubbing.text_track.auto" +msgstr "自動選擇" + +msgid "settings.dubbing.text_track.desc" +msgstr "選擇使用原文、譯文,或自動判斷產生配音。" + +msgid "settings.dubbing.text_track.first" +msgstr "第一行" + +msgid "settings.dubbing.text_track.second" +msgstr "第二行" + +msgid "settings.dubbing.timing" +msgstr "時間對齊" + +msgid "settings.dubbing.timing.balanced" +msgstr "均衡" + +msgid "settings.dubbing.timing.desc" +msgstr "控制配音語速與字幕時間軸的貼合程度。" + +msgid "settings.dubbing.timing.natural" +msgstr "自然" + +msgid "settings.dubbing.timing.none" +msgstr "不變速" + +msgid "settings.dubbing.timing.strict" +msgstr "嚴格" + +msgid "settings.dubbing.workers" +msgstr "配音並發" + +msgid "settings.dubbing.workers.desc" +msgstr "同時合成的字幕行數。" + +msgid "settings.live_caption.asr_model" +msgstr "識別模型" + +msgid "settings.live_caption.asr_model.desc" +msgstr "Fun-ASR 識別模型;多語種模型可自動識別多種語言。" + +msgid "settings.live_caption.bg.black" +msgstr "純黑" + +msgid "settings.live_caption.bg.translucent" +msgstr "半透明" + +msgid "settings.live_caption.bg_style" +msgstr "底色樣式" + +msgid "settings.live_caption.bg_style.desc" +msgstr "浮窗底色風格" + +msgid "settings.live_caption.deps_button" +msgstr "下載 / 偵測" + +msgid "settings.live_caption.display.bilingual" +msgstr "雙語" + +msgid "settings.live_caption.display.source" +msgstr "僅原文" + +msgid "settings.live_caption.display.target" +msgstr "僅譯文" + +msgid "settings.live_caption.display_mode" +msgstr "顯示內容" + +msgid "settings.live_caption.display_mode.desc" +msgstr "浮窗預設顯示的內容" + +msgid "settings.live_caption.engine" +msgstr "轉錄引擎" + +msgid "settings.live_caption.engine.desc" +msgstr "實時語音轉文字所用的識別引擎。" + +msgid "settings.live_caption.engine.group" +msgstr "轉錄引擎" + +msgid "settings.live_caption.font_scale" +msgstr "字體大小" + +msgid "settings.live_caption.font_scale.desc" +msgstr "浮窗譯文字體大小" + +msgid "settings.live_caption.fun_key" +msgstr "百煉 API Key" + +msgid "settings.live_caption.fun_key.desc" +msgstr "Fun-ASR / Qwen-ASR 調用阿里雲百煉所需的密鑰,與轉錄配置共用同一個。" + +msgid "settings.live_caption.overlay.group" +msgstr "浮窗顯示" + +msgid "settings.live_caption.source_language" +msgstr "識別語言" + +msgid "settings.live_caption.source_language.desc" +msgstr "你說話所用的語言;識別引擎會據此轉寫。自動識別可讓引擎按音頻判定語言。" + +msgid "settings.live_caption.target_language" +msgstr "目標語言" + +msgid "settings.live_caption.target_language.desc" +msgstr "譯文翻譯成的語言。" + +msgid "settings.live_caption.test.desc" +msgstr "使用內置短音頻實際轉錄一次,驗證目前引擎可正常運行。" + +msgid "settings.live_caption.translate" +msgstr "實時翻譯" + +msgid "settings.live_caption.translate.desc" +msgstr "開啟後即時翻譯轉錄文字;關閉則只顯示原文。" + +msgid "settings.live_caption.translate.group" +msgstr "翻譯" + +msgid "settings.live_caption.translator_service" +msgstr "翻譯引擎" + +msgid "settings.live_caption.translator_service.desc" +msgstr "生成譯文所用的翻譯服務。" + +msgid "settings.live_caption.voxgate" +msgstr "轉錄程式" + +msgid "settings.live_caption.voxgate.desc" +msgstr "voxgate 本地轉錄程式,未安裝時按此下載或檢測。" + +msgid "settings.llm.api_key" +msgstr "API Key" + +msgid "settings.llm.api_key.desc" +msgstr "{service} 調用大模型時使用。" + +msgid "settings.llm.base_url" +msgstr "Base URL" + +msgid "settings.llm.base_url.desc" +msgstr "只需為 OpenAI 相容或本地服務修改。" + +msgid "settings.llm.connect_error" +msgstr "LLM 連線錯誤" + +msgid "settings.llm.connect_failed" +msgstr "LLM 連線失敗" + +msgid "settings.llm.connect_success" +msgstr "LLM 連線成功" + +msgid "settings.llm.immersive_hint.desc" +msgstr "公益模型額度有限,高峰期可能限流或偶發失敗,追求穩定和更好的效果可配置自己的 LLM Key。" + +msgid "settings.llm.immersive_hint.title" +msgstr "免費 · 無需 API Key" + +msgid "settings.llm.load_models" +msgstr "載入模型" + +msgid "settings.llm.model" +msgstr "模型" + +msgid "settings.llm.model.desc" +msgstr "用於斷句、校正、翻譯的大模型名稱。" + +msgid "settings.llm.model_service" +msgstr "模型服務" + +msgid "settings.llm.model_service.desc" +msgstr "先載入可用模型,再用目前模型測試連通性。" + +msgid "settings.llm.models_load_failed" +msgstr "模型載入失敗" + +msgid "settings.llm.models_loaded" +msgstr "模型已載入" + +msgid "settings.llm.models_loaded.desc" +msgstr "已載入 {count} 個模型。" + +msgid "settings.llm.no_models" +msgstr "沒有可用模型" + +msgid "settings.llm.no_models.desc" +msgstr "未能從目前提供商取得模型列表,請檢查 Base URL 和 API Key。" + +msgid "settings.llm.provider" +msgstr "LLM 提供商" + +msgid "settings.llm.provider.desc" +msgstr "用於字幕斷句、校正和 LLM 翻譯。" + +msgid "settings.llm.test_connection" +msgstr "測試連線" + +msgid "settings.llm.warn.need_all" +msgstr "請先填寫目前提供商的 Base URL、API Key 和模型。" + +msgid "settings.llm.warn.need_base_key" +msgstr "請先填寫目前提供商的 Base URL 和 API Key。" + +msgid "settings.page.about" +msgstr "關於" + +msgid "settings.page.dubbing" +msgstr "配音設定" + +msgid "settings.page.live_caption" +msgstr "即時字幕設定" + +msgid "settings.page.llm" +msgstr "LLM 設定" + +msgid "settings.page.personal" +msgstr "個人化" + +msgid "settings.page.save" +msgstr "儲存設定" + +msgid "settings.page.subtitle" +msgstr "字幕合成設定" + +msgid "settings.page.transcribe" +msgstr "轉錄設定" + +msgid "settings.page.translate" +msgstr "翻譯與優化" + +msgid "settings.page.translate_service" +msgstr "翻譯服務" + +msgid "settings.personal.choose_theme_color" +msgstr "選擇主題顏色" + +msgid "settings.personal.follow_system" +msgstr "跟隨系統" + +msgid "settings.personal.language" +msgstr "語言" + +msgid "settings.personal.restart_required" +msgstr "修改後需要重新啟動應用程式。" + +msgid "settings.personal.theme" +msgstr "應用程式主題" + +msgid "settings.personal.theme.dark" +msgstr "深色" + +msgid "settings.personal.theme.desc" +msgstr "切換淺色、深色或跟隨系統。" + +msgid "settings.personal.theme.light" +msgstr "淺色" + +msgid "settings.personal.theme_color" +msgstr "主題顏色" + +msgid "settings.personal.theme_color.desc" +msgstr "影響按鈕、反白顯示及選取狀態。" + +msgid "settings.personal.theme_color.is_default" +msgstr "目前已是項目預設綠色" + +msgid "settings.personal.theme_color.pick_tip" +msgstr "點擊選擇主題顏色:{color}" + +msgid "settings.personal.theme_color.reset" +msgstr "恢復預設" + +msgid "settings.personal.theme_color.reset_tip" +msgstr "恢復為項目預設綠色" + +msgid "settings.personal.zoom" +msgstr "介面縮放" + +msgid "settings.placeholder.empty" +msgstr "未填寫" + +msgid "settings.placeholder.not_selected" +msgstr "未選擇" + +msgid "settings.restart.confirm" +msgstr "立即重新啟動" + +msgid "settings.restart.later" +msgstr "稍後" + +msgid "settings.restart.message" +msgstr "語言或顯示設定已更改,需要重新啟動應用程式後才會生效。是否立即重新啟動?" + +msgid "settings.restart.title" +msgstr "提示" + +msgid "settings.save.cache" +msgstr "啟用快取" + +msgid "settings.save.cache.desc" +msgstr "在相同設定下重用 ASR、翻譯及配音合成結果。" + +msgid "settings.save.cache_disabled" +msgstr "快取已停用" + +msgid "settings.save.cache_disabled.detail" +msgstr "後續任務會重新生成結果。" + +msgid "settings.save.cache_enabled" +msgstr "快取已啟用" + +msgid "settings.save.cache_enabled.detail" +msgstr "後續任務會優先重用現有結果。" + +msgid "settings.save.choose_work_dir" +msgstr "選擇工作目錄" + +msgid "settings.save.keep_intermediates" +msgstr "保留中間檔案" + +msgid "settings.save.keep_intermediates.desc" +msgstr "處理成功後保留任務目錄內的原始轉錄、樣式字幕等中間產物;預設完成後即清理。" + +msgid "settings.save.work_dir" +msgstr "工作目錄" + +msgid "settings.save.work_dir.desc" +msgstr "下載影片及處理任務的中間檔案會寫入此處。" + +msgid "settings.subtitle.layout" +msgstr "字幕版面配置" + +msgid "settings.subtitle.layout.desc" +msgstr "選擇單語、雙語及原文譯文位置。" + +msgid "settings.subtitle.need_video" +msgstr "合成影片" + +msgid "settings.subtitle.need_video.desc" +msgstr "關閉後只輸出字幕檔案,不生成成片。" + +msgid "settings.subtitle.open_style" +msgstr "開啟樣式頁" + +msgid "settings.subtitle.render_mode" +msgstr "渲染模式" + +msgid "settings.subtitle.render_mode.desc" +msgstr "選擇 ASS 樣式或圓角背景渲染。" + +msgid "settings.subtitle.soft" +msgstr "軟字幕" + +msgid "settings.subtitle.soft.desc" +msgstr "開啟後字幕不會燒錄到畫面。" + +msgid "settings.subtitle.style" +msgstr "字幕樣式" + +msgid "settings.subtitle.style.desc" +msgstr "字體、顏色及預覽圖可於樣式頁調整。" + +msgid "settings.subtitle.video_quality" +msgstr "影片質素" + +msgid "settings.subtitle.video_quality.desc" +msgstr "硬字幕合成時使用的編碼質素。" + +msgid "settings.test_transcribe" +msgstr "測試轉錄" + +msgid "settings.title" +msgstr "設定" + +msgid "settings.transcribe.faster_whisper.choose_dir" +msgstr "選擇 Faster Whisper 模型目錄" + +msgid "settings.transcribe.faster_whisper.device" +msgstr "運行裝置" + +msgid "settings.transcribe.faster_whisper.device.desc" +msgstr "模型運行裝置,通常保持 auto。" + +msgid "settings.transcribe.faster_whisper.model" +msgstr "Faster Whisper 模型" + +msgid "settings.transcribe.faster_whisper.model.desc" +msgstr "選擇已下載的 Faster Whisper 模型。" + +msgid "settings.transcribe.faster_whisper.model_dir" +msgstr "模型目錄" + +msgid "settings.transcribe.faster_whisper.model_dir.desc" +msgstr "Faster Whisper 模型所在資料夾。" + +msgid "settings.transcribe.faster_whisper.one_word" +msgstr "單字時間戳" + +msgid "settings.transcribe.faster_whisper.one_word.desc" +msgstr "開啟後生成單字級時間戳。" + +msgid "settings.transcribe.faster_whisper.vad_filter" +msgstr "VAD 過濾" + +msgid "settings.transcribe.faster_whisper.vad_filter.desc" +msgstr "過濾無人聲片段,減少識別幻覺。" + +msgid "settings.transcribe.faster_whisper.vad_method" +msgstr "VAD 方法" + +msgid "settings.transcribe.faster_whisper.vad_method.desc" +msgstr "選擇語音活動偵測方法。" + +msgid "settings.transcribe.faster_whisper.vad_threshold" +msgstr "VAD 閾值" + +msgid "settings.transcribe.faster_whisper.vad_threshold.desc" +msgstr "語音概率閾值,高於此值視為語音。" + +msgid "settings.transcribe.faster_whisper.voice_extraction" +msgstr "人聲分離" + +msgid "settings.transcribe.faster_whisper.voice_extraction.desc" +msgstr "處理前分離人聲和背景音樂。" + +msgid "settings.transcribe.fun_asr.key" +msgstr "百煉 API Key" + +msgid "settings.transcribe.fun_asr.key.desc" +msgstr "百煉 Fun-ASR 轉錄需要填寫。" + +msgid "settings.transcribe.fun_asr.model" +msgstr "百煉 ASR 模型" + +msgid "settings.transcribe.fun_asr.model.desc" +msgstr "填寫百煉控制台裏可用的語音識別模型 Code。" + +msgid "settings.transcribe.local_model" +msgstr "本地模型" + +msgid "settings.transcribe.local_model.desc" +msgstr "查看執行程式狀態、下載及管理模型檔案。" + +msgid "settings.transcribe.local_model.no_model" +msgstr "尚未下載模型,請開啟「管理模型」選擇下載。" + +msgid "settings.transcribe.local_model.not_installed" +msgstr "執行程式未安裝,請先在「管理模型」完成安裝。" + +msgid "settings.transcribe.manage_models" +msgstr "管理模型" + +msgid "settings.transcribe.missing.fun_asr_key" +msgstr "請先填寫百煉 API Key。" + +msgid "settings.transcribe.missing.local_model" +msgstr "尚未有可用的本機模型,請先在「管理模型」下載。" + +msgid "settings.transcribe.missing.whisper_api" +msgstr "請先填寫 Whisper Base URL、API Key 及模型。" + +msgid "settings.transcribe.model" +msgstr "轉錄模型" + +msgid "settings.transcribe.model.desc" +msgstr "選擇生成原始字幕時使用的語音識別服務。" + +msgid "settings.transcribe.output_format" +msgstr "輸出格式" + +msgid "settings.transcribe.output_format.desc" +msgstr "轉錄完成後儲存的字幕檔案格式。" + +msgid "settings.transcribe.prompt" +msgstr "提示詞" + +msgid "settings.transcribe.prompt.desc" +msgstr "可選的轉錄提示詞,預設留空。" + +msgid "settings.transcribe.source_language" +msgstr "來源語言" + +msgid "settings.transcribe.source_language.desc" +msgstr "音訊/影片中說話的語言,不確定時保持自動偵測。" + +msgid "settings.transcribe.test.desc" +msgstr "使用內置短音訊實際轉錄一次,驗證目前服務可正常運行。" + +msgid "settings.transcribe.test_error" +msgstr "轉錄測試錯誤" + +msgid "settings.transcribe.test_failed" +msgstr "轉錄測試失敗" + +msgid "settings.transcribe.test_result" +msgstr "識別結果:{text}" + +msgid "settings.transcribe.test_success" +msgstr "轉錄測試成功" + +msgid "settings.transcribe.whisper_api.base" +msgstr "Whisper API Base URL" + +msgid "settings.transcribe.whisper_api.base.desc" +msgstr "使用 Whisper API 時請求的服務地址。" + +msgid "settings.transcribe.whisper_api.key" +msgstr "Whisper API Key" + +msgid "settings.transcribe.whisper_api.key.desc" +msgstr "使用 Whisper API 轉錄時需要填寫。" + +msgid "settings.transcribe.whisper_api.model" +msgstr "Whisper 模型" + +msgid "settings.transcribe.whisper_api.model.desc" +msgstr "填寫服務商支援的音頻轉錄模型名稱。" + +msgid "settings.transcribe.whisper_cpp.model" +msgstr "WhisperCpp 模型" + +msgid "settings.transcribe.whisper_cpp.model.desc" +msgstr "選擇已下載的 whisper.cpp 轉錄模型。" + +msgid "settings.translate.cjk_length" +msgstr "中文字幕長度" + +msgid "settings.translate.cjk_length.desc" +msgstr "斷句時每條字幕的中文最多字數。" + +msgid "settings.translate.custom_prompt" +msgstr "自訂提示詞" + +msgid "settings.translate.custom_prompt.desc" +msgstr "補充給字幕校正和翻譯的大模型提示。" + +msgid "settings.translate.english_length" +msgstr "英文字幕長度" + +msgid "settings.translate.english_length.desc" +msgstr "斷句時每條字幕的英文最多詞數。" + +msgid "settings.translate.optimize" +msgstr "字幕校正" + +msgid "settings.translate.optimize.desc" +msgstr "處理字幕時修正識別錯誤和專有名詞。" + +msgid "settings.translate.split" +msgstr "字幕斷句" + +msgid "settings.translate.split.desc" +msgstr "按字數和語義重新切分長字幕。" + +msgid "settings.translate.target_language" +msgstr "目標語言" + +msgid "settings.translate.target_language.desc" +msgstr "翻譯字幕輸出的目標語言。" + +msgid "settings.translate.translate" +msgstr "字幕翻譯" + +msgid "settings.translate.translate.desc" +msgstr "處理字幕時產生目標語言譯文。" + +msgid "settings.translate_service.batch_size" +msgstr "批次處理大小" + +msgid "settings.translate_service.batch_size.desc" +msgstr "LLM 翻譯每批處理的字幕數量。" + +msgid "settings.translate_service.deeplx_endpoint" +msgstr "DeepLx 後端" + +msgid "settings.translate_service.deeplx_endpoint.desc" +msgstr "選擇 DeepLx 翻譯時需要填寫。" + +msgid "settings.translate_service.reflect" +msgstr "反思翻譯" + +msgid "settings.translate_service.reflect.desc" +msgstr "僅 LLM 翻譯時使用,會增加模型調用量。" + +msgid "settings.translate_service.service" +msgstr "翻譯服務" + +msgid "settings.translate_service.service.desc" +msgstr "選擇字幕翻譯使用的服務。" + +msgid "settings.translate_service.thread_num" +msgstr "並發數" + +msgid "settings.translate_service.thread_num.desc" +msgstr "模型服務允許的情況下可以調高。" + +msgid "settings.warn.incomplete" +msgstr "配置不完整" + +msgid "substyle.align.center" +msgstr "置中" + +msgid "substyle.align.left" +msgstr "靠左" + +msgid "substyle.align.right" +msgstr "靠右" + +msgid "substyle.content.bilingual" +msgstr "雙語" + +msgid "substyle.content.source" +msgstr "原文" + +msgid "substyle.content.target" +msgstr "譯文" + +msgid "substyle.copy_suffix" +msgstr "副本" + +msgid "substyle.custom_suffix" +msgstr "自訂" + +msgid "substyle.dialog.delete_body" +msgstr "確定要刪除樣式「{name}」嗎?此操作無法復原。" + +msgid "substyle.dialog.delete_title" +msgstr "刪除樣式" + +msgid "substyle.dialog.name_placeholder" +msgstr "輸入樣式名稱" + +msgid "substyle.dialog.new_style" +msgstr "新增樣式" + +msgid "substyle.dialog.pick_bg" +msgstr "選擇背景圖片" + +msgid "substyle.dialog.rename_style" +msgstr "重新命名樣式" + +msgid "substyle.dock.count" +msgstr "共 {n} 套" + +msgid "substyle.dock.folder" +msgstr "目錄" + +msgid "substyle.dock.new" +msgstr "新增" + +msgid "substyle.field.align" +msgstr "對齊方式" + +msgid "substyle.field.bg_color" +msgstr "背景顏色" + +msgid "substyle.field.bold" +msgstr "粗體" + +msgid "substyle.field.font" +msgstr "字型" + +msgid "substyle.field.letter_spacing" +msgstr "字距" + +msgid "substyle.field.margin_bottom" +msgstr "底部邊距" + +msgid "substyle.field.max_width" +msgstr "最大闊度" + +msgid "substyle.field.outline_color" +msgstr "描邊顏色" + +msgid "substyle.field.outline_width" +msgstr "描邊闊度" + +msgid "substyle.field.pad_h" +msgstr "水平內邊距" + +msgid "substyle.field.pad_v" +msgstr "垂直內邊距" + +msgid "substyle.field.radius" +msgstr "圓角半徑" + +msgid "substyle.field.size" +msgstr "字號" + +msgid "substyle.field.text_color" +msgstr "文字顏色" + +msgid "substyle.filter.image" +msgstr "圖片檔案" + +msgid "substyle.group.background" +msgstr "背景" + +msgid "substyle.group.layout" +msgstr "字幕排列" + +msgid "substyle.group.padding" +msgstr "內邊距" + +msgid "substyle.group.position" +msgstr "位置" + +msgid "substyle.group.primary" +msgstr "主字幕" + +msgid "substyle.group.secondary" +msgstr "副字幕" + +msgid "substyle.group.text" +msgstr "文字" + +msgid "substyle.group.text.sub" +msgstr "主副字幕" + +msgid "substyle.inspector.autosave" +msgstr "自動儲存" + +msgid "substyle.inspector.title" +msgstr "參數" + +msgid "substyle.mode.ass" +msgstr "ASS 描邊" + +msgid "substyle.mode.rounded" +msgstr "圓角背景" + +msgid "substyle.order.source_top" +msgstr "原文在上" + +msgid "substyle.order.target_top" +msgstr "譯文在上" + +msgid "substyle.preview.background" +msgstr "更換背景" + +msgid "substyle.preview.landscape" +msgstr "橫屏預覽" + +msgid "substyle.preview.portrait" +msgstr "直屏預覽" + +msgid "substyle.preview.source_placeholder" +msgstr "用於預覽的原文範例" + +msgid "substyle.preview.target_placeholder" +msgstr "用於預覽的譯文範例" + +msgid "substyle.preview.text" +msgstr "預覽文字" + +msgid "substyle.preview.title" +msgstr "預覽" + +msgid "substyle.row.content" +msgstr "顯示內容" + +msgid "substyle.row.gap" +msgstr "主副間距" + +msgid "substyle.row.order" +msgstr "雙語順序" + +msgid "substyle.title" +msgstr "字幕樣式設定" + +msgid "substyle.toast.bg_set" +msgstr "已設定預覽背景:" + +msgid "substyle.toast.created" +msgstr "已建立樣式「{name}」" + +msgid "substyle.toast.deleted" +msgstr "樣式已刪除" + +msgid "substyle.toast.duplicated" +msgstr "已複製為「{name}」" + +msgid "substyle.toast.renamed" +msgstr "已重新命名為「{name}」" + +msgid "subtitle.bottom.can_synthesize" +msgstr "可進入合成" + +msgid "subtitle.bottom.count" +msgstr "共 {count} 條" + +msgid "subtitle.bottom.hint" +msgstr "按右鍵可合併、刪除、重新翻譯" + +msgid "subtitle.bottom.loaded" +msgstr "已載入" + +msgid "subtitle.bottom.output" +msgstr "輸出:{name}" + +msgid "subtitle.bottom.progress" +msgstr "第 {current} / {total} 條" + +msgid "subtitle.btn.clear" +msgstr "清空" + +msgid "subtitle.btn.enter_synthesis" +msgstr "進入合成" + +msgid "subtitle.btn.folder" +msgstr "目錄" + +msgid "subtitle.btn.open_config" +msgstr "開啟處理配置" + +msgid "subtitle.btn.processing" +msgstr "處理中" + +msgid "subtitle.btn.replace" +msgstr "更換" + +msgid "subtitle.btn.start" +msgstr "開始處理" + +msgid "subtitle.btn.wait_subtitle" +msgstr "等待字幕" + +msgid "subtitle.clear.body" +msgstr "將清除已載入的字幕及未儲存的編輯,並返回初始狀態。確定繼續?" + +msgid "subtitle.clear.title" +msgstr "清空目前字幕" + +msgid "subtitle.col.end" +msgstr "結束" + +msgid "subtitle.col.original" +msgstr "原文" + +msgid "subtitle.col.start" +msgstr "開始" + +msgid "subtitle.col.translated" +msgstr "譯文" + +msgid "subtitle.dialog.choose_file" +msgstr "選擇字幕檔案" + +msgid "subtitle.dialog.save_file" +msgstr "儲存字幕檔案" + +msgid "subtitle.drop.pick" +msgstr "點擊選擇字幕" + +msgid "subtitle.drop.title" +msgstr "拖入一個字幕檔案" + +msgid "subtitle.error.need_llm" +msgstr "需要先配置可用的大模型 API Key、接口地址和模型。" + +msgid "subtitle.error.no_config" +msgstr "無法建立處理配置" + +msgid "subtitle.fail.config_missing" +msgstr "配置缺失" + +msgid "subtitle.fail.process" +msgstr "處理失敗" + +msgid "subtitle.file_filter" +msgstr "字幕檔案" + +msgid "subtitle.menu.merge" +msgstr "合併" + +msgid "subtitle.menu.retranslate" +msgstr "重新翻譯" + +msgid "subtitle.no_file" +msgstr "未選擇字幕檔案" + +msgid "subtitle.opt.language" +msgstr "翻譯語言" + +msgid "subtitle.opt.layout" +msgstr "譯文排列" + +msgid "subtitle.opt.optimize" +msgstr "字幕校正" + +msgid "subtitle.opt.prompt" +msgstr "文稿提示" + +msgid "subtitle.opt.split" +msgstr "斷句" + +msgid "subtitle.opt.translate" +msgstr "字幕翻譯" + +msgid "subtitle.process_settings" +msgstr "處理設定" + +msgid "subtitle.prompt.placeholder" +msgstr "" +"請輸入文稿提示(輔助校正字幕和翻譯)\n" +"\n" +"支援以下內容:\n" +"1. 術語表 - 專業術語、人名、特定詞語的修正對照表\n" +"示例:\n" +"機器學習->Machine Learning\n" +"馬斯克->Elon Musk\n" +"\n" +"2. 原字幕文稿 - 影片的原有文稿或相關內容\n" +"3. 修正要求 - 統一人稱代詞、規範專業術語等\n" +"\n" +"注意: 使用小型 LLM 模型時,建議將文稿控制在 1 千字內。" + +msgid "subtitle.prompt.set" +msgstr "已設定" + +msgid "subtitle.prompt.unset" +msgstr "未設定" + +msgid "subtitle.status.preparing" +msgstr "準備處理" + +msgid "subtitle.status.retranslating" +msgstr "重新翻譯所選行" + +msgid "subtitle.tip.collapse_settings" +msgstr "收起設定欄" + +msgid "subtitle.tip.expand_settings" +msgstr "展開處理設定" + +msgid "subtitle.toast.format_error" +msgstr "格式錯誤 " + +msgid "subtitle.toast.load_failed" +msgstr "載入失敗" + +msgid "subtitle.toast.load_first" +msgstr "請先載入字幕檔案" + +msgid "subtitle.toast.no_video" +msgstr "沒有關聯影片,請到「字幕影片合成」頁選擇影片檔案" + +msgid "subtitle.toast.processing_wait" +msgstr "正在處理中,請等待目前工作完成" + +msgid "subtitle.toast.save_done" +msgstr "儲存成功" + +msgid "subtitle.toast.save_failed" +msgstr "儲存失敗" + +msgid "subtitle.toast.saved_to" +msgstr "字幕已儲存至: " + +msgid "subtitle.toast.supported_formats" +msgstr "支援的字幕格式: " + +msgid "subtitle.toast.translate_done" +msgstr "翻譯完成" + +msgid "subtitle.toast.translate_done_body" +msgstr "已更新所選行的翻譯" + +msgid "subtitle.toast.translate_failed" +msgstr "翻譯失敗" + +msgid "synth.audio_mode.duck" +msgstr "降低原聲" + +msgid "synth.audio_mode.mix" +msgstr "混合原聲" + +msgid "synth.audio_mode.replace" +msgstr "取代原聲" + +msgid "synth.blocker.ffmpeg_missing" +msgstr "找不到 FFmpeg" + +msgid "synth.blocker.ffmpeg_missing_detail" +msgstr "請先安裝 FFmpeg,並確保 ffmpeg 在 PATH 中。" + +msgid "synth.blocker.ffprobe_missing" +msgstr "缺少 ffprobe,無法配音" + +msgid "synth.blocker.ffprobe_missing_detail" +msgstr "配音需要 ffprobe 處理音訊,請安裝完整的 FFmpeg 套件(含 ffprobe)後重試。" + +msgid "synth.blocker.key_required" +msgstr "目前音色需要 API Key" + +msgid "synth.blocker.key_required_detail" +msgstr "目前音色缺少 API Key,請檢查配音設定,或切換至 Edge 免費音色。" + +msgid "synth.btn.generate_audio" +msgstr "生成配音音訊" + +msgid "synth.btn.generate_full" +msgstr "生成成片" + +msgid "synth.btn.generate_subtitle_video" +msgstr "生成字幕影片" + +msgid "synth.btn.optional_video" +msgstr "可選影片" + +msgid "synth.btn.pick_output" +msgstr "選擇輸出內容" + +msgid "synth.btn.pick_subtitle" +msgstr "選擇字幕" + +msgid "synth.btn.pick_video" +msgstr "選擇影片" + +msgid "synth.btn.regenerate" +msgstr "重新生成" + +msgid "synth.btn.wait_files" +msgstr "等待檔案" + +msgid "synth.btn.wait_video" +msgstr "等待影片" + +msgid "synth.dialog.pick_media" +msgstr "選擇字幕及影片檔案" + +msgid "synth.dialog.pick_subtitle" +msgstr "選擇字幕檔案" + +msgid "synth.dialog.pick_video" +msgstr "選擇影片檔案" + +msgid "synth.drop.formats" +msgstr "字幕:srt / ass / vtt 影片:mp4 / mov / mkv" + +msgid "synth.drop.pick" +msgstr "點擊選擇檔案" + +msgid "synth.drop.title" +msgstr "拖入字幕及影片檔案" + +msgid "synth.error.ass_unsupported" +msgstr "FFmpeg 不支援 ASS 硬字幕,請安裝帶 libass 的完整 FFmpeg,或切換為圓角背景渲染。" + +msgid "synth.error.dubbed_video_path_empty" +msgstr "配音影片輸出路徑為空" + +msgid "synth.file.media" +msgstr "媒體檔案" + +msgid "synth.file.reference_video" +msgstr "參考影片" + +msgid "synth.file.subtitle" +msgstr "字幕檔案" + +msgid "synth.file.video" +msgstr "影片檔案" + +msgid "synth.hint.dub_only" +msgstr "只生成配音音訊,可不選影片檔案" + +msgid "synth.hint.dub_then_synthesize" +msgstr "會先生成配音,再合成字幕影片" + +msgid "synth.hint.need_subtitle" +msgstr "需要字幕檔案" + +msgid "synth.hint.need_subtitle_and_video" +msgstr "需要字幕檔案和影片檔案" + +msgid "synth.hint.need_video" +msgstr "仍需要影片檔案" + +msgid "synth.hint.pick_output" +msgstr "請在右側至少開啟一種輸出內容" + +msgid "synth.hint.synthesize_only" +msgstr "會將字幕合成到影片中" + +msgid "synth.link.style_page" +msgstr "樣式頁" + +msgid "synth.link.voice_library" +msgstr "音色庫" + +msgid "synth.opt.audio_mode" +msgstr "音訊處理" + +msgid "synth.opt.render_mode" +msgstr "渲染模式" + +msgid "synth.opt.style" +msgstr "樣式" + +msgid "synth.opt.subtitle_mode" +msgstr "字幕方式" + +msgid "synth.opt.subtitle_style" +msgstr "字幕樣式" + +msgid "synth.opt.text_track" +msgstr "文字軌道" + +msgid "synth.opt.timing" +msgstr "時間貼合" + +msgid "synth.opt.video_quality" +msgstr "影片質素" + +msgid "synth.opt.voice" +msgstr "音色" + +msgid "synth.output.dubbing" +msgstr "配音音軌" + +msgid "synth.output.dubbing_desc" +msgstr "按字幕生成配音" + +msgid "synth.output.subtitle_video" +msgstr "字幕影片" + +msgid "synth.output.subtitle_video_desc" +msgstr "將字幕合成到影片中" + +msgid "synth.panel.title" +msgstr "今次生成" + +msgid "synth.pill.completed" +msgstr "已完成" + +msgid "synth.pill.failed" +msgstr "失敗" + +msgid "synth.pill.missing_ffmpeg" +msgstr "缺少 FFmpeg" + +msgid "synth.pill.missing_ffprobe" +msgstr "缺 ffprobe" + +msgid "synth.pill.missing_key" +msgstr "缺少 Key" + +msgid "synth.pill.missing_video" +msgstr "缺少影片" + +msgid "synth.pill.no_output" +msgstr "未選擇輸出" + +msgid "synth.pill.ready" +msgstr "可以生成" + +msgid "synth.plan.collect" +msgstr "整理結果檔案" + +msgid "synth.plan.dubbing" +msgstr "生成配音音軌" + +msgid "synth.plan.pending" +msgstr "待生成" + +msgid "synth.plan.save" +msgstr "儲存結果檔案" + +msgid "synth.plan.synthesize" +msgstr "合成字幕影片" + +msgid "synth.result.dubbed_audio" +msgstr "配音音訊" + +msgid "synth.result.dubbed_video" +msgstr "配音影片" + +msgid "synth.result.subtitled_video" +msgstr "字幕影片" + +msgid "synth.row.subtitle_required" +msgstr "必填:選擇 SRT / ASS / VTT 字幕" + +msgid "synth.row.video_optional" +msgstr "可選:選擇後額外生成配音影片" + +msgid "synth.row.video_required" +msgstr "必填:選擇 MP4 / MOV / MKV 影片" + +msgid "synth.section.dubbing_params" +msgstr "配音參數" + +msgid "synth.section.output" +msgstr "輸出內容" + +msgid "synth.section.subtitle_params" +msgstr "字幕影片參數" + +msgid "synth.status.completed" +msgstr "生成完成" + +msgid "synth.status.done" +msgstr "完成" + +msgid "synth.status.failed" +msgstr "生成失敗" + +msgid "synth.status.generating" +msgstr "正在生成結果檔案" + +msgid "synth.status.missing" +msgstr "缺少" + +msgid "synth.status.optional" +msgstr "可選" + +msgid "synth.status.processing" +msgstr "生成中" + +msgid "synth.status.ready" +msgstr "已就緒" + +msgid "synth.status.waiting" +msgstr "等待" + +msgid "synth.subtitle_mode.hard" +msgstr "硬字幕" + +msgid "synth.subtitle_mode.soft" +msgstr "軟字幕" + +msgid "synth.text_track.auto" +msgstr "自動選擇" + +msgid "synth.text_track.first" +msgstr "第一行" + +msgid "synth.text_track.second" +msgstr "第二行" + +msgid "synth.timing.balanced" +msgstr "平衡" + +msgid "synth.timing.natural" +msgstr "自然" + +msgid "synth.timing.strict" +msgstr "嚴格貼合" + +msgid "synth.tip.collapse_panel" +msgstr "收起本欄" + +msgid "synth.tip.expand_panel" +msgstr "展開生成欄" + +msgid "synth.tip.open_config" +msgstr "開啟合成設定" + +msgid "synth.title.config_check" +msgstr "設定檢查" + +msgid "synth.title.confirm" +msgstr "生成前確認" + +msgid "synth.title.input" +msgstr "輸入檔案" + +msgid "synth.title.results" +msgstr "結果檔案" + +msgid "synth.title.running" +msgstr "生成中" + +msgid "t_dubbing.error.config_empty" +msgstr "配音設定為空" + +msgid "t_dubbing.error.output_audio_path_empty" +msgstr "輸出音訊路徑為空" + +msgid "t_dubbing.error.subtitle_path_empty" +msgstr "字幕路徑為空" + +msgid "t_dubbing.error.task_dir_empty" +msgstr "任務目錄為空" + +msgid "t_dubbing.status.done" +msgstr "配音完成" + +msgid "t_dubbing.status.preparing" +msgstr "準備配音" + +msgid "t_hardsub.error.read_video_failed" +msgstr "無法讀取此影片" + +msgid "t_hardsub.status.detecting_region" +msgstr "識別字幕區域" + +msgid "t_hardsub.status.recognizing" +msgstr "識別中 {count} 條" + +msgid "t_media.error.no_video_file" +msgstr "下載完成但找不到影片檔案,請換另一個連結重試" + +msgid "t_media.status.completed" +msgstr "下載完成" + +msgid "t_subtitle.error.llm_model_not_configured" +msgstr "LLM 模型未設定" + +msgid "t_subtitle.error.llm_not_configured" +msgstr "LLM API 未設定,請檢查 LLM 設定" + +msgid "t_subtitle.error.llm_test_failed" +msgstr "LLM API 測試失敗:{message}" + +msgid "t_subtitle.error.no_config" +msgstr "字幕設定為空" + +msgid "t_subtitle.error.no_subtitle_path" +msgstr "字幕檔案路徑為空" + +msgid "t_subtitle.error.no_target_language" +msgstr "目標語言未設定" + +msgid "t_subtitle.error.unsupported_service" +msgstr "不支援的翻譯服務:{service}" + +msgid "t_subtitle.status.done" +msgstr "處理完成" + +msgid "t_subtitle.status.optimizing" +msgstr "正在優化字幕..." + +msgid "t_subtitle.status.processing_percent" +msgstr "{percent}% 處理字幕" + +msgid "t_subtitle.status.splitting" +msgstr "正在為字幕斷句..." + +msgid "t_subtitle.status.translating" +msgstr "正在翻譯字幕..." + +msgid "t_subtitle.status.translating_percent" +msgstr "{percent}% 翻譯中" + +msgid "t_subtitle.status.verifying_llm" +msgstr "開始驗證 LLM 設定..." + +msgid "t_synth.error.no_output_path" +msgstr "輸出路徑為空" + +msgid "t_synth.error.no_subtitle_path" +msgstr "字幕路徑為空" + +msgid "t_synth.error.no_video_path" +msgstr "影片路徑為空" + +msgid "t_synth.status.done" +msgstr "合成完成" + +msgid "t_synth.status.synthesizing" +msgstr "正在合成" + +msgid "t_transcript.error.audio_extract_failed" +msgstr "音訊轉換失敗" + +msgid "t_transcript.error.file_not_found" +msgstr "媒體檔案不存在" + +msgid "t_transcript.error.no_config" +msgstr "轉錄設定為空" + +msgid "t_transcript.error.no_file_path" +msgstr "檔案路徑為空" + +msgid "t_transcript.error.no_output_path" +msgstr "輸出路徑為空" + +msgid "t_transcript.status.done" +msgstr "轉錄完成" + +msgid "t_transcript.status.extracting_audio" +msgstr "正在轉換音訊" + +msgid "t_transcript.status.transcribing" +msgstr "正在語音轉錄" + +msgid "t_voice.error.need_api_key" +msgstr "{provider} 試聽需要先在設定中填寫配音 API Key" + +msgid "t_voice.sample_text" +msgstr "你好,這是卡卡字幕助手的配音試聽。" + +msgid "transcribe.action.cancel" +msgstr "取消轉錄" + +msgid "transcribe.action.replace_file" +msgstr "更換檔案" + +msgid "transcribe.btn.open_subtitle" +msgstr "進入字幕優化" + +msgid "transcribe.btn.retranscribe" +msgstr "重新轉錄" + +msgid "transcribe.btn.running" +msgstr "轉錄中" + +msgid "transcribe.btn.start" +msgstr "開始轉錄" + +msgid "transcribe.btn.waiting_file" +msgstr "等待檔案" + +msgid "transcribe.col.content" +msgstr "字幕內容" + +msgid "transcribe.col.end" +msgstr "結束時間" + +msgid "transcribe.col.start" +msgstr "開始時間" + +msgid "transcribe.config.open_tip" +msgstr "開啟轉錄設定" + +msgid "transcribe.dialog.media_filter" +msgstr "媒體檔案" + +msgid "transcribe.dialog.pick_media" +msgstr "選擇媒體檔案" + +msgid "transcribe.drop.pick" +msgstr "按此選擇檔案" + +msgid "transcribe.drop.title" +msgstr "拖入一個音訊或影片檔案" + +msgid "transcribe.empty.title" +msgstr "未選擇媒體檔案" + +msgid "transcribe.error.title" +msgstr "失敗原因" + +msgid "transcribe.file.title" +msgstr "目前檔案" + +msgid "transcribe.format.txt" +msgstr "TXT" + +msgid "transcribe.opt.language" +msgstr "語言" + +msgid "transcribe.opt.model" +msgstr "模型" + +msgid "transcribe.opt.output" +msgstr "輸出" + +msgid "transcribe.opt.service" +msgstr "服務" + +msgid "transcribe.opt.track" +msgstr "音軌" + +msgid "transcribe.opt.word_timestamp" +msgstr "詞時間戳" + +msgid "transcribe.params.collapse_tip" +msgstr "收起參數列" + +msgid "transcribe.params.expand_tip" +msgstr "展開參數欄" + +msgid "transcribe.params.title" +msgstr "參數" + +msgid "transcribe.pending.not_started" +msgstr "尚未開始轉錄" + +msgid "transcribe.preview.count" +msgstr "{n} 條" + +msgid "transcribe.preview.title" +msgstr "原始字幕預覽" + +msgid "transcribe.progress.phase" +msgstr "語音識別" + +msgid "transcribe.result.collapse_tip" +msgstr "收起操作欄" + +msgid "transcribe.result.file" +msgstr "結果檔案" + +msgid "transcribe.result.open_file_tip" +msgstr "點擊開啟結果檔案" + +msgid "transcribe.result.title" +msgstr "結果操作" + +msgid "transcribe.stage.done" +msgstr "完成" + +msgid "transcribe.stage.read_audio" +msgstr "讀取音訊" + +msgid "transcribe.stage.recognize" +msgstr "識別語音" + +msgid "transcribe.stage.running" +msgstr "進行中" + +msgid "transcribe.stage.waiting" +msgstr "等待" + +msgid "transcribe.stage.write_subtitle" +msgstr "生成字幕檔案" + +msgid "transcribe.status.done" +msgstr "完成" + +msgid "transcribe.status.failed" +msgstr "轉錄失敗" + +msgid "transcribe.status.pending" +msgstr "待開始" + +msgid "transcribe.status.running" +msgstr "轉錄中" + +msgid "transcribe.status.unreachable" +msgstr "未連通" + +msgid "transcribe.text_preview.done" +msgstr "轉錄完成" + +msgid "transcribe.text_preview.title" +msgstr "純文字預覽" + +msgid "transcribe.toast.bad_format" +msgstr "格式錯誤 {suffix}" + +msgid "transcribe.toast.drop_media" +msgstr "請拖入音訊或影片檔案" + +msgid "transcribe.toast.no_subtitle" +msgstr "沒有可用於字幕優化的字幕檔案" + +msgid "transcribe.toast.processing" +msgstr "正在處理,請等待目前任務完成" + +msgid "transcribe.track.first" +msgstr "音軌 1" + +msgid "workbench.drop.or" +msgstr "或" + diff --git a/resource/subtitle_style/ass-anime.json b/resource/subtitle_styles/ass/anime.json similarity index 79% rename from resource/subtitle_style/ass-anime.json rename to resource/subtitle_styles/ass/anime.json index 24f36e9d..ecfb104b 100644 --- a/resource/subtitle_style/ass-anime.json +++ b/resource/subtitle_styles/ass/anime.json @@ -1,6 +1,6 @@ { - "name": "anime", - "description": "", + "name": "暖色动漫", + "description": "偏亮的暖色描边,适合二创和短视频", "mode": "ass", "font_name": "Noto Sans SC", "font_size": 46, @@ -18,4 +18,4 @@ "outline_width": 2.0, "spacing": 0.0 } -} \ No newline at end of file +} diff --git a/resource/subtitle_style/ass-default.json b/resource/subtitle_styles/ass/default.json similarity index 65% rename from resource/subtitle_style/ass-default.json rename to resource/subtitle_styles/ass/default.json index cc6ee7fd..b3885d85 100644 --- a/resource/subtitle_style/ass-default.json +++ b/resource/subtitle_styles/ass/default.json @@ -1,15 +1,15 @@ { - "name": "default", - "description": "", + "name": "默认描边", + "description": "通用白字黑边,适合多数横屏视频", "mode": "ass", - "font_name": "Noto Sans SC", + "font_name": "LXGW WenKai", "font_size": 40, "primary_color": "#ffffff", "outline_color": "#000000", "outline_width": 2.0, "bold": true, - "spacing": 3.2, - "margin_bottom": 30, + "spacing": 0.2, + "margin_bottom": 28, "secondary": { "font_name": "Noto Sans SC", "font_size": 30, @@ -18,4 +18,4 @@ "outline_width": 2.0, "spacing": 0.8 } -} \ No newline at end of file +} diff --git a/resource/subtitle_style/ass-vertical.json b/resource/subtitle_styles/ass/vertical.json similarity index 79% rename from resource/subtitle_style/ass-vertical.json rename to resource/subtitle_styles/ass/vertical.json index 3358c34e..5495f1a6 100644 --- a/resource/subtitle_style/ass-vertical.json +++ b/resource/subtitle_styles/ass/vertical.json @@ -1,6 +1,6 @@ { - "name": "vertical", - "description": "Vertical/portrait video layout with high bottom margin", + "name": "竖屏留白", + "description": "更高底部边距,适合 9:16 视频", "mode": "ass", "font_name": "Noto Sans SC", "font_size": 34, @@ -18,4 +18,4 @@ "outline_width": 2.0, "spacing": 0.8 } -} \ No newline at end of file +} diff --git a/resource/subtitle_style/rounded-default.json b/resource/subtitle_styles/rounded/default.json similarity index 55% rename from resource/subtitle_style/rounded-default.json rename to resource/subtitle_styles/rounded/default.json index e70ad7f9..801080ce 100644 --- a/resource/subtitle_style/rounded-default.json +++ b/resource/subtitle_styles/rounded/default.json @@ -1,15 +1,15 @@ { - "name": "default", - "description": "", + "name": "圆角胶囊", + "description": "半透明圆角背景,适合信息密度较高的视频", "mode": "rounded", - "font_name": "Noto Sans SC", + "font_name": "LXGW WenKai", "font_size": 28, "text_color": "#000000", "bg_color": "#0de3ffe5", - "corner_radius": 14, + "corner_radius": 12, "padding_h": 18, "padding_v": 16, "margin_bottom": 40, "line_spacing": 10, "letter_spacing": 2 -} \ No newline at end of file +} diff --git a/resource/translations/VideoCaptioner_en_US.qm b/resource/translations/VideoCaptioner_en_US.qm deleted file mode 100644 index 60d0c828..00000000 Binary files a/resource/translations/VideoCaptioner_en_US.qm and /dev/null differ diff --git a/resource/translations/VideoCaptioner_en_US.ts b/resource/translations/VideoCaptioner_en_US.ts deleted file mode 100644 index 9859b828..00000000 --- a/resource/translations/VideoCaptioner_en_US.ts +++ /dev/null @@ -1,2541 +0,0 @@ - - - - BatchProcessInterface - - - 批量处理 - Batch Process - - - - 添加文件 - Add Files - - - - 开始处理 - Start Processing - - - - 清空列表 - Clear List - - - - ColorPickerButton - - - Choose - Choose - - - - DonateDialog - - - 支持作者 - Support Author - - - - 感谢支持 - Thank You for Your Support - - - - 目前本人精力有限,您的支持让我有动力继续折腾这个项目! -感谢您对开源事业的热爱与支持! - I have limited energy, and your support motivates me to continue working on this project! -Thank you for your passion and support for open source! - - - - 支付宝 - Alipay - - - - 微信 - WeChat - - - - 关闭 - Close - - - - DownloadDialog - - - 下载模型 - Download Model - - - - 下载 - Download - - - - 关闭 - Close - - - - 提示 - Notice - - - - 模型文件已存在,无需重复下载 - Model file already exists, no need to download again - - - - 完成 - Complete - - - - 模型下载完成! - Model download completed! - - - - 下载完成 - Download completed - - - - 下载错误 - Download Error - - - - FasterWhisperDownloadDialog - - - 关闭 - Close - - - - Faster Whisper 下载 - Faster Whisper Download - - - - 打开程序文件夹 - Open Program Folder - - - - 已安装版本: {versions_text} - Installed Version: {versions_text} - - - - 您可以继续下载其他版本: - You can continue downloading other versions: - - - - 未下载Faster Whisper 程序 - Faster Whisper program not downloaded - - - - 下载程序 - Download Program - - - - 模型下载 - Model Download - - - - 打开模型文件夹 - Open Model Folder - - - - 模型名称 - Model Name - - - - 大小 - Size - - - - 状态 - Status - - - - 操作 - Action - - - - 已下载 - Downloaded - - - - 未下载 - Not Downloaded - - - - 重新下载 - Re-download - - - - 下载 - Download - - - - 下载进行中 - Downloading - - - - 请等待当前下载任务完成 - Please wait for the current download task to complete - - - - 下载错误 - Download Error - - - - 未找到对应的程序配置 - Program configuration not found - - - - 正在解压文件... - Extracting files... - - - - 安装失败 - Installation failed - - - - 下载失败 - Download failed - - - - 正在下载 {model['label']} 模型... - Downloading {model['label']} model... - - - - 下载成功 - Download successful - - - - {model['label']} 模型已下载完成 - {model['label']} model has been downloaded successfully - - - - 安装完成 - Installation completed - - - - Faster Whisper 程序已安装成功 - Faster Whisper program installed successfully - - - - FasterWhisperSettingWidget - - - Faster Whisper程序不存在,请先下载程序 - Faster Whisper program does not exist, please download it first - - - - Faster Whisper 设置(✨推荐✨)) - Faster Whisper Settings (✨ Recommended ✨) - - - - 模型 - Model - - - - 选择 Faster Whisper 模型 - Select Faster Whisper model - - - - 管理模型 - Manage Models - - - - 模型管理 - Model Management - - - - 下载或更新 Faster Whisper 模型 - Download or update Faster Whisper models - - - - 源语言 - Source Language - - - - 音频的源语言 - Source language of the audio - - - - 运行设备 - Device - - - - 模型运行设备 - Device to run the model - - - - VAD设置 - VAD Settings - - - - VAD过滤 - VAD Filter - - - - 过滤无人声语音片断,减少幻觉 - Filter non-speech segments to reduce hallucinations - - - - VAD阈值 - VAD Threshold - - - - 语音概率阈值,高于此值视为语音 - Speech probability threshold, values above this are considered speech - - - - VAD方法 - VAD Method - - - - 选择VAD检测方法 - Select VAD detection method - - - - 其他设置 - Other Settings - - - - 人声分离 - Voice Separation - - - - 处理前使用MDX-Net降噪,分离人声和背景音乐 - Use MDX-Net for noise reduction before processing, separating vocals and background music - - - - 单字时间戳 - Word-level Timestamps - - - - 开启生成单字级时间戳;关闭后使用原始分段断句 - Enable word-level timestamps; disable to use original segment breaks - - - - 提示词 - Prompt - - - - 可选的提示词,默认空 - Optional prompt, empty by default - - - - 错误 - Error - - - - 模型配置不存在 - Model configuration does not exist - - - - 模型文件不存在: - Model file does not exist: - - - - FileDownloadThread - - - 正在连接... - Connecting... - - - - HomeInterface - - - 任务创建 - Task Creation - - - - 语音转录 - Transcription - - - - 字幕优化与翻译 - Subtitle Optimization & Translation - - - - 字幕视频合成 - Video Synthesis - - - - LanguageSettingDialog - - - 确定 - OK - - - - 取消 - Cancel - - - - 语言设置 - Language Settings - - - - 源语言 - Source Language - - - - 音频的源语言 - Source language of the audio - - - - 设置已保存 - Settings saved - - - - 语言设置已更新 - Language settings updated - - - - 请注意身体!! - Please take care of yourself!! - - - - 小心肝儿,注意身体哦~ - Take care, stay healthy~ - - - - MainWindow - - - 主页 - Home - - - - 批量处理 - Batch Process - - - - 字幕样式 - Subtitle Style - - - - Settings - Settings - - - - 卡卡字幕助手 -- VideoCaptioner - Kaka Subtitle Assistant -- VideoCaptioner - - - - GitHub信息 - GitHub Info - - - - VideoCaptioner 由本人在课余时间独立开发完成,目前托管在GitHub上,欢迎Star和Fork。项目诚然还有很多地方需要完善,遇到软件的问题或者BUG欢迎提交Issue。 - - https://github.com/WEIFENG2333/VideoCaptioner - VideoCaptioner was independently developed by me in my spare time and is currently hosted on GitHub. Stars and Forks are welcome. The project still has many areas that need improvement. If you encounter any issues or bugs, please submit an Issue. - - https://github.com/WEIFENG2333/VideoCaptioner - - - - 打开 GitHub - Open GitHub - - - - 支持作者 - Support Author - - - - 当前版本部分功能已被禁用。请尽快更新。 - Some features in the current version have been disabled. Please update as soon as possible. - - - - PromptDialog - - - 文稿提示 - Script Prompt - - - - 请输入文稿提示(辅助校正字幕和翻译) - -支持以下内容: -1. 术语表 - 专业术语、人名、特定词语的修正对照表 -示例: -机器学习->Machine Learning -马斯克->Elon Musk -打call->应援 - -2. 原字幕文稿 - 视频的原有文稿或相关内容 -示例: 完整的演讲稿、课程讲义等 - -3. 修正要求 - 内容相关的具体修正要求 -示例: 统一人称代词、规范专业术语等 - -注意: 使用小型LLM模型时建议控制文稿在1千字内。对于不同字幕文件,请使用与该字幕相关的文稿提示。 - Please enter script prompt (to assist subtitle correction and translation) - -Supports the following content: -1. Glossary - Correction reference table for technical terms, names, and specific words -Example: -Machine Learning->Machine Learning -Elon Musk->Elon Musk -打call->应援 - -2. Original script - Original script or related content of the video -Example: Complete speech script, course handouts, etc. - -3. Correction requirements - Specific correction requirements related to content -Example: Unify personal pronouns, standardize technical terms, etc. - -Note: When using small LLM models, it is recommended to keep the script within 1,000 characters. For different subtitle files, please use script prompts related to that subtitle. - - - - 确定 - OK - - - - 取消 - Cancel - - - - SettingInterface - - - 设置 - Settings - - - - 转录配置 - Transcription Settings - - - - LLM配置 - LLM Settings - - - - 翻译服务 - Translation Service - - - - 翻译与优化 - Translation & Optimization - - - - 字幕合成配置 - Video Synthesis Settings - - - - 保存配置 - Save Settings - - - - 个性化 - Personalization - - - - 关于 - About - - - - 字幕校正 - Subtitle Correction - - - - 字幕处理过程是否对生成的字幕错别字、名词等进行校正 - Whether to correct typos and nouns in generated subtitles during processing - - - - 字幕翻译 - Subtitle Translation - - - - 字幕处理过程是否对生成的字幕进行翻译 - Whether to translate generated subtitles during processing - - - - 目标语言 - Target Language - - - - 选择翻译字幕的目标语言 - Select target language for subtitle translation - - - - 修改 - Edit - - - - 字幕样式 - Subtitle Style - - - - 选择字幕的样式(颜色、大小、字体等) - Select subtitle style (color, size, font, etc.) - - - - 字幕布局 - Subtitle Layout - - - - 选择字幕的布局(单语、双语) - Select subtitle layout (monolingual, bilingual) - - - - 需要合成视频 - Require Video Synthesis - - - - 开启时触发合成视频,关闭时跳过 - Enable to trigger video synthesis, disable to skip - - - - 软字幕 - Soft Subtitles - - - - 开启时字幕可在播放器中关闭或调整,关闭时字幕烧录到视频画面上 - When enabled, subtitles can be turned off or adjusted in the player; when disabled, subtitles are burned into the video - - - - 视频合成质量 - Video Synthesis Quality - - - - 硬字幕视频合成时的质量等级(质量越高文件越大,编码时间越长) - Quality level for hard subtitle video synthesis (higher quality means larger file size and longer encoding time) - - - - 工作文件夹 - Work Folder - - - - 工作目录路径 - Work directory path - - - - 启用缓存 - Enable Cache - - - - 相同配置下会复用之前的 ASR 和 LLM 结果;关闭缓存后每次重新生成 - Reuse previous ASR and LLM results under the same configuration; regenerate each time after disabling cache - - - - 应用主题 - Application Theme - - - - 更改应用程序的外观 - Change the appearance of the application - - - - 浅色 - Light - - - - 深色 - Dark - - - - 使用系统设置 - Use System Settings - - - - 主题颜色 - Theme Color - - - - 更改应用程序的主题颜色 - Change the theme color of the application - - - - 界面缩放 - Interface Scale - - - - 更改小部件和字体的大小 - Change the size of widgets and fonts - - - - 语言 - Language - - - - 设置您偏好的界面语言 - Set your preferred interface language - - - - 打开帮助页面 - Open Help Page - - - - 帮助 - Help - - - - 发现新功能并了解有关VideoCaptioner的使用技巧 - Discover new features and learn tips for using VideoCaptioner - - - - 提供反馈 - Provide Feedback - - - - 提供反馈帮助我们改进VideoCaptioner - Provide feedback to help us improve VideoCaptioner - - - - 检查更新 - Check for Updates - - - - 版权所有 - Copyright - - - - 版本 - Version - - - - LLM服务 - LLM Service - - - - 选择大模型服务,用于字幕断句、字幕优化、字幕翻译 - Select LLM service for subtitle segmentation, optimization, and translation - - - - 访问 - Visit - - - - VideoCaptioner 官方API - VideoCaptioner Official API - - - - 集成多种大语言模型,支持高并发字幕优化、翻译 - Integrates multiple LLMs, supports high-concurrency subtitle optimization and translation - - - - API Key - API Key - - - - 输入您的 {service.value} API Key - Enter your {service.value} API Key - - - - Base URL - Base URL - - - - 输入 {service.value} Base URL - Enter {service.value} Base URL - - - - 模型 - Model - - - - 选择 {service.value} 模型 - Select {service.value} model - - - - 检查连接 - Check Connection - - - - 检查 LLM 连接 - Check LLM Connection - - - - 点击检查 API 连接是否正常,并获取模型列表 - Click to check if API connection is normal and get model list - - - - 转录模型 - Transcription Model - - - - 语音转换文字要使用的语音识别服务 - Speech recognition service to use for speech-to-text conversion - - - - Whisper API Base URL - Whisper API Base URL - - - - 输入 Whisper API Base URL - Enter Whisper API Base URL - - - - Whisper API Key - Whisper API Key - - - - 输入 Whisper API Key - Enter Whisper API Key - - - - Whisper 模型 - Whisper Model - - - - 选择 Whisper 模型 - Select Whisper model - - - - 测试 Whisper 连接 - Test Whisper Connection - - - - 测试 Whisper API 连接 - Test Whisper API Connection - - - - 点击测试 API 连接是否正常 - Click to test if API connection is normal - - - - 选择翻译服务 - Select Translation Service - - - - 需要反思翻译 - Require Reflection Translation - - - - 启用反思翻译可以提高翻译质量,但耗费更多时间和token - Enabling reflection translation can improve translation quality but consumes more time and tokens - - - - DeepLx 后端 - DeepLx Backend - - - - 输入 DeepLx 的后端地址(开启deeplx翻译时必填) - Enter DeepLx backend address (required when enabling DeepLx translation) - - - - 批处理大小 - Batch Size - - - - 每批处理字幕的数量,建议为 10 的倍数 - Number of subtitles processed per batch, recommended to be a multiple of 10 - - - - 线程数 - Thread Count - - - - 请求并行处理的数量,模型服务商允许的情况下建议尽可能大,数值越大速度越快 - Number of parallel processing requests, recommended to be as large as possible if the model provider allows, larger values mean faster speed - - - - 更新成功 - Update successful - - - - 配置将在重启后生效 - Configuration will take effect after restart - - - - 选择文件夹 - Select Folder - - - - 缓存已启用 - Cache enabled - - - - ASR、翻译等操作将优先使用缓存 - ASR, translation and other operations will prioritize using cache - - - - 缓存已禁用 - Cache disabled - - - - 所有操作将重新生成,不使用缓存(建议开启缓存) - All operations will regenerate without using cache (recommended to enable cache) - - - - 正在检查... - Checking... - - - - LLM 连接测试错误 - LLM Connection Test Error - - - - 获取模型列表成功: - Successfully retrieved model list: - - - - 一共 - Total - - - - 个模型 - models - - - - LLM 连接测试成功 - LLM connection test successful - - - - 配置不完整 - Configuration incomplete - - - - 请输入 Whisper API Base URL - Please enter Whisper API Base URL - - - - 请输入 Whisper API Key - Please enter Whisper API Key - - - - 请输入 Whisper 模型名称 - Please enter Whisper model name - - - - 正在测试... - Testing... - - - - 连接成功 - Connection successful - - - - Whisper API 连接成功! -转录结果: - Whisper API connection successful! - - - - 连接失败 - Connection failed - - - - Whisper API 连接失败! -{result} - Whisper API connection failed! - - - - 测试错误 - Test error - - - - StyleNameDialog - - - 新建样式 - Create new style - - - - 输入样式名称 - Enter style name - - - - 确定 - OK - - - - 取消 - Cancel - - - - SubtitleInterface - - - 保存 - Save - - - - 字幕排布 - Subtitle layout - - - - 字幕校正 - Subtitle Correction - - - - 字幕翻译 - Subtitle Translation - - - - 翻译语言 - Translate Language - - - - 文稿提示 - Script Prompt - - - - 开始 - Start - - - - 请拖入字幕文件 - Please drag in the subtitle file - - - - 取消 - Cancel - - - - 已加载文件 - File loaded - - - - 警告 - Warning - - - - 请先加载字幕文件 - Please load the subtitle file first - - - - 开始优化 - Start Optimization - - - - 开始优化字幕 - Start optimizing subtitles - - - - 优化完成 - Optimization complete - - - - 优化完成字幕... - Optimized subtitles complete... - - - - 优化失败 - Optimization failed - - - - 选择字幕文件 - Select subtitle file - - - - 保存字幕文件 - Save subtitle file - - - - 保存成功 - Save successful - - - - 字幕已保存至: - Subtitles saved to: - - - - 保存失败 - Save failed - - - - 保存字幕文件失败: - Failed to save subtitle file: - - - - 导入成功 - Import successful - - - - 成功导入 - Successfully imported - - - - 格式错误 - Format error - - - - 支持的字幕格式: - Supported subtitle formats: - - - - 合并 - Merge - - - - 合并成功 - Merge successful - - - - 已成功合并选中的字幕行 - Successfully merged selected subtitle lines - - - - 已取消校正 - Correction canceled - - - - 已取消 - Canceled - - - - 字幕校正已取消 - Subtitle correction has been canceled - - - - SubtitlePipelineThread - - - 开始转录 - Start transcription - - - - 开始优化字幕 - Start optimizing subtitles - - - - 开始合成视频 - Start synthesizing video - - - - 处理完成 - Processing Complete - - - - SubtitleSettingDialog - - - 字幕设置 - Subtitle Settings - - - - 字幕分割 - Subtitle Segmentation - - - - 字幕是否使用大语言模型进行智能断句 - Use LLM for intelligent segmentation of subtitles? - - - - 中文最大字数 - Maximum Chinese Characters - - - - 单条字幕的最大字数 (对于中日韩等字符) - Maximum Characters per Subtitle (for CJK characters) - - - - 英文最大单词数 - Maximum English Words - - - - 单条字幕的最大单词数 (英文) - Maximum Words per Subtitle (English) - - - - 去除末尾标点符号 - Remove Trailing Punctuation - - - - 是否去除中文字幕中的末尾标点符号 - Remove trailing punctuation in Chinese subtitles? - - - - 关闭 - Close - - - - SubtitleStyleInterface - - - 字幕样式配置 - Subtitle Style Configuration - - - - 字幕排布 - Subtitle Layout - - - - 主字幕样式 - Main Subtitle Style - - - - 副字幕样式 - Secondary Subtitle Style - - - - 预览设置 - Preview Settings - - - - 预览效果 - Preview Effect - - - - 选择样式 - Select Style - - - - 选择已保存的字幕样式 - Select Saved Subtitle Style - - - - 新建样式 - Create New Style - - - - 基于当前样式新建预设 - Create Preset Based on Current Style - - - - 打开样式文件夹 - Open Style Folder - - - - 在文件管理器中打开样式文件夹 - Open Style Folder in File Manager - - - - 设置主字幕和副字幕的显示方式 - Set Display Method for Main and Secondary Subtitles - - - - 垂直间距 - Vertical Spacing - - - - 设置字幕的垂直间距 - Set Subtitle Vertical Spacing - - - - 主字幕字体 - Main Subtitle Font - - - - 设置主字幕的字体 - Set Main Subtitle Font - - - - 主字幕字号 - Main Subtitle Size - - - - 设置主字幕的大小 - Set Main Subtitle Size - - - - 主字幕间距 - Main Subtitle Spacing - - - - 设置主字幕的字符间距 - Set main subtitle character spacing - - - - 主字幕颜色 - Main subtitle color - - - - 设置主字幕的颜色 - Set main subtitle color - - - - 主字幕边框颜色 - Main subtitle border color - - - - 设置主字幕的边框颜色 - Set main subtitle border color - - - - 主字幕边框大小 - Main subtitle border size - - - - 设置主字幕的边框粗细 - Set main subtitle border thickness - - - - 副字幕字体 - Secondary subtitle font - - - - 设置副字幕的字体 - Set secondary subtitle font - - - - 副字幕字号 - Secondary subtitle font size - - - - 设置副字幕的大小 - Set secondary subtitle size - - - - 副字幕间距 - Secondary subtitle spacing - - - - 设置副字幕的字符间距 - Set secondary subtitle character spacing - - - - 副字幕颜色 - Secondary subtitle color - - - - 设置副字幕的颜色 - Set secondary subtitle color - - - - 副字幕边框颜色 - Subtitle Border Color - - - - 设置副字幕的边框颜色 - Set Subtitle Border Color - - - - 副字幕边框大小 - Subtitle Border Size - - - - 设置副字幕的边框粗细 - Set Subtitle Border Thickness - - - - 预览文字 - Preview Text - - - - 设置预览显示的文字内容 - Set Preview Text Content - - - - 预览方向 - Preview Direction - - - - 设置预览图片的显示方向 - Set Preview Image Orientation - - - - 选择图片 - Select Image - - - - 预览背景 - Preview Background - - - - 选择预览使用的背景图片 - Select Background Image for Preview - - - - 选择背景图片 - Select Background Image - - - - 图片文件 - Image File - - - - 成功 - Success - - - - 已加载样式 - Style Loaded - - - - 警告 - Warning - - - - 样式 - Style - - - - 已存在 - already exists - - - - 已创建新样式 - New style created - - - - SubtitleTableModel - - - 开始时间 - Start Time - - - - 结束时间 - End Time - - - - 字幕内容 - Subtitle Text - - - - 翻译字幕 - Translate Subtitles - - - - 优化字幕 - Optimize Subtitles - - - - SubtitleThread - - - LLM API 未配置, 请检查LLM配置 - LLM API not configured, please check LLM settings - - - - 字幕文件路径为空 - Subtitle file path is empty - - - - 字幕配置为空 - Subtitle settings are empty - - - - 开始验证 LLM 配置... - Validating LLM settings... - - - - 字幕断句... - Segmenting subtitles... - - - - 优化字幕... - Optimizing subtitles... - - - - LLM 模型未配置 - LLM model not configured - - - - 翻译字幕... - Translating subtitles... - - - - 目标语言未配置 - Target language not configured - - - - 不支持的翻译服务: {translator_service} - Unsupported translation service: {translator_service} - - - - 优化完成 - Optimization complete - - - - 字幕处理失败 - Subtitle processing failed - - - - {0}% 处理字幕 - {0}% processing subtitles - - - - 已终止 - Terminated - - - - 终止时发生错误 - An error occurred during termination - - - - TaskCreationInterface - - - 请拖拽文件或输入视频URL - Please drag files or enter video URL - - - - 准备就绪 - Ready - - - - 查看日志 - View logs - - - - 捐助 - Donate - - - - ©VideoCaptioner {VERSION} • By Weifeng - ©VideoCaptioner {VERSION} • By Weifeng - - - - 选择媒体文件 - Select media file - - - - 导入成功 - Import successful - - - - 导入媒体文件成功 - Media file import successful - - - - 格式错误 - Format error - - - - 不支持该文件格式 - Unsupported file format - - - - 错误 - Error - - - - 请输入有效的文件路径或视频URL - Please enter a valid file path or video URL - - - - 警告 - Warning - - - - 建议根据文档配置cookies.txt文件,以可以下载高清视频 - It is recommended to configure the cookies.txt file as per the documentation to download HD videos - - - - 开始下载 - Start download - - - - 开始下载视频... - Starting video download... - - - - 下载成功 - Download successful - - - - 视频下载完成,开始自动处理... - Video download complete, starting automatic processing... - - - - 视频下载失败 - Video download failed - - - - 请输入音视频文件路径或URL - Please enter the audio/video file path or URL - - - - TranscriptThread - - - 转录失败 - Transcription failed - - - - 文件路径为空 - File path is empty - - - - 视频文件不存在 - Video file does not exist - - - - 转录配置为空 - Transcription configuration is empty - - - - 输出路径为空 - Output path is empty - - - - 字幕已下载 - Subtitles downloaded - - - - 转换音频中 - Converting audio - - - - 音频转换失败 - Audio conversion failed - - - - 语音转录中 - Transcribing speech - - - - 转录完成 - Transcription completed - - - - TranscriptionInterface - - - 打开文件 - Open file - - - - 转录模型 - Transcription Model - - - - 转录完成 - Transcription completed - - - - 开始字幕优化... - Starting subtitle optimization... - - - - 选择媒体文件 - Select media file - - - - 错误 - Error - - - - 警告 - Warning - - - - 正在处理中,请等待当前任务完成 - Processing, please wait for the current task to complete - - - - 导入成功 - Import successful - - - - 开始语音转文字 - Starting speech to text - - - - 格式错误 - Format Error - - - - 请拖入音频或视频文件 - Please drag audio or video files here - - - - VideoInfoCard - - - 请拖入音频或视频文件 - Please drag audio or video files here - - - - 画质 - Video Quality - - - - 文件大小 - File Size - - - - 时长 - Duration - - - - 音轨 - Audio Track - - - - 打开文件夹 - Open Folder - - - - 开始转录 - Start Transcription - - - - 画质: - Video Quality: - - - - 大小: - Size: - - - - 时长: - Duration: - - - - 警告 - Warning - - - - 没有可用的字幕文件夹 - No available subtitle folder - - - - 重新转录 - Re-transcribe - - - - 转录失败 - Transcription failed - - - - 转录完成 - Transcription completed - - - - VideoSynthesisInterface - - - 开始合成 - Start synthesis - - - - 字幕文件 - Subtitle file - - - - 选择或者拖拽字幕文件 - Select or drag subtitle file - - - - 浏览 - Browse - - - - 视频文件 - Video file - - - - 选择或者拖拽视频文件 - Select or drag video file - - - - 就绪 - Ready - - - - 软字幕 - Soft Subtitles - - - - 使用软字幕嵌入视频 - Embed soft subtitles in video - - - - 视频质量 - Video quality - - - - 合成视频 - Synthesize video - - - - 是否生成新的视频文件 - Generate a new video file? - - - - 打开输出文件夹 - Open output folder - - - - 选择视频文件 - Select video file - - - - 开启软字幕 - Enable soft subtitles - - - - 字幕作为独立轨道嵌入视频,播放器中可关闭或调整 - Subtitles are embedded as a separate track in the video, which can be turned off or adjusted in the player - - - - 开启硬烧录字幕 - Enable hardcoded subtitles - - - - 字幕直接烧录到视频画面中,带有设置的样式 - Subtitles are directly burned into the video frame with the specified style - - - - 开启视频合成 - Enable video composition - - - - 将进行视频与字幕的合成操作 - Video and subtitles will be composed together - - - - 关闭视频合成 - Disable video composition - - - - 仅生成字幕文件,不生成新的视频文件 - Only generate subtitle file, no new video file will be created - - - - 选择字幕文件 - Select subtitle file - - - - 错误 - Error - - - - 请选择字幕文件和视频文件 - Please select a subtitle file and a video file - - - - 成功 - Success - - - - 视频合成已完成 - Video composition completed - - - - 警告 - Warning - - - - 没有可用的视频文件夹 - No available video folder - - - - 导入成功 - Import successful - - - - 字幕文件已放入输入框 - Subtitle file has been placed in the input box - - - - 视频文件已输入框 - Video file has been placed in the input box - - - - 格式错误 - Format error - - - - 请拖入视频或者字幕文件 - Please drag in a video or subtitle file - - - - VideoSynthesisThread - - - 合成完成 - Synthesis completed - - - - 正在合成 - Synthesizing - - - - 视频路径为空 - Video path is empty - - - - 字幕路径为空 - Subtitle path is empty - - - - 输出路径为空 - Output path is empty - - - - 视频合成失败 - Video synthesis failed - - - - WhisperAPISettingWidget - - - Whisper API 设置 - Whisper API Settings - - - - API Base URL - API Base URL - - - - 输入 Whisper API Base URL - Enter Whisper API Base URL - - - - API Key - API Key - - - - 输入 Whisper API Key - Enter Whisper API Key - - - - Whisper 模型 - Whisper Model - - - - 选择 Whisper 模型 - Select Whisper model - - - - 原语言 - Source Language - - - - 音频的原语言 - Original language of the audio - - - - 提示词 - Prompt - - - - 可选的提示词,默认空 - Optional prompt, empty by default - - - - 测试连接 - Test connection - - - - 测试 Whisper API 连接 - Test Whisper API Connection - - - - 点击测试 API 连接是否正常 - Click to test if API connection is normal - - - - 配置不完整 - Configuration is incomplete - - - - 请输入 API Base URL、API Key 和 model - Please enter API Base URL, API Key, and model - - - - 正在测试... - Testing... - - - - 连接成功 - Connection successful - - - - Whisper API 连接成功! - Whisper API connection successful! - - - - 连接失败 - Connection failed - - - - Whisper API 连接失败! -{result} - Whisper API connection failed! - - - - 测试错误 - Test error - - - - WhisperCppDownloadDialog - - - 关闭 - Close - - - - WhisperCpp程序 - WhisperCpp program - - - - 已安装版本: {versions_text} - Installed version: {versions_text} - - - - 未下载 WhisperCpp 程序 - WhisperCpp program not downloaded - - - - 模型下载 - Model download - - - - 打开模型文件夹 - Open model folder - - - - 模型名称 - Model name - - - - 大小 - Size - - - - 状态 - Status - - - - 操作 - Action - - - - 已下载 - Downloaded - - - - 未下载 - Not Downloaded - - - - 重新下载 - Re-download - - - - 下载 - Download - - - - 下载进行中 - Downloading in progress - - - - 请等待当前下载任务完成 - Please wait for the current download task to complete. - - - - 正在下载 {model['label']} 模型... - Downloading {model['label']} model... - - - - 下载成功 - Download successful - - - - {model['label']} 模型已下载完成 - {model['label']} model has been downloaded. - - - - 下载失败 - Download failed - - - - WhisperCppSettingWidget - - - Whisper CPP 设置 - Whisper CPP Settings (unstable 🤔) - - - - 模型 - Model - - - - 选择Whisper模型 - Select Whisper model - - - - 源语言 - Source Language - - - - 音频的源语言 - Source language of the audio - - - - 管理模型 - Manage Models - - - - 模型管理 - Model Management - - - - 下载或更新 Whisper CPP 模型 - Download or update Whisper CPP model. - - - \ No newline at end of file diff --git a/resource/translations/VideoCaptioner_zh_CN.qm b/resource/translations/VideoCaptioner_zh_CN.qm deleted file mode 100644 index be651eed..00000000 --- a/resource/translations/VideoCaptioner_zh_CN.qm +++ /dev/null @@ -1 +0,0 @@ -<�d��!�`��� \ No newline at end of file diff --git a/resource/translations/VideoCaptioner_zh_CN.ts b/resource/translations/VideoCaptioner_zh_CN.ts deleted file mode 100644 index 89595edc..00000000 --- a/resource/translations/VideoCaptioner_zh_CN.ts +++ /dev/null @@ -1,2524 +0,0 @@ - - - - - BatchProcessInterface - - - 批量处理 - - - - - 添加文件 - - - - - 开始处理 - - - - - 清空列表 - - - - - ColorPickerButton - - - Choose - - - - - DonateDialog - - - 支持作者 - - - - - 感谢支持 - - - - - 目前本人精力有限,您的支持让我有动力继续折腾这个项目! -感谢您对开源事业的热爱与支持! - - - - - 支付宝 - - - - - 微信 - - - - - 关闭 - - - - - DownloadDialog - - - 下载模型 - - - - - 下载 - - - - - 关闭 - - - - - 提示 - - - - - 模型文件已存在,无需重复下载 - - - - - 完成 - - - - - 模型下载完成! - - - - - 下载完成 - - - - - 下载错误 - - - - - FasterWhisperDownloadDialog - - - 关闭 - - - - - Faster Whisper 下载 - - - - - 打开程序文件夹 - - - - - 已安装版本: {versions_text} - - - - - 您可以继续下载其他版本: - - - - - 未下载Faster Whisper 程序 - - - - - 下载程序 - - - - - 模型下载 - - - - - 打开模型文件夹 - - - - - 模型名称 - - - - - 大小 - - - - - 状态 - - - - - 操作 - - - - - 已下载 - - - - - 未下载 - - - - - 重新下载 - - - - - 下载 - - - - - 下载进行中 - - - - - 请等待当前下载任务完成 - - - - - 下载错误 - - - - - 未找到对应的程序配置 - - - - - 正在解压文件... - - - - - 安装失败 - - - - - 下载失败 - - - - - 正在下载 {model['label']} 模型... - - - - - 下载成功 - - - - - {model['label']} 模型已下载完成 - - - - - 安装完成 - - - - - Faster Whisper 程序已安装成功 - - - - - FasterWhisperSettingWidget - - - Faster Whisper程序不存在,请先下载程序 - - - - - Faster Whisper 设置(✨推荐✨)) - - - - - 模型 - - - - - 选择 Faster Whisper 模型 - - - - - 管理模型 - - - - - 模型管理 - - - - - 下载或更新 Faster Whisper 模型 - - - - - 源语言 - - - - - 音频的源语言 - - - - - 运行设备 - - - - - 模型运行设备 - - - - - VAD设置 - - - - - VAD过滤 - - - - - 过滤无人声语音片断,减少幻觉 - - - - - VAD阈值 - - - - - 语音概率阈值,高于此值视为语音 - - - - - VAD方法 - - - - - 选择VAD检测方法 - - - - - 其他设置 - - - - - 人声分离 - - - - - 处理前使用MDX-Net降噪,分离人声和背景音乐 - - - - - 单字时间戳 - - - - - 开启生成单字级时间戳;关闭后使用原始分段断句 - - - - - 提示词 - - - - - 可选的提示词,默认空 - - - - - 错误 - - - - - 模型配置不存在 - - - - - 模型文件不存在: - - - - - FileDownloadThread - - - 正在连接... - - - - - HomeInterface - - - 任务创建 - - - - - 语音转录 - - - - - 字幕优化与翻译 - - - - - 字幕视频合成 - - - - - LanguageSettingDialog - - - 确定 - - - - - 取消 - - - - - 语言设置 - - - - - 源语言 - - - - - 音频的源语言 - - - - - 设置已保存 - - - - - 语言设置已更新 - - - - - 请注意身体!! - - - - - 小心肝儿,注意身体哦~ - - - - - MainWindow - - - 主页 - - - - - 批量处理 - - - - - 字幕样式 - - - - - Settings - - - - - 卡卡字幕助手 -- VideoCaptioner - - - - - GitHub信息 - - - - - VideoCaptioner 由本人在课余时间独立开发完成,目前托管在GitHub上,欢迎Star和Fork。项目诚然还有很多地方需要完善,遇到软件的问题或者BUG欢迎提交Issue。 - - https://github.com/WEIFENG2333/VideoCaptioner - - - - - 打开 GitHub - - - - - 支持作者 - - - - - 当前版本部分功能已被禁用。请尽快更新。 - - - - - PromptDialog - - - 文稿提示 - - - - - 请输入文稿提示(辅助校正字幕和翻译) - -支持以下内容: -1. 术语表 - 专业术语、人名、特定词语的修正对照表 -示例: -机器学习->Machine Learning -马斯克->Elon Musk -打call->应援 - -2. 原字幕文稿 - 视频的原有文稿或相关内容 -示例: 完整的演讲稿、课程讲义等 - -3. 修正要求 - 内容相关的具体修正要求 -示例: 统一人称代词、规范专业术语等 - -注意: 使用小型LLM模型时建议控制文稿在1千字内。对于不同字幕文件,请使用与该字幕相关的文稿提示。 - - - - - 确定 - - - - - 取消 - - - - - SettingInterface - - - 设置 - - - - - 转录配置 - - - - - LLM配置 - - - - - 翻译服务 - - - - - 翻译与优化 - - - - - 字幕合成配置 - - - - - 保存配置 - - - - - 个性化 - - - - - 关于 - - - - - 字幕校正 - - - - - 字幕处理过程是否对生成的字幕错别字、名词等进行校正 - - - - - 字幕翻译 - - - - - 字幕处理过程是否对生成的字幕进行翻译 - - - - - 目标语言 - - - - - 选择翻译字幕的目标语言 - - - - - 修改 - - - - - 字幕样式 - - - - - 选择字幕的样式(颜色、大小、字体等) - - - - - 字幕布局 - - - - - 选择字幕的布局(单语、双语) - - - - - 需要合成视频 - - - - - 开启时触发合成视频,关闭时跳过 - - - - - 软字幕 - - - - - 开启时字幕可在播放器中关闭或调整,关闭时字幕烧录到视频画面上 - - - - - 视频合成质量 - - - - - 硬字幕视频合成时的质量等级(质量越高文件越大,编码时间越长) - - - - - 工作文件夹 - - - - - 工作目录路径 - - - - - 启用缓存 - - - - - 相同配置下会复用之前的 ASR 和 LLM 结果;关闭缓存后每次重新生成 - - - - - 应用主题 - - - - - 更改应用程序的外观 - - - - - 浅色 - - - - - 深色 - - - - - 使用系统设置 - - - - - 主题颜色 - - - - - 更改应用程序的主题颜色 - - - - - 界面缩放 - - - - - 更改小部件和字体的大小 - - - - - 语言 - - - - - 设置您偏好的界面语言 - - - - - 打开帮助页面 - - - - - 帮助 - - - - - 发现新功能并了解有关VideoCaptioner的使用技巧 - - - - - 提供反馈 - - - - - 提供反馈帮助我们改进VideoCaptioner - - - - - 检查更新 - - - - - 版权所有 - - - - - 版本 - - - - - LLM服务 - - - - - 选择大模型服务,用于字幕断句、字幕优化、字幕翻译 - - - - - 访问 - - - - - VideoCaptioner 官方API - - - - - 集成多种大语言模型,支持高并发字幕优化、翻译 - - - - - API Key - - - - - 输入您的 {service.value} API Key - - - - - Base URL - - - - - 输入 {service.value} Base URL - - - - - 模型 - - - - - 选择 {service.value} 模型 - - - - - 检查连接 - - - - - 检查 LLM 连接 - - - - - 点击检查 API 连接是否正常,并获取模型列表 - - - - - 转录模型 - - - - - 语音转换文字要使用的语音识别服务 - - - - - Whisper API Base URL - - - - - 输入 Whisper API Base URL - - - - - Whisper API Key - - - - - 输入 Whisper API Key - - - - - Whisper 模型 - - - - - 选择 Whisper 模型 - - - - - 测试 Whisper 连接 - - - - - 测试 Whisper API 连接 - - - - - 点击测试 API 连接是否正常 - - - - - 选择翻译服务 - - - - - 需要反思翻译 - - - - - 启用反思翻译可以提高翻译质量,但耗费更多时间和token - - - - - DeepLx 后端 - - - - - 输入 DeepLx 的后端地址(开启deeplx翻译时必填) - - - - - 批处理大小 - - - - - 每批处理字幕的数量,建议为 10 的倍数 - - - - - 线程数 - - - - - 请求并行处理的数量,模型服务商允许的情况下建议尽可能大,数值越大速度越快 - - - - - 更新成功 - - - - - 配置将在重启后生效 - - - - - 选择文件夹 - - - - - 缓存已启用 - - - - - ASR、翻译等操作将优先使用缓存 - - - - - 缓存已禁用 - - - - - 所有操作将重新生成,不使用缓存(建议开启缓存) - - - - - 正在检查... - - - - - LLM 连接测试错误 - - - - - 获取模型列表成功: - - - - - 一共 - - - - - 个模型 - - - - - LLM 连接测试成功 - - - - - 配置不完整 - - - - - 请输入 Whisper API Base URL - - - - - 请输入 Whisper API Key - - - - - 请输入 Whisper 模型名称 - - - - - 正在测试... - - - - - 连接成功 - - - - - Whisper API 连接成功! -转录结果: - - - - - 连接失败 - - - - - Whisper API 连接失败! -{result} - - - - - 测试错误 - - - - - StyleNameDialog - - - 新建样式 - - - - - 输入样式名称 - - - - - 确定 - - - - - 取消 - - - - - SubtitleInterface - - - 保存 - - - - - 字幕排布 - - - - - 字幕校正 - - - - - 字幕翻译 - - - - - 翻译语言 - - - - - 文稿提示 - - - - - 开始 - - - - - 请拖入字幕文件 - - - - - 取消 - - - - - 已加载文件 - - - - - 警告 - - - - - 请先加载字幕文件 - - - - - 开始优化 - - - - - 开始优化字幕 - - - - - 优化完成 - - - - - 优化完成字幕... - - - - - 优化失败 - - - - - 选择字幕文件 - - - - - 保存字幕文件 - - - - - 保存成功 - - - - - 字幕已保存至: - - - - - 保存失败 - - - - - 保存字幕文件失败: - - - - - 导入成功 - - - - - 成功导入 - - - - - 格式错误 - - - - - 支持的字幕格式: - - - - - 合并 - - - - - 合并成功 - - - - - 已成功合并选中的字幕行 - - - - - 已取消校正 - - - - - 已取消 - - - - - 字幕校正已取消 - - - - - SubtitlePipelineThread - - - 开始转录 - - - - - 开始优化字幕 - - - - - 开始合成视频 - - - - - 处理完成 - - - - - SubtitleSettingDialog - - - 字幕设置 - - - - - 字幕分割 - - - - - 字幕是否使用大语言模型进行智能断句 - - - - - 中文最大字数 - - - - - 单条字幕的最大字数 (对于中日韩等字符) - - - - - 英文最大单词数 - - - - - 单条字幕的最大单词数 (英文) - - - - - 去除末尾标点符号 - - - - - 是否去除中文字幕中的末尾标点符号 - - - - - 关闭 - - - - - SubtitleStyleInterface - - - 字幕样式配置 - - - - - 字幕排布 - - - - - 主字幕样式 - - - - - 副字幕样式 - - - - - 预览设置 - - - - - 预览效果 - - - - - 选择样式 - - - - - 选择已保存的字幕样式 - - - - - 新建样式 - - - - - 基于当前样式新建预设 - - - - - 打开样式文件夹 - - - - - 在文件管理器中打开样式文件夹 - - - - - 设置主字幕和副字幕的显示方式 - - - - - 垂直间距 - - - - - 设置字幕的垂直间距 - - - - - 主字幕字体 - - - - - 设置主字幕的字体 - - - - - 主字幕字号 - - - - - 设置主字幕的大小 - - - - - 主字幕间距 - - - - - 设置主字幕的字符间距 - - - - - 主字幕颜色 - - - - - 设置主字幕的颜色 - - - - - 主字幕边框颜色 - - - - - 设置主字幕的边框颜色 - - - - - 主字幕边框大小 - - - - - 设置主字幕的边框粗细 - - - - - 副字幕字体 - - - - - 设置副字幕的字体 - - - - - 副字幕字号 - - - - - 设置副字幕的大小 - - - - - 副字幕间距 - - - - - 设置副字幕的字符间距 - - - - - 副字幕颜色 - - - - - 设置副字幕的颜色 - - - - - 副字幕边框颜色 - - - - - 设置副字幕的边框颜色 - - - - - 副字幕边框大小 - - - - - 设置副字幕的边框粗细 - - - - - 预览文字 - - - - - 设置预览显示的文字内容 - - - - - 预览方向 - - - - - 设置预览图片的显示方向 - - - - - 选择图片 - - - - - 预览背景 - - - - - 选择预览使用的背景图片 - - - - - 选择背景图片 - - - - - 图片文件 - - - - - 成功 - - - - - 已加载样式 - - - - - 警告 - - - - - 样式 - - - - - 已存在 - - - - - 已创建新样式 - - - - - SubtitleTableModel - - - 开始时间 - - - - - 结束时间 - - - - - 字幕内容 - - - - - 翻译字幕 - - - - - 优化字幕 - - - - - SubtitleThread - - - LLM API 未配置, 请检查LLM配置 - - - - - 字幕文件路径为空 - - - - - 字幕配置为空 - - - - - 开始验证 LLM 配置... - - - - - 字幕断句... - - - - - 优化字幕... - - - - - LLM 模型未配置 - - - - - 翻译字幕... - - - - - 目标语言未配置 - - - - - 不支持的翻译服务: {translator_service} - - - - - 优化完成 - - - - - 字幕处理失败 - - - - - {0}% 处理字幕 - - - - - 已终止 - - - - - 终止时发生错误 - - - - - TaskCreationInterface - - - 请拖拽文件或输入视频URL - - - - - 准备就绪 - - - - - 查看日志 - - - - - 捐助 - - - - - ©VideoCaptioner {VERSION} • By Weifeng - - - - - 选择媒体文件 - - - - - 导入成功 - - - - - 导入媒体文件成功 - - - - - 格式错误 - - - - - 不支持该文件格式 - - - - - 错误 - - - - - 请输入有效的文件路径或视频URL - - - - - 警告 - - - - - 建议根据文档配置cookies.txt文件,以可以下载高清视频 - - - - - 开始下载 - - - - - 开始下载视频... - - - - - 下载成功 - - - - - 视频下载完成,开始自动处理... - - - - - 视频下载失败 - - - - - 请输入音视频文件路径或URL - - - - - TranscriptThread - - - 转录失败 - - - - - 文件路径为空 - - - - - 视频文件不存在 - - - - - 转录配置为空 - - - - - 输出路径为空 - - - - - 字幕已下载 - - - - - 转换音频中 - - - - - 音频转换失败 - - - - - 语音转录中 - - - - - 转录完成 - - - - - TranscriptionInterface - - - 打开文件 - - - - - 转录模型 - - - - - 转录完成 - - - - - 开始字幕优化... - - - - - 选择媒体文件 - - - - - 错误 - - - - - 警告 - - - - - 正在处理中,请等待当前任务完成 - - - - - 导入成功 - - - - - 开始语音转文字 - - - - - 格式错误 - - - - - 请拖入音频或视频文件 - - - - - VideoInfoCard - - - 请拖入音频或视频文件 - - - - - 画质 - - - - - 文件大小 - - - - - 时长 - - - - - 音轨 - - - - - 打开文件夹 - - - - - 开始转录 - - - - - 画质: - - - - - 大小: - - - - - 时长: - - - - - 警告 - - - - - 没有可用的字幕文件夹 - - - - - 重新转录 - - - - - 转录失败 - - - - - 转录完成 - - - - - VideoSynthesisInterface - - - 开始合成 - - - - - 字幕文件 - - - - - 选择或者拖拽字幕文件 - - - - - 浏览 - - - - - 视频文件 - - - - - 选择或者拖拽视频文件 - - - - - 就绪 - - - - - 软字幕 - - - - - 使用软字幕嵌入视频 - - - - - 视频质量 - - - - - 合成视频 - - - - - 是否生成新的视频文件 - - - - - 打开输出文件夹 - - - - - 选择视频文件 - - - - - 开启软字幕 - - - - - 字幕作为独立轨道嵌入视频,播放器中可关闭或调整 - - - - - 开启硬烧录字幕 - - - - - 字幕直接烧录到视频画面中,带有设置的样式 - - - - - 开启视频合成 - - - - - 将进行视频与字幕的合成操作 - - - - - 关闭视频合成 - - - - - 仅生成字幕文件,不生成新的视频文件 - - - - - 选择字幕文件 - - - - - 错误 - - - - - 请选择字幕文件和视频文件 - - - - - 成功 - - - - - 视频合成已完成 - - - - - 警告 - - - - - 没有可用的视频文件夹 - - - - - 导入成功 - - - - - 字幕文件已放入输入框 - - - - - 视频文件已输入框 - - - - - 格式错误 - - - - - 请拖入视频或者字幕文件 - - - - - VideoSynthesisThread - - - 合成完成 - - - - - 正在合成 - - - - - 视频路径为空 - - - - - 字幕路径为空 - - - - - 输出路径为空 - - - - - 视频合成失败 - - - - - WhisperAPISettingWidget - - - Whisper API 设置 - - - - - API Base URL - - - - - 输入 Whisper API Base URL - - - - - API Key - - - - - 输入 Whisper API Key - - - - - Whisper 模型 - - - - - 选择 Whisper 模型 - - - - - 原语言 - - - - - 音频的原语言 - - - - - 提示词 - - - - - 可选的提示词,默认空 - - - - - 测试连接 - - - - - 测试 Whisper API 连接 - - - - - 点击测试 API 连接是否正常 - - - - - 配置不完整 - - - - - 请输入 API Base URL、API Key 和 model - - - - - 正在测试... - - - - - 连接成功 - - - - - Whisper API 连接成功! - - - - - 连接失败 - - - - - Whisper API 连接失败! -{result} - - - - - 测试错误 - - - - - WhisperCppDownloadDialog - - - 关闭 - - - - - WhisperCpp程序 - - - - - 已安装版本: {versions_text} - - - - - 未下载 WhisperCpp 程序 - - - - - 模型下载 - - - - - 打开模型文件夹 - - - - - 模型名称 - - - - - 大小 - - - - - 状态 - - - - - 操作 - - - - - 已下载 - - - - - 未下载 - - - - - 重新下载 - - - - - 下载 - - - - - 下载进行中 - - - - - 请等待当前下载任务完成 - - - - - 正在下载 {model['label']} 模型... - - - - - 下载成功 - - - - - {model['label']} 模型已下载完成 - - - - - 下载失败 - - - - - WhisperCppSettingWidget - - - Whisper CPP 设置 - - - - - 模型 - - - - - 选择Whisper模型 - - - - - 源语言 - - - - - 音频的源语言 - - - - - 管理模型 - - - - - 模型管理 - - - - - 下载或更新 Whisper CPP 模型 - - - - diff --git a/resource/translations/VideoCaptioner_zh_HK.qm b/resource/translations/VideoCaptioner_zh_HK.qm deleted file mode 100644 index 0de728db..00000000 Binary files a/resource/translations/VideoCaptioner_zh_HK.qm and /dev/null differ diff --git a/resource/translations/VideoCaptioner_zh_HK.ts b/resource/translations/VideoCaptioner_zh_HK.ts deleted file mode 100644 index 8e0c7659..00000000 --- a/resource/translations/VideoCaptioner_zh_HK.ts +++ /dev/null @@ -1,2545 +0,0 @@ - - - - - BatchProcessInterface - - - 批量处理 - 批量處理 - - - - 添加文件 - - - - - 开始处理 - - - - - 清空列表 - - - - - ColorPickerButton - - - Choose - Choose - - - - DonateDialog - - - 支持作者 - 支持作者 - - - - 感谢支持 - 感謝支持 - - - - 目前本人精力有限,您的支持让我有动力继续折腾这个项目! -感谢您对开源事业的热爱与支持! - 目前本人精力有限,您的支持讓我有動力繼續折騰這個項目! -感謝您對開源事業的熱愛與支持! - - - - 支付宝 - 支付寶 - - - - 微信 - 微信 - - - - 关闭 - 關閉 - - - - DownloadDialog - - - 下载模型 - 下載模型 - - - - 下载 - 下載 - - - - 关闭 - 關閉 - - - - 提示 - 提示 - - - - 模型文件已存在,无需重复下载 - 模型文件已存在,無需重複下載 - - - - 完成 - 完成 - - - - 模型下载完成! - 模型下載完成! - - - - 下载完成 - 下載完成 - - - - 下载错误 - 下載錯誤 - - - - FasterWhisperDownloadDialog - - - 关闭 - 關閉 - - - - Faster Whisper 下载 - Faster Whisper 下載 - - - - 打开程序文件夹 - 打開程序文件夾 - - - - 已安装版本: {versions_text} - 已安裝版本: {versions_text} - - - - 您可以继续下载其他版本: - 您可以繼續下載其他版本: - - - - 未下载Faster Whisper 程序 - 未下載Faster Whisper 程序 - - - - 下载程序 - 下載程序 - - - - 模型下载 - 模型下載 - - - - 打开模型文件夹 - 打開模型文件夾 - - - - 模型名称 - 模型名稱 - - - - 大小 - 大小 - - - - 状态 - 狀態 - - - - 操作 - 操作 - - - - 已下载 - 已下載 - - - - 未下载 - 未下載 - - - - 重新下载 - 重新下載 - - - - 下载 - 下載 - - - - 下载进行中 - 下載進行中 - - - - 请等待当前下载任务完成 - 請等待當前下載任務完成 - - - - 下载错误 - 下載錯誤 - - - - 未找到对应的程序配置 - 未找到對應的程序配置 - - - - 正在解压文件... - 正在解壓文件... - - - - 安装失败 - 安裝失敗 - - - - 下载失败 - 下載失敗 - - - - 正在下载 {model['label']} 模型... - 正在下載 {model['label']} 模型... - - - - 下载成功 - 下載成功 - - - - {model['label']} 模型已下载完成 - {model['label']} 模型已下載完成 - - - - 安装完成 - 安裝完成 - - - - Faster Whisper 程序已安装成功 - Faster Whisper 程序已安裝成功 - - - - FasterWhisperSettingWidget - - - Faster Whisper程序不存在,请先下载程序 - Faster Whisper程序不存在,請先下載程序 - - - - Faster Whisper 设置(✨推荐✨)) - Faster Whisper 設置(✨推薦✨)) - - - - 模型 - 模型 - - - - 选择 Faster Whisper 模型 - 選擇 Faster Whisper 模型 - - - - 管理模型 - 管理模型 - - - - 模型管理 - 模型管理 - - - - 下载或更新 Faster Whisper 模型 - 下載或更新 Faster Whisper 模型 - - - - 源语言 - 源語言 - - - - 音频的源语言 - 音頻的源語言 - - - - 运行设备 - 運行設備 - - - - 模型运行设备 - 模型運行設備 - - - - VAD设置 - VAD設置 - - - - VAD过滤 - VAD過濾 - - - - 过滤无人声语音片断,减少幻觉 - 過濾無人聲語音片斷,減少幻覺 - - - - VAD阈值 - VAD閾值 - - - - 语音概率阈值,高于此值视为语音 - 語音概率閾值,高於此值視為語音 - - - - VAD方法 - VAD方法 - - - - 选择VAD检测方法 - 選擇VAD檢測方法 - - - - 其他设置 - 其他設置 - - - - 人声分离 - 人聲分離 - - - - 处理前使用MDX-Net降噪,分离人声和背景音乐 - 處理前使用MDX-Net降噪,分離人聲和背景音樂 - - - - 单字时间戳 - 單字時間戳 - - - - 开启生成单字级时间戳;关闭后使用原始分段断句 - 開啓生成單字級時間戳;關閉後使用原始分段斷句 - - - - 提示词 - 提示詞 - - - - 可选的提示词,默认空 - 可選的提示詞,默認空 - - - - 错误 - 錯誤 - - - - 模型配置不存在 - 模型配置不存在 - - - - 模型文件不存在: - 模型文件不存在: - - - - FileDownloadThread - - - 正在连接... - 正在連接... - - - - HomeInterface - - - 任务创建 - 任務創建 - - - - 语音转录 - 語音轉錄 - - - - 字幕优化与翻译 - 字幕優化與翻譯 - - - - 字幕视频合成 - 字幕視頻合成 - - - - LanguageSettingDialog - - - 确定 - 確定 - - - - 取消 - 取消 - - - - 语言设置 - 語言設置 - - - - 源语言 - 源語言 - - - - 音频的源语言 - 音頻的源語言 - - - - 设置已保存 - 設置已保存 - - - - 语言设置已更新 - 語言設置已更新 - - - - 请注意身体!! - 請注意身體!! - - - - 小心肝儿,注意身体哦~ - 小心肝兒,注意身體哦~ - - - - MainWindow - - - 主页 - 主頁 - - - - 批量处理 - 批量處理 - - - - 字幕样式 - 字幕樣式 - - - - Settings - Settings - - - - 卡卡字幕助手 -- VideoCaptioner - 卡卡字幕助手 -- VideoCaptioner - - - - GitHub信息 - GitHub信息 - - - - VideoCaptioner 由本人在课余时间独立开发完成,目前托管在GitHub上,欢迎Star和Fork。项目诚然还有很多地方需要完善,遇到软件的问题或者BUG欢迎提交Issue。 - - https://github.com/WEIFENG2333/VideoCaptioner - VideoCaptioner 由本人在課餘時間獨立開發完成,目前託管在GitHub上,歡迎Star和Fork。項目誠然還有很多地方需要完善,遇到軟件的問題或者BUG歡迎提交Issue。 - - https://github.com/WEIFENG2333/VideoCaptioner - - - - 打开 GitHub - 打開 GitHub - - - - 支持作者 - 支持作者 - - - - 当前版本部分功能已被禁用。请尽快更新。 - 當前版本部分功能已被禁用。請儘快更新。 - - - - PromptDialog - - - 文稿提示 - 文稿提示 - - - - 请输入文稿提示(辅助校正字幕和翻译) - -支持以下内容: -1. 术语表 - 专业术语、人名、特定词语的修正对照表 -示例: -机器学习->Machine Learning -马斯克->Elon Musk -打call->应援 - -2. 原字幕文稿 - 视频的原有文稿或相关内容 -示例: 完整的演讲稿、课程讲义等 - -3. 修正要求 - 内容相关的具体修正要求 -示例: 统一人称代词、规范专业术语等 - -注意: 使用小型LLM模型时建议控制文稿在1千字内。对于不同字幕文件,请使用与该字幕相关的文稿提示。 - 請輸入文稿提示(輔助校正字幕和翻譯) - -支持以下內容: -1. 術語表 - 專業術語、人名、特定詞語的修正對照表 -示例: -機器學習->Machine Learning -馬斯克->Elon Musk -打call->應援 - -2. 原字幕文稿 - 視頻的原有文稿或相關內容 -示例: 完整的演講稿、課程講義等 - -3. 修正要求 - 內容相關的具體修正要求 -示例: 統一人稱代詞、規範專業術語等 - -注意: 使用小型LLM模型時建議控制文稿在1千字內。對於不同字幕文件,請使用與該字幕相關的文稿提示。 - - - - 确定 - 確定 - - - - 取消 - 取消 - - - - SettingInterface - - - 设置 - 設置 - - - - 转录配置 - 轉錄配置 - - - - LLM配置 - LLM配置 - - - - 翻译服务 - 翻譯服務 - - - - 翻译与优化 - 翻譯與優化 - - - - 字幕合成配置 - 字幕合成配置 - - - - 保存配置 - 保存配置 - - - - 个性化 - 個性化 - - - - 关于 - 關於 - - - - 字幕校正 - 字幕校正 - - - - 字幕处理过程是否对生成的字幕错别字、名词等进行校正 - 字幕處理過程是否對生成的字幕錯別字、名詞等進行校正 - - - - 字幕翻译 - 字幕翻譯 - - - - 字幕处理过程是否对生成的字幕进行翻译 - 字幕處理過程是否對生成的字幕進行翻譯 - - - - 目标语言 - 目標語言 - - - - 选择翻译字幕的目标语言 - 選擇翻譯字幕的目標語言 - - - - 修改 - 修改 - - - - 字幕样式 - 字幕樣式 - - - - 选择字幕的样式(颜色、大小、字体等) - 選擇字幕的樣式(顏色、大小、字體等) - - - - 字幕布局 - 字幕布局 - - - - 选择字幕的布局(单语、双语) - 選擇字幕的佈局(單語、雙語) - - - - 需要合成视频 - 需要合成視頻 - - - - 开启时触发合成视频,关闭时跳过 - 開啓時觸發合成視頻,關閉時跳過 - - - - 软字幕 - 軟字幕 - - - - 开启时字幕可在播放器中关闭或调整,关闭时字幕烧录到视频画面上 - 開啓時字幕可在播放器中關閉或調整,關閉時字幕燒錄到視頻畫面上 - - - - 视频合成质量 - 視頻合成質量 - - - - 硬字幕视频合成时的质量等级(质量越高文件越大,编码时间越长) - 硬字幕視頻合成時的質量等級(質量越高文件越大,編碼時間越長) - - - - 工作文件夹 - 工作文件夾 - - - - 工作目录路径 - 工作目錄路徑 - - - - 启用缓存 - 啓用緩存 - - - - 相同配置下会复用之前的 ASR 和 LLM 结果;关闭缓存后每次重新生成 - 相同配置下會複用之前的 ASR 和 LLM 結果;關閉緩存後每次重新生成 - - - - 应用主题 - 應用主題 - - - - 更改应用程序的外观 - 更改應用程序的外觀 - - - - 浅色 - 淺色 - - - - 深色 - 深色 - - - - 使用系统设置 - 使用系統設置 - - - - 主题颜色 - 主題顏色 - - - - 更改应用程序的主题颜色 - 更改應用程序的主題顏色 - - - - 界面缩放 - 界面縮放 - - - - 更改小部件和字体的大小 - 更改小部件和字體的大小 - - - - 语言 - 語言 - - - - 设置您偏好的界面语言 - 設置您偏好的界面語言 - - - - 打开帮助页面 - 打開幫助頁面 - - - - 帮助 - 幫助 - - - - 发现新功能并了解有关VideoCaptioner的使用技巧 - 發現新功能並瞭解有關VideoCaptioner的使用技巧 - - - - 提供反馈 - 提供反饋 - - - - 提供反馈帮助我们改进VideoCaptioner - 提供反饋幫助我們改進VideoCaptioner - - - - 检查更新 - 檢查更新 - - - - 版权所有 - 版權所有 - - - - 版本 - 版本 - - - - LLM服务 - LLM服務 - - - - 选择大模型服务,用于字幕断句、字幕优化、字幕翻译 - 選擇大模型服務,用於字幕斷句、字幕優化、字幕翻譯 - - - - 访问 - 訪問 - - - - VideoCaptioner 官方API - VideoCaptioner 官方API - - - - 集成多种大语言模型,支持高并发字幕优化、翻译 - 集成多種大語言模型,支持高併發字幕優化、翻譯 - - - - API Key - API Key - - - - 输入您的 {service.value} API Key - 輸入您的 {service.value} API Key - - - - Base URL - Base URL - - - - 输入 {service.value} Base URL - 輸入 {service.value} Base URL - - - - 模型 - 模型 - - - - 选择 {service.value} 模型 - 選擇 {service.value} 模型 - - - - 检查连接 - 檢查連接 - - - - 检查 LLM 连接 - 檢查 LLM 連接 - - - - 点击检查 API 连接是否正常,并获取模型列表 - 點擊檢查 API 連接是否正常,並獲取模型列表 - - - - 转录模型 - 轉錄模型 - - - - 语音转换文字要使用的语音识别服务 - 語音轉換文字要使用的語音識別服務 - - - - Whisper API Base URL - Whisper API Base URL - - - - 输入 Whisper API Base URL - 輸入 Whisper API Base URL - - - - Whisper API Key - Whisper API Key - - - - 输入 Whisper API Key - 輸入 Whisper API Key - - - - Whisper 模型 - Whisper 模型 - - - - 选择 Whisper 模型 - 選擇 Whisper 模型 - - - - 测试 Whisper 连接 - 測試 Whisper 連接 - - - - 测试 Whisper API 连接 - 測試 Whisper API 連接 - - - - 点击测试 API 连接是否正常 - 點擊測試 API 連接是否正常 - - - - 选择翻译服务 - 選擇翻譯服務 - - - - 需要反思翻译 - 需要反思翻譯 - - - - 启用反思翻译可以提高翻译质量,但耗费更多时间和token - 啓用反思翻譯可以提高翻譯質量,但耗費更多時間和token - - - - DeepLx 后端 - DeepLx 後端 - - - - 输入 DeepLx 的后端地址(开启deeplx翻译时必填) - 輸入 DeepLx 的後端地址(開啓deeplx翻譯時必填) - - - - 批处理大小 - 批處理大小 - - - - 每批处理字幕的数量,建议为 10 的倍数 - 每批處理字幕的數量,建議為 10 的倍數 - - - - 线程数 - 線程數 - - - - 请求并行处理的数量,模型服务商允许的情况下建议尽可能大,数值越大速度越快 - 請求並行處理的數量,模型服務商允許的情況下建議儘可能大,數值越大速度越快 - - - - 更新成功 - 更新成功 - - - - 配置将在重启后生效 - 配置將在重啓後生效 - - - - 选择文件夹 - 選擇文件夾 - - - - 缓存已启用 - 緩存已啓用 - - - - ASR、翻译等操作将优先使用缓存 - ASR、翻譯等操作將優先使用緩存 - - - - 缓存已禁用 - 緩存已禁用 - - - - 所有操作将重新生成,不使用缓存(建议开启缓存) - 所有操作將重新生成,不使用緩存(建議開啓緩存) - - - - 正在检查... - 正在檢查... - - - - LLM 连接测试错误 - LLM 連接測試錯誤 - - - - 获取模型列表成功: - 獲取模型列表成功: - - - - 一共 - 一共 - - - - 个模型 - 個模型 - - - - LLM 连接测试成功 - LLM 連接測試成功 - - - - 配置不完整 - 配置不完整 - - - - 请输入 Whisper API Base URL - 請輸入 Whisper API Base URL - - - - 请输入 Whisper API Key - 請輸入 Whisper API Key - - - - 请输入 Whisper 模型名称 - 請輸入 Whisper 模型名稱 - - - - 正在测试... - 正在測試... - - - - 连接成功 - 連接成功 - - - - Whisper API 连接成功! -转录结果: - Whisper API 連接成功! -轉錄結果: - - - - 连接失败 - 連接失敗 - - - - Whisper API 连接失败! -{result} - Whisper API 連接失敗! -{result} - - - - 测试错误 - 測試錯誤 - - - - StyleNameDialog - - - 新建样式 - 新建樣式 - - - - 输入样式名称 - 輸入樣式名稱 - - - - 确定 - 確定 - - - - 取消 - 取消 - - - - SubtitleInterface - - - 保存 - 保存 - - - - 字幕排布 - 字幕排布 - - - - 字幕校正 - 字幕校正 - - - - 字幕翻译 - 字幕翻譯 - - - - 翻译语言 - 翻譯語言 - - - - 文稿提示 - 文稿提示 - - - - 开始 - 開始 - - - - 请拖入字幕文件 - 請拖入字幕文件 - - - - 取消 - 取消 - - - - 已加载文件 - 已加載文件 - - - - 警告 - 警告 - - - - 请先加载字幕文件 - 請先加載字幕文件 - - - - 开始优化 - 開始優化 - - - - 开始优化字幕 - 開始優化字幕 - - - - 优化完成 - 優化完成 - - - - 优化完成字幕... - 優化完成字幕... - - - - 优化失败 - 優化失敗 - - - - 选择字幕文件 - 選擇字幕文件 - - - - 保存字幕文件 - 保存字幕文件 - - - - 保存成功 - 保存成功 - - - - 字幕已保存至: - 字幕已保存至: - - - - 保存失败 - 保存失敗 - - - - 保存字幕文件失败: - 保存字幕文件失敗: - - - - 导入成功 - 導入成功 - - - - 成功导入 - 成功導入 - - - - 格式错误 - 格式錯誤 - - - - 支持的字幕格式: - 支持的字幕格式: - - - - 合并 - 合併 - - - - 合并成功 - 合併成功 - - - - 已成功合并选中的字幕行 - 已成功合併選中的字幕行 - - - - 已取消校正 - 已取消校正 - - - - 已取消 - 已取消 - - - - 字幕校正已取消 - 字幕校正已取消 - - - - SubtitlePipelineThread - - - 开始转录 - 開始轉錄 - - - - 开始优化字幕 - 開始優化字幕 - - - - 开始合成视频 - 開始合成視頻 - - - - 处理完成 - 處理完成 - - - - SubtitleSettingDialog - - - 字幕设置 - 字幕設置 - - - - 字幕分割 - 字幕分割 - - - - 字幕是否使用大语言模型进行智能断句 - 字幕是否使用大語言模型進行智能斷句 - - - - 中文最大字数 - 中文最大字數 - - - - 单条字幕的最大字数 (对于中日韩等字符) - 單條字幕的最大字數 (對於中日韓等字符) - - - - 英文最大单词数 - 英文最大單詞數 - - - - 单条字幕的最大单词数 (英文) - 單條字幕的最大單詞數 (英文) - - - - 去除末尾标点符号 - 去除末尾標點符號 - - - - 是否去除中文字幕中的末尾标点符号 - 是否去除中文字幕中的末尾標點符號 - - - - 关闭 - 關閉 - - - - SubtitleStyleInterface - - - 字幕样式配置 - 字幕樣式配置 - - - - 字幕排布 - 字幕排布 - - - - 主字幕样式 - 主字幕樣式 - - - - 副字幕样式 - 副字幕樣式 - - - - 预览设置 - 預覽設置 - - - - 预览效果 - 預覽效果 - - - - 选择样式 - 選擇樣式 - - - - 选择已保存的字幕样式 - 選擇已保存的字幕樣式 - - - - 新建样式 - 新建樣式 - - - - 基于当前样式新建预设 - 基於當前樣式新建預設 - - - - 打开样式文件夹 - 打開樣式文件夾 - - - - 在文件管理器中打开样式文件夹 - 在文件管理器中打開樣式文件夾 - - - - 设置主字幕和副字幕的显示方式 - 設置主字幕和副字幕的顯示方式 - - - - 垂直间距 - 垂直間距 - - - - 设置字幕的垂直间距 - 設置字幕的垂直間距 - - - - 主字幕字体 - 主字幕字體 - - - - 设置主字幕的字体 - 設置主字幕的字體 - - - - 主字幕字号 - 主字幕字號 - - - - 设置主字幕的大小 - 設置主字幕的大小 - - - - 主字幕间距 - 主字幕間距 - - - - 设置主字幕的字符间距 - 設置主字幕的字符間距 - - - - 主字幕颜色 - 主字幕顏色 - - - - 设置主字幕的颜色 - 設置主字幕的顏色 - - - - 主字幕边框颜色 - 主字幕邊框顏色 - - - - 设置主字幕的边框颜色 - 設置主字幕的邊框顏色 - - - - 主字幕边框大小 - 主字幕邊框大小 - - - - 设置主字幕的边框粗细 - 設置主字幕的邊框粗細 - - - - 副字幕字体 - 副字幕字體 - - - - 设置副字幕的字体 - 設置副字幕的字體 - - - - 副字幕字号 - 副字幕字號 - - - - 设置副字幕的大小 - 設置副字幕的大小 - - - - 副字幕间距 - 副字幕間距 - - - - 设置副字幕的字符间距 - 設置副字幕的字符間距 - - - - 副字幕颜色 - 副字幕顏色 - - - - 设置副字幕的颜色 - 設置副字幕的顏色 - - - - 副字幕边框颜色 - 副字幕邊框顏色 - - - - 设置副字幕的边框颜色 - 設置副字幕的邊框顏色 - - - - 副字幕边框大小 - 副字幕邊框大小 - - - - 设置副字幕的边框粗细 - 設置副字幕的邊框粗細 - - - - 预览文字 - 預覽文字 - - - - 设置预览显示的文字内容 - 設置預覽顯示的文字內容 - - - - 预览方向 - 預覽方向 - - - - 设置预览图片的显示方向 - 設置預覽圖片的顯示方向 - - - - 选择图片 - 選擇圖片 - - - - 预览背景 - 預覽背景 - - - - 选择预览使用的背景图片 - 選擇預覽使用的背景圖片 - - - - 选择背景图片 - 選擇背景圖片 - - - - 图片文件 - 圖片文件 - - - - 成功 - 成功 - - - - 已加载样式 - 已加載樣式 - - - - 警告 - 警告 - - - - 样式 - 樣式 - - - - 已存在 - 已存在 - - - - 已创建新样式 - 已創建新樣式 - - - - SubtitleTableModel - - - 开始时间 - 開始時間 - - - - 结束时间 - 結束時間 - - - - 字幕内容 - 字幕內容 - - - - 翻译字幕 - 翻譯字幕 - - - - 优化字幕 - 優化字幕 - - - - SubtitleThread - - - LLM API 未配置, 请检查LLM配置 - LLM API 未配置, 請檢查LLM配置 - - - - 字幕文件路径为空 - 字幕文件路徑為空 - - - - 字幕配置为空 - 字幕配置為空 - - - - 开始验证 LLM 配置... - 開始驗證 LLM 配置... - - - - 字幕断句... - 字幕斷句... - - - - 优化字幕... - 優化字幕... - - - - LLM 模型未配置 - LLM 模型未配置 - - - - 翻译字幕... - 翻譯字幕... - - - - 目标语言未配置 - 目標語言未配置 - - - - 不支持的翻译服务: {translator_service} - 不支持的翻譯服務: {translator_service} - - - - 优化完成 - 優化完成 - - - - 字幕处理失败 - 字幕處理失敗 - - - - {0}% 处理字幕 - {0}% 處理字幕 - - - - 已终止 - 已終止 - - - - 终止时发生错误 - 終止時發生錯誤 - - - - TaskCreationInterface - - - 请拖拽文件或输入视频URL - 請拖拽文件或輸入視頻URL - - - - 准备就绪 - 準備就緒 - - - - 查看日志 - 查看日誌 - - - - 捐助 - 捐助 - - - - ©VideoCaptioner {VERSION} • By Weifeng - ©VideoCaptioner {VERSION} • By Weifeng - - - - 选择媒体文件 - 選擇媒體文件 - - - - 导入成功 - 導入成功 - - - - 导入媒体文件成功 - 導入媒體文件成功 - - - - 格式错误 - 格式錯誤 - - - - 不支持该文件格式 - 不支持該文件格式 - - - - 错误 - 錯誤 - - - - 请输入有效的文件路径或视频URL - 請輸入有效的文件路徑或視頻URL - - - - 警告 - 警告 - - - - 建议根据文档配置cookies.txt文件,以可以下载高清视频 - 建議根據文檔配置cookies.txt文件,以可以下載高清視頻 - - - - 开始下载 - 開始下載 - - - - 开始下载视频... - 開始下載視頻... - - - - 下载成功 - 下載成功 - - - - 视频下载完成,开始自动处理... - 視頻下載完成,開始自動處理... - - - - 视频下载失败 - 視頻下載失敗 - - - - 请输入音视频文件路径或URL - 請輸入音視頻文件路徑或URL - - - - TranscriptThread - - - 转录失败 - 轉錄失敗 - - - - 文件路径为空 - 文件路徑為空 - - - - 视频文件不存在 - 視頻文件不存在 - - - - 转录配置为空 - 轉錄配置為空 - - - - 输出路径为空 - 輸出路徑為空 - - - - 字幕已下载 - 字幕已下載 - - - - 转换音频中 - 轉換音頻中 - - - - 音频转换失败 - 音頻轉換失敗 - - - - 语音转录中 - 語音轉錄中 - - - - 转录完成 - 轉錄完成 - - - - TranscriptionInterface - - - 打开文件 - 打開文件 - - - - 转录模型 - 轉錄模型 - - - - 转录完成 - 轉錄完成 - - - - 开始字幕优化... - 開始字幕優化... - - - - 选择媒体文件 - 選擇媒體文件 - - - - 错误 - 錯誤 - - - - 警告 - 警告 - - - - 正在处理中,请等待当前任务完成 - 正在處理中,請等待當前任務完成 - - - - 导入成功 - 導入成功 - - - - 开始语音转文字 - 開始語音轉文字 - - - - 格式错误 - 格式錯誤 - - - - 请拖入音频或视频文件 - 請拖入音頻或視頻文件 - - - - VideoInfoCard - - - 请拖入音频或视频文件 - 請拖入音頻或視頻文件 - - - - 画质 - 畫質 - - - - 文件大小 - 文件大小 - - - - 时长 - 時長 - - - - 音轨 - 音軌 - - - - 打开文件夹 - 打開文件夾 - - - - 开始转录 - 開始轉錄 - - - - 画质: - 畫質: - - - - 大小: - 大小: - - - - 时长: - 時長: - - - - 警告 - 警告 - - - - 没有可用的字幕文件夹 - 沒有可用的字幕文件夾 - - - - 重新转录 - 重新轉錄 - - - - 转录失败 - 轉錄失敗 - - - - 转录完成 - 轉錄完成 - - - - VideoSynthesisInterface - - - 开始合成 - 開始合成 - - - - 字幕文件 - 字幕文件 - - - - 选择或者拖拽字幕文件 - 選擇或者拖拽字幕文件 - - - - 浏览 - 瀏覽 - - - - 视频文件 - 視頻文件 - - - - 选择或者拖拽视频文件 - 選擇或者拖拽視頻文件 - - - - 就绪 - 就緒 - - - - 软字幕 - 軟字幕 - - - - 使用软字幕嵌入视频 - 使用軟字幕嵌入視頻 - - - - 视频质量 - 視頻質量 - - - - 合成视频 - 合成視頻 - - - - 是否生成新的视频文件 - 是否生成新的視頻文件 - - - - 打开输出文件夹 - 打開輸出文件夾 - - - - 选择视频文件 - 選擇視頻文件 - - - - 开启软字幕 - 開啓軟字幕 - - - - 字幕作为独立轨道嵌入视频,播放器中可关闭或调整 - 字幕作為獨立軌道嵌入視頻,播放器中可關閉或調整 - - - - 开启硬烧录字幕 - 開啓硬燒錄字幕 - - - - 字幕直接烧录到视频画面中,带有设置的样式 - 字幕直接燒錄到視頻畫面中,帶有設置的樣式 - - - - 开启视频合成 - 開啓視頻合成 - - - - 将进行视频与字幕的合成操作 - 將進行視頻與字幕的合成操作 - - - - 关闭视频合成 - 關閉視頻合成 - - - - 仅生成字幕文件,不生成新的视频文件 - 僅生成字幕文件,不生成新的視頻文件 - - - - 选择字幕文件 - 選擇字幕文件 - - - - 错误 - 錯誤 - - - - 请选择字幕文件和视频文件 - 請選擇字幕文件和視頻文件 - - - - 成功 - 成功 - - - - 视频合成已完成 - 視頻合成已完成 - - - - 警告 - 警告 - - - - 没有可用的视频文件夹 - 沒有可用的視頻文件夾 - - - - 导入成功 - 導入成功 - - - - 字幕文件已放入输入框 - 字幕文件已放入輸入框 - - - - 视频文件已输入框 - 視頻文件已輸入框 - - - - 格式错误 - 格式錯誤 - - - - 请拖入视频或者字幕文件 - 請拖入視頻或者字幕文件 - - - - VideoSynthesisThread - - - 合成完成 - 合成完成 - - - - 正在合成 - 正在合成 - - - - 视频路径为空 - 視頻路徑為空 - - - - 字幕路径为空 - 字幕路徑為空 - - - - 输出路径为空 - 輸出路徑為空 - - - - 视频合成失败 - 視頻合成失敗 - - - - WhisperAPISettingWidget - - - Whisper API 设置 - Whisper API 設置 - - - - API Base URL - API Base URL - - - - 输入 Whisper API Base URL - 輸入 Whisper API Base URL - - - - API Key - API Key - - - - 输入 Whisper API Key - 輸入 Whisper API Key - - - - Whisper 模型 - Whisper 模型 - - - - 选择 Whisper 模型 - 選擇 Whisper 模型 - - - - 原语言 - 原語言 - - - - 音频的原语言 - 音頻的原語言 - - - - 提示词 - 提示詞 - - - - 可选的提示词,默认空 - 可選的提示詞,默認空 - - - - 测试连接 - 測試連接 - - - - 测试 Whisper API 连接 - 測試 Whisper API 連接 - - - - 点击测试 API 连接是否正常 - 點擊測試 API 連接是否正常 - - - - 配置不完整 - 配置不完整 - - - - 请输入 API Base URL、API Key 和 model - 請輸入 API Base URL、API Key 和 model - - - - 正在测试... - 正在測試... - - - - 连接成功 - 連接成功 - - - - Whisper API 连接成功! - Whisper API 連接成功! - - - - 连接失败 - 連接失敗 - - - - Whisper API 连接失败! -{result} - Whisper API 連接失敗! -{result} - - - - 测试错误 - 測試錯誤 - - - - WhisperCppDownloadDialog - - - 关闭 - 關閉 - - - - WhisperCpp程序 - WhisperCpp程序 - - - - 已安装版本: {versions_text} - 已安裝版本: {versions_text} - - - - 未下载 WhisperCpp 程序 - 未下載 WhisperCpp 程序 - - - - 模型下载 - 模型下載 - - - - 打开模型文件夹 - 打開模型文件夾 - - - - 模型名称 - 模型名稱 - - - - 大小 - 大小 - - - - 状态 - 狀態 - - - - 操作 - 操作 - - - - 已下载 - 已下載 - - - - 未下载 - 未下載 - - - - 重新下载 - 重新下載 - - - - 下载 - 下載 - - - - 下载进行中 - 下載進行中 - - - - 请等待当前下载任务完成 - 請等待當前下載任務完成 - - - - 正在下载 {model['label']} 模型... - 正在下載 {model['label']} 模型... - - - - 下载成功 - 下載成功 - - - - {model['label']} 模型已下载完成 - {model['label']} 模型已下載完成 - - - - 下载失败 - 下載失敗 - - - - WhisperCppSettingWidget - - - Whisper CPP 设置 - Whisper CPP 設置(不穩定 🤔) - - - - 模型 - 模型 - - - - 选择Whisper模型 - 選擇Whisper模型 - - - - 源语言 - 源語言 - - - - 音频的源语言 - 音頻的源語言 - - - - 管理模型 - 管理模型 - - - - 模型管理 - 模型管理 - - - - 下载或更新 Whisper CPP 模型 - 下載或更新 Whisper CPP 模型 - - - diff --git a/scripts/batch_state_shots.py b/scripts/batch_state_shots.py new file mode 100644 index 00000000..645168e9 --- /dev/null +++ b/scripts/batch_state_shots.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""把批量处理页驱动到设计稿 A 的 4 个状态并逐一截图。 + +用法: + .venv/bin/python scripts/batch_state_shots.py /tmp/vc-batch-shots \ + [--compare /tmp/vc-batch-design] [--theme light] +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +OUT_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/vc-batch-shots") +COMPARE_DIR = ( + Path(sys.argv[sys.argv.index("--compare") + 1]) if "--compare" in sys.argv else None +) +THEME = sys.argv[sys.argv.index("--theme") + 1] if "--theme" in sys.argv else "dark" + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault( + "VIDEOCAPTIONER_CONFIG_FILE", str(OUT_DIR / "state-shots-config.toml") +) + +STATES = ["v1a", "v1b", "v1c", "v1d"] + +# 设计稿状态 B/C/D 用到的演示文件清单 +DEMO_FILES = [ + ("Courses/linear-algebra", "线性代数基础 p01.mp4"), + ("Courses/linear-algebra", "线性代数基础 p02.mp4"), + ("Courses/audio", "linear-algebra-review.m4a"), + ("Courses/matrix", "矩阵专题讲义.mp4"), + ("Courses/vector-space", "向量空间例题 p03.mp4"), + ("Courses/audio", "basis-and-rank.m4a"), + ("Courses/eigen", "特征值专题 p01.mp4"), + ("Courses/qa", "课程答疑合集.mov"), + ("Courses/audio", "矩阵分解复习.wav"), + ("Courses/final-review", "期末串讲 p02.mp4"), +] + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + config = Path(os.environ["VIDEOCAPTIONER_CONFIG_FILE"]) + if config.exists(): + config.unlink() + + from PyQt5.QtWidgets import QApplication + + app = QApplication([]) + + from qfluentwidgets import Theme, setTheme + + from videocaptioner.ui.common.config import ThemeMode, cfg + from videocaptioner.ui.view.batch_process_interface import ( + BatchProcessInterface, + JobStatus, + ) + + cfg.set( + cfg.themeMode, + ThemeMode.LIGHT if THEME == "light" else ThemeMode.DARK, + save=False, + ) + setTheme(Theme.LIGHT if THEME == "light" else Theme.DARK) + + assets = OUT_DIR / "assets" + paths = [] + for folder, name in DEMO_FILES: + target = assets / folder / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"\0" * 1024) + paths.append(str(target)) + + page = BatchProcessInterface() + page.resize(1322, 820) + page.show() + + def settle(): + for _ in range(10): + app.processEvents() + + def grab(name: str): + settle() + page.resize(1322, 820) + settle() + page.grab().save(str(OUT_DIR / f"{name}.png")) + print(f"shot={OUT_DIR / f'{name}.png'}") + + # A 空队列(全流程模式) + cfg.set(cfg.batch_mode, "full", save=False) + cfg.set(cfg.dubbing_enabled, True, save=False) + page._switch_mode("full") + grab("v1a") + + # B 队列已就绪:10 个文件 + page.controller.add_paths(paths) + grab("v1b") + + # C 处理中:首个任务字幕处理 48%,其余等待 + jobs = page.controller.jobs + page._batch_ran = False + page.controller._dispatch_enabled = True + page.controller._stages = ["transcribe", "subtitle", "dubbing", "synthesis"] + jobs[0].status = JobStatus.RUNNING + jobs[0].progress = 48 + jobs[0].note = "字幕处理 · 第 57 / 124 条" + jobs[0].stage = "subtitle" + jobs[1].note = "等待上一个任务完成" + + class _FakeRunner: # 只为 active_stages/is_active 查询提供 job 映射 + pass + + page.controller._runners = {_FakeRunner(): jobs[0]} + for index in range(len(jobs)): + page.controller.jobChanged.emit(index) + grab("v1c") + + # D 完成与失败恢复:7 完成 1 失败(移除 2 个,剩 8 个任务) + page.controller._runners = {} + page.controller._dispatch_enabled = False + for job in (jobs[8], jobs[9]): + page.controller.jobs.remove(job) + page.controller.queueChanged.emit() + jobs = page.controller.jobs + for index, job in enumerate(jobs): + job.stage = "" + if job.name == "矩阵专题讲义.mp4": + job.status = JobStatus.FAILED + job.progress = 18 + job.note = "LLM API Key 缺失" + job.error = "字幕处理:LLM API Key 缺失" + else: + job.status = JobStatus.COMPLETED + job.progress = 100 + suffix = Path(job.name).suffix + job.note = ( + "已输出原始字幕" + if suffix in (".m4a", ".wav") + else f"已输出 【卡卡】{Path(job.name).stem}.mp4" + ) + page.controller.jobChanged.emit(index) + page._batch_ran = True + page._refresh() + grab("v1d") + + page.close() + if COMPARE_DIR is not None: + _build_comparison(COMPARE_DIR) + print("state-shots=ok") + return 0 + + +def _build_comparison(design_dir: Path) -> None: + from PIL import Image, ImageDraw + + for state in STATES: + design = design_dir / f"{state}-split.png" + ours = OUT_DIR / f"{state}.png" + if not design.exists() or not ours.exists(): + continue + top = Image.open(design).convert("RGB") + bottom = Image.open(ours).convert("RGB") + width = max(top.width, bottom.width) + sheet = Image.new("RGB", (width, top.height + bottom.height + 56), (10, 12, 11)) + draw = ImageDraw.Draw(sheet) + draw.text((12, 6), f"{state} design (top) vs app (bottom)", fill=(240, 245, 242)) + sheet.paste(top, (0, 28)) + sheet.paste(bottom, (0, top.height + 56)) + path = OUT_DIR / f"compare-{state}.png" + sheet.save(path) + print(f"compare={path}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_desktop.py b/scripts/build_desktop.py index 8dd5a8a8..3a2be137 100644 --- a/scripts/build_desktop.py +++ b/scripts/build_desktop.py @@ -70,7 +70,12 @@ def clean() -> None: def prepare_ffmpeg() -> None: - """Download the current platform's static ffmpeg/ffprobe into runtime resources.""" + """Download the current platform's static ffmpeg/ffprobe into runtime resources. + + 应用自身的媒体探测统一走 ``ffmpeg -i``(core/utils/media_info.py),但配音 + 管线的 pydub 读 mp3 仍硬依赖 ffprobe(AudioSegment.from_file → mediainfo_json), + 所以 ffprobe 必须随包,直到 pydub 被替换。 + """ try: from static_ffmpeg.run import ( get_or_fetch_platform_executables_else_raise, @@ -97,6 +102,44 @@ def prepare_ffmpeg() -> None: print(f"Bundled {dst.relative_to(ROOT)}") +def prepare_macsysaudio() -> None: + """Build the macOS system-audio helper (ScreenCaptureKit) into runtime resources. + + macOS only, ~91KB static Swift binary — small + unchanging, so it ships INSIDE the + bundle (no download step, no signing needed: an unsigned helper still works, the user + just grants「屏幕录制」once and the grant persists for a never-updated app). + + Same staging pattern as ffmpeg: build → copy into RUNTIME_DIR/resource/bin with +x; + PyInstaller's onedir COLLECT preserves the mode bit, so find_macsysaudio_binary finds + an executable at BUNDLED_BIN_PATH (else os.access(X_OK) fails and 「系统声音」 hides). + + Best-effort: without the Swift toolchain (Xcode CLT) it warns and continues — the app + still runs, system audio capture is just unavailable. Needs native/macsysaudio/ in the + repo (git-tracked) for the build.sh source to exist on a fresh checkout. + """ + if platform.system() != "Darwin": + return + runtime_bin = RUNTIME_DIR / "resource" / "bin" + runtime_bin.mkdir(parents=True, exist_ok=True) + build_sh = ROOT / "native" / "macsysaudio" / "build.sh" + src = ROOT / "resource" / "bin" / "macsysaudio" + try: + if build_sh.exists(): + _run(["bash", str(build_sh)]) + except Exception as exc: + print(f"WARNING: macsysaudio build failed ({exc}); system audio will be unavailable") + if not src.exists(): + print("WARNING: macsysaudio binary not found (native/macsysaudio not built); " + "system audio capture will be unavailable in this build") + return + dst = runtime_bin / "macsysaudio" + if dst.exists(): + dst.chmod(dst.stat().st_mode | stat.S_IWUSR) + shutil.copy2(src, dst) + dst.chmod(dst.stat().st_mode | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + print(f"Bundled {dst.relative_to(ROOT)}") + + def build_pyinstaller() -> None: env = os.environ.copy() env["VIDEOCAPTIONER_DESKTOP_RUNTIME_DIR"] = str(RUNTIME_DIR) @@ -125,10 +168,16 @@ def _archive_dir(source: Path, archive: Path) -> None: archive.parent.mkdir(parents=True, exist_ok=True) if archive.exists(): archive.unlink() - with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf: - for file in sorted(source.rglob("*")): - if file.is_file(): - zf.write(file, file.relative_to(source.parent)) + if platform.system() == "Darwin": + # macOS 必须用 ditto:zipfile 会把符号链接拍平成普通文件、丢掉可执行位,解压出的 + # .app(Python/Qt framework 的 Versions/Current 软链 + 主程序 +x)将无法启动。 + # ditto 保留软链/权限/代码签名,且产物是标准 zip。客户端 _extract 对应也用 ditto。 + _run(["ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", str(source), str(archive)]) + else: + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf: + for file in sorted(source.rglob("*")): + if file.is_file(): + zf.write(file, file.relative_to(source.parent)) print(f"Created {archive.relative_to(ROOT)}") @@ -145,13 +194,17 @@ def verify_bundle() -> None: required = [ data_root / "resource" / "assets" / "logo.png", data_root / "resource" / "fonts" / "NotoSansSC-Regular.ttf", - data_root / "resource" / "subtitle_style" / "ass-default.json", + data_root / "resource" / "subtitle_styles" / "ass" / "default.json", data_root / "resource" / "bin" / ("ffmpeg.exe" if platform.system() == "Windows" else "ffmpeg"), data_root / "resource" / "bin" / ("ffprobe.exe" if platform.system() == "Windows" else "ffprobe"), ] missing = [str(path.relative_to(ROOT)) for path in required if not path.exists()] if missing: raise RuntimeError("Missing bundled resources:\n - " + "\n - ".join(missing)) + if platform.system() == "Darwin": + # 软检查(不阻断构建):缺它只是「系统声音」不可用,不是整包坏了。 + helper = data_root / "resource" / "bin" / "macsysaudio" + print(f" macsysaudio (系统声音): {'present' if helper.exists() else 'MISSING — system audio disabled'}") print(f"Verified desktop bundle: {bundle.relative_to(ROOT)}") @@ -161,6 +214,10 @@ def archive(version: str) -> None: _archive_dir(bundle, ARTIFACT_DIR / f"VideoCaptioner-{version}-{tag}.zip") app = DIST_DIR / "VideoCaptioner.app" if app.exists(): + # ad-hoc 签名后再打包:更新负载(app-zip)与 dmg 走同一签名姿态,规避 Apple Silicon + # 「已损坏」硬拦截。无 Apple 证书,ad-hoc 不消除 Gatekeeper 首启提示(需公证)。 + if subprocess.run(["codesign", "--force", "--deep", "--sign", "-", str(app)]).returncode != 0: + print("⚠ ad-hoc codesign 失败(不阻断打包)") _archive_dir(app, ARTIFACT_DIR / f"VideoCaptioner-{version}-{tag}-app.zip") @@ -175,6 +232,7 @@ def main() -> int: clean() ensure_version_file(version) prepare_ffmpeg() + prepare_macsysaudio() build_pyinstaller() verify_bundle() if not args.no_archive: diff --git a/scripts/build_macos_dmg.py b/scripts/build_macos_dmg.py new file mode 100644 index 00000000..35864112 --- /dev/null +++ b/scripts/build_macos_dmg.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""把 dist/VideoCaptioner.app 打成拖拽安装的 .dmg(仅 macOS)。 + +无 Apple 证书时尽力而为:先 ad-hoc 签名(`codesign -s -`),避免 Apple Silicon 上未签名包被 +判「已损坏,移到废纸篓」的硬拦截 —— 降级成可「右键 → 打开」的软提示。.dmg 只是更顺手的安装 +形态,**不能**消除首次打开的 Gatekeeper 提示(那需要付费证书 + 公证)。自动更新换装后的 .app +由 helper 清 quarantine,所以只有首次手动下载需要右键打开一次。 +""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_desktop import ARTIFACT_DIR, DIST_DIR, ROOT, _platform_tag, _version # noqa: E402 + + +def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess: + print("+ " + " ".join(map(str, cmd))) + return subprocess.run(cmd, check=check) + + +def build_dmg() -> Path: + if platform.system() != "Darwin": + raise SystemExit("build_macos_dmg.py 只能在 macOS 上运行") + app = DIST_DIR / "VideoCaptioner.app" + if not app.exists(): + raise SystemExit(f"未找到 {app},请先运行 build_desktop.py") + version = _version() + ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) + out = ARTIFACT_DIR / f"VideoCaptioner-{version}-{_platform_tag()}.dmg" + out.unlink(missing_ok=True) + + # ad-hoc 签名:尽力而为,失败不阻断打包(CI 真机的 .app 应当成功)。 + res = _run(["codesign", "--force", "--deep", "--sign", "-", str(app)], check=False) + if res.returncode != 0: + print("⚠ ad-hoc codesign 失败(不阻断打包);首次打开可能被判「已损坏」,需手动清 quarantine") + + # 暂存目录放 .app + 指向 /Applications 的软链,hdiutil 打成拖拽安装的 dmg。 + with tempfile.TemporaryDirectory() as tmp: + stage = Path(tmp) / "dmg" + stage.mkdir() + shutil.copytree(app, stage / app.name, symlinks=True) + (stage / "Applications").symlink_to("/Applications") + _run([ + "hdiutil", "create", + "-volname", "VideoCaptioner", + "-srcfolder", str(stage), + "-fs", "HFS+", + "-format", "UDZO", + "-ov", + str(out), + ]) + print(f"Created {out.relative_to(ROOT)}") + return out + + +if __name__ == "__main__": + build_dmg() diff --git a/scripts/build_windows_installer.py b/scripts/build_windows_installer.py new file mode 100644 index 00000000..55a627e8 --- /dev/null +++ b/scripts/build_windows_installer.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""用 Inno Setup 把 dist/VideoCaptioner onedir 打成 Setup.exe(仅 Windows)。 + +per-user 安装到 %LOCALAPPDATA%\\Programs\\VideoCaptioner:无需管理员,安装目录可写 → 应用内 +自更新(core/update 的 rm+mv 换装)照常生效。带开始菜单/桌面快捷方式 + 卸载程序。便携 zip 由 +build_desktop.py 单独产出,两者基于同一 onedir。CI 用 `choco install innosetup` 装编译器。 +""" + +from __future__ import annotations + +import os +import platform +import subprocess +import sys +from pathlib import Path +from shutil import which + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_desktop import ARTIFACT_DIR, DIST_DIR, ROOT, _version # noqa: E402 + +ISS = ROOT / "packaging" / "windows" / "VideoCaptioner.iss" + + +def _find_iscc() -> str: + exe = which("ISCC") or which("iscc") + if exe: + return exe + for base in ( + os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), + os.environ.get("ProgramFiles", r"C:\Program Files"), + ): + cand = Path(base) / "Inno Setup 6" / "ISCC.exe" + if cand.is_file(): + return str(cand) + raise SystemExit("未找到 Inno Setup 编译器 ISCC.exe(CI 用 `choco install innosetup -y` 安装)") + + +def build_installer() -> Path: + if platform.system() != "Windows": + raise SystemExit("build_windows_installer.py 只能在 Windows 上运行") + source = DIST_DIR / "VideoCaptioner" + if not (source / "VideoCaptioner.exe").exists(): + raise SystemExit(f"未找到 {source}\\VideoCaptioner.exe,请先运行 build_desktop.py") + version = _version() + ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) + out_base = f"VideoCaptioner-{version}-windows-x64-setup" + cmd = [ + _find_iscc(), + f"/DAppVersion={version}", + f"/DSourceDir={source}", + f"/DOutputDir={ARTIFACT_DIR}", + f"/DOutputBase={out_base}", + str(ISS), + ] + print("+ " + " ".join(cmd)) + subprocess.run(cmd, check=True) + out = ARTIFACT_DIR / f"{out_base}.exe" + if not out.is_file(): + raise SystemExit(f"ISCC 未产出 {out}") + print(f"Created {out}") + return out + + +if __name__ == "__main__": + build_installer() diff --git a/scripts/design_reference_shots.py b/scripts/design_reference_shots.py new file mode 100644 index 00000000..f830dcf6 --- /dev/null +++ b/scripts/design_reference_shots.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""把 docs/dev 设计稿 HTML 里的每个 .client 状态截成基准图。 + +用系统 Chrome(playwright channel=chrome)逐元素截图,输出: + + /.png 整个 1440x900 client(含标题栏/侧边栏) + /-split.png 仅工作区(.split / .work 元素本身), + 与应用页面 widget 截图同尺寸,可直接对比 + +状态 id 取自 .client 之前最近的 .state-label / .version-title 的 id; +没有 id 时按序号 s1、s2... 命名。 + +用法: + .venv/bin/python scripts/design_reference_shots.py \ + docs/dev/design-transcription.html /tmp/vc-design-shots + .venv/bin/python scripts/design_reference_shots.py \ + docs/dev/design-subtitle.html /tmp/vc-sub-design-shots + # 第三个参数可覆盖工作区选择器(如批量处理设计稿整个 .app 区域): + .venv/bin/python scripts/design_reference_shots.py \ + docs/dev/design-batch.html /tmp/vc-batch-design .app +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +CONTENT_SELECTOR = ".split, .work" + + +def main() -> int: + if len(sys.argv) not in (3, 4): + raise SystemExit(__doc__) + html_path = Path(sys.argv[1]).resolve() + out_dir = Path(sys.argv[2]) + content_selector = sys.argv[3] if len(sys.argv) == 4 else CONTENT_SELECTOR + out_dir.mkdir(parents=True, exist_ok=True) + + with sync_playwright() as p: + browser = p.chromium.launch(channel="chrome", headless=True) + page = browser.new_page(viewport={"width": 1560, "height": 1200}) + page.goto(html_path.as_uri()) + page.wait_for_timeout(600) # 等图标 SVG 加载 + + clients = page.query_selector_all(".client") + labels = page.query_selector_all(".state-label, .version-title") + for index, client in enumerate(clients): + state_id = None + if index < len(labels): + state_id = labels[index].get_attribute("id") + if not state_id: + # 从标题文本提取状态字母,如“状态 C:处理中” -> c + text = labels[index].inner_text() + match = re.search(r"状态\s*([A-Z])", text) + if match: + state_id = f"v1{match.group(1).lower()}" + state_id = state_id or f"s{index + 1}" + + path = out_dir / f"{state_id}.png" + client.screenshot(path=str(path)) + print(f"shot={path}") + + content = client.query_selector(content_selector) + if content is not None: + split_path = out_dir / f"{state_id}-split.png" + content.screenshot(path=str(split_path)) + print(f"split={split_path}") + browser.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/i18n.py b/scripts/i18n.py new file mode 100644 index 00000000..f1df4298 --- /dev/null +++ b/scripts/i18n.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""VideoCaptioner UI 国际化工具链(key-based gettext)。 + +源串是 key(``tr("dubbing.btn.start")``),基准语言 zh_Hans 的 msgstr 存中文, +是 key→中文 的唯一真相源;en / zh_Hant 从它翻译。运行时只用标准库 gettext 读 .mo。 + +子命令: + extract 扫描源码 tr()/N_() → resource/i18n/videocaptioner.pot + update .pot → 各语言 .po(缺则 init,存则 merge 保留已译) + fill-base 用 {key: 中文} 填 zh_Hans.po 的空 msgstr(初次批量导入) + translate [lang...] 用 LLM 把 zh_Hans 中文译进 en/zh_Hant 的空 msgstr(需 OPENAI_API_KEY) + compile 各语言 .po → .mo + check CI 门禁:源码 key 集 == .pot;zh_Hans 无空 msgstr + sync extract→update→fill-base→translate→compile 全流程 + +用法: + python scripts/i18n.py extract + python scripts/i18n.py sync /tmp/i18n-base.json +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from babel.messages.pofile import read_po, write_po +from openai import OpenAI +from pydantic import BaseModel + +from videocaptioner.ui.common.config import source_language_i18n_map +from videocaptioner.ui.common.dubbing_options import i18n_base_map as dubbing_i18n_map +from videocaptioner.ui.common.enum_labels import enum_base_map + +# Windows GBK 控制台打印 +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except (AttributeError, ValueError): + pass + +ROOT = Path(__file__).resolve().parent.parent +I18N_DIR = ROOT / "resource" / "i18n" +POT = I18N_DIR / "videocaptioner.pot" +BABEL_CFG = ROOT / "babel.cfg" +DOMAIN = "videocaptioner" +BASE = "zh_Hans" +LANGS = ["zh_Hans", "zh_Hant", "en"] +# LLM 目标语言名(zh_Hans 是基准,不在此)。 +TARGET_NAME = {"en": "English", "zh_Hant": "Traditional Chinese (Hong Kong)"} + +PRESERVE_TERMS = [ + "ASR", "LLM", "TTS", "FFmpeg", "Whisper", "OpenAI", "GPU", "CPU", + "VAD", "OCR", "API", "Key", "URL", "SRT", "ASS", "Edge", "Gemini", + "SiliconFlow", "CosyVoice", "voxgate", "Fun-ASR", "RapidOCR", +] + + +class _TransItem(BaseModel): + index: int + translation: str + + +class _TransBatch(BaseModel): + items: list[_TransItem] + + +def _rel(p: Path) -> str: + try: + return str(p.relative_to(ROOT)) + except ValueError: + return str(p) + + +def _runtime_keys() -> dict[str, str]: + """运行时动态拼接、pybabel 抽不到的 key→基准中文(枚举标签 + 配音 + 识别语言)。""" + keys = dict(enum_base_map()) + keys.update(dubbing_i18n_map()) + keys.update(source_language_i18n_map()) + return keys + + +def _po_path(lang: str) -> Path: + return I18N_DIR / lang / "LC_MESSAGES" / f"{DOMAIN}.po" + + +def _pybabel(*args: str) -> None: + # PYTHONUTF8:Windows 默认 GBK 编码读不了含中文注释的 babel.cfg + env = {**os.environ, "PYTHONUTF8": "1"} + subprocess.run( + [sys.executable, "-m", "babel.messages.frontend", *args], + check=True, cwd=ROOT, env=env, + ) + + +# ---------------------------------------------------------------- extract / update / compile +def extract(out: Path = POT) -> None: + # f-string 内部的 tr() 依赖 PEP 701 tokenizer 才能抽到,低版本会静默丢 key + if sys.version_info < (3, 12): + sys.exit("✗ extract 需要 Python >= 3.12(与 CI 一致),否则抽不到 f-string 内的 tr()") + out.parent.mkdir(parents=True, exist_ok=True) + _pybabel( + "extract", "-F", str(BABEL_CFG), "-k", "tr", "-k", "N_", + "--no-location", "--sort-output", "--omit-header", + "-o", str(out), "videocaptioner", + ) + # 动态拼接 key(枚举/配音/识别语言)pybabel 抽不到 → 在此补进 .pot。 + with out.open("rb") as f: + cat = read_po(f) + have = {m.id for m in cat if m.id} + added = 0 + for key in _runtime_keys(): + if key not in have: + cat.add(key) + added += 1 + with out.open("wb") as f: + write_po(f, cat, omit_header=True, sort_output=True) + print(f"✓ extract → {_rel(out)}(+{added} 运行时 key)") + + +def update() -> None: + for lang in LANGS: + po = _po_path(lang) + po.parent.mkdir(parents=True, exist_ok=True) + if po.exists(): + _pybabel( + "update", "-i", str(POT), "-o", str(po), "-l", lang, + "--no-fuzzy-matching", "--ignore-obsolete", + ) + else: + _pybabel("init", "-i", str(POT), "-o", str(po), "-l", lang) + print(f"✓ update → {_rel(po)}") + + +def compile_() -> None: + for lang in LANGS: + po = _po_path(lang) + if not po.exists(): + print(f"⚠ skip compile (no po): {lang}") + continue + _pybabel("compile", "-i", str(po), "-o", str(po.with_suffix(".mo")), "-l", lang) + print(f"✓ compile → {_rel(po.with_suffix('.mo'))}") + + +# ---------------------------------------------------------------- fill-base +def fill_base(mapping_path: Path) -> None: + mapping = dict(_runtime_keys()) # 运行时动态 key(枚举/配音/语言)基准中文自动并入 + mapping.update(json.loads(Path(mapping_path).read_text(encoding="utf-8"))) + po = _po_path(BASE) + with po.open("rb") as f: + catalog = read_po(f) + filled = missing = 0 + for msg in catalog: + if not msg.id: + continue + zh = mapping.get(msg.id) + if zh is None: + missing += 1 + continue + if msg.string != zh: + msg.string = zh + filled += 1 + extra = [k for k in mapping if k not in {m.id for m in catalog if m.id}] + with po.open("wb") as f: + write_po(f, catalog, sort_output=True) + print(f"✓ fill-base: 写入 {filled} 条;.pot 中无中文映射 {missing} 条;映射里多余 {len(extra)} 条") + if extra: + print(" 多余 key(不在源码里):", ", ".join(extra[:20]) + (" …" if len(extra) > 20 else "")) + + +# ---------------------------------------------------------------- translate (LLM) +# 翻译端点由环境变量配置(默认 OpenAI;用 SiliconFlow 等兼容端点时覆盖三者): +# VC_TRANSLATE_API_KEY(缺省回退 OPENAI_API_KEY) / VC_TRANSLATE_BASE_URL / VC_TRANSLATE_MODEL +# VC_TRANSLATE_WORKERS(并发批数,默认 8) +def translate(langs: list[str]) -> None: + base_map = _base_map() + for lang in langs: + name = TARGET_NAME.get(lang) + if not name: + print(f"⚠ 跳过 {lang}(非 LLM 目标)") + continue + po = _po_path(lang) + with po.open("rb") as f: + catalog = read_po(f) + todo = [(m.id, base_map.get(m.id, m.id)) for m in catalog if m.id and not m.string] + if not todo: + print(f"✓ {lang}: 无空译文") + continue + print(f"… {lang}: 翻译 {len(todo)} 条") + result = _llm_translate([t[1] for t in todo], name) + by_id = {tid: text for (tid, _), text in zip(todo, result) if text} + for m in catalog: + if m.id in by_id: + m.string = by_id[m.id] + with po.open("wb") as f: + write_po(f, catalog, sort_output=True) + miss = len(todo) - len(by_id) + print(f"✓ {lang}: 写入 {len(by_id)} 条" + (f"({miss} 条未译)" if miss else "") + f" → {_rel(po)}") + + +def _base_map() -> dict[str, str]: + with _po_path(BASE).open("rb") as f: + catalog = read_po(f) + return {m.id: m.string for m in catalog if m.id and m.string} + + +def _translate_client() -> tuple[OpenAI, str]: + key = os.environ.get("VC_TRANSLATE_API_KEY") or os.environ.get("OPENAI_API_KEY") + if not key: + raise SystemExit("translate 需要 VC_TRANSLATE_API_KEY(或 OPENAI_API_KEY)") + base = os.environ.get("VC_TRANSLATE_BASE_URL", "https://api.openai.com/v1") + model = os.environ.get("VC_TRANSLATE_MODEL", "gpt-5") + return OpenAI(api_key=key, base_url=base), model + + +def _translate_batch( + client: OpenAI, model: str, target_name: str, indexed: list[tuple[int, str]] +) -> dict[int, str]: + """一批 (全局 index, 中文) → {index: 译文},用结构化输出保证 1:1。""" + numbered = "\n".join(f"{i}: {t}" for i, t in indexed) + comp = client.beta.chat.completions.parse( + model=model, + messages=[ + { + "role": "system", + "content": f"You are a professional software UI translator. Translate each " + f"Chinese source string to {target_name}, concise and natural for UI.", + }, + { + "role": "user", + "content": ( + f"Translate these UI strings from Chinese to {target_name}. Return one item per " + "input line with its SAME index. Keep placeholders like {name}/{count} EXACTLY. " + f"Do NOT translate these terms: {', '.join(PRESERVE_TERMS)}.\n\n{numbered}" + ), + }, + ], + response_format=_TransBatch, + temperature=0.3, + ) + parsed = comp.choices[0].message.parsed + return {it.index: it.translation for it in (parsed.items if parsed else [])} + + +def _llm_translate(texts: list[str], target_name: str, batch: int = 20) -> list[str]: + client, model = _translate_client() + workers = int(os.environ.get("VC_TRANSLATE_WORKERS", "8")) + out: list[str] = [""] * len(texts) + batches = [list(enumerate(texts))[i : i + batch] for i in range(0, len(texts), batch)] + total = len(batches) + done = 0 + with ThreadPoolExecutor(max_workers=workers) as ex: + futures = {ex.submit(_translate_batch, client, model, target_name, b): b for b in batches} + for fut in as_completed(futures): + try: + for i, text in fut.result().items(): + if 0 <= i < len(out): + out[i] = text + except Exception as exc: # noqa: BLE001 — 单批失败不应中断整体,缺失项后续重试 + print(f" ⚠ batch 失败: {type(exc).__name__}: {str(exc)[:120]}") + done += 1 + print(f" {done}/{total} 批完成", flush=True) + # 缺失项(解析丢条/批失败)串行重试一次 + missing = [i for i, v in enumerate(out) if not v] + if missing: + print(f" 重试 {len(missing)} 条缺失…") + try: + for i, text in _translate_batch( + client, model, target_name, [(i, texts[i]) for i in missing] + ).items(): + if 0 <= i < len(out): + out[i] = text + except Exception as exc: # noqa: BLE001 + print(f" ⚠ 重试失败: {type(exc).__name__}: {str(exc)[:120]}") + return out + + +# ---------------------------------------------------------------- check (CI) +def check() -> int: + with tempfile.NamedTemporaryFile(suffix=".pot", delete=False) as tmp: + fresh = Path(tmp.name) + extract(fresh) + src_keys = _pot_keys(fresh) + committed = _pot_keys(POT) if POT.exists() else set() + fresh.unlink(missing_ok=True) + + rc = 0 + if src_keys != committed: + only_src = sorted(src_keys - committed) + only_pot = sorted(committed - src_keys) + print("✗ 源码 key 集与提交的 .pot 不一致(需 i18n.py extract+update)") + if only_src: + print(f" 源码新增未抽取: {len(only_src)}:", ", ".join(only_src[:15])) + if only_pot: + print(f" .pot 残留已删: {len(only_pot)}:", ", ".join(only_pot[:15])) + rc = 1 + else: + print(f"✓ key 集一致({len(src_keys)} 条)") + + base = _po_path(BASE) + if base.exists(): + with base.open("rb") as f: + cat = read_po(f) + empty = [m.id for m in cat if m.id and not m.string] + if empty: + print(f"✗ 基准 {BASE} 有 {len(empty)} 条空译文:", ", ".join(empty[:15])) + rc = 1 + else: + print(f"✓ 基准 {BASE} 无空译文") + else: + print(f"✗ 缺基准 po: {base}") + rc = 1 + return rc + + +def _pot_keys(pot: Path) -> set[str]: + with pot.open("rb") as f: + cat = read_po(f) + return {m.id for m in cat if m.id} + + +# ---------------------------------------------------------------- main +def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 1 + cmd = sys.argv[1] + if cmd == "extract": + extract() + elif cmd == "update": + update() + elif cmd == "compile": + compile_() + elif cmd == "fill-base": + fill_base(Path(sys.argv[2])) + elif cmd == "translate": + translate(sys.argv[2:] or list(TARGET_NAME)) + elif cmd == "check": + return check() + elif cmd == "sync": + extract() + update() + fill_base(Path(sys.argv[2])) + translate(list(TARGET_NAME)) + compile_() + else: + print(__doc__) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/readme_preview_shots.py b/scripts/readme_preview_shots.py new file mode 100644 index 00000000..17849156 --- /dev/null +++ b/scripts/readme_preview_shots.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""生成 README 的界面预览图(docs/images/preview-*.png)。 + +每张图都驱动到「真实任务处理中」的有内容状态,而不是空态: + +- preview-home.png 首页(任务创建 tab,链接解析后正在下载 36%) +- preview-transcription.png 语音转录(转录完成:字幕表 + 视频封面 + 结果面板) +- preview-subtitle.png 字幕优化与翻译(翻译进行中 46%,第 57/124 条) +- preview-batch.png 批量处理(队列处理中,首个任务 48%) +- preview-dubbing.png 配音(音色库 + 试听文案) + +用法: + .venv/bin/python scripts/readme_preview_shots.py +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +OUT_DIR = ROOT / "docs" / "images" +ASSETS = Path(tempfile.mkdtemp(prefix="vc-readme-shots-")) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("VIDEOCAPTIONER_CONFIG_FILE", str(ASSETS / "config.toml")) + +SIZE = (1322, 804) + +SEGMENTS = [ + (1120, 3400, "大家好,欢迎来到这一节课。"), + (3520, 6200, "我们今天讲矩阵和向量空间。"), + (6360, 8900, "先从最基本的概念开始。"), + (9100, 12000, "后面会用几个例子来说明。"), + (12240, 15640, "我们先看一下矩阵的形式。"), + (15900, 18200, "它由若干行和若干列组成。"), +] + +ROWS = [ + (1120, 3400, "大家好,欢迎来到这一节课。", "Hello, welcome to this lesson."), + (3520, 6200, "我们今天讲矩阵和向量空间。", "Today we will talk about matrices and vector spaces."), + (6360, 8900, "先从最基本的概念开始。", "Let's start with the most basic concepts."), + (9100, 12000, "后面会用几个例子来说明。", "We will explain it with examples later."), + (12240, 15640, "我们先看一下矩阵的形式。", "Let's look at the form of a matrix."), + (15900, 18200, "它由若干行和若干列组成。", ""), +] + +BATCH_FILES = [ + ("Courses/linear-algebra", "线性代数基础 p01.mp4"), + ("Courses/linear-algebra", "线性代数基础 p02.mp4"), + ("Courses/audio", "linear-algebra-review.m4a"), + ("Courses/matrix", "矩阵专题讲义.mp4"), + ("Courses/vector-space", "向量空间例题 p03.mp4"), + ("Courses/audio", "basis-and-rank.m4a"), + ("Courses/eigen", "特征值专题 p01.mp4"), + ("Courses/qa", "课程答疑合集.mov"), +] + + +def make_cover(path: Path) -> None: + """画一张讲义风格的视频封面(渐变底 + 标题),供缩略图展示。""" + from PIL import Image, ImageDraw, ImageFont + + width, height = 640, 360 + image = Image.new("RGB", (width, height)) + top, bottom = (16, 42, 34), (4, 12, 10) + for y in range(height): + t = y / height + image.paste( + tuple(int(a + (b - a) * t) for a, b in zip(top, bottom)), + (0, y, width, y + 1), + ) + draw = ImageDraw.Draw(image) + accent = (62, 230, 145) + draw.rounded_rectangle((40, 96, 96, 104), 4, fill=accent) + font_path = ROOT / "resource" / "fonts" / "LXGWWenKai-Regular.ttf" + title_font = ImageFont.truetype(str(font_path), 44) + sub_font = ImageFont.truetype(str(font_path), 22) + draw.text((40, 128), "线性代数基础", font=title_font, fill=(238, 244, 240)) + draw.text((40, 188), "第 1 讲 · 矩阵与向量空间", font=sub_font, fill=(150, 168, 158)) + draw.text((40, 296), "p01 · 10:24", font=sub_font, fill=accent) + image.save(path) + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + + from PyQt5.QtWidgets import QApplication + + app = QApplication([]) + + from qfluentwidgets import Theme, setTheme, setThemeColor + + from videocaptioner.core.asr.asr_data import ASRData, ASRDataSeg + from videocaptioner.core.entities import ( + AudioStreamInfo, + TranscribeModelEnum, + VideoInfo, + ) + from videocaptioner.ui.common.config import DEFAULT_THEME_COLOR, ThemeMode, cfg + + cfg.set(cfg.themeMode, ThemeMode.DARK, save=False) + setTheme(Theme.DARK) + setThemeColor(DEFAULT_THEME_COLOR) + + def settle(widget, n: int = 12): + for _ in range(n): + app.processEvents() + + def grab(widget, name: str): + settle(widget) + widget.grab().save(str(OUT_DIR / name)) + print(f"shot={OUT_DIR / name}") + + cover = ASSETS / "cover.png" + make_cover(cover) + + # ---------------------------------------------------------- 首页(下载中) + from videocaptioner.ui.view.home_interface import HomeInterface + from videocaptioner.ui.view.task_creation_interface import PageState as TaskState + + home = HomeInterface() + home.resize(*SIZE) + home.show() + settle(home) + task = home.task_creation_interface + task.inputField.edit.setText("https://www.bilibili.com/video/BV1Et421E7jk") + task.state = TaskState.DOWNLOADING + task._refresh() + task.downloadPanel.setMedia( + { + "title": "线性代数基础与解法全集|长期更新|从零开始", + "uploader": "数学老师", + "duration": "10:24", + "site": "BiliBili", + } + ) + task._on_download_progress(36, "正在下载媒体") + task.downloadPanel.setStats("4.6 MB/s", "剩余 01:18") + grab(home, "preview-home.png") + home.close() + + # ----------------------------------------------- 语音转录(完成 + 字幕表) + from videocaptioner.ui.view.transcription_interface import ( + PageState as TransState, + ) + from videocaptioner.ui.view.transcription_interface import ( + TranscriptionInterface, + ) + + cfg.set(cfg.transcribe_model, TranscribeModelEnum.BIJIAN, save=False) + video_path = ASSETS / "线性代数基础与解法全集 p01.mp4" + video_path.write_bytes(b"\0" * 1024) + trans = TranscriptionInterface() + trans.resize(*SIZE) + trans.show() + settle(trans) + trans._media_path = str(video_path) + trans.media_info = VideoInfo( + file_name="线性代数基础与解法全集 p01.mp4", + file_path=str(video_path), + width=1920, + height=1080, + fps=30.0, + duration_seconds=624, + bitrate_kbps=4000, + video_codec="h264", + audio_codec="aac", + audio_sampling_rate=44100, + thumbnail_path=str(cover), + audio_streams=[AudioStreamInfo(index=0, codec="aac", language="zh")], + ) + segments = (SEGMENTS * 37)[:220] + trans.subtitlePreview.setSegments(segments) + trans.subtitlePreview.setActiveRow(1) + trans.resultPanel.thumb.setMedia(str(cover), False) + trans.resultPanel.setResult( + title="线性代数基础与解法全集 p01", + chips=["B 接口", "10:24", "SRT"], + file_name="【原始字幕】线性代数基础-B接口.srt", + ) + trans._apply_state(TransState.DONE) + grab(trans, "preview-transcription.png") + trans.close() + + # ------------------------------------------- 字幕优化与翻译(翻译中 46%) + from videocaptioner.ui.view.subtitle_interface import PageState as SubState + from videocaptioner.ui.view.subtitle_interface import SubtitleInterface + + cfg.set(cfg.need_optimize, True, save=False) + cfg.set(cfg.need_translate, True, save=False) + srt_path = ASSETS / "线性代数基础 p01.srt" + srt_segments = [] + for index in range(124): + start, end, text, _ = ROWS[index % len(ROWS)] + offset = (index // len(ROWS)) * 20000 + srt_segments.append(ASRDataSeg(text, start + offset, end + offset)) + ASRData(srt_segments).to_srt(save_path=str(srt_path)) + + subtitle = SubtitleInterface() + subtitle.resize(*SIZE) + subtitle.show() + settle(subtitle) + subtitle.load_subtitle_file(str(srt_path)) + keys = list(subtitle.model.raw().keys()) + translations = { + keys[index]: ROWS[index % len(ROWS)][3] + for index in range(5) + if ROWS[index % len(ROWS)][3] + } + translations[keys[5]] = "正在翻译..." + subtitle.model.merge_translations(translations) + subtitle._apply_state(SubState.RUNNING) + subtitle._translated_count = 57 + subtitle.model.set_dim_from(6) + subtitle.tablePanel.bottomBar.showRunning("字幕翻译", 46, 57, 124) + subtitle.tablePanel.table.selectRow(2) + grab(subtitle, "preview-subtitle.png") + subtitle.close() + + # ------------------------------------------------- 批量处理(处理中 48%) + from videocaptioner.ui.view.batch_process_interface import ( + BatchProcessInterface, + JobStatus, + ) + + cfg.set(cfg.batch_mode, "full", save=False) + cfg.set(cfg.dubbing_enabled, True, save=False) + # 批量行会展示文件所在目录,用干净的演示路径(而不是 /var/folders 临时目录) + batch_root = Path("/tmp/课程素材") + paths = [] + for folder, name in BATCH_FILES: + target = batch_root / folder / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"\0" * 1024) + paths.append(str(target)) + batch = BatchProcessInterface() + batch.resize(*SIZE) + batch.show() + settle(batch) + batch._switch_mode("full") + batch.controller.add_paths(paths) + jobs = batch.controller.jobs + batch.controller._dispatch_enabled = True + batch.controller._stages = ["transcribe", "subtitle", "dubbing", "synthesis"] + jobs[0].status = JobStatus.RUNNING + jobs[0].progress = 48 + jobs[0].note = "字幕处理 · 第 57 / 124 条" + jobs[0].stage = "subtitle" + jobs[1].note = "等待上一个任务完成" + + class _FakeRunner: + pass + + batch.controller._runners = {_FakeRunner(): jobs[0]} + for index in range(len(jobs)): + batch.controller.jobChanged.emit(index) + grab(batch, "preview-batch.png") + batch.controller._runners = {} # 假 runner 不参与关闭收尾 + batch.close() + + # ----------------------------------------------------------------- 配音 + from videocaptioner.ui.view.dubbing_interface import DubbingInterface + + dubbing = DubbingInterface() + dubbing.resize(*SIZE) + dubbing.show() + grab(dubbing, "preview-dubbing.png") + dubbing.close() + + print("readme-previews=ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/register_release.py b/scripts/register_release.py new file mode 100644 index 00000000..e01d20d8 --- /dev/null +++ b/scripts/register_release.py @@ -0,0 +1,105 @@ +"""发版时把新版本登记到更新后端(POST /api/admin/release)。 + +取代旧的「生成 latest.json 挂 Release」:后端(飞书多维表格驱动)据此在「版本」表建/改一行, +客户端启动调 /api/update/check 即可拿到。二进制仍在 GitHub Release,这里只上报下载地址 + +sha256 + size。资产名由 scripts/build_desktop.py 决定: + + VideoCaptioner-{version}-windows-x64.zip Windows onedir 目录包 + VideoCaptioner-{version}-macos-x64-app.zip macOS .app 包 + VideoCaptioner-{version}-macos-arm64-app.zip macOS .app 包 + (macOS 非 -app 的裸 onedir 包不参与更新,跳过) + +用法(CI): + CI_RELEASE_TOKEN=xxx python scripts/register_release.py \ + --tag v2.3.0 --repo WEIFENG2333/VideoCaptioner \ + --artifacts ./artifacts [--notes "本次更新…"] \ + [--endpoint https://backend.videocaptioner.cn/api/admin/release] +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import sys +from pathlib import Path + +import requests + +_DEFAULT_ENDPOINT = "https://backend.videocaptioner.cn/api/admin/release" +_NAME = re.compile(r"^VideoCaptioner-(?P.+?)-(?Pwindows|macos)-(?P[a-z0-9]+)(?P-app)?\.zip$") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as fh: + for block in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _platform_key(name: str) -> str | None: + """资产名 → 平台键;不参与更新的资产返回 None。""" + m = _NAME.match(name) + if not m: + return None + if m["os"] == "macos": + return f"macos-{m['arch']}" if m["app"] else None # 仅 .app 参与更新 + return f"windows-{m['arch']}" + + +def build_platforms(artifacts: Path, repo: str, tag: str) -> dict: + platforms: dict[str, dict] = {} + for zip_path in sorted(artifacts.rglob("VideoCaptioner-*.zip")): + key = _platform_key(zip_path.name) + if not key: + continue + platforms[key] = { + "url": f"https://github.com/{repo}/releases/download/{tag}/{zip_path.name}", + "sha256": _sha256(zip_path), + "size": zip_path.stat().st_size, + } + return platforms + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--tag", required=True, help="Release tag,如 v2.3.0") + parser.add_argument("--repo", required=True, help="owner/name,如 WEIFENG2333/VideoCaptioner") + parser.add_argument("--artifacts", required=True, type=Path, help="含 zip 资产的目录(递归扫描)") + parser.add_argument("--notes", default="", help="更新说明") + parser.add_argument("--endpoint", default=_DEFAULT_ENDPOINT, help="登记端点 URL") + args = parser.parse_args() + + token = os.environ.get("CI_RELEASE_TOKEN", "").strip() + if not token: + print("✗ 缺少环境变量 CI_RELEASE_TOKEN", file=sys.stderr) + return 1 + + version = args.tag.lstrip("vV") + platforms = build_platforms(args.artifacts, args.repo, args.tag) + if not platforms: + print(f"✗ 未在 {args.artifacts} 找到可发布资产(VideoCaptioner-*-{{windows,macos}}-*.zip)", file=sys.stderr) + return 1 + + payload = {"version": version, "notes": args.notes, "platforms": platforms} + resp = requests.post( + args.endpoint, + headers={"Authorization": f"Bearer {token}"}, + json=payload, + timeout=30, + ) + print(f"POST {args.endpoint} → {resp.status_code}") + print(resp.text[:500]) + resp.raise_for_status() + body = resp.json() + if not body.get("ok"): + print(f"✗ 后端登记失败:{body}", file=sys.stderr) + return 1 + print(f"✓ 已登记 {version}({len(platforms)} 个平台:{', '.join(platforms)})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_desktop.py b/scripts/smoke_desktop.py index 14322d9c..b30ca73c 100644 --- a/scripts/smoke_desktop.py +++ b/scripts/smoke_desktop.py @@ -4,14 +4,23 @@ from __future__ import annotations import argparse -import json import os import platform +import re import shutil import subprocess +import sys import tempfile from pathlib import Path +# Windows 控制台默认 cp1252,打印中文标签(如依赖校验的中文名)会 UnicodeEncodeError 崩掉 +# 冒烟测试 → 拦住产物上传。强制 UTF-8 输出,跨平台一致。 +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except (AttributeError, ValueError): + pass + def _run(cmd: list[str], *, env: dict[str, str] | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess: print("+ " + " ".join(str(part) for part in cmd)) @@ -62,6 +71,18 @@ def _write_sample_srt(path: Path) -> None: ) +def _write_smoke_config(path: Path) -> None: + path.write_text( + "[transcribe]\n" + 'asr = "bijian"\n\n' + "[dubbing]\n" + 'provider = "edge"\n' + 'preset = "edge-cn-female"\n' + 'voice = "zh-CN-XiaoxiaoNeural"\n', + encoding="utf-8", + ) + + def _create_sample_video(ffmpeg: Path, output: Path) -> None: _run([ str(ffmpeg), @@ -90,19 +111,36 @@ def _create_sample_video(ffmpeg: Path, output: Path) -> None: ]) -def _duration(ffprobe: Path, media: Path) -> float: - result = subprocess.run([ - str(ffprobe), - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "json", - str(media), - ], check=True, capture_output=True, text=True) - data = json.loads(result.stdout) - return float(data["format"]["duration"]) +def _duration(ffmpeg: Path, media: Path) -> float: + # 包里只带 ffmpeg(媒体探测统一走 ffmpeg -i,见 core/utils/media_info.py), + # 时长从其 stderr 的 Duration 行解析 + result = subprocess.run( + [str(ffmpeg), "-hide_banner", "-i", str(media)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + match = re.search(r"Duration: (\d+):(\d+):(\d+\.?\d*)", result.stderr or "") + if not match: + raise RuntimeError(f"Cannot parse duration from ffmpeg output: {media}") + h, m, s = match.groups() + return int(h) * 3600 + int(m) * 60 + float(s) + + +def _check_bundled_payload(bundle: Path) -> None: + """新功能的原生依赖必须真打进包;缺了让 smoke 失败,堵住『OCR/实时字幕没进包也 CI 绿』。 + + voxgate 不在此列(按设计运行时下载)。仅对 onedir 目录校验(传单可执行文件时跳过)。 + """ + if bundle.is_file(): + return + required = { + "onnxruntime 原生库(硬字幕 OCR)": ["libonnxruntime*", "onnxruntime_pybind11_state*", "onnxruntime*.dll"], + "rapidocr 模型(硬字幕 OCR)": ["*PP-OCR*.onnx", "*ppocr*.onnx"], + "PortAudio(实时字幕采集)": ["libportaudio*", "portaudio*.dll"], + } + for label, globs in required.items(): + if not any(any(bundle.rglob(g)) for g in globs): + raise RuntimeError(f"打包缺少新功能依赖:{label}(未在包内找到 {globs})") + print(f"Bundled payload OK: {label}") def main() -> int: @@ -112,15 +150,17 @@ def main() -> int: bundle = Path(args.bundle).resolve() exe = _find_executable(bundle) + _check_bundled_payload(bundle) ffmpeg = _find_bundled_tool(bundle, "ffmpeg") - ffprobe = _find_bundled_tool(bundle, "ffprobe") with tempfile.TemporaryDirectory(prefix="videocaptioner-smoke-") as tmp: tmp_path = Path(tmp) env = os.environ.copy() - env["PATH"] = os.defpath + env["PATH"] = str(ffmpeg.parent) + os.pathsep + os.defpath + env["VIDEOCAPTIONER_CONFIG_FILE"] = str(tmp_path / "config.toml") env["VIDEOCAPTIONER_LLM_API_KEY"] = "" env["VIDEOCAPTIONER_TTS_API_KEY"] = "" + _write_smoke_config(Path(env["VIDEOCAPTIONER_CONFIG_FILE"])) video = tmp_path / "sample.mp4" subtitle = tmp_path / "sample.srt" @@ -162,7 +202,7 @@ def main() -> int: for output in [soft_out, hard_out]: if not output.exists() or output.stat().st_size <= 0: raise RuntimeError(f"Expected output was not created: {output}") - seconds = _duration(ffprobe, output) + seconds = _duration(ffmpeg, output) if seconds < 2.5: raise RuntimeError(f"Output duration is unexpectedly short: {output} ({seconds:.2f}s)") print(f"Verified {output.name}: {output.stat().st_size} bytes, {seconds:.2f}s") diff --git a/scripts/subtitle_state_shots.py b/scripts/subtitle_state_shots.py new file mode 100644 index 00000000..f0c9b76a --- /dev/null +++ b/scripts/subtitle_state_shots.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""把字幕优化页驱动到设计稿 B 的 5 个状态并逐一截图。 + +数据与 docs/dev/design-subtitle.html 一致(124 条、46%、 +第 57 / 124 条、失败文案等),截图尺寸与 design_reference_shots.py 产出的 +*-split.png 对应(.work 元素 1278x717,含 24px 顶部留白)。 + +用法: + .venv/bin/python scripts/subtitle_state_shots.py /tmp/vc-sub-shots \ + [--compare /tmp/vc-sub-design] [--theme light] +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +OUT_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/vc-sub-shots") +COMPARE_DIR = ( + Path(sys.argv[sys.argv.index("--compare") + 1]) if "--compare" in sys.argv else None +) +THEME = sys.argv[sys.argv.index("--theme") + 1] if "--theme" in sys.argv else "dark" + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault( + "VIDEOCAPTIONER_CONFIG_FILE", str(OUT_DIR / "state-shots-config.toml") +) + +STATES = ["v1a", "v1b", "v1c", "v1d", "v1e"] + +DESIGN_ROWS = [ + (1120, 3400, "大家好,欢迎来到这一节课。", "Hello, welcome to this lesson."), + (3520, 6200, "我们今天讲矩阵和向量空间。", "Today we will talk about matrices and vector spaces."), + (6360, 8900, "先从最基本的概念开始。", "Let's start with the most basic concepts."), + (9100, 12000, "后面会用几个例子来说明。", "We will explain it with examples later."), + (12240, 15640, "我们先看一下矩阵的形式。", ""), + (15900, 18200, "它由若干行和若干列组成。", ""), +] + + +def write_design_srt(path: Path, count: int = 124) -> None: + """按设计稿内容生成 count 条的 SRT 文件。""" + from videocaptioner.core.asr.asr_data import ASRData, ASRDataSeg + + segments = [] + for index in range(count): + start, end, text, _ = DESIGN_ROWS[index % len(DESIGN_ROWS)] + offset = (index // len(DESIGN_ROWS)) * 20000 + segments.append(ASRDataSeg(text, start + offset, end + offset)) + path.parent.mkdir(parents=True, exist_ok=True) + ASRData(segments).to_srt(save_path=str(path)) + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + config = Path(os.environ["VIDEOCAPTIONER_CONFIG_FILE"]) + if config.exists(): + config.unlink() + + from PyQt5.QtWidgets import QApplication + + app = QApplication([]) + + from qfluentwidgets import Theme, setTheme + + from videocaptioner.ui.common.config import ThemeMode, cfg + from videocaptioner.ui.view.subtitle_interface import PageState, SubtitleInterface + + cfg.set( + cfg.themeMode, + ThemeMode.LIGHT if THEME == "light" else ThemeMode.DARK, + save=False, + ) + setTheme(Theme.LIGHT if THEME == "light" else Theme.DARK) + cfg.set(cfg.need_optimize, True, save=False) + cfg.set(cfg.need_translate, True, save=False) + cfg.set(cfg.need_split, False, save=False) + + srt_path = OUT_DIR / "assets" / "线性代数基础 p01.srt" + write_design_srt(srt_path) + + page = SubtitleInterface() + # 设计稿 .work 元素含 24px 顶部留白,对齐到相同的截图区域。 + page.layout().setContentsMargins(0, 24, 0, 0) + page.resize(1278, 717) + page.show() + + def settle(): + for _ in range(10): + app.processEvents() + + def grab(name: str): + settle() + page.grab().save(str(OUT_DIR / f"{name}.png")) + print(f"shot={OUT_DIR / f'{name}.png'}") + + # A 未加载字幕 + grab("v1a") + + # B 准备处理(124 条,选中第 2 行) + page.load_subtitle_file(str(srt_path)) + page.tablePanel.table.selectRow(1) + grab("v1b") + + # C 处理中(字幕翻译 46%,第 57 / 124 条) + keys = list(page.model.raw().keys()) + page.model.merge_translations({keys[0]: DESIGN_ROWS[0][3], keys[1]: "正在翻译..."}) + page._apply_state(PageState.RUNNING) + page._translated_count = 57 + page.model.set_dim_from(2) + page.tablePanel.bottomBar.showRunning("字幕翻译", 46, 57, 124) + page.tablePanel.table.selectRow(1) + grab("v1c") + + # D 完成检查(译文填充,输出文件名带排布后缀) + translations = { + keys[index]: DESIGN_ROWS[index % len(DESIGN_ROWS)][3] + for index in range(4) + if DESIGN_ROWS[index % len(DESIGN_ROWS)][3] + } + page.model.merge_translations(translations) + page._output_path = str(OUT_DIR / "assets" / "线性代数基础 p01-译文在上.srt") + page.tablePanel.setFile("线性代数基础 p01-译文在上.srt", loaded=True) + page._apply_state(PageState.DONE) + page.tablePanel.table.selectRow(1) + grab("v1d") + + # E 配置未就绪 + page.tablePanel.setFile("线性代数基础 p01.srt", loaded=True) + page._output_path = None + page._apply_state( + PageState.FAILED, error="需要先配置可用的大模型 API Key、接口地址和模型。" + ) + grab("v1e") + + page.close() + if COMPARE_DIR is not None: + _build_comparison(COMPARE_DIR) + print("state-shots=ok") + return 0 + + +def _build_comparison(design_dir: Path) -> None: + from PIL import Image, ImageDraw + + for state in STATES: + design = design_dir / f"{state}-split.png" + ours = OUT_DIR / f"{state}.png" + if not design.exists() or not ours.exists(): + continue + top = Image.open(design).convert("RGB") + bottom = Image.open(ours).convert("RGB") + width = max(top.width, bottom.width) + sheet = Image.new("RGB", (width, top.height + bottom.height + 56), (10, 12, 11)) + draw = ImageDraw.Draw(sheet) + draw.text((12, 6), f"{state} design (top) vs app (bottom)", fill=(240, 245, 242)) + sheet.paste(top, (0, 28)) + sheet.paste(bottom, (0, top.height + 56)) + path = OUT_DIR / f"compare-{state}.png" + sheet.save(path) + print(f"compare={path}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/synthesis_state_shots.py b/scripts/synthesis_state_shots.py new file mode 100644 index 00000000..615c14e5 --- /dev/null +++ b/scripts/synthesis_state_shots.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""把字幕视频合成页驱动到设计稿 A 的 7 个状态并逐一截图。 + +用法: + .venv/bin/python scripts/synthesis_state_shots.py /tmp/vc-syn-shots \ + [--compare /tmp/vc-syn-design] [--theme light] +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +OUT_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/vc-syn-shots") +COMPARE_DIR = ( + Path(sys.argv[sys.argv.index("--compare") + 1]) if "--compare" in sys.argv else None +) +THEME = sys.argv[sys.argv.index("--theme") + 1] if "--theme" in sys.argv else "dark" + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault( + "VIDEOCAPTIONER_CONFIG_FILE", str(OUT_DIR / "state-shots-config.toml") +) + +STATES = ["v1a", "v1b", "v1c", "v1d", "v1e", "v1f", "v1g"] + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + config = Path(os.environ["VIDEOCAPTIONER_CONFIG_FILE"]) + if config.exists(): + config.unlink() + + from PyQt5.QtWidgets import QApplication + + app = QApplication([]) + + from qfluentwidgets import Theme, setTheme + + import videocaptioner.ui.view.video_synthesis_interface as synthesis_module + from videocaptioner.core.asr.asr_data import ASRData, ASRDataSeg + from videocaptioner.ui.common.config import ThemeMode, cfg + from videocaptioner.ui.view.video_synthesis_interface import VideoSynthesisInterface + + cfg.set( + cfg.themeMode, + ThemeMode.LIGHT if THEME == "light" else ThemeMode.DARK, + save=False, + ) + setTheme(Theme.LIGHT if THEME == "light" else Theme.DARK) + # 截图时不真正打开访达 + synthesis_module.open_folder = lambda _path: None + + assets = OUT_DIR / "assets" + assets.mkdir(exist_ok=True) + srt_path = assets / "线性代数基础 p01-译文在上.srt" + ASRData([ASRDataSeg("示例字幕", 0, 2000)]).to_srt(save_path=str(srt_path)) + video_path = assets / "linear-algebra-demo.mp4" + video_path.write_bytes(b"\0" * 1024) + + page = VideoSynthesisInterface() + page.layout().setContentsMargins(0, 24, 0, 0) + page.resize(1278, 717) + page.show() + + def settle(): + for _ in range(10): + app.processEvents() + + def grab(name: str): + settle() + page.resize(1278, 717) + settle() + page.grab().save(str(OUT_DIR / f"{name}.png")) + print(f"shot={OUT_DIR / f'{name}.png'}") + + # A 空态:默认字幕视频开 + cfg.set(cfg.need_video, True, save=False) + cfg.set(cfg.dubbing_enabled, False, save=False) + page._refresh() + grab("v1a") + + # B 字幕视频缺视频 + page.set_subtitle_file(str(srt_path)) + grab("v1b") + + # C 只生成配音音频(Edge 免 Key) + cfg.set(cfg.dubbing_provider, "edge", save=False) + cfg.set(cfg.need_video, False, save=False) + cfg.set(cfg.dubbing_enabled, True, save=False) + grab("v1c") + + # D 全流程就绪 + cfg.set(cfg.need_video, True, save=False) + page.set_video_file(str(video_path)) + grab("v1d") + + # E 运行中(配音 38%) + page._enter_running() + page._on_progress(38, "生成配音") + grab("v1e") + + # F 完成 + wav_path = assets / "【配音音频】线性代数基础 p01-译文在上.wav" + wav_path.write_bytes(b"\0" * 2048) + page._on_completed([("字幕视频", str(video_path)), ("配音音频", str(wav_path))]) + grab("v1f") + + # G 配置缺失(SiliconFlow 无 Key) + page.state = synthesis_module.PageState.IDLE + cfg.set(cfg.dubbing_provider, "siliconflow", save=False) + cfg.set(cfg.dubbing_api_key, "", save=False) + page._refresh() + grab("v1g") + + page.close() + if COMPARE_DIR is not None: + _build_comparison(COMPARE_DIR) + print("state-shots=ok") + return 0 + + +def _build_comparison(design_dir: Path) -> None: + from PIL import Image, ImageDraw + + for state in STATES: + design = design_dir / f"{state}-split.png" + ours = OUT_DIR / f"{state}.png" + if not design.exists() or not ours.exists(): + continue + top = Image.open(design).convert("RGB") + bottom = Image.open(ours).convert("RGB") + width = max(top.width, bottom.width) + sheet = Image.new("RGB", (width, top.height + bottom.height + 56), (10, 12, 11)) + draw = ImageDraw.Draw(sheet) + draw.text((12, 6), f"{state} design (top) vs app (bottom)", fill=(240, 245, 242)) + sheet.paste(top, (0, 28)) + sheet.paste(bottom, (0, top.height + 56)) + path = OUT_DIR / f"compare-{state}.png" + sheet.save(path) + print(f"compare={path}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/task_state_shots.py b/scripts/task_state_shots.py new file mode 100644 index 00000000..faef6ac0 --- /dev/null +++ b/scripts/task_state_shots.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""把任务创建页驱动到设计稿 A 的 4 个状态并逐一截图。 + +用法: + .venv/bin/python scripts/task_state_shots.py /tmp/vc-task-shots \ + [--compare /tmp/vc-task-design] [--theme light] +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +OUT_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/vc-task-shots") +COMPARE_DIR = ( + Path(sys.argv[sys.argv.index("--compare") + 1]) if "--compare" in sys.argv else None +) +THEME = sys.argv[sys.argv.index("--theme") + 1] if "--theme" in sys.argv else "dark" + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault( + "VIDEOCAPTIONER_CONFIG_FILE", str(OUT_DIR / "state-shots-config.toml") +) + +STATES = ["v1a", "v1b", "v1c", "v1d"] + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + config = Path(os.environ["VIDEOCAPTIONER_CONFIG_FILE"]) + if config.exists(): + config.unlink() + + from PyQt5.QtWidgets import QApplication + + app = QApplication([]) + + from qfluentwidgets import Theme, setTheme + + from videocaptioner.ui.common.config import ThemeMode, cfg + from videocaptioner.ui.view.task_creation_interface import ( + PageState, + TaskCreationInterface, + ) + + cfg.set( + cfg.themeMode, + ThemeMode.LIGHT if THEME == "light" else ThemeMode.DARK, + save=False, + ) + setTheme(Theme.LIGHT if THEME == "light" else Theme.DARK) + + assets = OUT_DIR / "assets" / "课程素材" + assets.mkdir(parents=True, exist_ok=True) + video = assets / "线性代数基础与解法全集 p01.mp4" + video.write_bytes(b"\0" * (128 * 1024)) + + page = TaskCreationInterface() + page.resize(1322, 804) + page.show() + + def settle(): + for _ in range(10): + app.processEvents() + + def grab(name: str): + settle() + page.grab().save(str(OUT_DIR / f"{name}.png")) + print(f"shot={OUT_DIR / f'{name}.png'}") + + # A 空态 + grab("v1a") + + # B 本地文件已就绪 + page.inputField.edit.setText(str(video)) + grab("v1b") + + # C 链接下载中(36%) + page.inputField.edit.setText("https://www.bilibili.com/video/BV1Et421E7jk") + page.state = PageState.DOWNLOADING + page._refresh() + page.downloadPanel.setMedia( + { + "title": "线性代数基础与解法全集|长期更新|从零开始", + "uploader": "数学老师", + "duration": "10:24", + "site": "BiliBili", + } + ) + page._on_download_progress(36, "正在下载媒体") + page.downloadPanel.setStats("4.6 MB/s", "剩余 01:18") + grab("v1c") + + # D 输入错误 + page.state = PageState.INPUT + page.inputField.edit.setText("not-a-video") + grab("v1d") + + # E 下载前确认(解析完成,选择清晰度) + page.inputField.edit.setText("https://www.bilibili.com/video/BV1Et421E7jk") + page.state = PageState.PROBING + page._on_probed( + { + "title": "线性代数基础与解法全集|长期更新|从零开始", + "site": "BiliBili", + "uploader": "数学老师", + "duration": "10:24", + "qualities": [1080, 720, 480, 360], + "has_subtitle": True, + } + ) + grab("v1e") + + page.close() + if COMPARE_DIR is not None: + _build_comparison(COMPARE_DIR) + print("state-shots=ok") + return 0 + + +def _build_comparison(design_dir: Path) -> None: + from PIL import Image, ImageDraw + + for state in STATES: + design = design_dir / f"{state}-split.png" + ours = OUT_DIR / f"{state}.png" + if not design.exists() or not ours.exists(): + continue + top = Image.open(design).convert("RGB") + bottom = Image.open(ours).convert("RGB") + width = max(top.width, bottom.width) + sheet = Image.new("RGB", (width, top.height + bottom.height + 56), (10, 12, 11)) + draw = ImageDraw.Draw(sheet) + draw.text((12, 6), f"{state} design (top) vs app (bottom)", fill=(240, 245, 242)) + sheet.paste(top, (0, 28)) + sheet.paste(bottom, (0, top.height + 56)) + path = OUT_DIR / f"compare-{state}.png" + sheet.save(path) + print(f"compare={path}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/trans-compile.sh b/scripts/trans-compile.sh deleted file mode 100755 index 0a1e7c97..00000000 --- a/scripts/trans-compile.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -# Compile .ts translation files to .qm binary files -# Usage: ./scripts/trans-compile.sh [language_code] -# ./scripts/trans-compile.sh # Compile all languages -# ./scripts/trans-compile.sh en_US # Compile English only - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -TRANS_DIR="$PROJECT_ROOT/resource/translations" - -# Check for lrelease tool -check_lrelease() { - if command -v lrelease &> /dev/null; then - echo "lrelease" - elif command -v lrelease-qt5 &> /dev/null; then - echo "lrelease-qt5" - else - echo "" - fi -} - -LRELEASE=$(check_lrelease) - -if [ -z "$LRELEASE" ]; then - echo "❌ lrelease tool not found" - echo "" - echo "Please install Qt toolchain:" - echo " macOS: brew install qt@5" - echo " Linux: sudo apt-get install qttools5-dev-tools" - echo "" - echo "Then add lrelease to PATH:" - echo " export PATH=\"/opt/homebrew/opt/qt@5/bin:\$PATH\"" - exit 1 -fi - -echo "🔨 Compiling translation files..." -echo "" - -# Compile specific language if provided -if [ -n "$1" ]; then - LANG_CODE="$1" - TS_FILE="$TRANS_DIR/VideoCaptioner_$LANG_CODE.ts" - - if [ ! -f "$TS_FILE" ]; then - echo "❌ Translation file not found: $TS_FILE" - exit 1 - fi - - echo "📦 Compiling $LANG_CODE..." - $LRELEASE "$TS_FILE" -qm "$TRANS_DIR/VideoCaptioner_$LANG_CODE.qm" -else - # Compile all translation files - for ts_file in "$TRANS_DIR"/*.ts; do - if [ -f "$ts_file" ]; then - filename=$(basename "$ts_file" .ts) - echo "📦 Compiling $filename..." - $LRELEASE "$ts_file" -qm "$TRANS_DIR/$filename.qm" - fi - done -fi - -echo "" -echo "✅ Compilation completed!" -echo "📁 Output files: resource/translations/*.qm" diff --git a/scripts/trans-extract.sh b/scripts/trans-extract.sh deleted file mode 100755 index c51c4226..00000000 --- a/scripts/trans-extract.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -# Extract translation strings from Python code to .ts files -# Auto-removes obsolete entries -# Usage: ./scripts/trans-extract.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -TRANS_DIR="$PROJECT_ROOT/resource/translations" - -echo "🔍 Extracting translation strings..." -echo "" - -cd "$PROJECT_ROOT" - -# Check if pylupdate5 is available -if ! command -v pylupdate5 &> /dev/null; then - echo "❌ pylupdate5 not found" - exit 1 -fi - -# Extract all tr() calls from Python files to .ts files -echo "📝 Scanning tr() calls in Python code..." -pylupdate5 -verbose \ - $(find app -name "*.py") \ - -ts "$TRANS_DIR/VideoCaptioner_zh_CN.ts" \ - -ts "$TRANS_DIR/VideoCaptioner_zh_HK.ts" \ - -ts "$TRANS_DIR/VideoCaptioner_en_US.ts" - -# Remove obsolete translations -echo "" -echo "🧹 Cleaning obsolete translations..." - -for ts_file in "$TRANS_DIR"/*.ts; do - if [ -f "$ts_file" ]; then - filename=$(basename "$ts_file") - - # Count obsolete entries before removal - obsolete_count=$(grep -c 'type="obsolete"' "$ts_file" 2>/dev/null || echo "0") - obsolete_count=$(echo "$obsolete_count" | head -1) # Ensure single value - - if [ "$obsolete_count" -gt 0 ] 2>/dev/null; then - # Create temp file and remove obsolete messages - python3 << EOF -import re -from pathlib import Path - -ts_path = Path("$ts_file") -content = ts_path.read_text(encoding='utf-8') - -# Remove entire ... blocks that contain type="obsolete" -# This regex matches from to if it contains type="obsolete" -pattern = r' .*?type="obsolete".*?\n' -cleaned_content = re.sub(pattern, '', content, flags=re.DOTALL) - -ts_path.write_text(cleaned_content, encoding='utf-8') -EOF - - echo " ✓ $filename: Removed $obsolete_count obsolete entries" - else - echo " ✓ $filename: No obsolete entries" - fi - fi -done - -echo "" -echo "✅ Translation strings extracted and cleaned successfully!" -echo "📁 Translation files: resource/translations/" -echo "" -echo "💡 Next steps:" -echo " 1. Edit translations with Qt Linguist: linguist resource/translations/VideoCaptioner_en_US.ts" -echo " 2. Or compile directly: ./scripts/trans-compile.sh" diff --git a/scripts/transcription_state_shots.py b/scripts/transcription_state_shots.py new file mode 100644 index 00000000..2efca347 --- /dev/null +++ b/scripts/transcription_state_shots.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""把语音转录页驱动到设计稿的 6 个状态并逐一截图。 + +数据与 docs/dev/design-transcription.html 设计稿保持一致(文件名、 +进度 62%、失败文案、字幕条目等),截图尺寸 1268x702 与 +scripts/design_reference_shots.py 产出的 *-split.png 完全对应,可直接对比。 + +用法: + .venv/bin/python scripts/transcription_state_shots.py /tmp/vc-app-shots + +可选 --compare /tmp/vc-design-shots:生成 design|app 并排对比图。 +可选 --theme light:浅色主题(默认 dark)。 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +OUT_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/vc-app-shots") +COMPARE_DIR = ( + Path(sys.argv[sys.argv.index("--compare") + 1]) + if "--compare" in sys.argv + else None +) +THEME = sys.argv[sys.argv.index("--theme") + 1] if "--theme" in sys.argv else "dark" + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault( + "VIDEOCAPTIONER_CONFIG_FILE", str(OUT_DIR / "state-shots-config.toml") +) + +# 设计稿状态 id -> 截图文件名 +STATES = ["v1a", "v1b", "v1c", "v1d", "v1e", "v1f"] + +DESIGN_SEGMENTS = [ + (1120, 3400, "大家好,欢迎来到这一节课。"), + (3520, 6200, "我们今天讲矩阵和向量空间。"), + (6360, 8900, "先从最基本的概念开始。"), + (9100, 12000, "后面会用几个例子来说明。"), + (12240, 15640, "我们先看一下这个矩阵的形式。"), + (15900, 18200, "它由若干行和若干列组成。"), +] + +DESIGN_TEXT = "\n".join( + ["大家好,欢迎来到这一节课。", "我们今天讲矩阵和向量空间。", "先从最基本的概念开始。", "后面会用几个例子来说明。"] +) + + +def make_sparse_file(path: Path, size: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "wb") as handle: + handle.seek(size - 1) + handle.write(b"\0") + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + config = Path(os.environ["VIDEOCAPTIONER_CONFIG_FILE"]) + if config.exists(): + config.unlink() + + from PyQt5.QtWidgets import QApplication + + app = QApplication([]) + + from qfluentwidgets import Theme, setTheme + + from videocaptioner.core.entities import ( + AudioStreamInfo, + TranscribeModelEnum, + VideoInfo, + ) + from videocaptioner.ui.common.config import ThemeMode, cfg + from videocaptioner.ui.view.transcription_interface import ( + PageState, + TranscriptionInterface, + ) + + cfg.set( + cfg.themeMode, + ThemeMode.LIGHT if THEME == "light" else ThemeMode.DARK, + save=False, + ) + setTheme(Theme.LIGHT if THEME == "light" else Theme.DARK) + + # 与设计稿一致的参数:Fun-ASR / paraformer-v2 / 自动检测 / SRT / 词时间戳开。 + cfg.set(cfg.transcribe_model, TranscribeModelEnum.BAILIAN_FUN_ASR, save=False) + cfg.set(cfg.fun_asr_model, "paraformer-v2", save=False) + cfg.set(cfg.transcribe_word_timestamp, True, save=False) # 设计稿开关为开 + + video_path = OUT_DIR / "assets" / "线性代数基础与解法全集|从零开始 p01.mp4" + audio_path = OUT_DIR / "assets" / "lecture-audio.wav" + make_sparse_file(video_path, 1024) + make_sparse_file(audio_path, 48 * 1024 * 1024) + + video_info = VideoInfo( + file_name="线性代数基础与解法全集|从零开始 p01.mp4", + file_path=str(video_path), + width=1920, + height=1080, + fps=30.0, + duration_seconds=624, + bitrate_kbps=4000, + video_codec="h264", + audio_codec="aac", + audio_sampling_rate=44100, + thumbnail_path="", + audio_streams=[AudioStreamInfo(index=0, codec="aac", language="zh")], + ) + audio_info = VideoInfo( + file_name="lecture-audio.wav", + file_path=str(audio_path), + width=0, + height=0, + fps=0.0, + duration_seconds=372, + bitrate_kbps=0, + video_codec="", + audio_codec="pcm_s16le", + audio_sampling_rate=16000, + thumbnail_path="", + audio_streams=[AudioStreamInfo(index=0, codec="pcm_s16le")], + ) + + page = TranscriptionInterface() + page.layout().setContentsMargins(0, 0, 0, 0) + page.resize(1268, 702) + page.show() + + def settle(): + for _ in range(10): + app.processEvents() + + def grab(name: str): + settle() + pixmap = page.grab() + path = OUT_DIR / f"{name}.png" + pixmap.save(str(path)) + print(f"shot={path}") + + # A 未选择文件 + grab("v1a") + + # B 文件就绪(视频) + page._media_path = str(video_path) + page._apply_state(PageState.READY) + page._on_media_loaded(video_info) + grab("v1b") + + # C 转录中(音频,62%) + page._media_path = str(audio_path) + page.media_info = audio_info + page.mediaCard.setTitle(audio_info.file_name) + page.mediaCard.thumb.setMedia(None, True) + page._apply_state(PageState.RUNNING) + page.progressCard.setProgress(62) + grab("v1c") + + # D 失败恢复(视频 + Whisper API,模型未配置时显示服务名,与设计稿一致) + cfg.set(cfg.transcribe_model, TranscribeModelEnum.WHISPER_API, save=False) + cfg.set(cfg.whisper_api_model, "", save=False) + page._media_path = str(video_path) + page.media_info = video_info + page.mediaCard.setTitle("线性代数基础 p01.mp4") + page.mediaCard.thumb.setMedia(None, False) + page.errorBanner.setMessage("Whisper API Key 不可用,或网络连接失败。") + page._apply_state(PageState.FAILED) + grab("v1d") + + # E SRT 结果 + cfg.set(cfg.transcribe_model, TranscribeModelEnum.BIJIAN, save=False) + segments = (DESIGN_SEGMENTS * 37)[:220] + page.subtitlePreview.setSegments(segments) + page.subtitlePreview.setActiveRow(1) + page.resultPanel.thumb.setMedia(None, False) + page.resultPanel.setResult( + title="线性代数基础 p01", + chips=["B 接口", "10:24", "SRT"], + file_name="【原始字幕】线性代数基础-B接口.srt", + ) + page._apply_state(PageState.DONE) + grab("v1e") + + # F 纯文本结果 + page.textPreview.setContent(DESIGN_TEXT) + page.resultPanel.setResult( + title="线性代数基础 p01", + chips=["B 接口", "10:24", "TXT"], + file_name="linear-algebra-demo.txt", + ) + page._apply_state(PageState.DONE, preview_text=True) + grab("v1f") + + page.close() + + if COMPARE_DIR is not None: + _build_comparison(COMPARE_DIR) + print("state-shots=ok") + return 0 + + +def _build_comparison(design_dir: Path) -> None: + """每个状态输出 design(上) / app(下) 的对比图。""" + from PIL import Image, ImageDraw + + for state in STATES: + design = design_dir / f"{state}-split.png" + ours = OUT_DIR / f"{state}.png" + if not design.exists() or not ours.exists(): + continue + top = Image.open(design).convert("RGB") + bottom = Image.open(ours).convert("RGB") + width = max(top.width, bottom.width) + sheet = Image.new("RGB", (width, top.height + bottom.height + 56), (10, 12, 11)) + draw = ImageDraw.Draw(sheet) + draw.text((12, 6), f"{state} design (top) vs app (bottom)", fill=(240, 245, 242)) + sheet.paste(top, (0, 28)) + sheet.paste(bottom, (0, top.height + 56)) + path = OUT_DIR / f"compare-{state}.png" + sheet.save(path) + print(f"compare={path}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/translate_llm.py b/scripts/translate_llm.py deleted file mode 100755 index 66a5b1c8..00000000 --- a/scripts/translate_llm.py +++ /dev/null @@ -1,317 +0,0 @@ -#!/usr/bin/env python3 -""" -Translate .ts files using OpenAI Structured Outputs - -Ensures 1:1 mapping between source and translation with zero data loss. -Target language is automatically detected from filename. - -Usage: - python scripts/translate_llm.py - -Examples: - python scripts/translate_llm.py resource/translations/VideoCaptioner_en_US.ts - python scripts/translate_llm.py resource/translations/VideoCaptioner_zh_HK.ts - python scripts/translate_llm.py resource/translations/VideoCaptioner_ja_JP.ts -""" -import os -import re -import sys -import xml.etree.ElementTree as ET -from pathlib import Path -from typing import List - -from openai import OpenAI -from pydantic import BaseModel - -# ============================================================================ -# Configuration -# ============================================================================ - -BATCH_SIZE = 10 -MODEL = "gpt-5" -TEMPERATURE = 1 - -# Technical terms that should not be translated -PRESERVE_TERMS = [ - "ASR", - "LLM", - "TTS", - "FFmpeg", - "Whisper", - "FasterWhisper", - "WhisperCpp", - "OpenAI", - "GPU", - "CPU", - "CUDA", - "VAD", - "Silero", - "Pyannote", - "WebRTC", - "Auditok", -] - -# Language mapping from locale code to target language -LANGUAGE_MAP = { - "en_US": "English", - "zh_HK": "Traditional Chinese (Hong Kong)", - "zh_TW": "Traditional Chinese (Taiwan)", - "ja_JP": "Japanese", - "ko_KR": "Korean", - "fr_FR": "French", - "de_DE": "German", - "es_ES": "Spanish", - "it_IT": "Italian", - "pt_BR": "Portuguese (Brazil)", - "ru_RU": "Russian", - "ar_SA": "Arabic", - "th_TH": "Thai", - "vi_VN": "Vietnamese", -} - -# ============================================================================ -# Structured Output Models -# ============================================================================ - - -class Translation(BaseModel): - """Single translation with index for guaranteed ordering""" - - index: int - source: str - translation: str - - -class TranslationBatch(BaseModel): - """Batch of translations with strict schema""" - - translations: List[Translation] - - -# ============================================================================ -# OpenAI Client -# ============================================================================ - -# Use direct OpenAI API (bypass any custom base_url in environment) - -api_key = os.environ.get("OPENAI_API_KEY") -if not api_key: - raise ValueError("OPENAI_API_KEY environment variable is not set") - -client = OpenAI( - api_key=api_key, base_url="https://api.openai.com/v1" # Force direct OpenAI API -) - - -# ============================================================================ -# Core Functions -# ============================================================================ - - -def detect_target_language(filename: str) -> str: - """Detect target language from filename""" - # Extract locale code (e.g., "en_US" from "VideoCaptioner_en_US.ts") - match = re.search(r"_([a-z]{2}_[A-Z]{2})\.ts$", filename) - - if not match: - raise ValueError( - f"Cannot detect language from filename: {filename}\n" - f"Expected format: VideoCaptioner_.ts (e.g., VideoCaptioner_en_US.ts)" - ) - - locale = match.group(1) - - if locale not in LANGUAGE_MAP: - raise ValueError( - f"Unsupported locale: {locale}\n" - f"Supported: {', '.join(LANGUAGE_MAP.keys())}" - ) - - return LANGUAGE_MAP[locale] - - -def translate_batch( - texts: List[str], target_lang: str, start_index: int -) -> List[Translation]: - """ - Translate a batch of texts using structured outputs. - - Returns translations with guaranteed index matching. - """ - - # Build numbered input - items = [{"index": start_index + i, "text": text} for i, text in enumerate(texts)] - - # Construct clear, professional prompt - prompt = f"""You are a professional UI translator. Translate these texts to {target_lang}. - -**CRITICAL REQUIREMENTS:** -1. Maintain exact 1:1 mapping - every input MUST have corresponding output -2. Keep translations concise and natural for UI context -3. Use standard UI terminology (e.g., "Settings", "Cancel", "OK") -4. NEVER translate technical terms: {', '.join(PRESERVE_TERMS)} -5. Preserve formatting markers like {{variable}}, %s, \\n -6. Match the tone: formal for settings, friendly for messages - -**Input texts (index: text):** -{chr(10).join([f"{item['index']}: {item['text']}" for item in items])} - -**Your task:** -Return EXACTLY {len(texts)} translations with matching indices.""" - - # Call OpenAI with structured output - completion = client.beta.chat.completions.parse( - model=MODEL, - messages=[ - { - "role": "system", - "content": f"You are an expert UI translator specializing in {target_lang}. " - "You always return complete, accurate translations.", - }, - {"role": "user", "content": prompt}, - ], - response_format=TranslationBatch, - temperature=TEMPERATURE, - ) - - result = completion.choices[0].message.parsed - - # Validate we got all translations - if len(result.translations) != len(texts): - raise ValueError( - f"Translation mismatch: expected {len(texts)}, got {len(result.translations)}" - ) - - return sorted(result.translations, key=lambda x: x.index) - - -def translate_file(ts_file: Path, target_lang: str) -> None: - """Translate a .ts file with progress tracking""" - - # Parse XML - tree = ET.parse(ts_file) - root = tree.getroot() - - # Collect untranslated entries - entries = [] - for message in root.findall(".//message"): - source = message.find("source") - translation = message.find("translation") - - if source is not None and translation is not None: - text = source.text or "" - if not translation.text or translation.get("type") == "unfinished": - entries.append((text, translation)) - - if not entries: - print("✨ All translations already complete!") - return - - total = len(entries) - print(f"📊 Found {total} texts to translate") - print(f"🎯 Target language: {target_lang}") - print(f"🔧 Using model: {MODEL}") - print("─" * 60) - - # Process in batches - success_count = 0 - - for i in range(0, total, BATCH_SIZE): - batch_texts = [entry[0] for entry in entries[i : i + BATCH_SIZE]] - batch_elements = [entry[1] for entry in entries[i : i + BATCH_SIZE]] - - batch_num = i // BATCH_SIZE + 1 - total_batches = (total - 1) // BATCH_SIZE + 1 - - print( - f"🔄 Batch {batch_num}/{total_batches} ({len(batch_texts)} texts)...", - end=" ", - flush=True, - ) - - try: - # Get structured translations - translations = translate_batch(batch_texts, target_lang, i) - - # Verify and apply translations - for j, trans in enumerate(translations): - # Double-check index matches - expected_index = i + j - if trans.index != expected_index: - raise ValueError(f"Index mismatch at position {j}") - - # Apply translation - elem = batch_elements[j] - elem.text = trans.translation - - # Remove 'unfinished' attribute - if "type" in elem.attrib: - del elem.attrib["type"] - - success_count += len(translations) - print(f"✅ {len(translations)}") - - except Exception as e: - print(f"❌ {type(e).__name__}: {str(e)[:50]}") - continue - - # Save with pretty formatting - print("\n💾 Saving translations...") - tree.write(ts_file, encoding="utf-8", xml_declaration=True) - - # Summary - print("─" * 60) - print(f"✨ Complete! {success_count}/{total} translations applied") - print(f"📁 File: {ts_file}") - print("\n💡 Next steps:") - print(f" 1. Review: linguist {ts_file}") - print(f" 2. Compile: ./scripts/trans-compile.sh") - print(f" 3. Test: Switch to {target_lang} in app\n") - - -# ============================================================================ -# CLI Entry Point -# ============================================================================ - - -def main(): - # Validate arguments - if len(sys.argv) < 2: - print(__doc__) - sys.exit(1) - - ts_file = Path(sys.argv[1]) - - # Validate file exists - if not ts_file.exists(): - print(f"❌ File not found: {ts_file}") - sys.exit(1) - - # Auto-detect target language - try: - target_lang = detect_target_language(ts_file.name) - except ValueError as e: - print(f"❌ {e}") - sys.exit(1) - - # Banner - print("\n" + "=" * 60) - print("🌐 OpenAI Structured Translation") - print("=" * 60) - print(f"📄 File: {ts_file.name}") - print(f"🎯 Target: {target_lang} (auto-detected)") - print("=" * 60 + "\n") - - # Execute translation - try: - translate_file(ts_file, target_lang) - except KeyboardInterrupt: - print("\n\n⚠️ Translation interrupted by user") - sys.exit(1) - except Exception as e: - print(f"\n❌ Fatal error: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/ui_smoke_check.py b/scripts/ui_smoke_check.py new file mode 100644 index 00000000..916b7046 --- /dev/null +++ b/scripts/ui_smoke_check.py @@ -0,0 +1,1429 @@ +#!/usr/bin/env python3 +"""VideoCaptioner UI 截图与冒烟检查工具。 + +两种典型用法: + +1. 改完代码快速看效果(秒级,只截图、不跑断言):: + + # 只截某一页 + .venv/bin/python scripts/ui_smoke_check.py --pages dubbing + # 截多页 + 浅色主题 + .venv/bin/python scripts/ui_smoke_check.py --pages home,subtitle --theme light + # 全部页面截图(含设置子页),明暗两套一次出 + .venv/bin/python scripts/ui_smoke_check.py --shots-only --theme both + # 查看可用页面名 + .venv/bin/python scripts/ui_smoke_check.py --list + +2. 完整冒烟验收(截图 + 行为断言 + 紧凑窗口检查,CI / 验收用):: + + .venv/bin/python scripts/ui_smoke_check.py /tmp/vc-ui-check-dark --theme dark + .venv/bin/python scripts/ui_smoke_check.py /tmp/vc-ui-check-light --theme light + +约定: + +- 指定 ``--pages`` 后自动进入"只截图"模式;设置页子页面写作 + ``setting-``,例如 ``setting-dubbing``。 +- 截图输出到 ``/.png``,每张都会打印 ``shot=<路径>``, + 并生成 ``contact-sheet.png`` 缩略总览方便一眼扫完。 +- 默认使用 offscreen 平台插件和隔离的临时 TOML 配置 + (``VIDEOCAPTIONER_CONFIG_FILE``),不会污染真实用户配置。 +- 故意不启动 MainWindow:qframelesswindow 在 headless macOS 下可能直接 + abort,所以逐页单独构造 widget。主题初始化与 ``ui/main.py`` 保持一致 + (应用自有 ThemeMode -> qfluent Theme 的映射)。 +""" + +from __future__ import annotations + +import argparse +import math +import os +import struct +import subprocess +import sys +import wave +from pathlib import Path + +DEFAULT_OUTPUT_DIR = Path("/tmp/vc-ui-shots") + +# --------------------------------------------------------------------------- +# 页面注册表:截图名 -> (模块路径, 类名)。 +# 加新页面只需要在这里加一行,快速截图 / 完整冒烟 / contact sheet 都会带上。 +# --------------------------------------------------------------------------- +PAGE_REGISTRY: dict[str, tuple[str, str]] = { + "home": ("videocaptioner.ui.view.home_interface", "HomeInterface"), + "task": ("videocaptioner.ui.view.task_creation_interface", "TaskCreationInterface"), + "setting": ("videocaptioner.ui.view.setting_interface", "SettingInterface"), + "dubbing": ("videocaptioner.ui.view.dubbing_interface", "DubbingInterface"), + "live-caption": ( + "videocaptioner.ui.view.live_caption_interface", + "LiveCaptionInterface", + ), + "video-synthesis": ( + "videocaptioner.ui.view.video_synthesis_interface", + "VideoSynthesisInterface", + ), + "transcription": ( + "videocaptioner.ui.view.transcription_interface", + "TranscriptionInterface", + ), + "subtitle": ("videocaptioner.ui.view.subtitle_interface", "SubtitleInterface"), + "hardsub": ("videocaptioner.ui.view.hardsub_interface", "HardsubInterface"), + "batch": ("videocaptioner.ui.view.batch_process_interface", "BatchProcessInterface"), + "subtitle-style": ( + "videocaptioner.ui.view.subtitle_style_interface", + "SubtitleStyleInterface", + ), + "doctor": ("videocaptioner.ui.view.doctor_interface", "DoctorInterface"), + "llm-logs": ("videocaptioner.ui.view.llm_logs_interface", "LLMLogsInterface"), +} + +# 设置页内部的子页 key(SettingInterface.setCurrentPage 的合法值)。 +SETTING_PAGE_KEYS = [ + "transcribe", + "llm", + "translate-service", + "translate", + "subtitle", + "dubbing", + "live-caption", + "save", + "personal", + "about", +] + +PAGE_SIZE = (1280, 820) # 常规截图窗口 +COMPACT_SIZE = (960, 720) # 紧凑窗口检查 + + +# --------------------------------------------------------------------------- +# 命令行 +# --------------------------------------------------------------------------- + + +def _parse_cli(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="VideoCaptioner UI 截图与冒烟检查", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__.split("约定:")[0], + ) + parser.add_argument( + "output_dir", + nargs="?", + type=Path, + default=None, + help=f"截图输出目录(默认 {DEFAULT_OUTPUT_DIR}/)", + ) + parser.add_argument( + "--theme", + choices=["dark", "light", "both"], + default="dark", + help="主题;both 会分别跑两个子进程,输出到 /dark 和 /light", + ) + parser.add_argument( + "--pages", + default=None, + metavar="NAME[,NAME...]", + help="只截这些页面(自动进入只截图模式);支持 setting- 子页,见 --list", + ) + parser.add_argument( + "--shots-only", + action="store_true", + help="只截图,跳过行为断言和紧凑窗口检查(快速查看效果用)", + ) + parser.add_argument( + "--list", + action="store_true", + help="列出可用页面名后退出", + ) + args = parser.parse_args(argv) + if args.pages: + args.shots_only = True + return args + + +def _resolve_page_selection(raw: str) -> list[str]: + """校验 --pages 选择,返回合法页面名列表(保持输入顺序、去重)。""" + valid = set(PAGE_REGISTRY) | {f"setting-{key}" for key in SETTING_PAGE_KEYS} + selected: list[str] = [] + for name in (part.strip() for part in raw.split(",")): + if not name: + continue + if name not in valid: + raise SystemExit( + f"未知页面: {name!r}(用 --list 查看可用页面名)" + ) + if name not in selected: + selected.append(name) + if not selected: + raise SystemExit("--pages 至少要指定一个页面") + return selected + + +def _print_page_list() -> None: + print("主页面:") + for name in PAGE_REGISTRY: + print(f" {name}") + print("设置子页:") + for key in SETTING_PAGE_KEYS: + print(f" setting-{key}") + + +# --------------------------------------------------------------------------- +# 环境与基础工具 +# --------------------------------------------------------------------------- + + +def _prepare_environment(output_dir: Path) -> None: + """offscreen 渲染 + 隔离 TOML 配置,保证可重复、不污染真实配置。""" + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + if "VIDEOCAPTIONER_CONFIG_FILE" not in os.environ: + config_path = output_dir / "ui-smoke-config.toml" + if config_path.exists(): + config_path.unlink() + os.environ["VIDEOCAPTIONER_CONFIG_FILE"] = str(config_path) + os.environ.setdefault( + "QT_LOGGING_RULES", "qt.qpa.fonts=false;qt.qpa.fonts.warning=false" + ) + + +def _apply_theme(theme_name: str) -> None: + """与 ui/main.py 相同的主题初始化:应用 ThemeMode -> qfluent Theme。""" + from qfluentwidgets import Theme, setTheme, setThemeColor + + from videocaptioner.ui.common.config import ThemeMode, cfg + + cfg.set( + cfg.themeMode, + ThemeMode.DARK if theme_name == "dark" else ThemeMode.LIGHT, + save=False, + ) + setTheme(Theme.DARK if theme_name == "dark" else Theme.LIGHT) + setThemeColor(cfg.themeColor.value) + + +def _init_i18n() -> None: + """与 ui/main.py 一致:按当前语言装载 UI 翻译(缺了页面会显示 tr key 而非文案)。 + + 可用 VC_SMOKE_LANG=en/zh_Hant/zh_Hans 覆盖,用于截非中文界面。 + """ + from videocaptioner.config import I18N_PATH + from videocaptioner.ui.common.config import cfg + from videocaptioner.ui.i18n import init as init_i18n + + lang = os.environ.get("VC_SMOKE_LANG") or cfg.get(cfg.language).value.name() + init_i18n(I18N_PATH, lang) + + +def _settle_widget(widget, app) -> None: + """等事件队列消化完,再等页面里可能存在的预览线程,避免截到半成品。""" + for _ in range(12): + app.processEvents() + preview_threads = getattr(widget, "_preview_threads", None) + if preview_threads is not None: + for thread in list(preview_threads): + thread.wait(3000) + for _ in range(4): + app.processEvents() + + +def _grab(widget, output_dir: Path, name: str, app) -> None: + _settle_widget(widget, app) + pixmap = widget.grab() + if pixmap.isNull(): + raise RuntimeError(f"empty screenshot: {name}") + path = output_dir / f"{name}.png" + pixmap.save(str(path)) + print(f"shot={path}") + + +def _make_page(name: str): + """按注册表延迟导入并实例化页面,避免单页截图时拖入全部页面依赖。""" + import importlib + + module_path, class_name = PAGE_REGISTRY[name] + module = importlib.import_module(module_path) + return getattr(module, class_name)() + + +def _write_reference_wav(path: Path) -> None: + """生成 0.1 秒 440Hz 正弦波 wav,作为克隆参考音频的本地夹具。""" + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "w") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(16000) + frames = [] + for i in range(1600): + sample = int(12000 * math.sin(2 * math.pi * 440 * i / 16000)) + frames.append(struct.pack(" Path | None: + """把多张截图拼成 2 列缩略总览;没装 Pillow 时静默跳过。""" + if not names: + return None + try: + from PIL import Image, ImageDraw + except Exception: + return None + + thumbs = [] + for name in names: + image = Image.open(output_dir / f"{name}.png").convert("RGB") + image.thumbnail((480, 300), Image.LANCZOS) + canvas = Image.new("RGB", (500, 344), (31, 32, 32)) + canvas.paste(image, (10, 34)) + ImageDraw.Draw(canvas).text((10, 10), f"{name}.png", fill=(245, 247, 246)) + thumbs.append(canvas) + + cols = 2 + rows = (len(thumbs) + cols - 1) // cols + sheet = Image.new("RGB", (cols * 500, rows * 344), (31, 32, 32)) + for index, thumb in enumerate(thumbs): + sheet.paste(thumb, ((index % cols) * 500, (index // cols) * 344)) + + output_path = output_dir / filename + sheet.save(output_path) + return output_path + + +# --------------------------------------------------------------------------- +# 截图过程(不含断言) +# --------------------------------------------------------------------------- + + +def _capture_page(name: str, output_dir: Path, app) -> None: + """截一个主页面,或一个 setting- 设置子页。""" + if name.startswith("setting-"): + widget = _make_page("setting") + widget.resize(*PAGE_SIZE) + widget.show() + widget.setCurrentPage(name.removeprefix("setting-")) + else: + widget = _make_page(name) + widget.resize(*PAGE_SIZE) + widget.show() + _grab(widget, output_dir, name, app) + widget.close() + + +def _capture_settings_pages(output_dir: Path, app) -> list[str]: + """完整模式下:复用一个 SettingInterface 把所有设置子页截一遍。""" + widget = _make_page("setting") + widget.resize(*PAGE_SIZE) + widget.show() + names = [] + for key in SETTING_PAGE_KEYS: + name = f"setting-page-{key}" + widget.setCurrentPage(key) + _grab(widget, output_dir, name, app) + names.append(name) + widget.close() + return names + + +# --------------------------------------------------------------------------- +# 行为断言(完整冒烟模式)。 +# 每个 _check_* 覆盖一个页面的关键交互回归:状态切换、行可见性、 +# 配置写入是否生效等。它们顺带产出若干"状态截图"加入总览。 +# --------------------------------------------------------------------------- + + +def _assert_fits_parent(widget, label: str, tolerance: int = 4) -> None: + """断言控件没有横向溢出父容器(紧凑窗口下的常见回归)。""" + parent = widget.parentWidget() + if parent is None: + return + if widget.geometry().right() > parent.width() + tolerance: + raise AssertionError( + f"{label} overflows parent: right={widget.geometry().right()} parent={parent.width()}" + ) + + +def _assert_unique_action_texts(menu, label: str) -> list[str]: + """断言菜单非空且没有重复项(provider/枚举菜单的常见回归)。""" + texts = [action.text() for action in menu.actions() if action.text()] + if not texts: + raise AssertionError(f"{label} has no visible menu actions") + duplicates = sorted({text for text in texts if texts.count(text) > 1}) + if duplicates: + raise AssertionError(f"{label} has duplicate menu actions: {duplicates}") + return texts + + +def _check_navigation(output_dir: Path, app, screenshot_names: list[str]) -> None: + """自研侧栏:展开/收纳几何、动画收敛、选中互斥、收纳态 tooltip。""" + from PyQt5.QtCore import Qt + from PyQt5.QtWidgets import QHBoxLayout, QLabel, QWidget + + from videocaptioner.ui.common.app_icons import AppIcon + from videocaptioner.ui.common.theme_tokens import app_palette + from videocaptioner.ui.components.sidebar import ( + COLLAPSED_WIDTH, + EXPANDED_WIDTH, + Sidebar, + ) + from videocaptioner.ui.view.main_window import WINDOW_MINIMUM_WIDTH + + parent = QWidget() + parent.resize(1050, 800) + palette = app_palette() + parent.setStyleSheet( + f"background:{palette.bg}; QLabel {{ color:{palette.text}; font-size:24px; }}" + ) + layout = QHBoxLayout(parent) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + sidebar = Sidebar(parent) + for key, icon, text in [ + ("home", AppIcon.HOME, "主页"), + ("batch", AppIcon.VIDEO, "批量处理"), + ("style", AppIcon.SUBTITLE, "字幕样式"), + ("dubbing", AppIcon.VOLUME, "配音"), + ("doctor", AppIcon.DIAGNOSTIC, "诊断"), + ]: + sidebar.add_page(key, icon, text) + sidebar.add_action("settings", AppIcon.SETTING, "设置", lambda: None) + sidebar.set_current("style") + content = QLabel("内容区域") + content.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + layout.addWidget(sidebar) + layout.addWidget(content, 1) + + parent.show() + app.processEvents() + assert WINDOW_MINIMUM_WIDTH >= 960 + assert sidebar.width() == EXPANDED_WIDTH and sidebar.is_expanded() + _grab(parent, output_dir, "navigation-expanded", app) + screenshot_names.append("navigation-expanded") + + # 收纳:动画收敛到 rail 宽;tooltip 兜底出现 + sidebar.set_expanded(False) + import time as _time + deadline = _time.time() + 2 # 动画走真实时钟,等它收敛 + while sidebar.width() != COLLAPSED_WIDTH and _time.time() < deadline: + app.processEvents() + assert sidebar.width() == COLLAPSED_WIDTH and not sidebar.is_expanded() + assert sidebar._items["home"].toolTip() == "主页" + _grab(parent, output_dir, "navigation-collapsed", app) + screenshot_names.append("navigation-collapsed") + + # 再展开:宽度还原、tooltip 清空、选中保持 + sidebar.set_expanded(True, animate=False) + app.processEvents() + assert sidebar.width() == EXPANDED_WIDTH + assert sidebar._items["home"].toolTip() == "" + assert sidebar.current() == "style" + parent.close() + + +def _check_task_creation(output_dir: Path, app, screenshot_names: list[str]) -> None: + """任务创建页:输入形态派生(空/文件/链接/无效)与按钮、详情区联动。""" + from videocaptioner.ui.view.task_creation_interface import InputKind + + widget = _make_page("task") + widget.resize(*PAGE_SIZE) + widget.show() + app.processEvents() + + # 空态:选择文件;详情区固定占位(高度恒定) + assert widget._input_kind() == InputKind.EMPTY + assert widget.primaryButton.toolTip() == "选择文件" + assert widget.detailStack.height() == 118 + hero_y = widget.heroTitle.mapTo(widget, widget.heroTitle.rect().topLeft()).y() + input_y = widget.inputCard.mapTo(widget, widget.inputCard.rect().topLeft()).y() + + # URL:开始处理 + 可开始 + widget.inputField.edit.setText("https://www.bilibili.com/video/BV1xx411c7mD") + app.processEvents() + assert widget._input_kind() == InputKind.URL + assert widget.primaryButton.toolTip() == "开始处理" + assert widget.statusPill.text() == "可开始" + _grab(widget, output_dir, "task-url-state", app) + screenshot_names.append("task-url-state") + + # 本地文件:详情面板出现,元信息胶囊渲染 + media = output_dir / "task-media.mp4" + media.write_bytes(b"\0" * 2048) + widget.inputField.edit.setText(str(media)) + app.processEvents() + assert widget._input_kind() == InputKind.FILE + assert widget.detailStack.currentWidget() is widget.mediaHost + + # 确认面板:模拟解析完成 -> 清晰度档位与确认按钮就位 + from videocaptioner.ui.view.task_creation_interface import PageState + + widget.inputField.edit.setText("https://www.bilibili.com/video/BV1xx411c7mD") + app.processEvents() + widget.state = PageState.PROBING + widget._on_probed( + { + "title": "演示视频", + "site": "BiliBili", + "uploader": "UP 主", + "duration": "12:18", + "qualities": [1080, 720, 360], + "has_subtitle": True, + } + ) + app.processEvents() + assert widget.state == PageState.CONFIRM + assert widget.detailStack.currentWidget() is widget.confirmHost + assert widget.confirmPanel.qualitySelect.items() == ["最佳", "1080p", "720p", "360p"] + assert widget.statusPill.text() == "待确认" + _grab(widget, output_dir, "task-confirm-state", app) + screenshot_names.append("task-confirm-state") + widget.confirmPanel.qualitySelect.setCurrentText("720p") + assert widget.confirmPanel.selectedHeight() == 720 + widget._cancel_download() + app.processEvents() + + # 无效输入:错误卡 + 失败胶囊;hero/输入卡位置不随详情切换跳动 + widget.inputField.edit.setText("not-a-video") + app.processEvents() + assert widget._input_kind() == InputKind.INVALID + assert widget.detailStack.currentWidget() is widget.errorHost + assert widget.statusPill.text() == "输入无效" + assert widget.heroTitle.mapTo(widget, widget.heroTitle.rect().topLeft()).y() == hero_y + assert widget.inputCard.mapTo(widget, widget.inputCard.rect().topLeft()).y() == input_y + _grab(widget, output_dir, "task-invalid-state", app) + screenshot_names.append("task-invalid-state") + widget.inputField.edit.clear() + app.processEvents() + assert widget._input_kind() == InputKind.EMPTY + assert widget.primaryButton.toolTip() == "选择文件" + widget.close() + + +def _check_settings(app) -> None: + """设置页核心回归:provider 切换的行可见性、模型选项缓存、key 去空白、主题色重置。""" + from PyQt5.QtGui import QColor + + import videocaptioner.ui.view.setting_interface as setting_module + from videocaptioner.core.entities import ( + LLMServiceEnum, + SubtitleRenderModeEnum, + TranscribeModelEnum, + TranslatorServiceEnum, + ) + from videocaptioner.ui.common.config import DEFAULT_THEME_COLOR, cfg + from videocaptioner.ui.view.setting_interface import SettingInterface + + widget = SettingInterface() + widget.resize(*PAGE_SIZE) + widget.show() + app.processEvents() + + # 设置已改为弹窗(SettingsDialog),侧栏不再有「返回应用」按钮;关闭走右上角 X / Esc / 遮罩。 + assert not hasattr(widget, "backButton") + + # 翻译服务:LLM 类显示反思/批量/线程行,DeepLx 显示 endpoint 行。 + widget.setCurrentPage("translate-service") + widget.translatorServiceControl.setCurrentText(TranslatorServiceEnum.OPENAI.value) + app.processEvents() + assert cfg.translator_service.value == TranslatorServiceEnum.OPENAI + assert widget.needReflectTranslateRow.isVisible() + assert widget.batchSizeRow.isVisible() + assert widget.threadNumRow.isVisible() + assert not widget.deeplxEndpointRow.isVisible() + + widget.translatorServiceControl.setCurrentText(TranslatorServiceEnum.DEEPLX.value) + app.processEvents() + assert cfg.translator_service.value == TranslatorServiceEnum.DEEPLX + assert not widget.needReflectTranslateRow.isVisible() + assert widget.deeplxEndpointRow.isVisible() + + widget.setCurrentPage("subtitle") + cfg.set(cfg.subtitle_render_mode, SubtitleRenderModeEnum.ASS_STYLE) + app.processEvents() + assert cfg.subtitle_render_mode.value == SubtitleRenderModeEnum.ASS_STYLE + + # LLM 页:"加载模型"与"测试连接"分离;模型选项按 provider 隔离缓存。 + widget.setCurrentPage("llm") + widget.llmServiceControl.setCurrentText(LLMServiceEnum.SILICON_CLOUD.value) + app.processEvents() + assert widget.loadLLMModelsButton.text() == "加载模型" + assert widget.checkLLMButton.text() == "测试连接" + widget._save_llm_model_options( + LLMServiceEnum.SILICON_CLOUD, + [" cached-model-a ", "cached-model-a", "cached-model-b"], + ) + widget._refresh_llm_rows(LLMServiceEnum.SILICON_CLOUD) + app.processEvents() + silicon_model_control = widget.llmProviderControls[LLMServiceEnum.SILICON_CLOUD]["model"] + assert cfg.silicon_cloud_model_options.value == ["cached-model-a", "cached-model-b"] + assert silicon_model_control.findText("cached-model-a") >= 0 + assert silicon_model_control.findText("cached-model-b") >= 0 + widget._save_llm_model_options(LLMServiceEnum.OPENAI, ["openai-cached-model"]) + widget._refresh_llm_rows(LLMServiceEnum.OPENAI) + app.processEvents() + openai_model_control = widget.llmProviderControls[LLMServiceEnum.OPENAI]["model"] + assert openai_model_control.findText("openai-cached-model") >= 0 + assert openai_model_control.findText("cached-model-a") < 0 + widget._refresh_llm_rows(LLMServiceEnum.SILICON_CLOUD) + app.processEvents() + assert silicon_model_control.findText("cached-model-a") >= 0 + assert silicon_model_control.findText("openai-cached-model") < 0 + _assert_llm_threads_are_split(setting_module) + + # 转录页:Whisper API 与 WhisperCpp 互斥显示各自的行。 + widget.setCurrentPage("transcribe") + widget.transcribeModelControl.setCurrentText( + TranscribeModelEnum.WHISPER_API.value.replace(" ✨", "") + ) + app.processEvents() + assert cfg.transcribe_model.value == TranscribeModelEnum.WHISPER_API + assert widget.whisperApiKeyRow.isVisible() + assert not widget.whisperCppModelRow.isVisible() + + widget.transcribeModelControl.setCurrentText(TranscribeModelEnum.WHISPER_CPP.value) + app.processEvents() + assert cfg.transcribe_model.value == TranscribeModelEnum.WHISPER_CPP + # The model selector is visible only when at least one local whisper.cpp + # model is installed. Without downloaded models, the management row is the + # expected user-facing entry point. + assert widget.whisperCppModelRow.isVisible() or widget.whisperCppModelEntryRow.isVisible() + assert not widget.whisperApiKeyRow.isVisible() + + # 配音页:SiliconFlow 显示 key/model 行,Edge 隐藏;key 输入去首尾空白和换行 + # (历史上曾导致 `Bearer ...\n` 无效请求头)。 + widget.setCurrentPage("dubbing") + widget.dubbingProviderControl.setCurrentText("SiliconFlow CosyVoice") + app.processEvents() + assert cfg.dubbing_provider.value == "siliconflow" + assert widget.dubbingApiKeyRow.isVisible() + assert widget.dubbingModelRow.isVisible() + # 付费提供商可设并发;Edge 免费则并发写死、不暴露给用户 + assert widget.dubbingWorkersRow.isVisible() + assert widget.dubbingPresetControl.count() > 0 + widget.dubbingApiKeyControl.setText(" sk-smoke-test\n") + app.processEvents() + assert cfg.dubbing_api_key.value == "sk-smoke-test" + assert widget.dubbingApiKeyControl.text() == "sk-smoke-test" + + widget.dubbingProviderControl.setCurrentText("Edge 免费配音") + app.processEvents() + assert cfg.dubbing_provider.value == "edge" + assert not widget.dubbingApiKeyRow.isVisible() + assert not widget.dubbingWorkersRow.isVisible() + + # 个性化页:主题色色块联动 + 重置按钮回到默认色。 + widget.setCurrentPage("personal") + cfg.set(cfg.themeColor, QColor("#336699")) + app.processEvents() + assert widget.themeColorSwatch.color().name(QColor.HexRgb) == "#336699" + assert widget.themeColorResetButton.isEnabled() + widget.themeColorResetButton.click() + app.processEvents() + default_theme_color = QColor(DEFAULT_THEME_COLOR).name(QColor.HexRgb).lower() + assert cfg.themeColor.value.name(QColor.HexRgb).lower() == default_theme_color + assert widget.themeColorSwatch.color().name(QColor.HexRgb).lower() == default_theme_color + assert not widget.themeColorResetButton.isEnabled() + widget.close() + + +def _assert_llm_threads_are_split(setting_module) -> None: + """"加载模型"和"测试连接"必须是两条独立线程路径,互不混用。""" + original_check = setting_module.check_llm_connection + original_load = setting_module.get_available_models + calls: list[str] = [] + + def fake_check(api_base: str, api_key: str, model: str): + calls.append(f"check:{api_base}:{api_key}:{model}") + return True, "ok" + + def fake_load(api_base: str, api_key: str): + calls.append(f"load:{api_base}:{api_key}") + return ["model-a", "model-b"] + + setting_module.check_llm_connection = fake_check + setting_module.get_available_models = fake_load + try: + check_results = [] + check_thread = setting_module.LLMConnectionThread( + "https://example.test/v1", "sk-test", "model-a" + ) + check_thread.finished.connect( + lambda success, message: check_results.append((success, message)) + ) + check_thread.run() + assert check_results == [(True, "ok")] + assert calls == ["check:https://example.test/v1:sk-test:model-a"] + + calls.clear() + load_results = [] + load_thread = setting_module.LLMModelLoadThread( + setting_module.LLMServiceEnum.OPENAI, + "https://example.test/v1", + "sk-test", + ) + load_thread.finished.connect( + lambda service, models: load_results.append((service, models)) + ) + load_thread.run() + assert load_results == [ + (setting_module.LLMServiceEnum.OPENAI, ["model-a", "model-b"]) + ] + assert calls == ["load:https://example.test/v1:sk-test"] + finally: + setting_module.check_llm_connection = original_check + setting_module.get_available_models = original_load + + +def _check_settings_navigation(app) -> None: + """其他页面(诊断/转录/字幕)跳转设置页时落到正确子页,别名也能解析。""" + from PyQt5.QtWidgets import QVBoxLayout, QWidget + + from videocaptioner.ui.view.doctor_interface import DoctorInterface, ItemAction + from videocaptioner.ui.view.setting_interface import SettingInterface + from videocaptioner.ui.view.subtitle_interface import SubtitleInterface + from videocaptioner.ui.view.transcription_interface import TranscriptionInterface + + class SettingsHost(QWidget): + """模拟 MainWindow 的 openSettingsPage/switchTo 接口。""" + + def __init__(self): + super().__init__() + self.opened_pages: list[str] = [] + self.current_interface = None + self.layout = QVBoxLayout(self) + self.settingInterface = SettingInterface(self) + self.layout.addWidget(self.settingInterface) + + def openSettingsPage(self, page_key: str) -> bool: # noqa: N802 + if not self.settingInterface.setCurrentPage(page_key): + return False + self.current_interface = self.settingInterface + self.opened_pages.append(self.settingInterface.currentPageKey()) + return True + + def switchTo(self, interface): + self.current_interface = interface + + host = SettingsHost() + host.resize(*PAGE_SIZE) + host.show() + app.processEvents() + + doctor = DoctorInterface(host) + for action, page_key in [ + (ItemAction.TRANSCRIBE_SETTINGS, "transcribe"), + (ItemAction.LLM_SETTINGS, "llm"), + (ItemAction.TRANSLATE_SETTINGS, "translate-service"), + (ItemAction.DUBBING_SETTINGS, "dubbing"), + ]: + doctor._handle_action(action) + app.processEvents() + assert host.opened_pages[-1] == page_key + assert host.settingInterface.currentPageKey() == page_key + assert host.current_interface is host.settingInterface + + for alias, page_key in [ + ("asr", "transcribe"), + ("tts", "dubbing"), + ("voice", "dubbing"), + ("models", "llm"), + ("translation-service", "translate-service"), + ("video-synthesis", "subtitle"), + ]: + assert host.openSettingsPage(alias) + app.processEvents() + assert host.opened_pages[-1] == page_key + assert host.settingInterface.currentPageKey() == page_key + + transcription = TranscriptionInterface(host) + transcription._open_transcribe_settings() + app.processEvents() + assert host.opened_pages[-1] == "transcribe" + assert host.settingInterface.currentPageKey() == "transcribe" + transcription.close() + + subtitle = SubtitleInterface(host) + subtitle.show_subtitle_settings() + app.processEvents() + assert host.opened_pages[-1] == "translate" + assert host.settingInterface.currentPageKey() == "translate" + subtitle.close() + + doctor.close() + host.close() + + +def _check_transcription(output_dir: Path, app, screenshot_names: list[str]) -> None: + """转录页(单文件工作台):六状态切换、参数与配置同步、按钮状态。""" + from videocaptioner.core.entities import ( + AudioStreamInfo, + TranscribeModelEnum, + TranscribeOutputFormatEnum, + VideoInfo, + ) + from videocaptioner.ui.common.config import cfg + from videocaptioner.ui.view.transcription_interface import PageState + + widget = _make_page("transcription") + widget.resize(*PAGE_SIZE) + widget.show() + app.processEvents() + + # 空态:按钮禁用,左侧为拖放区。 + assert widget.state == PageState.EMPTY + assert not widget.paramsPanel.startButton.isEnabled() + assert widget.paramsPanel.startButton.text() == "等待文件" + assert not widget.paramsPanel.trackRow.isVisible() + + # 服务选择与共享配置双向同步(菜单无重复项)。 + items = widget.paramsPanel.serviceSelect.items() + assert items and len(set(items)) == len(items) + widget.paramsPanel.serviceSelect.setCurrentText("B 接口") + app.processEvents() + assert cfg.transcribe_model.value == TranscribeModelEnum.BIJIAN + # 提供商不再用头部胶囊表达(服务卡片已说明),平时隐藏。 + assert not widget.paramsPanel.statusPill.isVisibleTo(widget.paramsPanel) + # B 接口没有模型概念:模型行隐藏。 + assert not widget.paramsPanel.modelRow.isVisibleTo(widget.paramsPanel) + cfg.set(cfg.transcribe_model, TranscribeModelEnum.BAILIAN_FUN_ASR) + app.processEvents() + assert widget.paramsPanel.serviceSelect.currentText() == "Fun-ASR" + # Fun-ASR 有模型行:选择写回 fun_asr_model。 + assert widget.paramsPanel.modelRow.isVisibleTo(widget.paramsPanel) + widget.paramsPanel.modelSelect.setCurrentText("fun-asr-mtl") + app.processEvents() + assert cfg.fun_asr_model.value == "fun-asr-mtl" + + # 输出格式同步。 + widget.paramsPanel.outputSelect.setCurrentText(TranscribeOutputFormatEnum.TXT.value) + app.processEvents() + assert cfg.transcribe_output_format.value == TranscribeOutputFormatEnum.TXT + widget.paramsPanel.outputSelect.setCurrentText(TranscribeOutputFormatEnum.SRT.value) + app.processEvents() + assert cfg.transcribe_output_format.value == TranscribeOutputFormatEnum.SRT + + # 文件就绪:媒体信息填充后按钮可用、音轨行可见。 + fake_media = output_dir / "transcription-fake.mp4" + fake_media.write_bytes(b"\0") + info = VideoInfo( + file_name="transcription-fake.mp4", + file_path=str(fake_media), + width=1920, + height=1080, + fps=30.0, + duration_seconds=624, + bitrate_kbps=4000, + video_codec="h264", + audio_codec="aac", + audio_sampling_rate=44100, + thumbnail_path="", + audio_streams=[AudioStreamInfo(index=0, codec="aac", language="zh")], + ) + widget._media_path = str(fake_media) + widget._apply_state(PageState.READY) + widget._on_media_loaded(info) + app.processEvents() + assert widget.state == PageState.READY + assert widget.paramsPanel.startButton.isEnabled() + assert widget.paramsPanel.startButton.text() == "开始转录" + assert widget.paramsPanel.trackRow.isVisible() + assert widget.paramsPanel.trackSelect.currentText() == "音轨 1 · 中文" + + # 右栏折叠:紧凑主按钮与状态同步,展开还原。 + widget.sideHost.setCollapsed(True, animate=False) + app.processEvents() + assert cfg.transcribe_panel_collapsed.value + assert widget.fileCompactStart.isVisibleTo(widget.filePanel) + assert widget.fileCompactStart.text() == "开始转录" + widget.sideHost.setCollapsed(False, animate=False) + app.processEvents() + assert not cfg.transcribe_panel_collapsed.value + + _grab(widget, output_dir, "transcription-ready-state", app) + screenshot_names.append("transcription-ready-state") + + # 转录中:按钮禁用、进度卡可见、可取消但不可换文件。 + widget._apply_state(PageState.RUNNING) + widget.progressCard.setProgress(62) + app.processEvents() + assert not widget.paramsPanel.startButton.isEnabled() + assert widget.progressCard.isVisible() + assert not widget.replaceLink.isVisible() + assert widget.cancelLink.isVisible() + assert widget.progressCard.percentLabel.text() == "62%" + + # 取消转录:回到就绪态,文件保留。 + widget._cancel_transcription() + app.processEvents() + assert widget.state == PageState.READY + assert widget.paramsPanel.startButton.isEnabled() + assert not widget.cancelLink.isVisible() + widget._apply_state(PageState.RUNNING) + app.processEvents() + + # 失败:错误面板 + 重新转录。 + widget._on_transcript_failed("smoke 测试失败原因") + app.processEvents() + assert widget.state == PageState.FAILED + assert widget.errorBanner.isVisible() + assert widget.errorBanner.text() == "smoke 测试失败原因" + assert widget.paramsPanel.startButton.text() == "重新转录" + assert widget.paramsPanel.statusPill.text() == "未连通" + _grab(widget, output_dir, "transcription-failed-state", app) + screenshot_names.append("transcription-failed-state") + + # 完成:SRT 表格 + 结果操作面板。 + widget.subtitlePreview.setSegments([(0, 1200, "第一条"), (1300, 2400, "第二条")]) + widget.resultPanel.setResult( + title="transcription-fake", chips=["Fun-ASR", "10:24", "SRT"], file_name="x.srt" + ) + widget._apply_state(PageState.DONE) + app.processEvents() + assert widget.subtitlePreview.pill.text() == "2 条" + assert len(widget.subtitlePreview.rows) == 2 + assert widget.rightStack.currentWidget() is widget.resultPanel + _grab(widget, output_dir, "transcription-done-state", app) + screenshot_names.append("transcription-done-state") + widget.close() + + +def _check_subtitle(output_dir: Path, app, screenshot_names: list[str]) -> None: + """字幕页(两栏审校):选项与配置双向同步、加载与状态切换、行编辑。""" + from videocaptioner.core.entities import SubtitleLayoutEnum + from videocaptioner.core.translate.types import TargetLanguage + from videocaptioner.ui.common.config import cfg + from videocaptioner.ui.view.subtitle_interface import PageState + + widget = _make_page("subtitle") + widget.resize(*PAGE_SIZE) + widget.show() + app.processEvents() + + # 空态:按钮禁用,选项卡片可见。 + assert widget.state == PageState.EMPTY + assert not widget.sidePanel.primaryButton.isEnabled() + assert widget.sidePanel.primaryButton.text() == "等待字幕" + + layout_items = widget.sidePanel.layoutSelect.items() + language_items = widget.sidePanel.languageSelect.items() + assert set(layout_items) == {layout.value for layout in SubtitleLayoutEnum} + assert len(set(language_items)) == len(language_items) + assert TargetLanguage.ENGLISH.value in language_items + + # 选项与共享配置双向同步。 + widget.sidePanel.layoutSelect.setCurrentText(SubtitleLayoutEnum.ONLY_ORIGINAL.value) + app.processEvents() + assert cfg.subtitle_layout.value == SubtitleLayoutEnum.ONLY_ORIGINAL + + widget.sidePanel.translateSwitch.setChecked(True) + widget.sidePanel.languageSelect.setCurrentText(TargetLanguage.ENGLISH.value) + app.processEvents() + assert cfg.need_translate.value + assert cfg.target_language.value == TargetLanguage.ENGLISH + assert widget.sidePanel.languageCard.isVisible() + + widget.sidePanel.translateSwitch.setChecked(False) + app.processEvents() + assert not cfg.need_translate.value + assert not widget.sidePanel.languageCard.isVisible() + widget.sidePanel.translateSwitch.setChecked(True) + + # 加载真实字幕夹具 -> 就绪态。 + # 注意要用临时副本:开始处理会把表格内容写回源文件,不能污染仓库夹具。 + fixture = Path(__file__).resolve().parents[1] / "tests/fixtures/audio/zh.srt" + fixture_copy = output_dir / "subtitle-fixture.srt" + fixture_copy.write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8") + widget.load_subtitle_file(str(fixture_copy)) + app.processEvents() + assert widget.state == PageState.READY + assert widget.sidePanel.primaryButton.isEnabled() + assert widget.sidePanel.primaryButton.text() == "开始处理" + assert widget.tablePanel.bottomBar.infoLabel.text() == "共 1 条" + assert widget.tablePanel.bottomBar.rightPill.text() == "已加载" + _grab(widget, output_dir, "subtitle-ready-state", app) + screenshot_names.append("subtitle-ready-state") + + # 表格行操作:合并 / 删除(模型层)。 + widget.model.replace_all( + { + "1": {"start_time": 0, "end_time": 1000, "original_subtitle": "甲", "translated_subtitle": ""}, + "2": {"start_time": 1000, "end_time": 2000, "original_subtitle": "乙", "translated_subtitle": ""}, + "3": {"start_time": 2000, "end_time": 3000, "original_subtitle": "丙", "translated_subtitle": ""}, + } + ) + widget._merge_rows([0, 1]) + assert widget.model.rowCount() == 2 + assert widget.model.raw()["1"]["original_subtitle"] == "甲 乙" + widget._delete_rows([1]) + assert widget.model.rowCount() == 1 + + # 右栏折叠:窄条 + 表头主按钮出现,展开后还原,状态写入配置。 + widget.sideHost.setCollapsed(True, animate=False) + app.processEvents() + assert widget.sideHost.isCollapsed() + assert cfg.subtitle_panel_collapsed.value + assert widget.tablePanel.headStartButton.isVisibleTo(widget.tablePanel) + widget.sideHost.setCollapsed(False, animate=False) + app.processEvents() + assert not cfg.subtitle_panel_collapsed.value + assert not widget.tablePanel.headStartButton.isVisibleTo(widget.tablePanel) + + # 配置未就绪态(断句开启但 LLM 未配置)。 + cfg.set(cfg.need_split, True) + widget._start_processing() + app.processEvents() + assert widget.state == PageState.FAILED + assert widget.sidePanel.errorCard.isVisible() + assert widget.sidePanel.primaryButton.text() == "打开处理配置" + cfg.set(cfg.need_split, False) + _grab(widget, output_dir, "subtitle-blocked-state", app) + screenshot_names.append("subtitle-blocked-state") + widget.close() + + +def _check_subtitle_style(output_dir: Path, app, screenshot_names: list[str]) -> None: + """字幕样式页(三栏):样式库加载、ASS/圆角分页切换重建参数面板、显示内容分段控双语顺序行显隐。""" + widget = _make_page("subtitle-style") + widget.resize(*PAGE_SIZE) + widget.show() + _settle_widget(widget, app) + + # 样式库有卡片,参数面板已构建 + assert widget._cards, "subtitle style library has no cards" + assert widget._ass or widget._rounded, "inspector controls not built" + _grab(widget, output_dir, "subtitle-style-ass", app) + screenshot_names.append("subtitle-style-ass") + + # 显示内容切到"原文"时,双语顺序行应隐藏;切回"双语"恢复 + widget.contentSeg.setCurrent("source") + _settle_widget(widget, app) + assert not widget.orderRow.isVisible() + widget.contentSeg.setCurrent("bilingual") + _settle_widget(widget, app) + assert widget.orderRow.isVisible() + + # 切到圆角背景:参数面板重建为圆角控件集 + widget.modeTabs.setCurrent("rounded") + _settle_widget(widget, app) + assert widget._rounded and "bg_color" in widget._rounded + _grab(widget, output_dir, "subtitle-style-rounded", app) + screenshot_names.append("subtitle-style-rounded") + + widget.modeTabs.setCurrent("ass") + _settle_widget(widget, app) + + # 大窗回归守卫:自绘卡片/参数行若漏设透明背景,深色主题下会露出 Qt 白底。 + # 采样左栏样式库下方空白区 + 右栏参数区,断言必须是深色(亮度 < 80)。 + # 仅在深色主题下有意义——浅色主题底色本就接近白,跑此断言会误报。 + from videocaptioner.ui.common.theme_tokens import is_dark_theme + + if is_dark_theme(): + widget.resize(1900, 1400) + _settle_widget(widget, app) + image = widget.grab().toImage() + w_px, h_px = image.width(), image.height() + samples = { + "library-empty": (90, int(h_px * 0.7)), + "inspector-area": (w_px - 180, int(h_px * 0.45)), + } + for where, (x, y) in samples.items(): + color = image.pixelColor(x, y) + lum = (color.red() + color.green() + color.blue()) // 3 + if lum >= 80: + raise AssertionError( + f"subtitle-style {where} 在深色主题下发白(亮度 {lum} @ {x},{y}):" + "自绘组件可能漏设透明背景,露出了 Qt 默认白底。" + ) + widget.close() + + +def _check_dubbing(output_dir: Path, app, screenshot_names: list[str]) -> None: + """配音页:三个 provider 切换后声线表非空、克隆区只在 SiliconFlow 显示, + 性别筛选生效,设置/清除克隆参考音频联动播放按钮。""" + from videocaptioner.ui.common.config import cfg + + reference_wav = output_dir / "reference.wav" + _write_reference_wav(reference_wav) + + widget = _make_page("dubbing") + widget.resize(*PAGE_SIZE) + widget.show() + app.processEvents() + for provider in ["edge", "gemini", "siliconflow"]: + widget._on_provider_changed(provider) + app.processEvents() + assert cfg.dubbing_provider.value == provider + assert len(widget.voiceTable.rows) > 0 + assert widget.previewPanel.cloneSection.isVisible() == (provider == "siliconflow") + for key, card in widget.providerCards.items(): + assert card.isActive() == (key == provider) + + # 回归:切换提供商必须中止进行中的试听播放,并复位按钮文案(避免「停止」标签残留、点击行为相反) + play_btn = widget.previewPanel.customPreviewButton + widget._playing_button = play_btn + widget._set_preview_button(play_btn, "playing") + assert play_btn.text() == "停止" + widget._on_provider_changed("siliconflow") + app.processEvents() + assert widget._playing_button is None, "切换提供商后未停止播放" + assert play_btn.text() != "停止", "切换提供商后按钮文案仍是「停止」" + + widget._on_gender_filter("女声") + app.processEvents() + assert widget.voiceTable.rows + assert all("女声" in row.voice.tags for row in widget.voiceTable.rows) + + cfg.set(cfg.dubbing_clone_audio, str(reference_wav), save=False) + widget.previewPanel.setAudioPath(str(reference_wav)) + widget.previewPanel.setCloneText("这是一段用于克隆测试的参考音频。") + app.processEvents() + assert widget.bodyPanel.height() >= widget.sidePanel.sizeHint().height() + assert widget.previewPanel.playButton.isEnabled() + assert widget.previewPanel.cloneTextInput.isVisible() + _grab(widget, output_dir, "dubbing-clone-state", app) + screenshot_names.append("dubbing-clone-state") + + widget._clear_clone_audio() + app.processEvents() + assert not cfg.dubbing_clone_audio.value + assert not widget.previewPanel.playButton.isEnabled() + widget.close() + + +def _check_video_synthesis(output_dir: Path, app, screenshot_names: list[str]) -> None: + """视频合成页(组合开关工作台):开关组合、文件就绪度、预检与状态切换。""" + from videocaptioner.core.entities import VideoQualityEnum + from videocaptioner.ui.common.config import cfg + from videocaptioner.ui.view.video_synthesis_interface import ( + SUBTITLE_MODE_LABELS, + PageState, + ) + + widget = _make_page("video-synthesis") + widget.resize(*PAGE_SIZE) + widget.show() + app.processEvents() + + # 默认:字幕视频开、配音关、空态等待文件。 + cfg.set(cfg.need_video, True) + cfg.set(cfg.dubbing_enabled, False) + widget._refresh() + app.processEvents() + assert widget.state == PageState.IDLE + assert not widget.generatePanel.primaryButton.isEnabled() + assert widget.generatePanel.subtitleCard.isChecked() + assert not widget.generatePanel.dubbingCard.isChecked() + + # 字幕方式互锁:软字幕不烧录样式,渲染模式和样式入口都应隐藏/锁定。 + widget.generatePanel.subtitleModeSelect.setCurrentText(SUBTITLE_MODE_LABELS()[True]) + app.processEvents() + assert cfg.soft_subtitle.value + assert not widget.generatePanel.renderModeSelect.isEnabled() + assert not widget.generatePanel.renderModeCard.isVisible() + assert not widget.generatePanel.stylePageCard.isVisible() + widget.generatePanel.subtitleModeSelect.setCurrentText(SUBTITLE_MODE_LABELS()[False]) + app.processEvents() + assert not cfg.soft_subtitle.value + assert widget.generatePanel.renderModeSelect.isEnabled() + assert widget.generatePanel.renderModeCard.isVisible() + assert widget.generatePanel.stylePageCard.isVisible() + + # 质量选择与配置同步。 + widget.generatePanel.qualitySelect.setCurrentText(VideoQualityEnum.LOW.value) + app.processEvents() + assert cfg.video_quality.value == VideoQualityEnum.LOW + + # 文件就绪 -> 可生成;输出开关联动按钮文案。 + fixture = Path(__file__).resolve().parents[1] / "tests/fixtures/audio/zh.srt" + subtitle_copy = output_dir / "synthesis-subtitle.srt" + subtitle_copy.write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8") + fake_video = output_dir / "synthesis-video.mp4" + fake_video.write_bytes(b"\0") + widget.set_subtitle_file(str(subtitle_copy)) + app.processEvents() + assert not widget.generatePanel.primaryButton.isEnabled() # 还缺视频 + widget.set_video_file(str(fake_video)) + app.processEvents() + assert widget.generatePanel.primaryButton.isEnabled() + assert widget.generatePanel.primaryButton.text() == "生成字幕视频" + + cfg.set(cfg.dubbing_provider, "edge") + widget.generatePanel.dubbingCard.setChecked(True) + app.processEvents() + assert cfg.dubbing_enabled.value + assert widget.generatePanel.primaryButton.text() == "生成成片" + _grab(widget, output_dir, "video-ready-state", app) + screenshot_names.append("video-ready-state") + + # 预检拦截:非 Edge 音色缺 Key -> 错误卡 + 禁用。 + cfg.set(cfg.dubbing_provider, "siliconflow") + cfg.set(cfg.dubbing_api_key, "") + widget._refresh() + app.processEvents() + assert not widget.generatePanel.primaryButton.isEnabled() + assert widget.generatePanel.errorCard.isVisible() + _grab(widget, output_dir, "video-blocked-state", app) + screenshot_names.append("video-blocked-state") + cfg.set(cfg.dubbing_provider, "edge") + widget.close() + + +def _check_batch(output_dir: Path, app, screenshot_names: list[str]) -> None: + """批量处理页(队列工作台):加文件、模式过滤、状态行渲染与清空。""" + from videocaptioner.ui.common.config import cfg + from videocaptioner.ui.view.batch_process_interface import JobStatus, PageState + + widget = _make_page("batch") + widget.resize(*PAGE_SIZE) + widget.show() + app.processEvents() + + # 空态:拖放区可见、主按钮禁用、模式卡选中持久化值。 + cfg.set(cfg.batch_mode, "full") + widget._switch_mode("full") + app.processEvents() + assert widget._page_state() == PageState.EMPTY + assert widget.queueStack.currentIndex() == 0 + assert not widget.primaryButton.isEnabled() + assert not widget.clearButton.isEnabled() + active_cards = [card for card in widget.modeCards if card._active] + assert len(active_cards) == 1 and active_cards[0].key == "full" + + # 加入两个媒体文件 + 一个不支持的扩展名 -> 只收两个,进入 READY。 + batch_dir = output_dir / "batch-files" + batch_dir.mkdir(exist_ok=True) + media_a = batch_dir / "课程 p01.mp4" + media_a.write_bytes(b"\0") + media_b = batch_dir / "课程 p02.mp3" + media_b.write_bytes(b"\0") + (batch_dir / "notes.txt").write_text("x", encoding="utf-8") + widget.add_paths([str(batch_dir)]) + app.processEvents() + assert len(widget.controller.jobs) == 2 + assert widget._page_state() == PageState.READY + assert widget.queueStack.currentIndex() == 1 + assert widget.primaryButton.isEnabled() + assert widget.countPill.text() == "2 个任务" + assert len(widget._rows) == 2 + # 行内文字必须套用调色板(历史 bug:syncStyle 未调用导致默认黑字) + assert "color:" in widget._rows[0].styleSheet() + # 过滤分段固定高度,不允许被头部行拉伸贴边 + assert widget.filterTabs.height() <= 40 + + # 行状态渲染:模拟完成 / 失败,失败行主按钮变成重试。 + jobs = widget.controller.jobs + jobs[0].status = JobStatus.COMPLETED + jobs[0].progress = 100 + jobs[0].note = "已输出 课程 p01.subtitled.mp4" + jobs[1].status = JobStatus.FAILED + jobs[1].note = "LLM API Key 缺失" + jobs[1].error = "字幕处理:LLM API Key 缺失" + widget.controller.jobChanged.emit(0) + widget.controller.jobChanged.emit(1) + widget._batch_ran = True + widget._refresh() + app.processEvents() + assert widget._page_state() == PageState.DONE + assert widget._rows[1].primaryButton.toolTip() == "重试任务" + assert widget.primaryButton.isEnabled() # 有失败可重跑 + + # 过滤 tab:失败筛选只显示失败行。 + widget.filterTabs.setCurrent("failed") + app.processEvents() + assert not widget._rows[0].isVisible() + assert widget._rows[1].isVisible() + widget.filterTabs.setCurrent("all") + app.processEvents() + _grab(widget, output_dir, "batch-done-state", app) + screenshot_names.append("batch-done-state") + + # 模式切换过滤:切到批量字幕翻译,媒体文件被移出队列。 + widget._switch_mode("subtitle") + app.processEvents() + assert not widget.controller.jobs + assert widget._page_state() == PageState.EMPTY + srt = batch_dir / "字幕.srt" + srt.write_text("1\n00:00:00,000 --> 00:00:01,000\nhi\n", encoding="utf-8") + widget.add_paths([str(srt)]) + app.processEvents() + assert len(widget.controller.jobs) == 1 + + # 清空恢复空态;并发选择写回配置。 + widget._on_clear_clicked() + app.processEvents() + assert widget._page_state() == PageState.EMPTY + widget.concurrencySelect.setCurrentText("并发 2") + app.processEvents() + assert int(cfg.batch_concurrency.value) == 2 + widget.concurrencySelect.setCurrentText("并发 1") + app.processEvents() + widget._switch_mode("full") + widget.close() + + +def _capture_compact_states(output_dir: Path, app) -> list[str]: + """960x720 紧凑窗口下的布局回归:关键面板不溢出、克隆区完整可见。""" + from videocaptioner.ui.common.config import cfg + + names: list[str] = [] + + dubbing = _make_page("dubbing") + dubbing.resize(*COMPACT_SIZE) + dubbing.show() + dubbing._on_provider_changed("edge") + _settle_widget(dubbing, app) + _assert_fits_parent(dubbing.providerPanel, "compact dubbing provider") + _assert_fits_parent(dubbing.voiceTable.header, "compact dubbing filter") + _assert_fits_parent(dubbing.bodyPanel, "compact dubbing body") + _grab(dubbing, output_dir, "compact-dubbing-edge", app) + names.append("compact-dubbing-edge") + + reference_wav = output_dir / "compact-reference.wav" + _write_reference_wav(reference_wav) + dubbing._on_provider_changed("siliconflow") + cfg.set(cfg.dubbing_clone_audio, str(reference_wav), save=False) + dubbing.previewPanel.setAudioPath(str(reference_wav)) + dubbing.previewPanel.setCloneText("这是一段用于克隆测试的参考音频。") + _settle_widget(dubbing, app) + assert dubbing.previewPanel.cloneSection.isVisible() + assert dubbing.bodyPanel.height() >= dubbing.sidePanel.sizeHint().height() + _assert_fits_parent(dubbing.sidePanel, "compact dubbing side") + _grab(dubbing, output_dir, "compact-dubbing-clone", app) + names.append("compact-dubbing-clone") + dubbing.close() + + synthesis = _make_page("video-synthesis") + synthesis.resize(*COMPACT_SIZE) + synthesis.show() + cfg.set(cfg.need_video, True) + cfg.set(cfg.dubbing_enabled, True) + synthesis._refresh() + _settle_widget(synthesis, app) + _assert_fits_parent(synthesis.workspace, "compact synthesis workspace") + _assert_fits_parent(synthesis.sideHost, "compact synthesis side host") + _grab(synthesis, output_dir, "compact-video-synthesis", app) + names.append("compact-video-synthesis") + synthesis.close() + + settings = _make_page("setting") + settings.resize(*COMPACT_SIZE) + settings.show() + settings.setCurrentPage("dubbing") + _settle_widget(settings, app) + assert settings.dubbingProviderRow.isVisible() + _grab(settings, output_dir, "compact-setting-dubbing", app) + names.append("compact-setting-dubbing") + settings.close() + + for name, page in [ + ("compact-transcription", "transcription"), + ("compact-subtitle-style", "subtitle-style"), + ]: + widget = _make_page(page) + widget.resize(*COMPACT_SIZE) + widget.show() + _grab(widget, output_dir, name, app) + names.append(name) + widget.close() + + return names + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- + + +def _run_both_themes(args: argparse.Namespace) -> int: + """--theme both:每个主题各跑一个子进程,输出到 /。""" + base_dir = args.output_dir or DEFAULT_OUTPUT_DIR + exit_code = 0 + for theme in ["dark", "light"]: + cmd = [sys.executable, __file__, str(base_dir / theme), "--theme", theme] + if args.shots_only: + cmd.append("--shots-only") + if args.pages: + cmd.extend(["--pages", args.pages]) + result = subprocess.run(cmd) + exit_code = exit_code or result.returncode + return exit_code + + +def main() -> int: + args = _parse_cli(sys.argv[1:]) + if args.list: + _print_page_list() + return 0 + if args.theme == "both": + return _run_both_themes(args) + + output_dir = args.output_dir or DEFAULT_OUTPUT_DIR / args.theme + output_dir.mkdir(parents=True, exist_ok=True) + _prepare_environment(output_dir) + + from PyQt5.QtCore import QTimer + from PyQt5.QtWidgets import QApplication + + app = QApplication([]) + _apply_theme(args.theme) + _init_i18n() + + if args.shots_only: + # 快速模式:只截图,不跑断言。 + selected = ( + _resolve_page_selection(args.pages) + if args.pages + else list(PAGE_REGISTRY) + [f"setting-{key}" for key in SETTING_PAGE_KEYS] + ) + for name in selected: + _capture_page(name, output_dir, app) + contact_sheet = _make_contact_sheet(output_dir, selected) + if contact_sheet: + print(f"contact_sheet={contact_sheet}") + print(f"screenshots={output_dir}") + print(f"theme={args.theme}") + print("ui-shots=ok") + else: + # 完整模式:全页截图 + 设置子页 + 行为断言 + 紧凑窗口检查。 + screenshot_names: list[str] = [] + for name in PAGE_REGISTRY: + _capture_page(name, output_dir, app) + screenshot_names.append(name) + + settings_screenshot_names = _capture_settings_pages(output_dir, app) + _check_navigation(output_dir, app, screenshot_names) + _check_task_creation(output_dir, app, screenshot_names) + _check_settings(app) + _check_settings_navigation(app) + _check_transcription(output_dir, app, screenshot_names) + _check_subtitle(output_dir, app, screenshot_names) + _check_subtitle_style(output_dir, app, screenshot_names) + _check_dubbing(output_dir, app, screenshot_names) + _check_video_synthesis(output_dir, app, screenshot_names) + _check_batch(output_dir, app, screenshot_names) + compact_screenshot_names = _capture_compact_states(output_dir, app) + + contact_sheet = _make_contact_sheet(output_dir, screenshot_names) + settings_contact_sheet = _make_contact_sheet( + output_dir, settings_screenshot_names, "settings-contact-sheet.png" + ) + compact_contact_sheet = _make_contact_sheet( + output_dir, compact_screenshot_names, "compact-contact-sheet.png" + ) + if contact_sheet: + print(f"contact_sheet={contact_sheet}") + if settings_contact_sheet: + print(f"settings_contact_sheet={settings_contact_sheet}") + if compact_contact_sheet: + print(f"compact_contact_sheet={compact_contact_sheet}") + print(f"screenshots={output_dir}") + print(f"theme={args.theme}") + print("ui-smoke=ok") + + QTimer.singleShot(0, app.quit) + app.exec_() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/SKILL.md b/skills/SKILL.md index 449ce3a2..db80a202 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -1,7 +1,7 @@ --- name: videocaptioner description: Process video subtitles — transcribe speech, optimize/translate text, burn styled subtitles into video. Use when you need to add subtitles to a video, transcribe audio, translate subtitles, or customize subtitle styles. -allowed-tools: Bash(videocaptioner *, ffprobe *, ffmpeg -ss *) +allowed-tools: Bash(videocaptioner *, ffmpeg -i *, ffmpeg -ss *) --- # VideoCaptioner CLI diff --git a/tests/conftest.py b/tests/conftest.py index 99fe58d4..6b87354b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,138 @@ """Root-level test configuration and shared fixtures.""" +import json import os +import re from typing import Dict, List +import json_repair import pytest from videocaptioner.core.asr.asr_data import ASRData, ASRDataSeg from videocaptioner.core.translate import SubtitleProcessData, TargetLanguage from videocaptioner.core.utils import cache +from videocaptioner.core.utils.text_utils import count_words, is_mainly_cjk # Disable cache for testing cache.disable_cache() +@pytest.fixture(autouse=True) +def isolate_cache_state(): + """Keep tests from leaking the process-wide cache switch into each other.""" + cache.disable_cache() + yield + cache.disable_cache() + + +class _FakeLLMMessage: + def __init__(self, content: str): + self.content = content + + +class _FakeLLMChoice: + def __init__(self, content: str): + self.message = _FakeLLMMessage(content) + + +class _FakeLLMResponse: + def __init__(self, content: str): + self.choices = [_FakeLLMChoice(content)] + + +def _split_mock_text(text: str) -> List[str]: + """Produce deterministic mock LLM sentence splits without changing content.""" + if not text.strip(): + return [text] + + if is_mainly_cjk(text) or re.search(r"[\u4e00-\u9fff]", text): + limit = 12 + segments: List[str] = [] + current = "" + for char in text: + current += char + should_break = char in "。!?;" or count_words(current) >= limit + if should_break and current.strip(): + segments.append(current) + current = "" + if current: + segments.append(current) + return segments + + words = text.split() + if len(words) <= 10: + return [text] + return [" ".join(words[i : i + 10]) for i in range(0, len(words), 10)] + + +def _extract_json_dict(text: str) -> Dict[str, str]: + match = re.search(r"(.*?)", text, re.S) + payload = match.group(1) if match else text + parsed = json_repair.loads(payload) + if isinstance(parsed, dict): + return {str(k): str(v) for k, v in parsed.items()} + return {} + + +@pytest.fixture +def mock_llm_client(monkeypatch): + """Patch all direct LLM call sites with a deterministic OpenAI-like response.""" + monkeypatch.setenv("OPENAI_BASE_URL", "https://mock.local/v1") + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-4o-mini") + + def fake_call_llm(messages, model, **kwargs): + system_prompt = "\n".join( + str(message.get("content", "")) + for message in messages + if message.get("role") == "system" + ) + user_prompt = next( + ( + str(message.get("content", "")) + for message in reversed(messages) + if message.get("role") == "user" + ), + "", + ) + + if "Please use multiple
tags" in user_prompt: + text = user_prompt.split("\n", 1)[-1] + return _FakeLLMResponse("
".join(_split_mock_text(text))) + + if "" in user_prompt or "Correct the following subtitles" in user_prompt: + subtitle_dict = _extract_json_dict(user_prompt) + return _FakeLLMResponse(json.dumps(subtitle_dict, ensure_ascii=False)) + + try: + subtitle_dict = _extract_json_dict(user_prompt) + except Exception: + subtitle_dict = {} + + if subtitle_dict: + if "native_translation" in system_prompt or "reflect" in system_prompt.lower(): + payload = { + key: {"native_translation": f"{value} 译文"} + for key, value in subtitle_dict.items() + } + else: + payload = {key: f"{value} 译文" for key, value in subtitle_dict.items()} + return _FakeLLMResponse(json.dumps(payload, ensure_ascii=False)) + + return _FakeLLMResponse('{"1": "mock"}') + + monkeypatch.setattr("videocaptioner.core.llm.client.call_llm", fake_call_llm) + monkeypatch.setattr("videocaptioner.core.llm.call_llm", fake_call_llm) + monkeypatch.setattr("videocaptioner.core.translate.llm_translator.call_llm", fake_call_llm) + monkeypatch.setattr("videocaptioner.core.optimize.optimize.call_llm", fake_call_llm) + monkeypatch.setattr("videocaptioner.core.split.split_by_llm.call_llm", fake_call_llm) + monkeypatch.setattr( + "videocaptioner.ui.thread.subtitle_thread.check_llm_connection", + lambda *args, **kwargs: (True, "mock"), + ) + return fake_call_llm + + @pytest.fixture def sample_asr_data(): """Create sample ASR data for translation testing.""" diff --git a/tests/test_application/__init__.py b/tests/test_application/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_application/test_output_paths.py b/tests/test_application/test_output_paths.py new file mode 100644 index 00000000..f339a0c4 --- /dev/null +++ b/tests/test_application/test_output_paths.py @@ -0,0 +1,149 @@ +"""output_paths 是全项目文件命名的唯一事实源,这里把语法钉死。 + +任何想改输出命名的人都必须先改这份测试——这正是设计意图。 +""" + + +import pytest + +from videocaptioner.core.application import output_paths as op +from videocaptioner.core.entities import SubtitleLayoutEnum +from videocaptioner.core.translate.types import TargetLanguage + + +class TestProductPath: + def test_subtitle_product_uses_dotted_language_tag(self, tmp_path): + src = tmp_path / "video.mp4" + assert op.product_path(src, "zh-Hans", ext=".srt") == tmp_path / "video.zh-Hans.srt" + + def test_optimized_subtitle(self, tmp_path): + src = tmp_path / "video.srt" + assert op.product_path(src, op.TAG_OPTIMIZED, ext=".srt") == tmp_path / "video.optimized.srt" + + def test_subtitled_video_keeps_source_suffix(self, tmp_path): + src = tmp_path / "video.mkv" + assert op.product_path(src, op.TAG_SUBTITLED) == tmp_path / "video.subtitled.mkv" + + def test_dubbed_audio_and_video_share_one_tag(self, tmp_path): + src = tmp_path / "video.mp4" + assert op.product_path(src, op.TAG_DUBBED) == tmp_path / "video.dubbed.mp4" + assert op.product_path(src, op.TAG_DUBBED, ext=".wav") == tmp_path / "video.dubbed.wav" + + def test_tags_compose_in_processing_order(self, tmp_path): + src = tmp_path / "video.mp4" + assert ( + op.product_path(src, op.TAG_DUBBED, op.TAG_SUBTITLED) + == tmp_path / "video.dubbed.subtitled.mp4" + ) + + def test_existing_tags_are_stripped_before_appending(self, tmp_path): + translated = tmp_path / "video.zh-Hans.srt" + assert op.product_path(translated, "en", ext=".srt") == tmp_path / "video.en.srt" + dubbed_from_subtitle = tmp_path / "video.zh-Hans.srt" + assert ( + op.product_path(dubbed_from_subtitle, op.TAG_DUBBED, ext=".wav") + == tmp_path / "video.dubbed.wav" + ) + + def test_dots_in_real_names_are_not_tags(self, tmp_path): + src = tmp_path / "my.holiday.video.mp4" + assert ( + op.product_path(src, op.TAG_SUBTITLED) + == tmp_path / "my.holiday.video.subtitled.mp4" + ) + + def test_directory_override(self, tmp_path): + src = tmp_path / "a" / "video.mp4" + out = tmp_path / "b" + assert op.product_path(src, op.TAG_SUBTITLED, directory=out) == out / "video.subtitled.mp4" + + def test_unknown_tag_rejected(self, tmp_path): + with pytest.raises(ValueError): + op.product_path(tmp_path / "video.mp4", "captioned") + + +class TestLanguageTag: + def test_common_codes(self): + assert op.language_tag(TargetLanguage.SIMPLIFIED_CHINESE) == "zh-Hans" + assert op.language_tag(TargetLanguage.ENGLISH) == "en" + assert op.language_tag(TargetLanguage.JAPANESE) == "ja" + + def test_every_target_language_has_a_tag(self): + # 新增 TargetLanguage 成员时必须同步补 BING_LANG_MAP,否则文件名无码可用。 + for language in TargetLanguage: + tag = op.language_tag(language) + assert tag and "/" not in tag and "\\" not in tag + + +class TestUniquePath: + def test_free_path_returned_as_is(self, tmp_path): + target = tmp_path / "video.dubbed.mp4" + assert op.unique_path(target) == target + + def test_increments_like_os_convention(self, tmp_path): + target = tmp_path / "video.dubbed.mp4" + target.write_bytes(b"") + assert op.unique_path(target) == tmp_path / "video.dubbed (2).mp4" + (tmp_path / "video.dubbed (2).mp4").write_bytes(b"") + assert op.unique_path(target) == tmp_path / "video.dubbed (3).mp4" + + +class TestTaskDir: + def test_layout_and_uniqueness(self, tmp_path): + first = op.new_task_dir(tmp_path, "/somewhere/视频 demo.mp4", op.TASK_TRANSCRIBE) + assert first.is_dir() + assert first.parent == tmp_path / "transcribe" # 按功能归类到 {work_dir}/{task_type}/ + assert first.name.endswith("-视频 demo") + second = op.new_task_dir(tmp_path, "/somewhere/视频 demo.mp4", op.TASK_TRANSCRIBE) + assert second != first and second.is_dir() + + def test_task_type_routes_to_subdir(self, tmp_path): + for tt in (op.TASK_TRANSCRIBE, op.TASK_SYNTHESIS, op.TASK_BATCH, op.TASK_DUBBING): + assert op.new_task_dir(tmp_path, "v.mp4", tt).parent == tmp_path / tt + + def test_unknown_task_type_rejected(self, tmp_path): + import pytest + with pytest.raises(ValueError): + op.new_task_dir(tmp_path, "v.mp4", "bogus") + + def test_hostile_stem_sanitized(self, tmp_path): + created = op.new_task_dir(tmp_path, 'a/b<>:"|?*.mp4', op.TASK_TRANSCRIBE) + assert created.is_dir() + + def test_cleanup_removes_only_task_dirs(self, tmp_path): + task = op.new_task_dir(tmp_path, "video.mp4", op.TASK_BATCH) + (task / "transcript.srt").write_text("1", encoding="utf-8") + op.cleanup_task_dir(task, keep=True) + assert task.exists() + op.cleanup_task_dir(task, keep=False) + assert not task.exists() + + def test_cleanup_refuses_non_task_dirs(self, tmp_path): + # 父目录名不是已知 task_type → 绝不删(防 rmtree 误删到用户目录) + outsider = tmp_path / "precious" + outsider.mkdir() + op.cleanup_task_dir(outsider, keep=False) + assert outsider.exists() + nested = tmp_path / "notatype" / "20260101-000000-x" + nested.mkdir(parents=True) + op.cleanup_task_dir(nested, keep=False) + assert nested.exists() + + def test_cleanup_tolerates_none_and_missing(self, tmp_path): + op.cleanup_task_dir(None, keep=False) + op.cleanup_task_dir(tmp_path / "transcribe" / "gone", keep=False) + + +class TestHelpers: + def test_layout_copy_names(self): + assert op.layout_copy_name(SubtitleLayoutEnum.TRANSLATE_ON_TOP) == "layout-target-above.srt" + assert op.layout_copy_name(SubtitleLayoutEnum.ONLY_ORIGINAL) == "layout-source-only.srt" + + def test_downloads_dir_created(self, tmp_path): + target = op.downloads_dir(tmp_path) + assert target == tmp_path / "downloads" and target.is_dir() + + def test_strip_tags_only_touches_known_vocabulary(self): + assert op.strip_tags("video.zh-Hans") == "video" + assert op.strip_tags("video.dubbed.subtitled") == "video" + assert op.strip_tags("my.holiday.video") == "my.holiday.video" diff --git a/tests/test_asr/test_asr_data.py b/tests/test_asr/test_asr_data.py index ad09293e..0a31562d 100644 --- a/tests/test_asr/test_asr_data.py +++ b/tests/test_asr/test_asr_data.py @@ -425,6 +425,38 @@ def test_load_nonexistent_file(self): with pytest.raises(FileNotFoundError): ASRData.from_subtitle_file("/nonexistent/path/file.srt") + def test_save_vtt_and_round_trip(self): + """回归:save() 曾漏接 .vtt 分支,输出格式选 VTT/All 时整个转录被误判失败""" + segments = [ASRDataSeg("今天天气", 200, 1440), ASRDataSeg("怎么样", 1500, 2280)] + asr_data = ASRData(segments) + + with tempfile.TemporaryDirectory() as tmpdir: + vtt_path = Path(tmpdir) / "result.vtt" + asr_data.save(str(vtt_path)) + content = vtt_path.read_text(encoding="utf-8") + assert content.startswith("WEBVTT") + assert "00:00:00.200 --> 00:00:01.440" in content + loaded = ASRData.from_subtitle_file(str(vtt_path)) + assert [seg.text for seg in loaded.segments] == ["今天天气", "怎么样"] + assert loaded.segments[0].start_time == 200 + + def test_save_supports_every_transcribe_output_format(self): + """转录输出格式枚举里的每种格式(含 All 展开)都必须能落盘""" + from videocaptioner.core.entities import TranscribeOutputFormatEnum + + segments = [ASRDataSeg("Test", 0, 1000)] + asr_data = ASRData(segments) + formats = [ + fmt.value.lower() + for fmt in TranscribeOutputFormatEnum + if fmt != TranscribeOutputFormatEnum.ALL + ] + with tempfile.TemporaryDirectory() as tmpdir: + for ext in formats: + target = Path(tmpdir) / f"result.{ext}" + asr_data.save(str(target)) + assert target.exists() and target.stat().st_size > 0, ext + def test_save_load_unicode_path(self): """测试Unicode文件路径""" segments = [ASRDataSeg("测试", 0, 1000)] @@ -549,3 +581,42 @@ def test_windows_already_prefixed_path_is_idempotent(self, monkeypatch): twice = handle_long_path(once) assert twice == once assert "\\\\?\\\\" not in twice + + +class TestFromSrtBilingualDetection: + """from_srt 双语识别:中↔英用便宜的脚本启发式(不碰 langdetect),同脚本退回 detect。""" + + @staticmethod + def _srt(pairs): + blocks = [] + for i, (top, bottom) in enumerate(pairs, 1): + blocks.append(f"{i}\n00:00:{i:02d},000 --> 00:00:{i:02d},900\n{top}\n{bottom}") + return "\n\n".join(blocks) + + def test_zh_en_bilingual_via_heuristic(self, monkeypatch): + # 中↔英 4 行块应判为双语:原文中文、译文英文;且不应调用 langdetect + import videocaptioner.core.asr.asr_data as mod + + def _boom(*_a, **_k): # langdetect 一旦被调用就炸,证明走的是启发式 + raise AssertionError("langdetect.detect should not be called for CJK pairs") + + monkeypatch.setattr(mod, "detect", _boom) + srt = self._srt( + [("你好世界", "Hello world"), ("今天天气不错", "Nice weather today"), + ("我们开始吧", "Let us begin"), ("谢谢观看", "Thanks for watching")] + ) + data = ASRData.from_srt(srt) + assert len(data.segments) == 4 + assert data.segments[0].text == "你好世界" + assert data.segments[0].translated_text == "Hello world" + + def test_single_language_multiline_not_bilingual(self, monkeypatch): + # 同语种(都英文)的 4 行块不应判为双语:两行合成一条多行字幕,无译文 + import videocaptioner.core.asr.asr_data as mod + + monkeypatch.setattr(mod, "detect", lambda _t: "en") # 同语种 → detect 都返回 en + srt = self._srt([("First line here", "second line continues")] * 4) + data = ASRData.from_srt(srt) + assert len(data.segments) == 4 + assert data.segments[0].translated_text == "" + assert "\n" in data.segments[0].text # 多行合并而非原文/译文拆分 diff --git a/tests/test_asr/test_bcut_asr.py b/tests/test_asr/test_bcut_asr.py index e59ff526..5b5c9bad 100644 --- a/tests/test_asr/test_bcut_asr.py +++ b/tests/test_asr/test_bcut_asr.py @@ -1,5 +1,6 @@ """BcutASR integration tests.""" +import os from pathlib import Path import pytest @@ -11,6 +12,11 @@ @pytest.mark.integration @pytest.mark.slow +@pytest.mark.skipif( + os.getenv("RUN_LIVE_ASR_TESTS") != "1" + and os.getenv("RUN_BCUT_ASR_TESTS") != "1", + reason="BcutASR uses the public Bilibili ASR service; set RUN_BCUT_ASR_TESTS=1 to run.", +) class TestBcutASR: """Test suite for BcutASR using public Bilibili API. diff --git a/tests/test_asr/test_check.py b/tests/test_asr/test_check.py new file mode 100644 index 00000000..1eb7995a --- /dev/null +++ b/tests/test_asr/test_check.py @@ -0,0 +1,103 @@ +"""转录连通性检查(core.asr.check)契约测试,不联网。 + +关键契约:检查必须绕过缓存(use_cache=False),否则坏掉的 Key 会 +因缓存命中误报成功。 +""" + +from pathlib import Path + +from videocaptioner.core.asr.check import TEST_AUDIO_PATH, check_transcribe +from videocaptioner.core.asr.transcribe import _create_asr_instance +from videocaptioner.core.entities import ( + TranscribeConfig, + TranscribeLanguageEnum, + TranscribeModelEnum, + transcribe_languages_for, +) + + +def _config(model=TranscribeModelEnum.BIJIAN) -> TranscribeConfig: + return TranscribeConfig(transcribe_model=model) + + +class TestCheckTranscribe: + def test_bundled_audio_exists(self): + assert TEST_AUDIO_PATH.exists(), "内置测试音频缺失,测试转录与 doctor 都会失效" + + def test_missing_audio_returns_failure(self): + result = check_transcribe(_config(), audio_path="/nonexistent/audio.mp3") + assert not result.success + assert "测试音频不存在" in result.detail + + def test_exception_collapsed_to_result(self, monkeypatch, tmp_path): + audio = tmp_path / "a.mp3" + audio.write_bytes(b"\0" * 64) + monkeypatch.setattr( + "videocaptioner.core.asr.check.video2audio", + lambda src, output="": Path(output).write_bytes(b"\0" * 64) or True, + ) + + def boom(path, config, callback=None, *, use_cache=True): + raise RuntimeError("provider exploded") + + monkeypatch.setattr("videocaptioner.core.asr.check.transcribe", boom) + result = check_transcribe(_config(), audio_path=audio) + assert not result.success + assert "provider exploded" in result.detail + + +class TestUseCachePassthrough: + """use_cache 必须贯通到每个提供商的 ASR 构造参数。""" + + def test_all_providers_disable_cache(self, tmp_path): + audio = tmp_path / "a.wav" + audio.write_bytes(b"\0" * 64) + configs = [ + _config(TranscribeModelEnum.BIJIAN), + _config(TranscribeModelEnum.JIANYING), + TranscribeConfig( + transcribe_model=TranscribeModelEnum.WHISPER_API, + whisper_api_key="k", + whisper_api_base="https://example.com/v1", + whisper_api_model="whisper-1", + ), + TranscribeConfig( + transcribe_model=TranscribeModelEnum.BAILIAN_FUN_ASR, + fun_asr_api_key="k", + ), + ] + for config in configs: + asr = _create_asr_instance(str(audio), config, use_cache=False) + assert asr.asr_kwargs["use_cache"] is False, config.transcribe_model + asr = _create_asr_instance(str(audio), config) + assert asr.asr_kwargs["use_cache"] is True, config.transcribe_model + + +class TestTranscribeLanguageSupport: + """各接口支持的源语言(唯一真源,GUI 据此收窄下拉)。""" + + def test_bijian_jianying_only_zh_en(self): + expected = [ + TranscribeLanguageEnum.AUTO, + TranscribeLanguageEnum.CHINESE, + TranscribeLanguageEnum.ENGLISH, + ] + assert transcribe_languages_for(TranscribeModelEnum.BIJIAN) == expected + assert transcribe_languages_for(TranscribeModelEnum.JIANYING) == expected + + def test_whisper_and_fun_asr_support_all_languages(self): + full = list(TranscribeLanguageEnum) + for model in ( + TranscribeModelEnum.WHISPER_API, + TranscribeModelEnum.WHISPER_CPP, + TranscribeModelEnum.FASTER_WHISPER, + TranscribeModelEnum.BAILIAN_FUN_ASR, + ): + assert transcribe_languages_for(model) == full + + def test_bijian_jianying_do_not_receive_language(self): + # B/J 接口忽略源语言(服务端自动判别中英),不应把 language 透传给它们 + audio = str(TEST_AUDIO_PATH) + for model in (TranscribeModelEnum.BIJIAN, TranscribeModelEnum.JIANYING): + asr = _create_asr_instance(audio, _config(model), use_cache=False) + assert "language" not in asr.asr_kwargs, model diff --git a/tests/test_asr/test_chunking.py b/tests/test_asr/test_chunking.py index a1e5aacf..eb5c2df7 100644 --- a/tests/test_asr/test_chunking.py +++ b/tests/test_asr/test_chunking.py @@ -176,7 +176,7 @@ def test_split_long_audio_into_chunks(self): try: chunked_asr = ChunkedASR( asr_class=MockASR, - audio_input=audio_path, + audio_path=audio_path, asr_kwargs={}, chunk_length=10, # 10秒 chunk_overlap=2, # 2秒重叠 @@ -210,7 +210,7 @@ def test_split_short_audio_no_chunks(self): try: chunked_asr = ChunkedASR( asr_class=MockASR, - audio_input=audio_path, + audio_path=audio_path, asr_kwargs={}, chunk_length=10, chunk_overlap=2, @@ -234,7 +234,7 @@ def test_split_exact_chunk_length(self): try: chunked_asr = ChunkedASR( asr_class=MockASR, - audio_input=audio_path, + audio_path=audio_path, asr_kwargs={}, chunk_length=10, chunk_overlap=2, @@ -255,7 +255,7 @@ def test_split_with_zero_overlap(self): try: chunked_asr = ChunkedASR( asr_class=MockASR, - audio_input=audio_path, + audio_path=audio_path, asr_kwargs={}, chunk_length=10, chunk_overlap=0, diff --git a/tests/test_asr/test_jianying_asr.py b/tests/test_asr/test_jianying_asr.py index 99cfc590..efcd737c 100644 --- a/tests/test_asr/test_jianying_asr.py +++ b/tests/test_asr/test_jianying_asr.py @@ -1,5 +1,6 @@ """JianYingASR integration tests.""" +import os from pathlib import Path import pytest @@ -11,6 +12,11 @@ @pytest.mark.integration @pytest.mark.slow +@pytest.mark.skipif( + os.getenv("RUN_LIVE_ASR_TESTS") != "1" + and os.getenv("RUN_JIANYING_ASR_TESTS") != "1", + reason="JianYingASR uses an external signing/transcription service; set RUN_JIANYING_ASR_TESTS=1 to run.", +) class TestJianYingASR: """Test suite for JianYingASR using public JianYing (CapCut) API. diff --git a/tests/test_cli/test_config.py b/tests/test_cli/test_config.py index a9f15698..f5e4e888 100644 --- a/tests/test_cli/test_config.py +++ b/tests/test_cli/test_config.py @@ -2,17 +2,18 @@ import pytest -from videocaptioner.cli.config import ( +from videocaptioner.core.application.config_store import ( DEFAULTS, - _deep_merge, - _get_nested, - _parse_value, - _set_nested, - _toml_value, build_config, + deep_merge, + get_nested, load_config_file, load_env_overrides, + parse_value, save_config_value, + save_many, + set_nested, + toml_value, ) @@ -25,96 +26,111 @@ def test_default_dubbing_uses_keyless_edge_tts(): class TestDeepMerge: def test_flat_override(self): - assert _deep_merge({"a": 1}, {"a": 2}) == {"a": 2} + assert deep_merge({"a": 1}, {"a": 2}) == {"a": 2} def test_nested_merge(self): base = {"x": {"a": 1, "b": 2}} override = {"x": {"b": 3, "c": 4}} - result = _deep_merge(base, override) + result = deep_merge(base, override) assert result == {"x": {"a": 1, "b": 3, "c": 4}} def test_does_not_mutate_base(self): base = {"a": 1} - _deep_merge(base, {"a": 2}) + deep_merge(base, {"a": 2}) assert base == {"a": 1} def test_empty_override(self): base = {"a": 1} - assert _deep_merge(base, {}) == {"a": 1} + assert deep_merge(base, {}) == {"a": 1} class TestNestedAccess: def test_get_nested(self): d = {"a": {"b": {"c": 42}}} - assert _get_nested(d, "a.b.c") == 42 + assert get_nested(d, "a.b.c") == 42 def test_get_nested_missing(self): - assert _get_nested({"a": 1}, "b", "default") == "default" + assert get_nested({"a": 1}, "b", "default") == "default" def test_get_nested_deep_missing(self): - assert _get_nested({"a": {"b": 1}}, "a.c.d", None) is None + assert get_nested({"a": {"b": 1}}, "a.c.d", None) is None def test_set_nested(self): d: dict = {} - _set_nested(d, "a.b.c", 42) + set_nested(d, "a.b.c", 42) assert d == {"a": {"b": {"c": 42}}} def test_set_nested_overwrite(self): d = {"a": {"b": 1}} - _set_nested(d, "a.b", 2) + set_nested(d, "a.b", 2) assert d == {"a": {"b": 2}} class TestParseValue: def test_bool_true(self): - assert _parse_value("true", "subtitle.optimize") is True - assert _parse_value("yes", "subtitle.optimize") is True - assert _parse_value("1", "subtitle.optimize") is True + assert parse_value("true", "subtitle.optimize") is True + assert parse_value("yes", "subtitle.optimize") is True + assert parse_value("1", "subtitle.optimize") is True def test_bool_false(self): - assert _parse_value("false", "subtitle.optimize") is False - assert _parse_value("no", "subtitle.optimize") is False - assert _parse_value("0", "subtitle.optimize") is False + assert parse_value("false", "subtitle.optimize") is False + assert parse_value("no", "subtitle.optimize") is False + assert parse_value("0", "subtitle.optimize") is False def test_bool_invalid(self): with pytest.raises(ValueError, match="Expected boolean"): - _parse_value("maybe", "subtitle.optimize") + parse_value("maybe", "subtitle.optimize") def test_int(self): - assert _parse_value("8", "subtitle.thread_num") == 8 - assert isinstance(_parse_value("8", "subtitle.thread_num"), int) + assert parse_value("8", "subtitle.thread_num") == 8 + assert isinstance(parse_value("8", "subtitle.thread_num"), int) def test_int_invalid(self): with pytest.raises(ValueError, match="Expected integer"): - _parse_value("abc", "subtitle.thread_num") + parse_value("abc", "subtitle.thread_num") def test_string(self): - assert _parse_value("gpt-4o", "llm.model") == "gpt-4o" + assert parse_value("gpt-4o", "llm.model") == "gpt-4o" + + def test_list_from_comma_separated_text(self): + assert parse_value( + "gpt-5, gemini-2.5-pro", + "llm.providers.openai.model_options", + ) == ["gpt-5", "gemini-2.5-pro"] + + def test_list_from_toml_array(self): + assert parse_value( + '["gpt-5", "gemini-2.5-pro"]', + "llm.providers.openai.model_options", + ) == ["gpt-5", "gemini-2.5-pro"] def test_unknown_key_stays_string(self): # Key not in DEFAULTS → stays string - assert _parse_value("anything", "unknown.key") == "anything" + assert parse_value("anything", "unknown.key") == "anything" class TestTomlValue: def test_bool(self): - assert _toml_value(True) == "true" - assert _toml_value(False) == "false" + assert toml_value(True) == "true" + assert toml_value(False) == "false" def test_int(self): - assert _toml_value(42) == "42" + assert toml_value(42) == "42" def test_float(self): - assert _toml_value(0.5) == "0.5" + assert toml_value(0.5) == "0.5" def test_string(self): - assert _toml_value("hello") == '"hello"' + assert toml_value("hello") == '"hello"' def test_string_with_quotes(self): - assert _toml_value('say "hi"') == '"say \\"hi\\""' + assert toml_value('say "hi"') == '"say \\"hi\\""' def test_string_with_newline(self): - assert _toml_value("line1\nline2") == '"line1\\nline2"' + assert toml_value("line1\nline2") == '"line1\\nline2"' + + def test_list(self): + assert toml_value(["gpt-5", "gemini-2.5-pro"]) == '["gpt-5", "gemini-2.5-pro"]' class TestConfigRoundtrip: @@ -130,16 +146,86 @@ def test_save_and_load(self, tmp_path): assert loaded["subtitle"]["thread_num"] == 8 assert loaded["subtitle"]["optimize"] is False + def test_active_provider_key_updates_generic_alias(self, tmp_path): + config_file = tmp_path / "config.toml" + + save_config_value("llm.service", "silicon_cloud", config_path=config_file) + save_config_value( + "llm.providers.silicon_cloud.api_key", + "sk-provider", + config_path=config_file, + ) + + config = build_config(config_path=config_file) + assert config["llm"]["api_key"] == "sk-provider" + assert config["llm"]["providers"]["silicon_cloud"]["api_key"] == "sk-provider" + + def test_secret_values_are_stripped(self, tmp_path): + config_file = tmp_path / "config.toml" + + save_config_value("llm.service", "silicon_cloud", config_path=config_file) + save_config_value( + "llm.providers.silicon_cloud.api_key", + " sk-provider\n", + config_path=config_file, + ) + + config = build_config(config_path=config_file) + assert config["llm"]["api_key"] == "sk-provider" + assert config["llm"]["providers"]["silicon_cloud"]["api_key"] == "sk-provider" + + def test_generic_key_updates_active_provider(self, tmp_path): + config_file = tmp_path / "config.toml" + + save_config_value("llm.service", "deepseek", config_path=config_file) + save_config_value("llm.api_key", "sk-generic", config_path=config_file) + + config = build_config(config_path=config_file) + assert config["llm"]["api_key"] == "sk-generic" + assert config["llm"]["providers"]["deepseek"]["api_key"] == "sk-generic" + + def test_provider_model_options_roundtrip(self, tmp_path): + config_file = tmp_path / "config.toml" + + save_many( + { + "llm.providers.silicon_cloud.model_options": [ + "moonshotai/Kimi-K2-Instruct-0905", + "deepseek-ai/DeepSeek-V3", + ] + }, + config_path=config_file, + ) + + config = build_config(config_path=config_file) + assert config["llm"]["providers"]["silicon_cloud"]["model_options"] == [ + "moonshotai/Kimi-K2-Instruct-0905", + "deepseek-ai/DeepSeek-V3", + ] + class TestBuildConfig: - def test_defaults_only(self): - config = build_config(config_path=None) + def test_defaults_only(self, tmp_path): + config = build_config(config_path=tmp_path / "missing.toml") assert config["llm"]["model"] == DEFAULTS["llm"]["model"] def test_cli_overrides(self): config = build_config(cli_overrides={"llm": {"model": "custom"}}) assert config["llm"]["model"] == "custom" + def test_active_provider_aliases_are_normalized(self): + config = build_config( + cli_overrides={ + "llm": { + "service": "gemini", + "providers": {"gemini": {"api_key": "sk-gemini", "model": "gemini-test"}}, + } + } + ) + + assert config["llm"]["api_key"] == "sk-gemini" + assert config["llm"]["model"] == "gemini-test" + def test_env_overrides(self, monkeypatch): monkeypatch.setenv("VIDEOCAPTIONER_LLM_MODEL", "env-model") config = build_config() diff --git a/tests/test_cli/test_config_consistency.py b/tests/test_cli/test_config_consistency.py new file mode 100644 index 00000000..758a4fb6 --- /dev/null +++ b/tests/test_cli/test_config_consistency.py @@ -0,0 +1,106 @@ +"""配置链路一致性回归(2026-06 代码质量审查 CF-M1/M2/H2/H3)。 + +锁住三件事,防止 DEFAULTS / SettingField / dataclass / CLI 适配器再次漂移: +- 曾经的孤儿键补进 DEFAULTS 后,`config set` 能按声明类型解析(CF-M2); +- subtitle_mode 与 soft_subtitle 默认同义,CLI/GUI 出厂行为一致(CF-H2); +- CLI 适配器在全新配置下产出的默认值与 config_store DEFAULTS 对齐(CF-M1); +- CLI 适配器读取 faster_whisper.program / one_word,不再硬编码(CF-H3)。 +""" + +from pathlib import Path + +import pytest + +from videocaptioner.cli.config_adapter import app_config_from_cli +from videocaptioner.core.application.config_store import ( + DEFAULTS, + build_config, + get_nested, + parse_value, +) +from videocaptioner.core.entities import ( + FasterWhisperModelEnum, + SubtitleRenderModeEnum, + VadMethodEnum, + WhisperModelEnum, +) + +# 用不存在的路径隔离用户/CI 本地配置,build_config 只剩 DEFAULTS(+env)。 +_NONEXISTENT = Path("/tmp/vc-config-consistency-nonexistent.toml") + + +def _fresh_config() -> dict: + return build_config(config_path=_NONEXISTENT) + + +class TestOrphanKeysParse: + """CF-M2:6 个曾经的孤儿键补进 DEFAULTS 后,config set 按类型解析。""" + + @pytest.mark.parametrize( + "key", + [ + "ui.transcribe_panel_collapsed", + "ui.subtitle_panel_collapsed", + "ui.synthesis_panel_collapsed", + "transcribe.word_timestamp", + ], + ) + def test_bool_orphan_keys_parse_as_bool(self, key): + # 过去 parse_value 查不到键直接返回字符串 "false",BoolValidator 当真 → 反向。 + assert get_nested(DEFAULTS, key) is False + assert parse_value("false", key) is False + assert parse_value("true", key) is True + + def test_batch_concurrency_parses_as_int(self): + assert get_nested(DEFAULTS, "ui.batch_concurrency") == 1 + assert parse_value("2", "ui.batch_concurrency") == 2 + + def test_batch_mode_present(self): + assert get_nested(DEFAULTS, "ui.batch_mode") == "full" + + +class TestSubtitleModeConsistency: + """CF-H2:subtitle_mode 与 soft_subtitle 默认同义(硬字幕)。""" + + def test_defaults_agree(self): + assert get_nested(DEFAULTS, "synthesize.soft_subtitle") is False + assert get_nested(DEFAULTS, "synthesize.subtitle_mode") == "hard" + ac = app_config_from_cli(_fresh_config()) + assert ac.synthesis.soft_subtitle is False + + +class TestCliDefaultsMatchStore: + """CF-M1:CLI 适配器在全新配置下产出默认值与 config_store DEFAULTS 对齐。""" + + def test_subtitle_defaults(self): + ac = app_config_from_cli(_fresh_config()) + assert ac.subtitle.thread_num == get_nested(DEFAULTS, "subtitle.thread_num") == 10 + assert ac.subtitle.batch_size == get_nested(DEFAULTS, "subtitle.batch_size") == 10 + assert ac.subtitle.need_optimize is False + assert ac.subtitle.need_split is False + assert ac.subtitle.max_word_count_cjk == 28 + assert ac.subtitle.max_word_count_english == 20 + + def test_transcribe_defaults(self): + ac = app_config_from_cli(_fresh_config()) + assert ac.transcribe.faster_whisper_model == FasterWhisperModelEnum.TINY + assert ac.transcribe.whisper_model == WhisperModelEnum.TINY + assert ac.transcribe.faster_whisper_vad_method == VadMethodEnum.SILERO_V4 + assert ac.transcribe.faster_whisper_vad_threshold == 0.4 + assert ac.transcribe.faster_whisper_device == "auto" + + def test_render_mode_default(self): + ac = app_config_from_cli(_fresh_config()) + assert ac.synthesis.render_mode == SubtitleRenderModeEnum.ROUNDED_BG + + +class TestCliReadsFasterWhisperKeys: + """CF-H3:CLI 适配器读取 faster_whisper.program / one_word(不再硬编码 True)。""" + + def test_program_and_one_word_from_config(self): + cfg = _fresh_config() + cfg["transcribe"]["faster_whisper"]["program"] = "custom-fw" + cfg["transcribe"]["faster_whisper"]["one_word"] = False + ac = app_config_from_cli(cfg) + assert ac.transcribe.faster_whisper_program == "custom-fw" + assert ac.transcribe.faster_whisper_one_word is False diff --git a/tests/test_cli/test_doctor_live_caption.py b/tests/test_cli/test_doctor_live_caption.py new file mode 100644 index 00000000..42584ce6 --- /dev/null +++ b/tests/test_cli/test_doctor_live_caption.py @@ -0,0 +1,61 @@ +"""doctor 的实时字幕检测契约。 + +锁住:① voxgate 找得到 → ok;② 找不到 → warn + 修复提示(实时字幕可选,不让 doctor +整体失败);③ 配置的二进制路径透传给 finder;④ fun-asr 按是否有百炼 Key 给 ok/warn; +⑤ 该检测确实被接进 run_diagnostics。voxgate 走 ``voxgate transcribe`` 子进程 stdio, +不再有本地服务/探活,故无 server_url 分支。 +""" + +import videocaptioner.core.realtime.backends.voxgate as voxgate_backend +from videocaptioner.cli.commands import doctor + + +def test_voxgate_found_is_ok(monkeypatch): + monkeypatch.setattr(voxgate_backend, "find_voxgate_binary", lambda configured="": "/usr/bin/voxgate") + checks = doctor._check_live_caption({}) + assert len(checks) == 1 + assert checks[0].name == "live_caption.voxgate" + assert checks[0].status == "ok" + assert "/usr/bin/voxgate" in checks[0].message + + +def test_voxgate_missing_is_warn_not_error(monkeypatch): + monkeypatch.setattr(voxgate_backend, "find_voxgate_binary", lambda configured="": None) + checks = doctor._check_live_caption({}) + assert checks[0].name == "live_caption.voxgate" + # 可选功能:缺失只 warn,不应让 doctor 整体退出码失败 + assert checks[0].status == "warn" + assert checks[0].fix # 给出修复指引(指定路径 / 放到 PATH) + + +def test_configured_binary_path_is_passed_through(monkeypatch): + seen = {} + + def fake_find(configured=""): + seen["configured"] = configured + return configured or None + + monkeypatch.setattr(voxgate_backend, "find_voxgate_binary", fake_find) + doctor._check_live_caption({"live_caption": {"voxgate_binary": "/opt/vox/voxgate"}}) + assert seen["configured"] == "/opt/vox/voxgate" + + +def test_fun_asr_with_key_is_ok(): + checks = doctor._check_live_caption( + {"live_caption": {"provider": "fun-asr", "api_key": "sk-xxx"}} + ) + assert checks[0].name == "live_caption.funasr" + assert checks[0].status == "ok" + + +def test_fun_asr_without_key_is_warn(): + checks = doctor._check_live_caption({"live_caption": {"provider": "fun-asr"}}) + assert checks[0].name == "live_caption.funasr" + assert checks[0].status == "warn" + assert checks[0].fix + + +def test_wired_into_run_diagnostics(monkeypatch): + monkeypatch.setattr(voxgate_backend, "find_voxgate_binary", lambda configured="": "/usr/bin/voxgate") + names = [c.name for c in doctor.run_diagnostics({})] + assert any(n.startswith("live_caption") for n in names) diff --git a/tests/test_cli/test_parser.py b/tests/test_cli/test_parser.py index 73447e22..62e6778c 100644 --- a/tests/test_cli/test_parser.py +++ b/tests/test_cli/test_parser.py @@ -133,10 +133,22 @@ def test_dub_options_parse_with_missing_input(self): ]) assert result == EXIT.FILE_NOT_FOUND - def test_process_dub_final_output_defaults_to_dubbed_captioned(self, tmp_path): - result = _resolve_final_output_path(None, tmp_path, tmp_path / "talk.mp4", True, False, False) - - assert result.endswith("talk_dubbed_captioned.mp4") + def test_process_final_output_uses_dotted_tag_grammar(self, tmp_path): + """成品命名统一 {stem}.{tag}.{ext},tag 按加工顺序组合。""" + dub_and_sub = _resolve_final_output_path( + None, tmp_path, tmp_path / "talk.mp4", True, False, False + ) + assert dub_and_sub.endswith("talk.dubbed.subtitled.mp4") + + dub_only = _resolve_final_output_path( + None, tmp_path, tmp_path / "talk.mp4", True, True, False + ) + assert dub_only.endswith("talk.dubbed.mp4") + + subtitle_only = _resolve_final_output_path( + None, tmp_path, tmp_path / "talk.mp4", False, False, False + ) + assert subtitle_only.endswith("talk.subtitled.mp4") def test_process_dub_only_uses_user_output_file(self, tmp_path): result = _resolve_final_output_path(str(tmp_path / "final.mp4"), tmp_path, tmp_path / "talk.mp4", True, True, False) @@ -243,11 +255,15 @@ def test_show(self, capsys): assert "llm:" in out assert "api_key" in out - def test_path(self, capsys): + def test_path_prints_active_config_file(self, capsys): + # 契约:打印当前生效的配置路径(含 VIDEOCAPTIONER_CONFIG_FILE 覆盖, + # 该 env 在 config_store 导入时固化,测试不假设具体取值)。 + from videocaptioner.core.application.config_store import CONFIG_FILE + result = main(["config", "path"]) assert result == EXIT.SUCCESS out = capsys.readouterr().out - assert "config.toml" in out + assert str(CONFIG_FILE) in out def test_init_print_template(self, capsys): result = main(["config", "init", "--non-interactive", "--print-template", "--profile", "dubbing"]) @@ -272,3 +288,80 @@ def test_doctor_help(self, capsys): out = capsys.readouterr().out assert "--json" in out assert "--check-api" in out + + +class TestModelsParser: + def test_models_list(self, capsys, tmp_path): + result = main(["models", "list", "--models-dir", str(tmp_path)]) + assert result == EXIT.SUCCESS + out = capsys.readouterr().out + assert "whisper-cpp" in out + assert "faster-whisper" in out + assert "large-v3-turbo" in out + + def test_models_list_kind_filter(self, capsys, tmp_path): + result = main(["models", "list", "--kind", "whisper-cpp", "--models-dir", str(tmp_path)]) + assert result == EXIT.SUCCESS + out = capsys.readouterr().out + assert "whisper-cpp" in out + assert "large-v3-turbo" not in out + + def test_models_list_shows_installed(self, capsys, tmp_path): + (tmp_path / "ggml-tiny.bin").write_bytes(b"x") + result = main(["models", "list", "--models-dir", str(tmp_path)]) + assert result == EXIT.SUCCESS + out = capsys.readouterr().out + assert "✓ installed" in out + + def test_models_download_unknown_model(self, capsys, tmp_path): + result = main(["models", "download", "whisper-cpp", "nope", "--models-dir", str(tmp_path)]) + assert result == EXIT.USAGE_ERROR + + def test_models_download_already_installed(self, capsys, tmp_path): + (tmp_path / "ggml-tiny.bin").write_bytes(b"x") + result = main(["models", "download", "whisper-cpp", "tiny", "--models-dir", str(tmp_path)]) + assert result == EXIT.SUCCESS + + def test_models_help(self, capsys): + with pytest.raises(SystemExit) as exc: + main(["models", "--help"]) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "download" in out + + +class TestAsrChoicesDrift: + """三处 --asr choices 必须与 CLI_ASR_MAPPING 完全一致(防手写清单漂移)。""" + + @staticmethod + def _asr_choices(command: str, sub_action: str | None = None) -> list[str]: + import argparse + + from videocaptioner.cli.main import build_parser + + parser = build_parser() + subs = next( + a for a in parser._actions if isinstance(a, argparse._SubParsersAction) + ) + target = subs.choices[command] + if sub_action is not None: + inner = next( + a for a in target._actions if isinstance(a, argparse._SubParsersAction) + ) + target = inner.choices[sub_action] + action = next(a for a in target._actions if "--asr" in a.option_strings) + return list(action.choices) + + def test_all_asr_choices_match_canonical_mapping(self): + from videocaptioner.core.application.app_config import CLI_ASR_MAPPING + + expected = list(CLI_ASR_MAPPING) + assert self._asr_choices("transcribe") == expected + assert self._asr_choices("process") == expected + assert self._asr_choices("config", "init") == expected + + def test_faster_whisper_selectable_in_transcribe(self): + assert "faster-whisper" in self._asr_choices("transcribe") + + def test_fun_asr_selectable_in_config_init(self): + assert "fun-asr" in self._asr_choices("config", "init") diff --git a/tests/test_download/__init__.py b/tests/test_download/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_download/test_dependencies.py b/tests/test_download/test_dependencies.py new file mode 100644 index 00000000..9358ad20 --- /dev/null +++ b/tests/test_download/test_dependencies.py @@ -0,0 +1,155 @@ +"""运行依赖注册表与安装(ffmpeg / voxgate)。 + +锁住:① 镜像梯子结构(ghproxy 优先 + GitHub 直连兜底)+ 固定 tag URL;② 平台归一; +③ ffmpeg 在主仓库 latest、voxgate 在自己仓库固定版;④ 压缩包安装取出可执行 + 随附动态库 +(Windows voxgate 的 dll)、扁平落地、兼容嵌套目录、跳过文档;⑤ 裸二进制安装 + 可执行位; +⑥ 平台无件抛 DependencyUnsupported。 +""" + +import os +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from videocaptioner.core.download import dependencies as deps + + +def test_gh_urls_latest_and_pinned(): + latest = deps._gh_urls("WEIFENG2333/VideoCaptioner", "latest", "ffmpeg-macos-arm64.zip") + assert latest[-1] == ( + "https://github.com/WEIFENG2333/VideoCaptioner/releases/latest/download/ffmpeg-macos-arm64.zip" + ) + assert len(latest) > 1 # 镜像在前、直连兜底(不锁定具体镜像域名,镜像生态更迭快) + assert all(u.endswith(latest[-1]) for u in latest[:-1]) + pinned = deps._gh_urls("WEIFENG2333/voxgate", "v0.2.10", "voxgate_darwin_arm64.tar.gz") + assert pinned[-1] == ( + "https://github.com/WEIFENG2333/voxgate/releases/download/v0.2.10/voxgate_darwin_arm64.tar.gz" + ) + + +def test_current_platform_shape(): + os_key, arch = deps.current_platform() + assert os_key in {"macos", "windows", "linux"} + assert arch in {"arm64", "x64"} + + +def test_registry_has_ffmpeg_and_voxgate(): + keys = {spec.key for spec in deps.iter_dependencies()} + assert {"ffmpeg", "voxgate"} <= keys + assert deps.dependency_for("ffmpeg").optional is False + assert deps.dependency_for("voxgate").optional is True + for spec in deps.iter_dependencies(): + for plat in ("macos-arm64", "windows-x64", "linux-x64", "linux-arm64", "macos-x64"): + assert plat in spec.assets + + +def test_ffmpeg_asset_main_repo_latest(): + ff = deps.dependency_for("ffmpeg").asset_for("windows", "x64") + assert ff.asset == "ffmpeg-windows-x64.zip" + assert ff.archive is True + assert ff.executables == ("ffmpeg.exe", "ffprobe.exe") + assert ff.repo == "WEIFENG2333/VideoCaptioner" and ff.tag == "ffmpeg-bin" + + +def test_voxgate_asset_own_repo_pinned(): + # voxgate 走自己的仓库、固定版、Go 命名 + 压缩包 + vox_mac = deps.dependency_for("voxgate").asset_for("macos", "arm64") + assert vox_mac.asset == "voxgate_darwin_arm64.tar.gz" + assert vox_mac.archive is True + assert vox_mac.executables == ("voxgate",) + assert vox_mac.repo == "WEIFENG2333/voxgate" + assert vox_mac.tag.startswith("v") + vox_win = deps.dependency_for("voxgate").asset_for("windows", "x64") + assert vox_win.asset == "voxgate_windows_amd64.zip" + assert vox_win.executables == ("voxgate.exe",) + + +def test_voxgate_detection_uses_finder(monkeypatch): + import videocaptioner.core.realtime.backends.voxgate as vb + + monkeypatch.setattr(vb, "find_voxgate_binary", lambda configured="": "/x/voxgate") + assert deps.is_installed(deps.dependency_for("voxgate")) is True + monkeypatch.setattr(vb, "find_voxgate_binary", lambda configured="": None) + assert deps.is_installed(deps.dependency_for("voxgate")) is False + + +def _synthetic_spec(asset: deps.DependencyAsset) -> deps.DependencySpec: + os_key, arch = deps.current_platform() + return deps.DependencySpec( + key="x", display_name="X", description="", optional=True, + assets={f"{os_key}-{arch}": asset}, + ) + + +def test_install_raw_binary(tmp_path, monkeypatch): + spec = _synthetic_spec(deps.DependencyAsset(asset="tool-bin", executables=("mytool",))) + + def fake_download(urls, dest, **kwargs): + Path(dest).parent.mkdir(parents=True, exist_ok=True) + Path(dest).write_bytes(b"#!/bin/sh\necho hi\n") + return Path(dest) + + monkeypatch.setattr(deps, "download_file", fake_download) + result = deps.install_dependency(spec, bin_dir=tmp_path) + assert result == tmp_path / "mytool" + assert result.is_file() + if os.name != "nt": + assert os.access(result, os.X_OK) + + +def test_install_archive_extracts_ffmpeg(tmp_path, monkeypatch): + spec = deps.dependency_for("ffmpeg") + asset = deps.asset_for(spec) + archive = tmp_path / "src.zip" + with zipfile.ZipFile(archive, "w") as zf: + for exe in asset.executables: + zf.writestr(f"ffmpeg-build/bin/{exe}", b"BINARY") # 嵌套目录 + zf.writestr("ffmpeg-build/README.txt", b"docs") + + def fake_download(urls, dest, **kwargs): + Path(dest).parent.mkdir(parents=True, exist_ok=True) + Path(dest).write_bytes(archive.read_bytes()) + return Path(dest) + + monkeypatch.setattr(deps, "download_file", fake_download) + main = deps.install_dependency(spec, bin_dir=tmp_path) + assert main == tmp_path / asset.executables[0] + for exe in asset.executables: + assert (tmp_path / exe).read_bytes() == b"BINARY" + assert not (tmp_path / "README.txt").exists() # 文档不落地 + + +def test_windows_voxgate_extracts_bundled_dlls(tmp_path): + # voxgate Windows 包随附 libogg/libopus.dll,必须一起取出,否则跑不起来 + archive = tmp_path / "voxgate_windows_amd64.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("voxgate_windows_amd64/voxgate.exe", b"EXE") + zf.writestr("voxgate_windows_amd64/libogg.dll", b"OGG") + zf.writestr("voxgate_windows_amd64/libopus.dll", b"OPUS") + zf.writestr("voxgate_windows_amd64/LICENSE", b"license") + zf.writestr("voxgate_windows_amd64/README.md", b"readme") + out = tmp_path / "out" + placed = deps._extract_runtime_files(archive, ("voxgate.exe",), out) + names = {p.name for p in placed} + assert names == {"voxgate.exe", "libogg.dll", "libopus.dll"} # 取 exe + dll,丢文档 + assert not (out / "LICENSE").exists() + + +def test_extract_from_tar(tmp_path): + archive = tmp_path / "src.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + data = tmp_path / "voxgate" + data.write_bytes(b"X") + tf.add(data, arcname="voxgate_darwin_arm64/voxgate") # 嵌套 + placed = deps._extract_runtime_files(archive, ("voxgate",), tmp_path / "out") + assert [p.name for p in placed] == ["voxgate"] + assert placed[0].read_bytes() == b"X" + + +def test_install_unsupported_platform_raises(tmp_path, monkeypatch): + spec = deps.dependency_for("voxgate") + monkeypatch.setattr(deps, "asset_for", lambda s: None) + with pytest.raises(deps.DependencyUnsupported): + deps.install_dependency(spec, bin_dir=tmp_path) diff --git a/tests/test_download/test_downloader.py b/tests/test_download/test_downloader.py new file mode 100644 index 00000000..847ed760 --- /dev/null +++ b/tests/test_download/test_downloader.py @@ -0,0 +1,153 @@ +"""downloader 单测:本地 HTTP 服务模拟镜像,离线覆盖核心路径。""" + +from __future__ import annotations + +import hashlib +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from videocaptioner.core.download import ( + DownloadCancelled, + DownloadError, + download_file, +) + +PAYLOAD = b"0123456789abcdef" * 65536 # 1 MB,大于下载 chunk,保证取消能落在中途 +PAYLOAD_SHA1 = hashlib.sha1(PAYLOAD).hexdigest() +PAYLOAD_SHA256 = hashlib.sha256(PAYLOAD).hexdigest() + + +class _Handler(BaseHTTPRequestHandler): + """支持 Range 的静态响应;路径决定行为。""" + + def do_GET(self): # noqa: N802 + if self.path == "/missing.bin": + self.send_error(404) + return + if self.path == "/flaky.bin" and not getattr(self.server, "flaky_served", False): + self.server.flaky_served = True + self.send_error(503) + return + + data = PAYLOAD + start = 0 + range_header = self.headers.get("Range") + if range_header and range_header.startswith("bytes="): + start = int(range_header.split("=")[1].rstrip("-")) + self.send_response(206) + else: + self.send_response(200) + body = data[start:] + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): # 静默 + pass + + +@pytest.fixture() +def server(): + httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{httpd.server_address[1]}" + yield base + httpd.shutdown() + + +def test_download_success_with_sha1(server, tmp_path: Path): + dest = tmp_path / "model.bin" + result = download_file([f"{server}/ok.bin"], dest, sha1=PAYLOAD_SHA1) + assert result == dest + assert dest.read_bytes() == PAYLOAD + assert not dest.with_suffix(".bin.part").exists() + + +def test_download_success_with_sha256(server, tmp_path: Path): + dest = tmp_path / "update.zip" + result = download_file([f"{server}/ok.bin"], dest, sha256=PAYLOAD_SHA256) + assert result == dest + assert dest.read_bytes() == PAYLOAD + assert not dest.with_suffix(".zip.part").exists() + + +def test_sha256_mismatch_fails(server, tmp_path: Path): + dest = tmp_path / "update.zip" + with pytest.raises(DownloadError) as excinfo: + download_file([f"{server}/ok.bin"], dest, sha256="0" * 64) + assert "SHA256" in str(excinfo.value) + assert not dest.exists() + + +def test_mirror_fallback_on_404(server, tmp_path: Path): + dest = tmp_path / "model.bin" + download_file([f"{server}/missing.bin", f"{server}/ok.bin"], dest) + assert dest.read_bytes() == PAYLOAD + + +def test_mirror_fallback_on_5xx(server, tmp_path: Path): + dest = tmp_path / "model.bin" + download_file([f"{server}/flaky.bin", f"{server}/ok.bin"], dest) + assert dest.read_bytes() == PAYLOAD + + +def test_all_mirrors_fail(server, tmp_path: Path): + dest = tmp_path / "model.bin" + with pytest.raises(DownloadError) as excinfo: + download_file([f"{server}/missing.bin", f"{server}/missing.bin"], dest) + assert "2 个镜像" in str(excinfo.value) + assert not dest.exists() + + +def test_resume_from_part_file(server, tmp_path: Path): + dest = tmp_path / "model.bin" + part = tmp_path / "model.bin.part" + part.write_bytes(PAYLOAD[: 16 * 1024]) + download_file([f"{server}/ok.bin"], dest, sha1=PAYLOAD_SHA1) + assert dest.read_bytes() == PAYLOAD + assert not part.exists() + + +def test_sha1_mismatch_tries_next_mirror_then_fails(server, tmp_path: Path): + dest = tmp_path / "model.bin" + with pytest.raises(DownloadError) as excinfo: + download_file([f"{server}/ok.bin"], dest, sha1="0" * 40) + assert "SHA1" in str(excinfo.value) + assert not dest.exists() + # 校验失败的 .part 必须清掉,避免坏数据被续传 + assert not dest.with_suffix(".bin.part").exists() + + +def test_cancel_keeps_part_for_resume(server, tmp_path: Path): + dest = tmp_path / "model.bin" + calls = {"n": 0} + + def cancel_after_first_chunk() -> bool: + calls["n"] += 1 + return calls["n"] > 1 + + with pytest.raises(DownloadCancelled): + download_file( + [f"{server}/ok.bin"], dest, should_cancel=cancel_after_first_chunk + ) + assert not dest.exists() + # 部分数据保留在 .part,下次可走 Range 续传 + part = dest.with_suffix(".bin.part") + assert part.exists() and 0 < part.stat().st_size < len(PAYLOAD) + + +def test_progress_reported(server, tmp_path: Path): + dest = tmp_path / "model.bin" + seen: list[tuple[int, int | None]] = [] + download_file( + [f"{server}/ok.bin"], + dest, + on_progress=lambda p: seen.append((p.received, p.total)), + ) + assert seen + assert seen[-1][0] == len(PAYLOAD) + assert seen[-1][1] == len(PAYLOAD) diff --git a/tests/test_download/test_media.py b/tests/test_download/test_media.py new file mode 100644 index 00000000..0bdc5b31 --- /dev/null +++ b/tests/test_download/test_media.py @@ -0,0 +1,196 @@ +"""core/download/media.py 下载引擎契约测试(不联网,FakeYoutubeDL 替身)。 + +GUI 线程与 CLI download 都是本引擎的薄壳,这里覆盖的行为对两端生效。 +""" + +from pathlib import Path + +import pytest + +import videocaptioner.core.download.media as media +from videocaptioner.core.download.media import ( + MediaDownloader, + media_summary, + resolve_downloaded_subtitle, + sanitize_filename, +) + + +class TestSanitizeFilename: + def test_forbidden_chars(self): + assert sanitize_filename('a:c"/d') == "a_b__c__d" + + def test_windows_reserved(self): + assert sanitize_filename("CON.mp4") == "CON.mp4_" + + def test_empty_fallback(self): + assert sanitize_filename(" ..") == "media" + + def test_strips_all_control_chars(self): + # 旧正则 [\0-\31] 把 \31 当八进制(=chr25),漏清 0x1a-0x1f;新版覆盖整段 0x00-0x1f + name = "a\x00b\x19c\x1ad\x1fe" + assert sanitize_filename(name) == "abcde" + + +class TestMediaSummary: + def test_fields(self): + info = { + "title": "标题", + "uploader": "UP主", + "duration": 3725, + "extractor_key": "BiliBili", + } + assert media_summary(info) == { + "title": "标题", + "uploader": "UP主", + "duration": "1:02:05", + "site": "BiliBili", + } + assert media_summary({"title": "x", "extractor_key": "Generic"}) == {"title": "x"} + + +class TestResolveSubtitle: + def test_danmaku_and_other_videos_ignored(self, tmp_path): + """字幕 sidecar 按视频名前缀匹配;弹幕 xml 与别的视频的字幕都不算。""" + video = tmp_path / "BV Test.mp4" + video.write_bytes(b"v") + (tmp_path / "BV Test.danmaku.xml").write_text("") + (tmp_path / "Other Video.zh.srt").write_text( + "1\n00:00:00,000 --> 00:00:01,000\nhi\n" + ) + assert resolve_downloaded_subtitle({}, video, None) is None + + (tmp_path / "BV Test.zh.srt").write_text("1\n00:00:00,000 --> 00:00:01,000\nhi\n") + resolved = resolve_downloaded_subtitle({}, video, None) + assert resolved and resolved.endswith("BV Test.zh.srt") + + +def _fake_ydl(behaviors: dict): + """构造 FakeYoutubeDL 类;behaviors 控制 extract_info/process_info 行为。""" + + class FakeYoutubeDL: + instances: list = [] + + def __init__(self, params): + self.params = params + FakeYoutubeDL.instances.append(self) + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def extract_info(self, url, download=False): + return behaviors["extract_info"](self, url) + + def process_info(self, info): + home = Path(self.params["paths"]["home"]) + home.mkdir(parents=True, exist_ok=True) + (home / f"{info['title']}.mp4").write_bytes(b"video") + + def prepare_filename(self, info): + return str(Path(self.params["paths"]["home"]) / f"{info['title']}.mp4") + + return FakeYoutubeDL + + +class TestSiteFallbacks: + def test_ted_403_retries_with_hls(self, tmp_path, monkeypatch): + used_formats: list[str] = [] + + def extract_info(ydl, url): + used_formats.append(ydl.params["format"]) + if "[ext=mp4]" in ydl.params["format"]: + raise Exception("HTTP Error 403: Forbidden") + return {"title": "TED Test", "ext": "mp4"} + + monkeypatch.setattr(media.yt_dlp, "YoutubeDL", _fake_ydl({"extract_info": extract_info})) + video, subtitle = MediaDownloader( + "https://www.ted.com/talks/example", str(tmp_path) + ).run() + assert Path(video).exists() and subtitle is None + assert used_formats == [media._DEFAULT_FORMAT, media._HLS_FORMAT] + + def test_youtube_403_falls_back_to_android_client(self, tmp_path, monkeypatch): + clients: list = [] + + def extract_info(ydl, url): + clients.append( + (ydl.params.get("extractor_args") or {}).get("youtube", {}).get("player_client") + ) + if ydl.params.get("extractor_args") is None: + raise Exception("unable to download video data: HTTP Error 403: Forbidden") + return {"title": "YT", "ext": "mp4"} + + monkeypatch.setattr(media.yt_dlp, "YoutubeDL", _fake_ydl({"extract_info": extract_info})) + video, _sub = MediaDownloader( + "https://www.youtube.com/watch?v=x", str(tmp_path) + ).run() + assert Path(video).exists() + assert clients == [None, ["android", "web_safari"]] + + def test_412_falls_back_to_browser_cookies(self, tmp_path, monkeypatch): + """登录态错误走 net 阶梯换浏览器 cookies 重试(与诊断同一条链路)。""" + attempts: list = [] + + def extract_info(ydl, url): + attempts.append(ydl.params.get("cookiesfrombrowser")) + if ydl.params.get("cookiesfrombrowser") is None: + raise Exception("BiliBili: HTTP Error 412 Precondition Failed") + return {"title": "BV Test", "ext": "mp4"} + + monkeypatch.setattr(media.yt_dlp, "YoutubeDL", _fake_ydl({"extract_info": extract_info})) + monkeypatch.setattr( + "videocaptioner.core.download.net.detect_cookie_browsers", lambda: ["chrome"] + ) + monkeypatch.setattr( + "videocaptioner.core.download.net.cookies_file", lambda: tmp_path / "no.txt" + ) + monkeypatch.setattr( + "videocaptioner.core.download.net.fetch_bilibili_buvid", lambda **kw: {} + ) + video, _sub = MediaDownloader( + "https://www.bilibili.com/video/BVx", str(tmp_path) + ).run() + assert attempts == [None, ("chrome",)] + assert Path(video).exists() + + +class TestGuards: + def test_options_use_noplaylist(self, tmp_path, monkeypatch): + """多分 P / 合集链接必须只取当前一集。""" + captured = {} + + def extract_info(ydl, url): + captured.update(ydl.params) + return {"title": "T", "ext": "mp4"} + + monkeypatch.setattr(media.yt_dlp, "YoutubeDL", _fake_ydl({"extract_info": extract_info})) + MediaDownloader("https://example.com/v", str(tmp_path)).run() + assert captured.get("noplaylist") is True + + def test_live_stream_rejected(self, tmp_path, monkeypatch): + def extract_info(ydl, url): + return {"title": "Live", "is_live": True} + + monkeypatch.setattr(media.yt_dlp, "YoutubeDL", _fake_ydl({"extract_info": extract_info})) + with pytest.raises(RuntimeError, match="直播"): + MediaDownloader("https://example.com/live", str(tmp_path)).run() + + def test_probe_only_requires_no_target_dir_and_downloads_nothing(self, monkeypatch): + probed = [] + + def extract_info(ydl, url): + return {"title": "T", "ext": "mp4", "formats": [], "subtitles": {}} + + monkeypatch.setattr(media.yt_dlp, "YoutubeDL", _fake_ydl({"extract_info": extract_info})) + video, subtitle = MediaDownloader( + "https://example.com/v", probe_only=True, on_probed=probed.append + ).run() + assert video is None and subtitle is None + assert probed and probed[0]["title"] == "T" + + def test_download_requires_target_dir(self): + with pytest.raises(ValueError): + MediaDownloader("https://example.com/v") diff --git a/tests/test_download/test_models.py b/tests/test_download/test_models.py new file mode 100644 index 00000000..ceb4d5a7 --- /dev/null +++ b/tests/test_download/test_models.py @@ -0,0 +1,144 @@ +"""模型清单与安装状态单测(不依赖网络)。""" + +from __future__ import annotations + +from pathlib import Path + +from videocaptioner.core.download import ( + FASTER_WHISPER_MODELS, + WHISPER_CPP_MODELS, + find_model, + iter_models, + model_install_state, +) +from videocaptioner.core.download.models import KIND_FASTER_WHISPER, KIND_WHISPER_CPP + + +def test_catalog_covers_expected_models(): + assert [spec.name for spec in WHISPER_CPP_MODELS] == [ + "tiny", "base", "small", "medium", "large-v1", "large-v2", + ] + assert [spec.name for spec in FASTER_WHISPER_MODELS] == [ + "tiny", "base", "small", "medium", + "large-v1", "large-v2", "large-v3", "large-v3-turbo", + ] + + +def test_every_file_has_three_mirrors_in_priority_order(): + for spec in iter_models(): + for file in spec.files: + assert len(file.urls) == 3, f"{spec.key}/{file.name}" + assert file.urls[0].startswith("https://huggingface.co/") + assert file.urls[1].startswith("https://hf-mirror.com/") + assert file.urls[2].startswith("https://www.modelscope.cn/") + + +def test_whisper_cpp_models_have_sha1(): + for spec in WHISPER_CPP_MODELS: + assert spec.files[0].sha1, spec.key + + +def test_every_file_has_exact_size_bytes(): + # 尺寸来自官方仓库实测 Content-Length;展示文案由字节数换算 + for spec in iter_models(): + for file in spec.files: + assert file.size_bytes and file.size_bytes > 0, f"{spec.key}/{file.name}" + assert spec.total_bytes > 0 + + +def test_size_text_derived_from_real_bytes(): + cpp_tiny = find_model(KIND_WHISPER_CPP, "tiny") + assert cpp_tiny is not None + assert cpp_tiny.total_bytes == 77_691_713 + assert cpp_tiny.size_text == "78 MB" + fw_large = find_model(KIND_FASTER_WHISPER, "large-v2") + assert fw_large is not None + assert fw_large.size_text == "3.1 GB" + + +def test_find_model(): + spec = find_model(KIND_WHISPER_CPP, "tiny") + assert spec is not None and spec.files[0].name == "ggml-tiny.bin" + assert find_model(KIND_WHISPER_CPP, "nope") is None + + +def test_whisper_cpp_install_state(tmp_path: Path): + spec = find_model(KIND_WHISPER_CPP, "tiny") + assert spec is not None + assert not model_install_state(spec, tmp_path) + (tmp_path / "ggml-tiny.bin").write_bytes(b"x") + assert model_install_state(spec, tmp_path) + + +def test_faster_whisper_install_state_requires_all_files(tmp_path: Path): + spec = find_model(KIND_FASTER_WHISPER, "tiny") + assert spec is not None + target = spec.target_dir(tmp_path) + target.mkdir(parents=True) + (target / "model.bin").write_bytes(b"x") + assert not model_install_state(spec, tmp_path) + for file in spec.files: + (target / file.name).write_bytes(b"x") + assert model_install_state(spec, tmp_path) + + +def test_target_dir_layout(tmp_path: Path): + cpp = find_model(KIND_WHISPER_CPP, "base") + fw = find_model(KIND_FASTER_WHISPER, "base") + assert cpp is not None and fw is not None + assert cpp.target_dir(tmp_path) == tmp_path + assert fw.target_dir(tmp_path) == tmp_path / "faster-whisper-base" + + +def test_model_descriptions_and_display_names(): + for spec in iter_models(): + assert spec.description, spec.key + cpp = find_model(KIND_WHISPER_CPP, "tiny") + fw = find_model(KIND_FASTER_WHISPER, "tiny") + assert cpp is not None and cpp.display_name == "ggml-tiny.bin" + assert fw is not None and fw.display_name == "faster-whisper-tiny" + + +def test_remove_model_whisper_cpp(tmp_path: Path): + from videocaptioner.core.download import remove_model + + spec = find_model(KIND_WHISPER_CPP, "tiny") + assert spec is not None + (tmp_path / "ggml-tiny.bin").write_bytes(b"x") + (tmp_path / "ggml-tiny.bin.part").write_bytes(b"y") + assert model_install_state(spec, tmp_path) + remove_model(spec, tmp_path) + assert not model_install_state(spec, tmp_path) + assert not (tmp_path / "ggml-tiny.bin.part").exists() + # 不误删其他模型 + (tmp_path / "ggml-base.bin").write_bytes(b"x") + remove_model(spec, tmp_path) + assert (tmp_path / "ggml-base.bin").exists() + + +def test_remove_model_faster_whisper(tmp_path: Path): + from videocaptioner.core.download import remove_model + + spec = find_model(KIND_FASTER_WHISPER, "tiny") + assert spec is not None + target = spec.target_dir(tmp_path) + target.mkdir(parents=True) + for file in spec.files: + (target / file.name).write_bytes(b"x") + assert model_install_state(spec, tmp_path) + remove_model(spec, tmp_path) + assert not target.exists() + + +def test_has_partial_download(tmp_path: Path): + from videocaptioner.core.download import has_partial_download + + spec = find_model(KIND_WHISPER_CPP, "base") + assert spec is not None + assert not has_partial_download(spec, tmp_path) + (tmp_path / "ggml-base.bin.part").write_bytes(b"y") + assert has_partial_download(spec, tmp_path) + # 下载完成后 .part 已被替换,不再算续传态 + (tmp_path / "ggml-base.bin.part").unlink() + (tmp_path / "ggml-base.bin").write_bytes(b"x") + assert not has_partial_download(spec, tmp_path) diff --git a/tests/test_download/test_net.py b/tests/test_download/test_net.py new file mode 100644 index 00000000..b446d169 --- /dev/null +++ b/tests/test_download/test_net.py @@ -0,0 +1,151 @@ +"""net.py 浏览器登录态兜底阶梯的契约测试(纯函数,不联网、不依赖 yt-dlp)。""" + +import pytest + +import videocaptioner.core.download.net as net + + +class TestCookieErrorDetection: + def test_known_login_failures(self): + assert net.looks_like_cookie_error("BiliBili: HTTP Error 412 Precondition Failed") + assert net.looks_like_cookie_error("Sign in to confirm you're not a bot") + assert not net.looks_like_cookie_error("HTTP Error 404: File not found") + + +class TestDetectBrowsers: + def test_detects_existing_profiles(self, tmp_path, monkeypatch): + monkeypatch.setattr( + net, + "_BROWSER_PROFILES", + { + "chrome": (str(tmp_path / "chrome"),), + "firefox": (str(tmp_path / "nope"),), + }, + ) + (tmp_path / "chrome").mkdir() + assert net.detect_cookie_browsers() == ["chrome"] + + def test_last_good_browser_promoted(self, tmp_path, monkeypatch): + monkeypatch.setattr( + net, + "_BROWSER_PROFILES", + {"chrome": (str(tmp_path),), "edge": (str(tmp_path),)}, + ) + monkeypatch.setattr(net, "_last_good_browser", "edge") + assert net.detect_cookie_browsers() == ["edge", "chrome"] + + +class TestFallbackLadder: + """run_with_browser_cookie_fallback:下载与诊断共用的回退原语。""" + + def test_anonymous_success_short_circuits(self): + result, used = net.run_with_browser_cookie_fallback( + "https://example.com", lambda browser: f"ok:{browser}" + ) + assert result == "ok:None" and used is None + + def test_non_cookie_error_fails_fast_with_friendly_message(self, monkeypatch): + monkeypatch.setattr(net, "detect_cookie_browsers", lambda: ["chrome"]) + + def attempt(browser): + raise RuntimeError("HTTP Error 404: File not found") + + with pytest.raises(RuntimeError, match="不存在"): + net.run_with_browser_cookie_fallback("https://example.com/v", attempt) + + def test_cookie_error_retries_with_browser_and_remembers_it( + self, tmp_path, monkeypatch + ): + monkeypatch.setattr(net, "detect_cookie_browsers", lambda: ["chrome"]) + monkeypatch.setattr(net, "cookies_file", lambda: tmp_path / "none.txt") + monkeypatch.setattr(net, "_last_good_browser", None) + attempts = [] + + def attempt(browser): + attempts.append(browser) + if browser is None: + raise RuntimeError("HTTP Error 412 Precondition Failed") + return "title" + + result, used = net.run_with_browser_cookie_fallback( + "https://www.bilibili.com/video/BVx", attempt + ) + assert attempts == [None, "chrome"] + assert result == "title" and used == "Chrome" + assert net._last_good_browser == "chrome" + + def test_all_browsers_fail_raises_actionable_error(self, tmp_path, monkeypatch): + monkeypatch.setattr(net, "detect_cookie_browsers", lambda: ["chrome", "edge"]) + cookies = tmp_path / "cookies.txt" + cookies.write_text("# Netscape HTTP Cookie File\n") + monkeypatch.setattr(net, "cookies_file", lambda: cookies) + notified = [] + + def attempt(browser): + raise RuntimeError("HTTP Error 412 Precondition Failed") + + with pytest.raises(RuntimeError) as exc: + net.run_with_browser_cookie_fallback( + "https://www.bilibili.com/video/BVx", + attempt, + on_attempt=notified.append, + ) + message = str(exc.value) + assert "已用 Chrome、Edge 登录态重试仍被拒绝" in message + assert "已失效" in message # 提示清理失效的 cookies.txt + assert notified == ["Chrome", "Edge"] + + def test_headline_uses_original_error_not_last_attempt(self, tmp_path, monkeypatch): + """兜底自身的失败(如 Safari TCC 权限)不能反客为主成为报错标题。""" + monkeypatch.setattr(net, "detect_cookie_browsers", lambda: ["chrome", "safari"]) + monkeypatch.setattr(net, "cookies_file", lambda: tmp_path / "none.txt") + + def attempt(browser): + if browser == "safari": + raise RuntimeError( + "[Errno 1] Operation not permitted: " + "'/Users/x/Library/Containers/com.apple.Safari/Data'" + ) + raise RuntimeError("Sign in to confirm you're not a bot") + + with pytest.raises(RuntimeError) as exc: + net.run_with_browser_cookie_fallback("https://youtube.com/watch?v=x", attempt) + message = str(exc.value) + # 标题来自最初的站点报错(登录验证),而不是 Safari 的本机权限错误 + assert message.startswith("YouTube 判定当前网络异常") + assert "Operation not permitted" not in message + # 权限问题与"被网站拒绝"分开表述;具体措辞按平台对症(macOS 给隐私放行指引) + assert "已用 Chrome 登录态重试仍被拒绝" in message + import platform as _platform + + if _platform.system() == "Darwin": + assert "Safari 被系统隐私保护拦截" in message + assert "完全磁盘访问权限" in message + else: + assert "Safari 登录态无法读取" in message + + def test_ansi_codes_stripped_from_messages(self): + friendly = net.friendly_download_error( + "https://example.com/v", + "\x1b[0;31mERROR:\x1b[0m something exploded badly", + ) + assert "\x1b" not in friendly and "[0;31m" not in friendly + assert net.strip_ansi("\x1b[0;31mERROR:\x1b[0m boom") == "ERROR: boom" + + def test_cancel_check_called_between_attempts(self, monkeypatch): + monkeypatch.setattr(net, "detect_cookie_browsers", lambda: ["chrome"]) + + class Cancelled(Exception): + pass + + def cancel_check(): + raise Cancelled + + def attempt(browser): + raise RuntimeError("HTTP Error 412") + + # 取消异常必须原样穿透(不被包装成下载失败) + with pytest.raises(Cancelled): + net.run_with_browser_cookie_fallback( + "https://example.com", attempt, cancel_check=cancel_check + ) diff --git a/tests/test_download/test_programs.py b/tests/test_download/test_programs.py new file mode 100644 index 00000000..f8dc19ac --- /dev/null +++ b/tests/test_download/test_programs.py @@ -0,0 +1,111 @@ +"""运行程序检测与安装方案单测(不依赖网络)。""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from videocaptioner.core.download import detect_program, program_install_plan +from videocaptioner.core.download.models import KIND_FASTER_WHISPER, KIND_WHISPER_CPP + + +def test_detect_program_finds_bin_dir_executable(tmp_path: Path, monkeypatch): + monkeypatch.setattr("shutil.which", lambda _name: None) + (tmp_path / "whisper-faster.exe").write_bytes(b"x") + status = detect_program(KIND_FASTER_WHISPER, extra_dirs=(tmp_path,)) + assert status.installed + assert status.name == "whisper-faster" + assert status.path == str(tmp_path / "whisper-faster.exe") + + +def test_detect_program_missing(tmp_path: Path, monkeypatch): + monkeypatch.setattr("shutil.which", lambda _name: None) + status = detect_program(KIND_WHISPER_CPP, extra_dirs=(tmp_path,)) + assert not status.installed + assert status.name is None + + +def test_detect_program_prefers_path(monkeypatch, tmp_path: Path): + monkeypatch.setattr( + "shutil.which", lambda name: "/usr/bin/whisper-cli" if name == "whisper-cli" else None + ) + status = detect_program(KIND_WHISPER_CPP, extra_dirs=(tmp_path,)) + assert status.installed and status.name == "whisper-cli" + + +def test_whisper_cpp_plan_mac_has_brew_command(): + plan = program_install_plan(KIND_WHISPER_CPP, platform="darwin") + assert plan.supported + assert plan.command == "brew install whisper-cpp" + + +def test_whisper_cpp_plan_windows_links_releases(): + plan = program_install_plan(KIND_WHISPER_CPP, platform="win32") + assert plan.supported + assert plan.command is None + assert plan.link and "whisper.cpp/releases" in plan.link + + +def test_faster_whisper_plan_windows_has_direct_download(): + plan = program_install_plan(KIND_FASTER_WHISPER, platform="win32") + assert plan.supported + assert plan.download is not None + assert plan.download.name == "whisper-faster.exe" + assert plan.download.size_bytes + assert plan.link and plan.link.endswith(".7z") + + +def test_faster_whisper_plan_mac_unsupported(): + plan = program_install_plan(KIND_FASTER_WHISPER, platform="darwin") + assert not plan.supported + assert plan.command is None and plan.download is None + assert "Windows" in plan.summary + + +def test_unknown_kind_raises(): + with pytest.raises(ValueError): + program_install_plan("nope", platform="darwin") + + +def test_available_model_kinds_platform_filter(): + from videocaptioner.ui.components.model_manager_dialog import available_model_kinds + + assert available_model_kinds("darwin") == ["whisper-cpp"] + assert available_model_kinds("win32") == ["whisper-cpp", "faster-whisper"] + assert available_model_kinds("linux") == ["whisper-cpp"] + + +def test_program_variants_whisper_cpp_mac(): + from videocaptioner.core.download import program_variants + + variants = program_variants("whisper-cpp", platform="darwin") + assert len(variants) == 1 + assert variants[0].command == "brew install whisper-cpp" + + +def test_program_variants_faster_whisper_windows(): + from videocaptioner.core.download import program_variants + + variants = program_variants("faster-whisper", platform="win32") + assert [v.key for v in variants] == ["cpu", "gpu"] + assert variants[0].download is not None + assert variants[1].link and variants[1].link.endswith(".7z") + # CPU/GPU 检测名单互不重叠 + assert not set(variants[0].executables) & set(variants[1].executables) + + +def test_program_variants_faster_whisper_mac_empty(): + from videocaptioner.core.download import program_variants + + assert program_variants("faster-whisper", platform="darwin") == () + + +def test_variant_detect(tmp_path, monkeypatch): + from videocaptioner.core.download import program_variants + + monkeypatch.setattr("shutil.which", lambda _n: None) + cpu = program_variants("faster-whisper", platform="win32")[0] + assert not cpu.detect(extra_dirs=(tmp_path,)).installed + (tmp_path / "whisper-faster.exe").write_bytes(b"x") + assert cpu.detect(extra_dirs=(tmp_path,)).installed diff --git a/tests/test_download/test_source_check.py b/tests/test_download/test_source_check.py new file mode 100644 index 00000000..61dae0ac --- /dev/null +++ b/tests/test_download/test_source_check.py @@ -0,0 +1,185 @@ +"""下载源检查与网络环境(core.download.net / source_check)契约测试,不联网。""" + +import yt_dlp + +from videocaptioner.core.download.net import ( + friendly_download_error, + inject_bilibili_buvid, + is_bilibili_url, +) +from videocaptioner.core.download.source_check import ( + DOWNLOAD_SOURCES, + check_download_source, +) + + +class TestFriendlyError: + def test_bilibili_412_gets_specific_hint(self): + message = friendly_download_error( + "https://www.bilibili.com/video/BVx", "HTTP Error 412: Precondition Failed" + ) + assert "风控" in message + + def test_non_bilibili_412_keeps_generic_hint(self): + message = friendly_download_error("https://example.com/v", "HTTP Error 412") + assert "登录态" in message + + def test_youtube_bot_check(self): + message = friendly_download_error( + "https://youtube.com/watch?v=x", "Sign in to confirm you're not a bot" + ) + assert "登录" in message + + +class TestBilibiliHelpers: + def test_is_bilibili_url(self): + assert is_bilibili_url("https://www.bilibili.com/video/BV1x") + assert is_bilibili_url("https://b23.tv/abc") + assert not is_bilibili_url("https://www.youtube.com/watch?v=x") + + def test_inject_without_cookiejar_is_noop(self): + inject_bilibili_buvid(object()) # 不应抛错、不应联网 + + def test_inject_skips_when_buvid_present(self, monkeypatch): + from http.cookiejar import Cookie, CookieJar + + jar = CookieJar() + jar.set_cookie( + Cookie( + 0, "buvid3", "x", None, False, ".bilibili.com", True, True, + "/", True, False, None, False, None, None, {}, + ) + ) + + class FakeYdl: + cookiejar = jar + + def boom(*args, **kwargs): + raise AssertionError("已有 buvid3 时不应再请求 spi 接口") + + monkeypatch.setattr( + "videocaptioner.core.download.net.fetch_bilibili_buvid", boom + ) + inject_bilibili_buvid(FakeYdl()) + + +class TestSourceCheck: + def test_sources_cover_youtube_and_bilibili(self): + keys = [source.key for source in DOWNLOAD_SOURCES] + assert keys == ["youtube", "bilibili"] + for source in DOWNLOAD_SOURCES: + assert source.probe_url.startswith("https://") + assert source.fix_hint + + def test_success_returns_title(self, monkeypatch): + class FakeYdl: + def __init__(self, params): + from http.cookiejar import CookieJar + + self.cookiejar = CookieJar() + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def extract_info(self, url, download=False, process=True): + return {"title": "Me at the zoo"} + + monkeypatch.setattr(yt_dlp, "YoutubeDL", FakeYdl) + monkeypatch.setattr( + "videocaptioner.core.download.net.fetch_bilibili_buvid", lambda **kw: {} + ) + result = check_download_source(DOWNLOAD_SOURCES[0]) + assert result.success and result.detail == "Me at the zoo" + + def test_412_falls_back_to_browser_cookies_and_reports_usable(self, monkeypatch): + """匿名被风控但浏览器登录态能解析 → 站点判定为可用(带说明)。""" + + class FakeYdl: + def __init__(self, params): + from http.cookiejar import CookieJar + + self.cookiejar = CookieJar() + self.params = params + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def extract_info(self, url, download=False, process=True): + if self.params.get("cookiesfrombrowser") is None: + raise RuntimeError("HTTP Error 412: Precondition Failed") + return {"title": "官方 MV"} + + monkeypatch.setattr(yt_dlp, "YoutubeDL", FakeYdl) + monkeypatch.setattr( + "videocaptioner.core.download.net.fetch_bilibili_buvid", lambda **kw: {} + ) + monkeypatch.setattr( + "videocaptioner.core.download.net.detect_cookie_browsers", lambda: ["chrome"] + ) + result = check_download_source(DOWNLOAD_SOURCES[1]) + assert result.success + assert "官方 MV" in result.detail and "Chrome 登录态" in result.detail + + def test_unavailable_only_after_browser_fallback_also_fails(self, monkeypatch): + """连浏览器登录态都被拒绝,才报不可用,且说明已尝试过兜底。""" + + class FailingYdl: + def __init__(self, params): + from http.cookiejar import CookieJar + + self.cookiejar = CookieJar() + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def extract_info(self, url, download=False, process=True): + raise RuntimeError("HTTP Error 412: Precondition Failed") + + monkeypatch.setattr(yt_dlp, "YoutubeDL", FailingYdl) + monkeypatch.setattr( + "videocaptioner.core.download.net.fetch_bilibili_buvid", lambda **kw: {} + ) + monkeypatch.setattr( + "videocaptioner.core.download.net.detect_cookie_browsers", lambda: ["chrome"] + ) + result = check_download_source(DOWNLOAD_SOURCES[1]) + assert not result.success + # 结论说明兜底已试(点名浏览器)且给出可行动的出路 + assert "Chrome" in result.detail + assert "cookies.txt" in result.detail + + def test_no_browser_available_reports_friendly_412(self, monkeypatch): + class FailingYdl: + def __init__(self, params): + from http.cookiejar import CookieJar + + self.cookiejar = CookieJar() + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def extract_info(self, url, download=False, process=True): + raise RuntimeError("HTTP Error 412: Precondition Failed") + + monkeypatch.setattr(yt_dlp, "YoutubeDL", FailingYdl) + monkeypatch.setattr( + "videocaptioner.core.download.net.fetch_bilibili_buvid", lambda **kw: {} + ) + monkeypatch.setattr( + "videocaptioner.core.download.net.detect_cookie_browsers", lambda: [] + ) + result = check_download_source(DOWNLOAD_SOURCES[1]) + assert not result.success + assert "风控" in result.detail and "cookies.txt" in result.detail diff --git a/tests/test_dubbing/test_audio_mux.py b/tests/test_dubbing/test_audio_mux.py new file mode 100644 index 00000000..761b06bc --- /dev/null +++ b/tests/test_dubbing/test_audio_mux.py @@ -0,0 +1,63 @@ +"""mux_dubbed_audio 回归:源为纯音频(mp3/wav 等无视频流)时不能崩。 + +历史 bug:mux 恒 ``-map 0:v:0``,对纯音频源 ffmpeg 以 exit 234 报 +"Stream map '0:v:0' matches no streams",导致对 mp3 配音整条失败。 +""" + +import shutil +import subprocess + +import pytest +from pydub import AudioSegment + +from videocaptioner.core.dubbing.audio import ( + _video_has_audio, + _video_has_video_stream, + mux_dubbed_audio, +) + +pytestmark = pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="needs ffmpeg/ffprobe on PATH", +) + + +def _stream_types(path: str) -> set[str]: + out = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", path], + capture_output=True, + text=True, + ).stdout + return {line.strip() for line in out.splitlines() if line.strip()} + + +@pytest.fixture +def audio_only_source(tmp_path): + src = tmp_path / "source.mp3" + AudioSegment.silent(duration=1500, frame_rate=24000).export(str(src), format="mp3") + dub = tmp_path / "dubbed.wav" + AudioSegment.silent(duration=1500, frame_rate=24000).export(str(dub), format="wav") + return str(src), str(dub) + + +def test_probe_distinguishes_audio_only(audio_only_source): + src, _ = audio_only_source + assert _video_has_video_stream(src) is False + assert _video_has_audio(src) is True + + +def test_mux_audio_only_replace(audio_only_source, tmp_path): + src, dub = audio_only_source + out = tmp_path / "out.mp3" + mux_dubbed_audio(src, dub, str(out)) # must not raise (used to fail exit 234) + assert out.exists() and out.stat().st_size > 0 + types = _stream_types(str(out)) + assert "audio" in types and "video" not in types + + +def test_mux_audio_only_mix(audio_only_source, tmp_path): + src, dub = audio_only_source + out = tmp_path / "out_mix.mp3" + mux_dubbed_audio(src, dub, str(out), mix_original_audio=True, original_audio_volume=0.25) + assert out.exists() and out.stat().st_size > 0 + assert "audio" in _stream_types(str(out)) diff --git a/tests/test_dubbing/test_pipeline.py b/tests/test_dubbing/test_pipeline.py index 5da026e7..8205937f 100644 --- a/tests/test_dubbing/test_pipeline.py +++ b/tests/test_dubbing/test_pipeline.py @@ -1,11 +1,22 @@ from pathlib import Path +import pytest from pydub import AudioSegment from videocaptioner.core.dubbing import DubbingConfig, DubbingPipeline from videocaptioner.core.speech import SynthesisResult +@pytest.fixture(autouse=True) +def isolated_tts_cache(tmp_path, monkeypatch): + """TTS 分段缓存是全局内容寻址目录,测试必须隔离到 tmp。""" + cache_dir = tmp_path / "tts-cache" + monkeypatch.setattr( + "videocaptioner.core.dubbing.pipeline.TTS_SEGMENT_CACHE_DIR", cache_dir + ) + return cache_dir + + class FakeSynthesizer: calls = [] @@ -50,7 +61,89 @@ def test_dubbing_pipeline_creates_timeline_audio(tmp_path, monkeypatch): assert len(result.segments) == 2 assert result.segments[0].speaker == "Alice" assert result.segments[1].speaker == "Bob" - assert output.with_suffix(".dubbing.json").exists() + # 报告进工作目录(随任务目录生灭),不再散落在音频旁 + assert result.report_path == tmp_path / "parts" / "report.json" + assert result.report_path.exists() + assert not output.with_suffix(".dubbing.json").exists() + + +def test_segment_cache_reused_across_runs(tmp_path, monkeypatch, isolated_tts_cache): + """同文本同配置重跑命中全局缓存,不再调 TTS。""" + srt = tmp_path / "input.srt" + srt.write_text("1\n00:00:00,000 --> 00:00:01,000\nHello\n", encoding="utf-8") + monkeypatch.setattr( + "videocaptioner.core.dubbing.pipeline.create_speech_synthesizer", + lambda _config: FakeSynthesizer(), + ) + config = DubbingConfig( + provider="gemini", api_key="t", base_url="", model="m", voice="Kore" + ) + + FakeSynthesizer.calls = [] + DubbingPipeline(config).run(str(srt), str(tmp_path / "a.wav"), work_dir=str(tmp_path / "w1")) + first_calls = len(FakeSynthesizer.calls) + DubbingPipeline(config).run(str(srt), str(tmp_path / "b.wav"), work_dir=str(tmp_path / "w2")) + + assert first_calls == 1 + assert len(FakeSynthesizer.calls) == 1 # 第二次全部命中缓存 + assert any(isolated_tts_cache.iterdir()) + + +def test_stretch_to_fit_slows_short_speech_by_default(tmp_path, monkeypatch, isolated_tts_cache): + """默认把短于槽位的配音放慢拉伸到原时长;关闭后保持原速不调速。""" + srt = tmp_path / "input.srt" + srt.write_text("1\n00:00:00,000 --> 00:00:01,000\nHello\n", encoding="utf-8") + monkeypatch.setattr( + "videocaptioner.core.dubbing.pipeline.create_speech_synthesizer", + lambda _config: FakeSynthesizer(), + ) + factors: list[float] = [] + + def fake_change_tempo(src, out, factor): + factors.append(factor) + AudioSegment.silent(duration=max(1, int(350 / factor)), frame_rate=24000).export( + out, format="wav" + ) + + monkeypatch.setattr( + "videocaptioner.core.dubbing.pipeline.change_tempo", fake_change_tempo + ) + base = dict(provider="gemini", api_key="t", base_url="", model="m", voice="Kore") + + # 默认 stretch_to_fit=True:350ms 配音 < ~920ms 槽位 → 放慢(factor<1)拉伸 + DubbingPipeline(DubbingConfig(**base)).run( + str(srt), str(tmp_path / "a.wav"), work_dir=str(tmp_path / "w1") + ) + assert factors, "短配音未被拉伸" + assert all(f < 1.0 for f in factors), factors + + # 关闭后短配音保持原速,不调用 change_tempo + factors.clear() + DubbingPipeline(DubbingConfig(**base, stretch_to_fit=False)).run( + str(srt), str(tmp_path / "b.wav"), work_dir=str(tmp_path / "w2") + ) + assert not factors, f"关闭拉伸后不应调速: {factors}" + + +def test_segment_cache_key_includes_synthesis_config(tmp_path, monkeypatch): + """音色/速度等配置变化必须使缓存失效(曾因漏 speed/gain 复用旧音频)。""" + srt = tmp_path / "input.srt" + srt.write_text("1\n00:00:00,000 --> 00:00:01,000\nHello\n", encoding="utf-8") + monkeypatch.setattr( + "videocaptioner.core.dubbing.pipeline.create_speech_synthesizer", + lambda _config: FakeSynthesizer(), + ) + base = dict(provider="gemini", api_key="t", base_url="", model="m", voice="Kore") + + FakeSynthesizer.calls = [] + DubbingPipeline(DubbingConfig(**base)).run( + str(srt), str(tmp_path / "a.wav"), work_dir=str(tmp_path / "w1") + ) + DubbingPipeline(DubbingConfig(**base, speed=1.5)).run( + str(srt), str(tmp_path / "b.wav"), work_dir=str(tmp_path / "w2") + ) + + assert len(FakeSynthesizer.calls) == 2 # speed 变了 → 重新合成 def test_dubbing_pipeline_uses_configured_workers(tmp_path, monkeypatch): @@ -94,3 +187,46 @@ def __exit__(self, exc_type, exc, tb): DubbingPipeline(config).run(str(srt), str(output), work_dir=str(tmp_path / "parts")) assert seen_workers == [2] + + +def test_dubbing_pipeline_edge_forces_fixed_workers(tmp_path, monkeypatch): + """Edge 免费,并发由程序内部固定为 5,忽略 config.tts_workers。""" + lines = "".join( + f"{i}\n00:00:0{i-1},000 --> 00:00:0{i},000\nLine {i}\n\n" for i in range(1, 8) + ) + srt = tmp_path / "input.srt" + srt.write_text(lines, encoding="utf-8") + output = tmp_path / "dub.wav" + seen_workers = [] + + class CapturingExecutor: + def __init__(self, max_workers): + seen_workers.append(max_workers) + from concurrent.futures import ThreadPoolExecutor + + self._executor = ThreadPoolExecutor(max_workers=max_workers) + + def __enter__(self): + return self._executor.__enter__() + + def __exit__(self, exc_type, exc, tb): + return self._executor.__exit__(exc_type, exc, tb) + + monkeypatch.setattr( + "videocaptioner.core.dubbing.pipeline.create_speech_synthesizer", + lambda _config: FakeSynthesizer(), + ) + monkeypatch.setattr("videocaptioner.core.dubbing.pipeline.ThreadPoolExecutor", CapturingExecutor) + + config = DubbingConfig( + provider="edge", + api_key="", + base_url="", + model="edge-tts", + voice="zh-CN-XiaoxiaoNeural", + tts_workers=20, + ) + DubbingPipeline(config).run(str(srt), str(output), work_dir=str(tmp_path / "parts")) + + # 7 段、配置 20 并发,但 Edge 固定 5 → min(5, 7) == 5 + assert seen_workers == [5] diff --git a/tests/test_feedback/__init__.py b/tests/test_feedback/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_feedback/test_client.py b/tests/test_feedback/test_client.py new file mode 100644 index 00000000..23816a24 --- /dev/null +++ b/tests/test_feedback/test_client.py @@ -0,0 +1,129 @@ +"""FeedbackClient:multipart 组装 + 头部 + 响应解析(用假 session,不联网)。""" + +import json + +import pytest +import requests + +from videocaptioner.core.feedback import FeedbackAttachment, FeedbackClient, FeedbackReport +from videocaptioner.core.feedback import client as client_mod + + +class _Resp: + def __init__(self, status, payload): + self.status_code = status + self._payload = payload + + def json(self): + if self._payload is None: + raise ValueError("no json") + return self._payload + + +class _Session: + def __init__(self, resp=None, exc=None): + self._resp = resp + self._exc = exc + self.captured = {} + + def post(self, url, **kwargs): + self.captured = {"url": url, **kwargs} + if self._exc is not None: + raise self._exc + return self._resp + + +@pytest.fixture(autouse=True) +def _no_proxy_no_real_config(monkeypatch): + monkeypatch.setattr(client_mod, "system_proxy", lambda: None) + monkeypatch.setattr(client_mod, "get_or_create_client_id", lambda: "cid-test") + + +def _report(**over): + base = dict( + category="bug", + message="导出报错", + contact="a@b.c", + attachments=[FeedbackAttachment("s.png", b"PNGDATA", "image/png")], + diagnostics={"app_version": "2.1.0", "platform": "windows-x64"}, + ) + base.update(over) + return FeedbackReport(**base) + + +def _parse_parts(captured): + """从 requests files=parts 还原 (字段名→值, 图片列表, 日志列表)。字段 part 为 (name, (None, value))。""" + assert "data" not in captured # 一切走 multipart,无 urlencoded data + fields, images, logs = {}, [], [] + for name, spec in captured["files"]: + if name == "files": + images.append(spec) # (filename, data, mime) + elif name == "logs": + logs.append(spec) + else: + fields[name] = spec[1] + return fields, images, logs + + +def test_submit_success_builds_multipart(): + sess = _Session(resp=_Resp(200, {"ok": True, "id": "FB-1"})) + result = FeedbackClient("https://host/api", session=sess).submit( + _report(logs=[FeedbackAttachment("recent.log", b"log tail", "text/plain")]) + ) + assert result.ok and result.id == "FB-1" + cap = sess.captured + assert cap["url"] == "https://host/api" + headers = cap["headers"] + assert headers["X-App-Version"] and headers["X-App-Platform"] + assert headers["User-Agent"].startswith("VideoCaptioner/") + fields, images, logs = _parse_parts(cap) + assert logs[0][0] == "recent.log" and logs[0][2] == "text/plain" + assert fields["category"] == "bug" + assert fields["message"] == "导出报错" + assert fields["client_id"] == "cid-test" + assert fields["request_id"] # 每次新 UUID + assert fields["contact"] == "a@b.c" + assert json.loads(fields["diagnostics"])["platform"] == "windows-x64" + assert images[0][0] == "s.png" and images[0][2] == "image/png" + + +def test_no_image_still_multipart(): + # 关键回归:无截图也必须是 multipart(字段作为 part 发送),否则后端拒 invalid_request + sess = _Session(resp=_Resp(200, {"ok": True, "id": "FB-2"})) + FeedbackClient("https://host/api", session=sess).submit( + _report(contact="", attachments=[], diagnostics={}) + ) + fields, images, logs = _parse_parts(sess.captured) + assert sess.captured["files"] # 始终走 files(multipart),不退化成 data + assert images == [] and logs == [] + assert "contact" not in fields # 空联系方式省略 + assert "diagnostics" not in fields + assert fields["category"] == "bug" and fields["message"] + + +def test_error_response_maps_code(): + sess = _Session(resp=_Resp(400, {"ok": False, "code": "invalid_request", "error": "问题描述需为 1-5000 字符"})) + result = FeedbackClient("https://host/api", session=sess).submit(_report()) + assert not result.ok and result.code == "invalid_request" + assert "1-5000" in result.error + + +def test_http_status_without_code_falls_back(): + sess = _Session(resp=_Resp(413, None)) # 非 JSON 响应体 + result = FeedbackClient("https://host/api", session=sess).submit(_report()) + assert not result.ok and result.code == "too_large" + + +def test_network_error_returns_network_code(): + sess = _Session(exc=requests.ConnectionError("boom")) + result = FeedbackClient("https://host/api", session=sess).submit(_report()) + assert not result.ok and result.code == "network" + + +def test_invalid_report_raises_before_post(): + sess = _Session(resp=_Resp(200, {"ok": True, "id": "x"})) + from videocaptioner.core.feedback import FeedbackValidationError + + with pytest.raises(FeedbackValidationError): + FeedbackClient("https://host/api", session=sess).submit(_report(message="")) + assert sess.captured == {} # 校验失败不发请求 diff --git a/tests/test_feedback/test_diagnostics.py b/tests/test_feedback/test_diagnostics.py new file mode 100644 index 00000000..9e61f250 --- /dev/null +++ b/tests/test_feedback/test_diagnostics.py @@ -0,0 +1,54 @@ +"""诊断采集:绝不含密钥;client_id 持久化稳定。""" + +import json + +from videocaptioner.core.application import config_store +from videocaptioner.core.feedback import diagnostics + + +def test_is_safe_rejects_secret_like(): + assert diagnostics._is_safe("openai") + assert diagnostics._is_safe("bing") + assert not diagnostics._is_safe("sk-abcdef123456") + assert not diagnostics._is_safe("Bearer xyz") + assert not diagnostics._is_safe("https://api.openai.com/v1") + assert not diagnostics._is_safe("x" * 80) # 过长 + + +def test_gather_diagnostics_has_no_secret_values(monkeypatch, tmp_path): + cfg = tmp_path / "config.toml" + monkeypatch.setattr(config_store, "CONFIG_FILE", cfg) + # 即使有人把密钥误塞进白名单字段,_is_safe 也兜底丢弃 + config_store.save_config_value("llm.service", "sk-should-be-dropped") + diag = diagnostics.gather_diagnostics() + blob = json.dumps(diag).lower() + for marker in ("sk-", "bearer", "api_key", "://", "token"): + assert marker not in blob + assert diag["app_version"] + assert diag["platform"] + assert diag.get("llm_service") != "sk-should-be-dropped" # 被兜底丢弃 + + +def test_gather_diagnostics_keeps_safe_provider_names(monkeypatch, tmp_path): + cfg = tmp_path / "config.toml" + monkeypatch.setattr(config_store, "CONFIG_FILE", cfg) + config_store.save_config_value("llm.service", "deepseek") + config_store.save_config_value("dubbing.provider", "edge") + diag = diagnostics.gather_diagnostics() + assert diag["llm_service"] == "deepseek" + assert diag["dubbing_provider"] == "edge" + + +def test_client_id_is_stable_and_persisted_to_file(monkeypatch, tmp_path): + # client_id 存独立文件,不入配置文件 + id_file = tmp_path / "feedback_client_id" + monkeypatch.setattr(diagnostics, "_CLIENT_ID_FILE", id_file) + first = diagnostics.get_or_create_client_id() + second = diagnostics.get_or_create_client_id() + assert first and first == second + assert id_file.read_text(encoding="utf-8").strip() == first + + +def test_platform_tag_in_contract_enum(): + # header X-App-Platform 只能是契约枚举:dev/linux、windows-arm64 都收敛进来 + assert diagnostics.platform_tag() in ("windows-x64", "macos-x64", "macos-arm64") diff --git a/tests/test_feedback/test_logs.py b/tests/test_feedback/test_logs.py new file mode 100644 index 00000000..93fbcdba --- /dev/null +++ b/tests/test_feedback/test_logs.py @@ -0,0 +1,61 @@ +"""最近日志采集 + 脱敏。""" + +from videocaptioner.core.feedback import logs as logs_mod +from videocaptioner.core.feedback.logs import collect_recent_logs, scrub_log_text + + +def test_scrub_redacts_secrets(): + raw = ( + 'INFO request headers: Authorization: Bearer sk-abcDEF123456789\n' + 'DEBUG {"api_key": "sk-proj-zzzz9999", "model": "gpt-4o"}\n' + 'WARN url=https://user:p4ssw0rd@host.example.com/v1\n' + 'INFO api_base=https://api.openai.com/v1\n' + ' API Base: https://api.siliconflow.cn/v1\n' + 'DEBUG {"base_url": "https://api.private.example/v1"}\n' + 'ERROR ...:generateContent?key=AIzaSyD_realsecret_abcdefghijklmnop123\n' + 'INFO google configured with AIzaSyD-EXAMPLErealkeyshapevalue1234567\n' + ) + out = scrub_log_text(raw) + assert "sk-abcDEF123456789" not in out + assert "sk-proj-zzzz9999" not in out + assert "p4ssw0rd" not in out + assert "Bearer ***" in out + assert "***" in out + # base URL(含 provider/自建主机)一律打码 + assert "api.siliconflow.cn" not in out + assert "api.openai.com" not in out + assert "api.private.example" not in out + # query ?key= 与裸 AIza Google key 打码 + assert "AIzaSyD_realsecret_abcdefghijklmnop123" not in out + assert "AIzaSyD-EXAMPLErealkeyshapevalue1234567" not in out + # 非敏感内容保留 + assert "gpt-4o" in out + assert "host.example.com" in out # url 凭据行只抹账密,保留主机 + + +def test_collect_recent_logs_returns_scrubbed_tail(monkeypatch, tmp_path): + log = tmp_path / "app.log" + log.write_text("line1 Bearer sk-shouldberedacted0001\nline2 normal\n", encoding="utf-8") + monkeypatch.setattr(logs_mod, "_LOG_FILE", log) + items = collect_recent_logs() + assert len(items) == 1 + att = items[0] + assert att.filename == "recent.log" and att.mime == "text/plain" + text = att.data.decode("utf-8") + assert "sk-shouldberedacted0001" not in text + assert "line2 normal" in text + + +def test_collect_recent_logs_tail_only(monkeypatch, tmp_path): + log = tmp_path / "app.log" + big = "\n".join(f"line-{i}" for i in range(200000)) # 远超 256KB + log.write_text(big, encoding="utf-8") + monkeypatch.setattr(logs_mod, "_LOG_FILE", log) + att = collect_recent_logs()[0] + assert len(att.data) <= logs_mod._MAX_TAIL_BYTES + assert att.data.decode("utf-8").rstrip().endswith("line-199999") # 取的是尾部 + + +def test_collect_recent_logs_missing_file(monkeypatch, tmp_path): + monkeypatch.setattr(logs_mod, "_LOG_FILE", tmp_path / "nope.log") + assert collect_recent_logs() == [] diff --git a/tests/test_feedback/test_models.py b/tests/test_feedback/test_models.py new file mode 100644 index 00000000..96d7457d --- /dev/null +++ b/tests/test_feedback/test_models.py @@ -0,0 +1,85 @@ +"""反馈本地校验:限制对齐后端契约。""" + +import pytest + +from videocaptioner.core.feedback.models import ( + MAX_FILE_BYTES, + MAX_TOTAL_BYTES, + FeedbackAttachment, + FeedbackReport, + FeedbackValidationError, +) + + +def _png(size: int = 10) -> FeedbackAttachment: + return FeedbackAttachment(filename="s.png", data=b"x" * size, mime="image/png") + + +def test_valid_report_passes(): + FeedbackReport(category="bug", message="导出报错").validate() + + +def test_empty_message_rejected(): + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="bug", message=" ").validate() + assert e.value.code == "message_required" + + +def test_message_too_long(): + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="bug", message="x" * 5001).validate() + assert e.value.code == "message_too_long" + + +def test_bad_category(): + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="nope", message="hi").validate() + assert e.value.code == "category_invalid" + + +def test_contact_too_long_counts_bytes(): + # 200 字节上限:用多字节中文确保按字节而非字符判断 + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="bug", message="hi", contact="中" * 80).validate() + assert e.value.code == "contact_too_long" + + +def test_too_many_files(): + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="bug", message="hi", attachments=[_png() for _ in range(4)]).validate() + assert e.value.code == "too_many_files" + + +def test_bad_file_type(): + bad = FeedbackAttachment(filename="a.gif", data=b"x", mime="image/gif") + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="bug", message="hi", attachments=[bad]).validate() + assert e.value.code == "file_type" + + +def test_file_too_large(): + big = FeedbackAttachment(filename="a.png", data=b"x" * (MAX_FILE_BYTES + 1), mime="image/png") + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="bug", message="hi", attachments=[big]).validate() + assert e.value.code == "file_too_large" + + +def test_total_too_large(): + # 3 张各 5MB = 15MB > 12MB 总上限 + files = [FeedbackAttachment(f"{i}.png", b"x" * MAX_FILE_BYTES, "image/png") for i in range(3)] + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="bug", message="hi", attachments=files).validate() + assert e.value.code == "total_too_large" + + +def test_total_counts_text_not_just_binary(): + # 二进制正好 12MB:旧逻辑用严格 > 会放行,但加上文本字段+multipart 框架后实际超 12MB, + # 后端会 413。本地必须计入文本/框架,先于后端拒掉(让本地校验是后端上限的真超集)。 + files = [ + FeedbackAttachment("a.png", b"x" * MAX_FILE_BYTES, "image/png"), + FeedbackAttachment("b.png", b"x" * MAX_FILE_BYTES, "image/png"), + FeedbackAttachment("c.png", b"x" * (MAX_TOTAL_BYTES - 2 * MAX_FILE_BYTES), "image/png"), + ] + with pytest.raises(FeedbackValidationError) as e: + FeedbackReport(category="bug", message="导出报错", attachments=files).validate() + assert e.value.code == "total_too_large" diff --git a/tests/test_hardsub/__init__.py b/tests/test_hardsub/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_hardsub/test_pipeline.py b/tests/test_hardsub/test_pipeline.py new file mode 100644 index 00000000..25708f78 --- /dev/null +++ b/tests/test_hardsub/test_pipeline.py @@ -0,0 +1,361 @@ +"""硬字幕提取状态机的离线测试(不依赖真实视频 / 真实 OCR)。 + +两类: +- 纯函数(``_changed`` / ``_is_blank`` / ``_norm`` / ``_similar``)用真实合成灰度帧测阈值; +- 状态机(变化检测跳保持帧、空白收尾、变化点 OCR、相似度合并、时长过滤、时间轴)用 marker 帧 + + monkeypatch 掉 ``_changed`` / ``_is_blank``,把「场景切换」简化成 marker 比较,精确锁状态转移。 +""" + +from __future__ import annotations + +import numpy as np + +import videocaptioner.core.hardsub.pipeline as P +from videocaptioner.core.hardsub.config import HardsubConfig +from videocaptioner.core.hardsub.pipeline import ( + HardsubCue, + _changed, + _is_blank, + _norm, + _read_lines, + _similar, + cues_to_asrdata, + extract_hardsub, +) +from videocaptioner.core.ocr.base import OcrEngine, OcrLine + +H, W = 40, 600 + + +# ---------- 纯函数(真实合成帧 / 真实阈值)---------- + +def _scene(width: int) -> np.ndarray: + """暗底 + 宽度=width 的亮块。width=0 即空白帧。""" + arr = np.full((H, W), 20, np.uint8) + if width > 0: + arr[10:30, 10 : 10 + width] = 240 + return arr + + +def test_changed_detects_block_growth(): + assert _changed(_scene(100), _scene(200), area_thr=144) + assert not _changed(_scene(100), _scene(100), area_thr=144) + + +def test_is_blank_only_when_uniform(): + assert _is_blank(_scene(0), blank_thr=43) + assert not _is_blank(_scene(100), blank_thr=43) + + +def test_norm_strips_whitespace(): + assert _norm("a b\tc\n") == "abc" + + +def test_similar_identical_and_different(): + assert _similar("你好世界", "你好世界") == 100 + assert _similar("abcdef", "abcdef") == 100 + assert _similar("你好", "再见天涯") < 60 + + +# ---------- 状态机(marker 帧 + 简化切换判定)---------- + +def _marker_frame(marker: int) -> np.ndarray: + # 真实 ROI 尺寸的 RGB 帧(居中过滤要按 image 宽算偏移);marker 编码在绿通道 [0,0,1]。 + arr = np.zeros((H, W, 3), np.uint8) + arr[0, 0, 1] = marker # 0 = 空白 + return arr + + +class _FakeEngine(OcrEngine): + """按帧 marker(绿通道)取脚本文本(marker=0 视为无字幕)。""" + + def __init__(self, texts: dict[int, str]): + self._texts = texts + + @property + def name(self) -> str: + return "fake" + + def recognize(self, image: np.ndarray) -> list[OcrLine]: + text = self._texts.get(int(image[0, 0, 1]), "") + if not text: + return [] + # 框居中(ROI 宽 600 → 中心 300):烧录字幕都居中,过滤器按「居中+字号」留它。 + return [OcrLine(text=text, score=0.99, box=((250, 6), (350, 6), (350, 34), (250, 34)))] + + def detect(self, image: np.ndarray) -> list: + return [] + + +def _run(monkeypatch, markers, texts, interval=0.3, **cfg_kw): + frames = [_marker_frame(m) for m in markers] + monkeypatch.setattr(P, "probe_dimensions", lambda _p: (W, H, 25.0, len(frames) * interval)) + monkeypatch.setattr( + P, "iter_roi_rgb", + lambda _p, _roi, _iv: ((i * interval, f) for i, f in enumerate(frames)), + ) + # 场景切换 = marker 变化;空白 = marker 0。g 是绿通道(2D),g[0,0] 即 marker。 + monkeypatch.setattr(P, "_is_blank", lambda g, _thr: int(g[0, 0]) == 0) + monkeypatch.setattr(P, "_changed", lambda a, b, _thr: int(a[0, 0]) != int(b[0, 0])) + cfg = HardsubConfig(video_path="x.mp4", roi=(0, 0, W, H), sample_interval=interval, **cfg_kw) + cues: list[HardsubCue] = [] + data = extract_hardsub(cfg, _FakeEngine(texts), on_cue=cues.append) + return data, cues + + +def test_single_subtitle_one_cue(monkeypatch): + data, cues = _run(monkeypatch, [0, 1, 1, 1, 0], {1: "第一句字幕"}) + assert len(data) == 1 + assert data.segments[0].text == "第一句字幕" + assert len(cues) == 1 + + +def test_two_subtitles_split_on_change(monkeypatch): + data, _ = _run(monkeypatch, [0, 1, 1, 0, 2, 2, 0], {1: "第一句", 2: "第二句"}) + assert [s.text for s in data] == ["第一句", "第二句"] + + +def test_timeline_milliseconds(monkeypatch): + # marker1 出现于 t=0.3,末次出现 t=0.6,t=0.9 空白:start=300,end=600+一个间隔=900。 + data, _ = _run(monkeypatch, [0, 1, 1, 0], {1: "句子"}) + seg = data.segments[0] + assert seg.start_time == 300 + assert seg.end_time == 900 + + +def test_min_duration_filters_blip(monkeypatch): + data, _ = _run(monkeypatch, [0, 1, 0, 0], {1: "闪现"}, min_duration=1.0) + assert len(data) == 0 + + +def test_merge_keeps_longest_text(monkeypatch): + # 同一句先识别半句(marker1)后识别全句(marker2),文本相似 → 合并取更完整的。 + data, _ = _run( + monkeypatch, [0, 1, 2, 2, 0], + {1: "今天我们讲矩阵", 2: "今天我们讲矩阵和向量"}, + text_sim_threshold=60, + ) + assert len(data) == 1 + assert data.segments[0].text == "今天我们讲矩阵和向量" + + +def test_cancel_stops_early(monkeypatch): + markers = [1] * 50 + seen = {"n": 0} + + def should_cancel(): + seen["n"] += 1 + return seen["n"] > 5 + + frames = [_marker_frame(m) for m in markers] + monkeypatch.setattr(P, "probe_dimensions", lambda _p: (W, H, 25.0, 15.0)) + monkeypatch.setattr( + P, "iter_roi_rgb", + lambda _p, _roi, _iv: ((i * 0.3, f) for i, f in enumerate(frames)), + ) + monkeypatch.setattr(P, "_is_blank", lambda g, _thr: int(g[0, 0]) == 0) + monkeypatch.setattr(P, "_changed", lambda a, b, _thr: int(a[0, 0]) != int(b[0, 0])) + cfg = HardsubConfig(video_path="x.mp4", roi=(0, 0, W, H)) + extract_hardsub(cfg, _FakeEngine({1: "x"}), should_cancel=should_cancel) + assert seen["n"] <= 7 + + +# ---------- 手动 ROI:尊重所见即所得(不学全局字号 / 不按字号过滤)---------- + +def test_manual_roi_skips_global_font_gate(monkeypatch): + # 用户手动框选/--roi 指定区域:不学「全局主导字号」、提取不按字号门过滤——否则会把用户 + # 特意框进来的小字号译文挡掉。这里断言 manual 时根本不调用 _dominant_font_height。 + called = {"n": 0} + + def _spy(*_a, **_k): + called["n"] += 1 + return 28.0 + + monkeypatch.setattr(P, "_dominant_font_height", _spy) + data, _ = _run(monkeypatch, [0, 1, 1, 0], {1: "字幕"}, roi_is_manual=True) + assert called["n"] == 0 + assert data.segments[0].text == "字幕" + + +def test_auto_roi_learns_global_font(monkeypatch): + # 自动检测区域(默认 roi_is_manual=False):仍学一次全局主导字号用于过滤带内杂质。 + called = {"n": 0} + + def _spy(*_a, **_k): + called["n"] += 1 + return 28.0 + + monkeypatch.setattr(P, "_dominant_font_height", _spy) + _run(monkeypatch, [0, 1, 1, 0], {1: "字幕"}) + assert called["n"] == 1 + + +# ---------- cue → ASRData ---------- + +def test_cues_to_asrdata_ms_and_filter(): + cues = [ + HardsubCue(start=1.0, end=2.5, lines=["你好"]), + HardsubCue(start=3.0, end=4.0, lines=[" "]), + ] + data = cues_to_asrdata(cues) + assert len(data) == 1 + assert data.segments[0].start_time == 1000 + assert data.segments[0].end_time == 2500 + + +def test_multiline_cue_joins_with_newline(): + cue = HardsubCue(start=0.0, end=1.0, lines=["中文行", "English line"]) + assert cue.text == "中文行\nEnglish line" + + +# ---------- 杂质过滤:居中(核心,与区域检测同模型)---------- + +def _poly(x0, y0, x1, y1): + return ((x0, y0), (x1, y0), (x1, y1), (x0, y1)) + + +class _ScriptedEngine(OcrEngine): + """recognize 固定返回脚本里的 OcrLine(与图像无关),用于测过滤几何。""" + + def __init__(self, lines): + self._lines = lines + + @property + def name(self): + return "scripted" + + def recognize(self, image): + return list(self._lines) + + def detect(self, image): + return [] + + +def test_read_lines_drops_offcenter(): + # ROI 宽 1920:居中字幕 + 偏右计数(偏心)。同一视觉行被大间隙切段,只留居中段。 + img = np.zeros((150, 1920, 3), np.uint8) + lines = [ + OcrLine(text="当你第一次开始学他", score=0.99, box=_poly(600, 20, 1320, 138)), # 居中=字幕 + OcrLine(text="49:00", score=0.99, box=_poly(1780, 50, 1860, 66)), # 偏右=时长 + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, center_tolerance=0.34) + assert [t for t, _ in out] == ["当你第一次开始学他"] + + +def test_read_lines_font_height_drops_smaller(): + # 全局主导字号=大字(75)。小字行(40 < 0.6×75)被字号门挡掉,只留主导字号行。 + img = np.zeros((220, 1920, 3), np.uint8) + lines = [ + OcrLine(text="中文字幕大字", score=0.99, box=_poly(700, 20, 1220, 95)), # 字高 75 + OcrLine(text="English smaller", score=0.99, box=_poly(740, 112, 1180, 152)), # 字高 40 + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, + center_tolerance=0.34, font_height=75.0) + assert [t for t, _ in out] == ["中文字幕大字"] + + +def test_read_lines_font_height_drops_translation_ratio(): + # 中英双语:英文译文 ≈ 0.65× 中文(实测比值),落在 0.75 门下界之下被稳定排除—— + # 不再逐帧骑门槛忽留忽丢。主导字号=中文 28。 + img = np.zeros((220, 1920, 3), np.uint8) + lines = [ + OcrLine(text="答案就是只因你太美", score=0.99, box=_poly(700, 20, 1220, 48)), # 字高 28 + OcrLine(text="The answer is", score=0.99, box=_poly(740, 60, 1180, 78)), # 字高 18 ≈0.64× + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, + center_tolerance=0.34, font_height=28.0) + assert [t for t, _ in out] == ["答案就是只因你太美"] + + +def test_read_lines_font_height_keeps_same_size_second_line(): + # 同一字幕的真·第二行(同字号,OCR 抖动 0.85×)仍保留——门下界 0.75 不误杀同号多行。 + img = np.zeros((220, 1920, 3), np.uint8) + lines = [ + OcrLine(text="第一行字幕内容", score=0.99, box=_poly(700, 20, 1220, 60)), # 字高 40 + OcrLine(text="第二行字幕内容", score=0.99, box=_poly(710, 72, 1210, 106)), # 字高 34 ≈0.85× + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, + center_tolerance=0.34, font_height=40.0) + assert [t for t, _ in out] == ["第一行字幕内容", "第二行字幕内容"] + + +def test_read_lines_frame_relative_drops_translation(): + # 英文译文 0.78×主导字号 → 过绝对字号门(0.75),但同帧中文主行 1.1×;帧内相对门按「< 0.8×本帧最大」 + # 剔掉英文(治英文字高骑在绝对门上的忽中忽英)。主导字号 24.5。 + img = np.zeros((150, 1920, 3), np.uint8) + lines = [ + OcrLine(text="火焰就像丝绸一样", score=0.99, box=_poly(700, 18, 1220, 45)), # 字高27 + OcrLine(text="theflameflowslikesilk", score=0.99, box=_poly(720, 60, 1200, 79)), # 字高19 ≈0.7×主行 + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, + center_tolerance=0.34, font_height=24.5) + assert [t for t, _ in out] == ["火焰就像丝绸一样"] + + +def test_read_lines_no_font_filter_keeps_both(): + # font_height=None:不按字号过滤,两行都居中 → 都保留。 + img = np.zeros((220, 1920, 3), np.uint8) + lines = [ + OcrLine(text="中文字幕大字", score=0.99, box=_poly(700, 20, 1220, 95)), + OcrLine(text="English smaller", score=0.99, box=_poly(740, 112, 1180, 152)), + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, center_tolerance=0.34) + assert [t for t, _ in out] == ["中文字幕大字", "English smaller"] + + +def test_read_lines_trims_offcenter_same_font_junk(): + # 片尾卡片标题与歌词同字号、同一视觉行、横向贴近(间隙 < 2×行高)——段级中心拦不住(合并后 + # 段中心仍接近画面中心),靠单框中心偏移从两端修剪剔掉。ROI 宽 1920,半宽 960。 + img = np.zeros((150, 1920, 3), np.uint8) + lines = [ + OcrLine(text="一课通", score=0.92, box=_poly(200, 20, 480, 138)), # 卡片标题(左,偏移0.65) + OcrLine(text="你会眼里泛着泪花", score=0.97, box=_poly(603, 22, 1243, 138)), # 歌词(居中0.04) + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, center_tolerance=0.34) + assert [t for t, _ in out] == ["你会眼里泛着泪花"] + + +def test_read_lines_keeps_ocr_split_centered_line(): + # 一条居中长字幕被 OCR 拆成左右两块:每块自身略偏中心(≈0.19)但都靠中心,不该被修剪,应合并保留。 + img = np.zeros((150, 1920, 3), np.uint8) + lines = [ + OcrLine(text="你会眼里", score=0.95, box=_poly(620, 22, 940, 138)), # 左半(中心780,偏移0.19) + OcrLine(text="泛着泪花", score=0.95, box=_poly(980, 22, 1300, 138)), # 右半(中心1140,偏移0.19) + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, center_tolerance=0.34) + assert [t for t, _ in out] == ["你会眼里 泛着泪花"] + + +def test_read_lines_drops_offcenter_single_line(): + # 单框行(完整一行)偏移 0.29:未达拆块容差 0.34,但单框行用更严的 0.25 门 → 偏置署名(如 MV 演唱者名 + # 「马嘉祺」)被剔除;居中主行保留。ROI 宽 1920,半宽 960。 + img = np.zeros((220, 1920, 3), np.uint8) + lines = [ + OcrLine(text="炎炎夏日万般滋味", score=0.99, box=_poly(700, 20, 1220, 60)), # 居中(偏移≈0) + OcrLine(text="马嘉祺", score=0.99, box=_poly(560, 80, 800, 118)), # 偏左(中心680, 偏移0.29) + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, center_tolerance=0.34) + assert [t for t, _ in out] == ["炎炎夏日万般滋味"] + + +def test_read_lines_keeps_fullwidth_centered_split(): + # 近全宽居中长句被 OCR 拆成两半:每半自身偏移≈0.47 > 0.45 会触发修剪,但修剪后不居中 → 回退 + # 保留整段(整体居中)→ 不该整条丢失。ROI 宽 1920,半宽 960。 + img = np.zeros((150, 1920, 3), np.uint8) + lines = [ + OcrLine(text="大家觉得声音为什么", score=0.96, box=_poly(60, 20, 960, 138)), # 左半(中心510,偏移0.47) + OcrLine(text="能熄灭蜡烛呢", score=0.96, box=_poly(960, 20, 1860, 138)), # 右半(中心1410,偏移0.47) + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, center_tolerance=0.34) + assert [t for t, _ in out] == ["大家觉得声音为什么 能熄灭蜡烛呢"] + + +def test_read_lines_disabled_keeps_offcenter(): + # center_tolerance>=1:不过滤,居中与偏心段都留。 + img = np.zeros((150, 1920, 3), np.uint8) + lines = [ + OcrLine(text="字幕", score=0.99, box=_poly(880, 20, 1040, 130)), + OcrLine(text="49:00", score=0.99, box=_poly(1780, 50, 1860, 66)), + ] + out = _read_lines(_ScriptedEngine(lines), img, conf_threshold=0.6, center_tolerance=1.0) + assert set(t for t, _ in out) == {"字幕", "49:00"} diff --git a/tests/test_hardsub/test_region.py b/tests/test_hardsub/test_region.py new file mode 100644 index 00000000..2e23d213 --- /dev/null +++ b/tests/test_hardsub/test_region.py @@ -0,0 +1,129 @@ +"""自动字幕区域检测的离线测试:居中文字段 + 行聚类 + 打分 + 端到端选带。 + +验证字幕模型:水平居中 + 时间稳定 + 内容变化 + 偏底部;两侧台标/卡片(偏心)与顶部标题被剔除。 +""" + +from __future__ import annotations + +import numpy as np + +import videocaptioner.core.hardsub.region as R +from videocaptioner.core.hardsub.region import ( + _Line, + _score_line, + centered_segments, + detect_subtitle_region, +) +from videocaptioner.core.ocr.base import OcrEngine + + +def _line(cy, frames_hit, widths, centers_x, top=None, bottom=None) -> _Line: + return _Line( + cy=cy, + top=top if top is not None else cy - 20, + bottom=bottom if bottom is not None else cy + 20, + frames_hit=frames_hit, + widths=widths, + centers_x=centers_x, + ) + + +# ---------- 居中文字段 ---------- + +def test_centered_segments_keeps_center_drops_sides(): + # 同一行:居中字幕段保留;左/右两侧的角标/卡片(偏心)被丢弃,且大间隔切成独立段。 + half, line_h = 480.0, 30.0 + boxes = [ + (380, 470, 580, 510), # 居中字幕 cx=480 + (10, 470, 120, 510), # 左侧角标 cx=65 + (820, 470, 950, 510), # 右侧 cx=885 + ] + segs = centered_segments(boxes, half, line_h, center_tol=0.34) + assert len(segs) == 1 + assert segs[0][0][0] == 380 + + +def test_centered_segments_bilingual_two_lines(): + # 中英双行都居中、垂直分开 → 各自成段。 + half, line_h = 480.0, 24.0 + boxes = [ + (300, 440, 660, 470), # 中文行 cx=480 + (320, 478, 640, 498), # 英文行 cx=480(下一行) + ] + segs = centered_segments(boxes, half, line_h) + assert len(segs) == 2 + + +# ---------- 行打分 ---------- + +def test_score_static_logo_is_zero(): + # 底部但「一直在且不变」= 台标/水印 → 0 分。 + logo = _line(cy=500, frames_hit=30, widths=[100] * 30, centers_x=[480.0] * 30) + assert _score_line(logo, num_frames=30, fh=540) == 0.0 + + +def test_score_top_text_is_zero(): + # 画面上半部分(标题/正文)→ 0 分。 + top = _line(cy=60, frames_hit=10, widths=[200, 300, 250], centers_x=[480, 470, 490]) + assert _score_line(top, num_frames=10, fh=540) == 0.0 + + +def test_score_subtitle_positive(): + # 底部、出现率中高、宽度/位置随句子变化 → 正分。 + widths = [300, 420, 360, 500, 280, 440] + centers = [480, 500, 470, 520, 460, 510] + sub = _line(cy=490, frames_hit=6, widths=widths, centers_x=centers, top=470, bottom=510) + assert _score_line(sub, num_frames=8, fh=540) > 0.35 + + +# ---------- 端到端(FakeDetEngine)---------- + +class _FakeDetEngine(OcrEngine): + """detect 按调用顺序返回脚本框(每帧一组)。""" + + def __init__(self, boxes_per_frame: list[list[tuple]]): + self._boxes = boxes_per_frame + self._i = 0 + + @property + def name(self) -> str: + return "fakedet" + + def recognize(self, image): + return [] + + def detect(self, image): + boxes = self._boxes[self._i] if self._i < len(self._boxes) else [] + self._i += 1 + return boxes + + +def test_detect_region_picks_bottom_scaled_to_original(monkeypatch): + fw, fh = 960, 540 + frame = np.zeros((fh, fw, 3), np.uint8) + frames = [(i * 0.5, frame) for i in range(10)] + boxes_per_frame = [] + for i in range(10): + sub_w = 300 + (i % 4) * 70 # 宽度逐帧变(内容变化) + sub = (330.0, 470.0, 330.0 + sub_w, 512.0) # 底部居中字幕带(cx≈480) + logo = (20.0, 20.0, 120.0, 60.0) # 顶部 + 偏心 logo + boxes_per_frame.append([sub, logo]) + monkeypatch.setattr(R, "sample_frames", lambda *a, **k: frames) + monkeypatch.setattr(R, "probe_dimensions", lambda _p: (1920, 1080, 25.0, 30.0)) + + result = detect_subtitle_region("x.mp4", _FakeDetEngine(boxes_per_frame)) + assert result is not None + _x, y, w, h = result.roi + # 缩放回原始分辨率(×2):字幕带应落在画面下方,不是顶部 logo。 + assert y > 1080 * 0.7 + assert w > 0 and h > 0 + assert result.font_height > 0 # 顺带学到主导字号(原始分辨率) + + +def test_detect_region_none_when_no_detections(monkeypatch): + fw, fh = 960, 540 + frames = [(i * 0.5, np.zeros((fh, fw, 3), np.uint8)) for i in range(5)] + monkeypatch.setattr(R, "sample_frames", lambda *a, **k: frames) + monkeypatch.setattr(R, "probe_dimensions", lambda _p: (1920, 1080, 25.0, 30.0)) + roi = detect_subtitle_region("x.mp4", _FakeDetEngine([[] for _ in range(5)])) + assert roi is None diff --git a/tests/test_i18n/test_i18n_runtime.py b/tests/test_i18n/test_i18n_runtime.py new file mode 100644 index 00000000..b3972a7e --- /dev/null +++ b/tests/test_i18n/test_i18n_runtime.py @@ -0,0 +1,67 @@ +"""UI i18n 运行时与枚举标签的确定性测试(无网络)。 + +工具链层面的「源码 key == .pot」「基准无空译文」由 `scripts/i18n.py check`(CI)负责。 +""" + +from __future__ import annotations + +import pytest + +from videocaptioner.config import I18N_PATH +from videocaptioner.ui import i18n +from videocaptioner.ui.common import enum_labels + + +def test_normalize_locale(): + n = i18n.catalog._normalize + assert n("en_US") == "en" + assert n("en") == "en" + assert n("zh_CN") == "zh_Hans" + assert n("zh_Hans") == "zh_Hans" + assert n("zh_HK") == "zh_Hant" + assert n("zh_TW") == "zh_Hant" + assert n("fr_FR") == "zh_Hans" # 未知归基准 + assert n("") == "zh_Hans" + + +def test_tr_fallback_returns_key_without_catalog(): + # 未 init / 缺 key 时回退 key 本身,永不抛。 + i18n.set_language("en") + assert i18n.tr("nonexistent.key.xyz") == "nonexistent.key.xyz" + + +def test_tr_named_placeholder(): + # 无翻译时回退 key,但带 params 仍不抛(key 无占位则原样)。 + assert i18n.tr("nonexistent.key.fmt", n=3) == "nonexistent.key.fmt" + + +def test_N_is_identity(): + assert i18n.N_("enum.Foo.BAR") == "enum.Foo.BAR" + + +def test_enum_base_map_covers_all_members(): + base = enum_labels.enum_base_map() + for cls in enum_labels.TRANSLATABLE_ENUMS: + for member in cls: + key = enum_labels.enum_key(member) + assert key in base, f"缺枚举 key: {key}" + assert base[key], f"枚举基准中文为空: {key}" + + +def test_enum_key_format(): + from videocaptioner.core.translate.types import TargetLanguage + + assert enum_labels.enum_key(TargetLanguage.JAPANESE) == "enum.TargetLanguage.JAPANESE" + + +@pytest.mark.skipif( + not (I18N_PATH / "zh_Hans" / "LC_MESSAGES" / "videocaptioner.mo").exists(), + reason="zh_Hans.mo 未编译(先跑 scripts/i18n.py compile)", +) +def test_compiled_base_catalog_translates_enum(): + from videocaptioner.core.entities import VideoQualityEnum + from videocaptioner.core.translate.types import TargetLanguage + + i18n.init(I18N_PATH, "zh_CN") + assert enum_labels.enum_label(TargetLanguage.JAPANESE) == "日本語" + assert enum_labels.enum_label(VideoQualityEnum.ULTRA_HIGH) == "极高质量" diff --git a/tests/test_llm/test_client.py b/tests/test_llm/test_client.py new file mode 100644 index 00000000..8df425a2 --- /dev/null +++ b/tests/test_llm/test_client.py @@ -0,0 +1,61 @@ +"""LLM 客户端单例契约:按 (base, key) 指纹缓存,provider 变了要重建。 + +修「同进程内先用一个 provider 建好 client、再换 provider 时 env 改了却仍复用旧端点」的串台 +bug——译文静默走错端点/模型,难排查。 +""" + +from videocaptioner.core.llm import client as cli + + +class _FakeOpenAI: + def __init__(self, base_url, api_key, http_client=None): + self.base_url = base_url + self.api_key = api_key + + +def _reset(): + cli._global_client = None + cli._client_fingerprint = None + + +def test_same_env_reuses_singleton(monkeypatch): + _reset() + built = [] + monkeypatch.setattr(cli, "OpenAI", lambda **k: built.append(k) or _FakeOpenAI(**k)) + monkeypatch.setattr(cli, "create_logging_http_client", lambda: None) + monkeypatch.setenv("OPENAI_BASE_URL", "https://a/v1") + monkeypatch.setenv("OPENAI_API_KEY", "k1") + c1 = cli.get_llm_client() + c2 = cli.get_llm_client() + assert c1 is c2 and len(built) == 1 # 同端点同 key → 复用,不重建 + + +def test_provider_change_rebuilds_client(monkeypatch): + _reset() + built = [] + monkeypatch.setattr(cli, "OpenAI", lambda **k: built.append(k) or _FakeOpenAI(**k)) + monkeypatch.setattr(cli, "create_logging_http_client", lambda: None) + monkeypatch.setenv("OPENAI_BASE_URL", "https://a/v1") + monkeypatch.setenv("OPENAI_API_KEY", "k1") + c1 = cli.get_llm_client() + # 切到另一个 provider(key 变):必须重建,不能复用旧端点 + monkeypatch.setenv("OPENAI_API_KEY", "k2") + c2 = cli.get_llm_client() + assert c2 is not c1 + assert len(built) == 2 and built[-1]["api_key"] == "k2" + # base 变同理 + monkeypatch.setenv("OPENAI_BASE_URL", "https://b/v1") + c3 = cli.get_llm_client() + assert c3 is not c2 and built[-1]["base_url"] == "https://b/v1" + + +def test_missing_env_raises(monkeypatch): + _reset() + monkeypatch.setattr(cli, "OpenAI", lambda **k: _FakeOpenAI(**k)) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + try: + cli.get_llm_client() + assert False, "应因缺少环境变量而报错" + except ValueError: + pass diff --git a/tests/test_llm/test_free_model.py b/tests/test_llm/test_free_model.py new file mode 100644 index 00000000..8ad02beb --- /dev/null +++ b/tests/test_llm/test_free_model.py @@ -0,0 +1,76 @@ +"""公益大模型免费网关:令牌管理 + LLM client 接入(确定性单测,不打网络)。""" + +import base64 +import json + +from videocaptioner.core.llm import free_model + + +def _fake_jwt(exp: int) -> str: + def b64(d: dict) -> str: + return base64.urlsafe_b64encode(json.dumps(d).encode()).decode().rstrip("=") + + return f"{b64({'alg': 'HS256'})}.{b64({'exp': exp})}.sig" + + +def test_is_free_base(): + assert free_model.is_free_base(free_model.BASE_URL) + assert free_model.is_free_base("https://aigw1.immersivetranslate.com/v1/free/") + assert not free_model.is_free_base("https://api.openai.com/v1") + assert not free_model.is_free_base("") + + +def test_jwt_exp_parses_and_degrades(): + assert free_model._jwt_exp(_fake_jwt(1782371006)) == 1782371006.0 + assert free_model._jwt_exp("garbage") == 0.0 + + +def test_new_device_id_shape(): + a, b = free_model._new_device_id(), free_model._new_device_id() + assert len(a) == 64 and a.isalnum() and a != b + + +def test_device_id_persists(monkeypatch, tmp_path): + monkeypatch.setattr(free_model, "_DEVICE_ID_FILE", tmp_path / "did") + p = free_model._TokenProvider() + first = p.device_id() + assert (tmp_path / "did").read_text(encoding="utf-8").strip() == first + assert free_model._TokenProvider().device_id() == first # 跨实例读盘稳定 + + +def test_token_caches_and_force_refreshes(monkeypatch, tmp_path): + monkeypatch.setattr(free_model, "_DEVICE_ID_FILE", tmp_path / "did") + calls = {"n": 0} + + class _Resp: + def raise_for_status(self): + pass + + def json(self): + return {"code": 0, "data": _fake_jwt(2_000_000_000)} + + class _FakeCffi: + def get(self, url, params=None, impersonate=None, timeout=None): + calls["n"] += 1 + return _Resp() + + monkeypatch.setattr(free_model, "cffi_requests", _FakeCffi()) + p = free_model._TokenProvider() + t1, t2 = p.token(), p.token() # 第二次命中缓存 + assert t1 == t2 and calls["n"] == 1 + p.token(force=True) # 强制续期 + assert calls["n"] == 2 + + +def test_client_uses_free_token_for_free_base(monkeypatch): + """get_llm_client 命中公益 base 时,忽略占位 key、用实时令牌建 client。""" + from videocaptioner.core.llm import client as llm_client + + monkeypatch.setenv("OPENAI_BASE_URL", free_model.BASE_URL) + monkeypatch.setenv("OPENAI_API_KEY", free_model.PLACEHOLDER_KEY) + monkeypatch.setattr(free_model, "token", lambda force=False: "real-jwt-token") + # 复位单例,避免上一个测试的指纹缓存串台 + monkeypatch.setattr(llm_client, "_global_client", None) + monkeypatch.setattr(llm_client, "_client_fingerprint", None) + c = llm_client.get_llm_client() + assert c.api_key == "real-jwt-token" # 占位 key 被实时令牌取代 diff --git a/tests/test_realtime/test_audio_source.py b/tests/test_realtime/test_audio_source.py new file mode 100644 index 00000000..f9b6d6ba --- /dev/null +++ b/tests/test_realtime/test_audio_source.py @@ -0,0 +1,94 @@ +"""AudioCapture 设备打开/下混契约(离线,mock sounddevice)。 + +锁住真机崩溃回归:部分设备(蓝牙耳机 / 聚合 / 虚拟 / 立体声麦克风)不接受以单声道打开 +(PortAudio "Invalid number of channels")。先试 1 声道,设备不收则回退到其原生声道数, +多声道时回调里取第一声道下混成 mono s16le。 +""" + +import types + +import numpy as np +import pytest + +from videocaptioner.core.realtime.audio import capture as audio_source +from videocaptioner.core.realtime.audio.capture import AudioCapture, AudioSourceError + + +class _FakeStream: + def __init__(self, **kw): + self.channels = kw["channels"] + self.callback = kw["callback"] + self.started = False + self.closed = False + + def start(self): + self.started = True + + def stop(self): + pass + + def close(self): + self.closed = True + + +class _FakeSD: + """只接受 accept 里那些声道数打开,其余抛错(模拟 PortAudio 声道校验)。""" + + def __init__(self, accept, max_channels=2): + self._accept = set(accept) + self._max_channels = max_channels + self.default = types.SimpleNamespace(device=[3, 0]) + self.opened = [] + + def InputStream(self, **kw): + if kw["channels"] not in self._accept: + raise RuntimeError(f"Invalid number of channels (ch={kw['channels']})") + s = _FakeStream(**kw) + self.opened.append(s) + return s + + def query_devices(self, index=None): + return {"max_input_channels": self._max_channels} + + +def _patch(monkeypatch, fake): + monkeypatch.setattr(audio_source, "_sd", lambda: fake) + + +def test_mono_device_opens_with_one_channel(monkeypatch): + fake = _FakeSD(accept={1}) + _patch(monkeypatch, fake) + cap = AudioCapture(device_index=3) + cap.start() + assert cap._channels == 1 + assert [s.channels for s in fake.opened] == [1] # 一次成功,不必回退 + + +def test_falls_back_to_native_channels(monkeypatch): + # 设备拒绝单声道、只接受 2 声道 → 回退到 2,且开流前的失败尝试不泄漏 + fake = _FakeSD(accept={2}, max_channels=2) + _patch(monkeypatch, fake) + cap = AudioCapture(device_index=3) + cap.start() + assert cap._channels == 2 + assert fake.opened[-1].channels == 2 and fake.opened[-1].started + + +def test_multichannel_callback_downmixes_by_mean(monkeypatch): + fake = _FakeSD(accept={2}) + _patch(monkeypatch, fake) + cap = AudioCapture(device_index=3) + cap.start() + indata = np.array([[10, 100], [20, 80], [30, 78]], dtype="int16") # (frames, 2) + cap._callback(indata, 3, None, None) + pcm = cap.read(timeout=0.5) + # 各声道均值(int32 累加防溢出,round 回 int16):(10+100)/2=55, (20+80)/2=50, (30+78)/2=54 + assert pcm == np.array([[55], [50], [54]], dtype="int16").tobytes() + + +def test_all_channel_counts_fail_raises(monkeypatch): + fake = _FakeSD(accept=set(), max_channels=0) # 设备无效/无输入声道 + _patch(monkeypatch, fake) + cap = AudioCapture(device_index=3) + with pytest.raises(AudioSourceError): + cap.start() diff --git a/tests/test_realtime/test_caption.py b/tests/test_realtime/test_caption.py new file mode 100644 index 00000000..7843e2ce --- /dev/null +++ b/tests/test_realtime/test_caption.py @@ -0,0 +1,137 @@ +"""CaptionAssembler 离线契约(无 Qt、无网络)。 + +分段在后端做(每段独立 seg_id);装配器只做:按 seg_id upsert、双色(最长公共前缀)、 +异步翻译回填、定稿守卫(定稿后忽略迟到的「当前段」更新,但允许 is_final 精修复述)、 +close() 兜底定稿最后未定稿段。 +""" + +from videocaptioner.core.realtime.caption import CaptionAssembler +from videocaptioner.core.realtime.events import CaptionEntry, TranscriptSegment + + +def _collect(): + out: list[CaptionEntry] = [] + return out, out.append + + +def _seg(sid, text, final=False, st=None, et=None): + return TranscriptSegment(sid, text, is_final=final, start_time=st, end_time=et) + + +class TestUpsert: + def test_double_color_common_prefix(self): + out, cb = _collect() + a = CaptionAssembler(cb) + a.ingest(_seg("s0", "今天天气")) + a.ingest(_seg("s0", "今天天气真不错")) # 后端改写尾巴:公共前缀「今天天气」=稳定 + e = out[-1] + assert e.seg_id == "s0" and not e.is_final + assert e.source_text == "今天天气真不错" + assert e.source_stable_len == 4 # 「今天天气」 + + def test_final_marks_whole_stable_and_carries_timing(self): + out, cb = _collect() + a = CaptionAssembler(cb) + a.ingest(_seg("s0", "今天天气真不错", final=True, st=0.0, et=2.5)) + e = out[-1] + assert e.is_final and e.source_stable_len == len("今天天气真不错") + assert e.start_time == 0.0 and e.end_time == 2.5 + + def test_distinct_seg_ids_get_increasing_seq(self): + out, cb = _collect() + a = CaptionAssembler(cb) + a.ingest(_seg("s0", "第一句", final=True)) + a.ingest(_seg("s1", "第二句")) + seqs = {e.seg_id: e.seq for e in out} + assert seqs["s0"] == 0 and seqs["s1"] == 1 + + def test_finalized_seg_ignores_late_active_update(self): + out, cb = _collect() + a = CaptionAssembler(cb) + a.ingest(_seg("s0", "定稿文本", final=True)) + n = len(out) + a.ingest(_seg("s0", "回退的临时文本")) # 非 final 迟到更新 → 丢弃,防回退 + assert len(out) == n + + def test_finalized_seg_allows_final_polish(self): + out, cb = _collect() + a = CaptionAssembler(cb) + a.ingest(_seg("s0", "今天天气真不错", final=True)) + a.ingest(_seg("s0", "今天天气真不错。", final=True)) # is_final 精修复述 → 允许覆盖 + assert out[-1].source_text == "今天天气真不错。" + + def test_punctuation_only_skipped(self): + out, cb = _collect() + a = CaptionAssembler(cb) + a.ingest(_seg("s0", ",。 ")) + assert out == [] + + def test_close_finalizes_last_active(self): + out, cb = _collect() + a = CaptionAssembler(cb) + a.ingest(_seg("s0", "未定稿的话")) # 后端没发 is_final 就停 + a.close() + finals = [e for e in out if e.is_final] + assert finals and finals[-1].source_text == "未定稿的话" + + +class TestTranslate: + def test_final_force_translates_and_backfills(self): + out, cb = _collect() + a = CaptionAssembler(cb, translate_fn=lambda s: f"<{s}>") + a.ingest(_seg("s0", "你好", final=True)) + a.close() # 收尾会 drain 待译定稿句 → 等翻译落地 + assert any(e.target_text == "<你好>" for e in out) + + def test_finalized_seg_drops_late_active_translation(self): + # 定稿段译文唯一且正确(定稿 force 译文为准) + out, cb = _collect() + a = CaptionAssembler(cb, translate_fn=lambda s: f"<{s}>") + a.ingest(_seg("s0", "你好", final=True)) + a.close() + targets = [e.target_text for e in out if e.target_text] + assert targets and all(t == "<你好>" for t in targets) + + def test_active_then_final_translation(self): + # 当前句先译(合并最新),定稿后定稿译文落地(覆盖当前句译文) + out, cb = _collect() + a = CaptionAssembler(cb, translate_fn=lambda s: f"<{s}>") + a.ingest(_seg("s0", "今天")) # 当前句 + a.ingest(_seg("s0", "今天天气真不错", final=True)) # 定稿 + a.close() + finals = [e for e in out if e.is_final and e.target_text] + assert finals and finals[-1].target_text == "<今天天气真不错>" + + def test_finals_translate_concurrently(self): + # 定稿句并发翻译(非串行):用 4-party 屏障——只有 ≥4 个翻译同时在飞才放行;串行则超时失败。 + import threading + out, cb = _collect() + barrier = threading.Barrier(4, timeout=5) + + def gated(s: str) -> str: + barrier.wait() # 并发度不足 → 超时抛 BrokenBarrierError → 该句译不出 + return f"<{s}>" + + a = CaptionAssembler(cb, translate_fn=gated) + for i in range(4): + a.ingest(_seg(f"s{i}", f"句{i}", final=True)) + a.close() + finals = {e.seg_id for e in out if e.is_final and e.target_text} + assert len(finals) == 4 # 4 句同时在飞翻译 → 屏障放行 → 全部译到(证明真并发) + + def test_high_latency_no_final_translation_lost(self): + # 高延迟 API 下,定稿句快速连发也全部译到、一句不丢(旧实现会在停止时丢弃积压任务)。 + import time + out, cb = _collect() + + def slow(s: str) -> str: + time.sleep(0.03) # 模拟高延迟翻译 + return f"<{s}>" + + a = CaptionAssembler(cb, translate_fn=slow) + for i in range(8): # 8 句飞快定稿(远快于翻译) + a.ingest(_seg(f"s{i}", f"句子{i}", final=True)) + a.close() # drain:把所有待译定稿句译完 + finals = {e.seg_id: e.target_text for e in out if e.is_final and e.target_text} + assert len(finals) == 8 # 全部译到,无丢失 + assert all(finals[f"s{i}"] == f"<句子{i}>" for i in range(8)) diff --git a/tests/test_realtime/test_check.py b/tests/test_realtime/test_check.py new file mode 100644 index 00000000..c04ce686 --- /dev/null +++ b/tests/test_realtime/test_check.py @@ -0,0 +1,93 @@ +"""实时字幕「测试转录」核心检查(core.realtime.check.check_live_caption)契约。 + +锁住:抽 16k PCM → 喂后端 → 收到非空文本算通过;后端报错按错误返回;无字无错按 +「结果为空」返回。用假后端 + 假 ffmpeg,不依赖 voxgate 二进制 / 网络,故可跨平台跑。 +""" + +import wave + +import pytest + +import videocaptioner.core.realtime.check as chk +from videocaptioner.core.realtime.config import LiveCaptionConfig +from videocaptioner.core.realtime.events import TranscriptSegment + + +def _write_wav(path, seconds=0.3): + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(chk.SAMPLE_RATE) + wf.writeframes(b"\x00\x00" * int(chk.SAMPLE_RATE * seconds)) + + +class _FakeBackend: + """喂到 PCM 就吐一句累积文本;可配置成报错 / 沉默。""" + + def __init__(self, on_segment, on_error, text="hello world", err=None): + self._on_segment = on_segment + self._on_error = on_error + self._text = text + self._err = err + self.stopped = False + + def start(self): + if self._err: + self._on_error(self._err) + + def feed(self, pcm16): + if self._text and not self._err: + self._on_segment(TranscriptSegment("s", self._text, False, False)) + + def stop(self): + self.stopped = True + + +@pytest.fixture() +def fast_audio(monkeypatch, tmp_path): + """把 video2audio 换成写一段真 WAV;并把等待收紧,测试秒级返回。""" + + def fake_video2audio(_input, output="", **_kw): + _write_wav(output) + return True + + monkeypatch.setattr(chk, "video2audio", fake_video2audio) + monkeypatch.setattr(chk, "_PACE_S", 0.0) + monkeypatch.setattr(chk, "_GRACE_S", 0.3) + monkeypatch.setattr(chk, "TEST_AUDIO_PATH", tmp_path / "src.mp3") + (tmp_path / "src.mp3").write_bytes(b"fake-mp3") + + +def _patch_backend(monkeypatch, **kw): + def fake_build_backend(_cfg, on_segment, on_state, on_error): + return _FakeBackend(on_segment, on_error, **kw) + + monkeypatch.setattr(chk, "build_backend", fake_build_backend) + + +def test_success_returns_recognized_text(fast_audio, monkeypatch): + _patch_backend(monkeypatch, text="What's the weather") + res = chk.check_live_caption(LiveCaptionConfig(backend="voxgate")) + assert res.success + assert res.detail == "What's the weather" + + +def test_backend_error_returns_failure(fast_audio, monkeypatch): + _patch_backend(monkeypatch, err="未找到 voxgate 可执行文件。") + res = chk.check_live_caption(LiveCaptionConfig(backend="voxgate")) + assert not res.success + assert "voxgate" in res.detail + + +def test_silent_backend_reports_empty(fast_audio, monkeypatch): + _patch_backend(monkeypatch, text="") + res = chk.check_live_caption(LiveCaptionConfig(backend="voxgate")) + assert not res.success + assert "为空" in res.detail + + +def test_missing_audio_returns_failure(monkeypatch, tmp_path): + monkeypatch.setattr(chk, "TEST_AUDIO_PATH", tmp_path / "nope.mp3") + res = chk.check_live_caption(LiveCaptionConfig(backend="voxgate")) + assert not res.success + assert "不存在" in res.detail diff --git a/tests/test_realtime/test_fun_asr_backend.py b/tests/test_realtime/test_fun_asr_backend.py new file mode 100644 index 00000000..6f977486 --- /dev/null +++ b/tests/test_realtime/test_fun_asr_backend.py @@ -0,0 +1,233 @@ +"""Fun-ASR 实时后端协议映射契约(离线,mock WebSocket)。 + +锁住:run-task 带正确 model/format/language_hints;result-generated.sentence 拼成单一 +seg_id 的累积全文;**段落边界用 sentence_id 切换**(不是 sentence_end——后者同句会多次 +触发且其后仍改写,据此累加会整句重复,见 test_repeated_sentence_end_no_duplicate); +心跳跳过;task-finished 把末句作为 is_final 冲刷。真实联网调用在手动验证里做过。 +""" + +import json +import threading + +import websocket + +from videocaptioner.core.realtime.backends.fun_asr import FunAsrBackend + + +class _FakeWS: + """脚本化的假 WebSocket:recv 依次吐预设服务端事件,send 记录下来。""" + + def __init__(self, scripted): + self._scripted = list(scripted) + self.sent_json = [] + self.sent_binary = [] + self._closed = threading.Event() + + def settimeout(self, _t): + pass + + def send(self, data, opcode=None): + if opcode == websocket.ABNF.OPCODE_BINARY: + self.sent_binary.append(data) + else: + self.sent_json.append(json.loads(data)) + + def recv(self): + if self._scripted: + return self._scripted.pop(0) + self._closed.wait(timeout=2) + raise websocket.WebSocketConnectionClosedException() + + def close(self): + self._closed.set() + + +def _ev(event, sentence=None, **header): + h = {"task_id": "t", "event": event, **header} + payload = {"output": {"sentence": sentence}} if sentence is not None else {} + return json.dumps({"header": h, "payload": payload}) + + +def test_protocol_mapping(monkeypatch): + scripted = [ + _ev("task-started"), + _ev("result-generated", {"text": "今天", "sentence_id": 1, "begin_time": 0, "end_time": 500}), + _ev("result-generated", {"text": "今天深圳天气", "sentence_id": 1, "begin_time": 0, "end_time": 1500}), + _ev("result-generated", {"text": "今天深圳天气怎么样?", "sentence_id": 1, "begin_time": 0, "end_time": 2000, "sentence_end": True}), + _ev("result-generated", {"text": "心跳", "sentence_id": 2, "heartbeat": True}), + _ev("result-generated", {"text": "走吧。", "sentence_id": 2, "begin_time": 2100, "end_time": 2600, "sentence_end": True}), + _ev("task-finished"), + ] + fake = _FakeWS(scripted) + monkeypatch.setattr(websocket, "create_connection", lambda *a, **k: fake) + + segs = [] + be = FunAsrBackend(api_key="sk-x", model="fun-asr-mtl-realtime", language="en", + on_segment=segs.append) + be.start() + be.feed(b"\x00\x01" * 100) + be.stop() + + # run-task 参数正确 + run = next(m for m in fake.sent_json if m["header"]["action"] == "run-task") + p = run["payload"] + assert p["model"] == "fun-asr-mtl-realtime" + assert p["parameters"]["format"] == "pcm" + assert p["parameters"]["sample_rate"] == 16000 + assert p["parameters"]["language_hints"] == ["en"] + # VAD 断句(非语义分句):按停顿切句,段落细、定稿勤 + assert p["parameters"]["semantic_punctuation_enabled"] is False + assert p["parameters"]["max_sentence_silence"] == 800 + assert p["parameters"]["multi_threshold_mode_enabled"] is True + assert p["parameters"]["heartbeat"] is True + assert fake.sent_binary == [b"\x00\x01" * 100] # 喂的音频以二进制帧发出 + assert any(m["header"]["action"] == "finish-task" for m in fake.sent_json) + + # 映射:每句一个独立 seg_id(funasr##),心跳跳过 + assert {s.seg_id for s in segs} == {"funasr#0#1", "funasr#0#2"} + assert not any(s.text == "心跳" for s in segs) + # 句1 在句2 出现时定稿;时间戳 ms→s + s1 = [s for s in segs if s.seg_id == "funasr#0#1" and s.is_final] + assert s1 and s1[-1].text == "今天深圳天气怎么样?" + assert s1[-1].start_time == 0.0 and s1[-1].end_time == 2.0 + # 句2 没有「下一句」触发边界 → task-finished 时定稿 + s2 = [s for s in segs if s.seg_id == "funasr#0#2" and s.is_final] + assert s2 and s2[-1].text == "走吧。" + + +def test_repeated_sentence_end_no_duplicate(monkeypatch): + """同一句多次 sentence_end + 改写:每句独立 seg_id、upsert 同一 id,绝不拼成重复段。 + + 这是「前后两句各重复一个词 / 整段重复」线上 bug 的回归锁:每句自己的 seg_id,同句改写 + 只更新自己,不会累加进别人。 + """ + scripted = [ + _ev("task-started"), + # 句1 在停顿处「过早」sentence_end,随后同 id 续写 + _ev("result-generated", {"text": "我觉得今天的天气非常的", "sentence_id": 1, "sentence_end": True}), + _ev("result-generated", {"text": "我觉得今天的天气非常的不错。", "sentence_id": 1, "sentence_end": True}), + _ev("result-generated", {"text": "下一句。", "sentence_id": 2, "sentence_end": True}), + _ev("task-finished"), + ] + fake = _FakeWS(scripted) + monkeypatch.setattr(websocket, "create_connection", lambda *a, **k: fake) + segs = [] + be = FunAsrBackend(api_key="sk-x", on_segment=segs.append) + be.start() + be.feed(b"\x00\x01" * 100) + be.stop() + + s1 = [s for s in segs if s.seg_id == "funasr#0#1"] + assert s1 and s1[-1].text == "我觉得今天的天气非常的不错。" # 句1 最新文本(改写覆盖,不重复) + s2 = [s for s in segs if s.seg_id == "funasr#0#2"] + assert s2 and s2[-1].text == "下一句。" + assert all("非常的不错。我觉得" not in s.text for s in segs) # 绝不出现整句重复拼接 + + +def test_missing_api_key_raises(): + import pytest + + from videocaptioner.core.realtime.backends.base import LiveCaptionError + + with pytest.raises(LiveCaptionError): + FunAsrBackend(api_key="") + + +def test_stop_without_audio_skips_finish_task(monkeypatch): + # 没喂过音频就停(点开始立刻停):不应发 finish-task(否则空缓冲会报错 → 曾导致崩溃) + fake = _FakeWS([_ev("task-started")]) + monkeypatch.setattr(websocket, "create_connection", lambda *a, **k: fake) + errors = [] + be = FunAsrBackend(api_key="sk-x", on_error=errors.append) + be.start() + be.stop() # 没 feed 直接 stop + assert not any(m["header"].get("action") == "finish-task" for m in fake.sent_json) + assert errors == [] + + +def test_task_failed_emits_error(monkeypatch): + fake = _FakeWS([ + _ev("task-started"), + _ev("task-failed", error_message="bad key"), + ]) + monkeypatch.setattr(websocket, "create_connection", lambda *a, **k: fake) + errors = [] + be = FunAsrBackend(api_key="sk-x", on_error=errors.append) + be.start() + be.stop() + assert errors and "bad key" in errors[0] + + +def test_midsession_task_finished_reconnects(monkeypatch): + """服务端在会话中途发 task-finished(单 task 时长上限)→ 不当结束,重起 task 续录。 + + 这是「转录到一半突然什么都没了」的真因回归:旧逻辑把中途 task-finished 当正常结束、不重连, + 音频还在喂却再无结果。修复后应透明续录,新一代(gen)的句子照常产出。""" + import time + + ws1 = _FakeWS([ + _ev("task-started"), + _ev("result-generated", {"text": "hello", "sentence_id": 1, + "begin_time": 0, "end_time": 1000}), + _ev("task-finished"), # 服务端中途结束(我们没 stopping) + ]) + ws2 = _FakeWS([ + _ev("task-started"), + _ev("result-generated", {"text": "world", "sentence_id": 1, + "begin_time": 0, "end_time": 1000, "sentence_end": True}), + ]) + pending = [ws1, ws2] + monkeypatch.setattr(websocket, "create_connection", + lambda *a, **k: pending.pop(0) if pending else _FakeWS([])) + + segs = [] + be = FunAsrBackend(api_key="sk-x", model="fun-asr-mtl-realtime", language="zh", + on_segment=segs.append) + be.start() + be.feed(b"\x00\x01" * 100) # 喂过音频 → 非空、非 stopping + deadline = time.time() + 5 + while time.time() < deadline and not any(s.seg_id == "funasr#1#1" for s in segs): + time.sleep(0.02) + be.stop() + + seg_ids = {s.seg_id for s in segs} + assert "funasr#0#1" in seg_ids # 第一代(重连前)的句子 + assert "funasr#1#1" in seg_ids # 中途 task-finished 后重起、续录出的句子 + assert len(pending) == 0 # 确实重连建立了第二个连接 + + +def test_language_hints_auto_uses_model_supported_set(): + """auto:多语种模型给整套支持语言作 hint(中/粤/英/日/泰/越/印尼);中英日模型给中英日。""" + mtl = FunAsrBackend(api_key="sk-x", model="fun-asr-mtl-realtime", language="auto") + assert mtl._language_hints() == ["zh", "yue", "en", "ja", "th", "vi", "id"] + realtime = FunAsrBackend(api_key="sk-x", model="fun-asr-realtime", language="auto") + assert realtime._language_hints() == ["zh", "en", "ja"] + # 显式选语言:原样作单一 hint(即便模型多语种) + one = FunAsrBackend(api_key="sk-x", model="fun-asr-mtl-realtime", language="yue") + assert one._language_hints() == ["yue"] + + +def test_feed_gated_until_task_started(): + # 连接/重连后未收到 task-started 前不喂:服务端会丢弃这些帧 → 转录空洞。就绪后才喂。 + be = FunAsrBackend(api_key="sk-x", model="fun-asr-mtl-realtime", language="auto") + fake = _FakeWS([]) + be._ws = fake + be._started_event.clear() + be.feed(b"\x00\x01" * 100) + assert fake.sent_binary == [] # 未就绪 → 不喂 + be._started_event.set() + be.feed(b"\x00\x01" * 100) + assert len(fake.sent_binary) == 1 # 就绪 → 喂 + + +def test_reconnect_backoff_gives_up_after_repeated_failures(monkeypatch): + """连不上时退避重试,连续失败超上限 → 上报致命并放弃(与 qwen 共用 base 退避治理)。""" + def boom(*a, **k): + raise OSError("network down") + monkeypatch.setattr(websocket, "create_connection", boom) + errs = [] + be = FunAsrBackend(api_key="sk-x", on_segment=lambda s: None, on_error=errs.append) + be._RECONNECT_MAX_FAILS = 3 + be._RECONNECT_MAX_DELAY_S = 0.0 # 退避归零,测试不阻塞 + assert be._try_reconnect("boom") is False + assert errs and "网络持续不可用" in errs[0] diff --git a/tests/test_realtime/test_history.py b/tests/test_realtime/test_history.py new file mode 100644 index 00000000..ddb1ea5d --- /dev/null +++ b/tests/test_realtime/test_history.py @@ -0,0 +1,168 @@ +"""实时字幕历史存储 + 录制器契约。 + +锁住:① 记录往返(save→load);② 列表按创建时间倒序;③ 搜索命中名称/正文;④ 删除; +⑤ 导出 SRT/TXT;⑥ 录制器写 WAV + 收集段落 + finalize 成可加载记录、空会话丢弃。 +""" + +import wave + +from videocaptioner.core.realtime.events import CaptionEntry +from videocaptioner.core.realtime.recording.history import ( + CaptionSegment, + LiveCaptionRecord, + LiveCaptionStore, +) +from videocaptioner.core.realtime.recording.recorder import SessionRecorder + + +def _record(rid="20260617-164900", name="2026-06-17 16:49 记录", created=1_700_000_000.0): + return LiveCaptionRecord( + id=rid, name=name, source="系统声音", translate="微软翻译", + created_at=created, duration=6.0, audio="", + segments=[ + CaptionSegment(0.0, 3.0, "Hello everyone", "大家好"), + CaptionSegment(3.0, 6.0, "How are you", "你好吗"), + ], + ) + + +def test_save_and_load_round_trip(tmp_path): + store = LiveCaptionStore(root=tmp_path) + rec = _record() + store.save(rec) + loaded = store.load(rec.id) + assert loaded is not None + assert loaded.name == rec.name and loaded.source == "系统声音" + assert len(loaded.segments) == 2 + assert loaded.segments[0].source == "Hello everyone" + assert loaded.segments[1].target == "你好吗" + assert loaded.dir == store.dir_for(rec.id) + + +def test_list_sorted_newest_first(tmp_path): + store = LiveCaptionStore(root=tmp_path) + store.save(_record("20260101-000000", "老记录", created=1_000.0)) + store.save(_record("20260617-164900", "新记录", created=2_000.0)) + ids = [r.id for r in store.list()] + assert ids == ["20260617-164900", "20260101-000000"] + + +def test_search_matches_name_and_body(tmp_path): + store = LiveCaptionStore(root=tmp_path) + store.save(_record("20260101-000000", "会议纪要", created=1_000.0)) + store.save(_record("20260102-000000", "随便", created=2_000.0)) + assert [r.id for r in store.search("会议")] == ["20260101-000000"] + # 正文命中(英文/中文都可) + assert [r.id for r in store.search("大家好")] == ["20260102-000000", "20260101-000000"][:2] + assert len(store.search("everyone")) == 2 + assert store.search("不存在的词") == [] + + +def test_delete_removes_dir(tmp_path): + store = LiveCaptionStore(root=tmp_path) + rec = _record() + store.save(rec) + assert store.dir_for(rec.id).is_dir() + store.delete(rec.id) + assert not store.dir_for(rec.id).exists() + assert store.load(rec.id) is None + + +def test_rename_changes_name_only_keeps_folder(tmp_path): + store = LiveCaptionStore(root=tmp_path) + rec = _record() + store.save(rec) + folder = store.dir_for(rec.id) + out = store.rename(rec.id, " 改名后 ") + assert out is not None and out.name == "改名后" # 去空白 + assert store.dir_for(rec.id) == folder and folder.is_dir() # 文件夹(id)不变 + assert store.load(rec.id).name == "改名后" # 落盘生效 + # 空名 / 不存在的 id 不改动、返回 None + assert store.rename(rec.id, " ") is None + assert store.load(rec.id).name == "改名后" + assert store.rename("nope", "x") is None + + +def test_export_srt_and_txt(tmp_path): + rec = _record() + srt = rec.export_srt(bilingual=True) + assert "00:00:00,000 --> 00:00:03,000" in srt + assert "Hello everyone\n大家好" in srt + assert srt.strip().startswith("1\n") + txt = rec.export_txt(bilingual=False) + assert txt == "Hello everyone\nHow are you" + + +def test_labels(): + rec = _record(created=1_700_000_000.0) + rec.duration = 378.0 # 6 分 18 秒 + assert rec.duration_label == "06:18" + assert rec.summary == "06:18 · 系统声音 · 微软翻译" + + +def _entry(seg_id, seq, source, target="", final=True, started_at=0.0): + return CaptionEntry( + seg_id=seg_id, seq=seq, source_text=source, source_stable_len=len(source), + target_text=target, is_final=final, started_at=started_at, + ) + + +def test_recorder_writes_wav_and_collects_segments(tmp_path): + store = LiveCaptionStore(root=tmp_path) + rec = SessionRecorder(store, "麦克风", "微软翻译", when=1_700_000_000.0) + # 起点取「该句首现时已录音频位置」(WAV 偏移),故两句间各写 1s 让偏移区分开。 + rec.write_pcm(b"\x00\x00" * 16000) # 1s 16k/mono/s16le 静音 + rec.note_caption(_entry("voxgate#0", 0, "Hello", "你好", started_at=1_700_000_000.2)) + rec.write_pcm(b"\x00\x00" * 16000) # +1s → 共 2s + rec.note_caption(_entry("voxgate#1", 1, "World", "世界", started_at=1_700_000_000.6)) + # 译文回填:同 seg_id 覆盖 + rec.note_caption(_entry("voxgate#1", 1, "World", "世界!", started_at=1_700_000_000.6)) + record = rec.finalize() + assert record is not None + assert record.source == "麦克风" and record.translate == "微软翻译" + assert abs(record.duration - 2.0) < 0.05 + assert [s.source for s in record.segments] == ["Hello", "World"] + assert record.segments[1].target == "世界!" # 取最新译文 + assert record.segments[0].start < record.segments[1].start # 1.0 < 2.0(WAV 偏移) + # WAV 真写了、可读 + assert record.audio_path is not None and record.audio_path.is_file() + with wave.open(str(record.audio_path)) as w: + assert w.getframerate() == 16000 and w.getnchannels() == 1 + # 存盘后能从 store 加载回来 + assert store.load(record.id) is not None + + +def test_recorder_discards_empty_session(tmp_path): + store = LiveCaptionStore(root=tmp_path) + rec = SessionRecorder(store, "麦克风", "原文记录") + rec.write_pcm(b"\x00\x00" * 100) + assert rec.finalize() is None # 没有任何句子 → 丢弃 + assert store.list() == [] + + +def test_migrate_legacy_root_moves_records(tmp_path, monkeypatch): + """旧 APPDATA/live_captions 一次性迁入工作目录:新目录不存在 + 旧有内容 → 整体移过去。""" + from videocaptioner.core.realtime.recording import history + + legacy = tmp_path / "legacy" + (legacy / "20260101-000000").mkdir(parents=True) + monkeypatch.setattr(history, "_LEGACY_ROOT", legacy) + new_root = tmp_path / "work" / "live-caption" + history.migrate_legacy_root(new_root) + assert (new_root / "20260101-000000").is_dir() # 记录迁过去 + assert not legacy.exists() # 旧目录已移走(幂等:下次不再迁) + + +def test_migrate_legacy_root_skips_when_new_exists(tmp_path, monkeypatch): + """新目录已存在 → 绝不迁移/覆盖(保护用户已有记录)。""" + from videocaptioner.core.realtime.recording import history + + legacy = tmp_path / "legacy" + (legacy / "old").mkdir(parents=True) + monkeypatch.setattr(history, "_LEGACY_ROOT", legacy) + new_root = tmp_path / "work" / "live-caption" + (new_root / "existing").mkdir(parents=True) + history.migrate_legacy_root(new_root) + assert legacy.exists() # 旧目录原封不动 + assert not (new_root / "old").exists() # 没迁入 + assert (new_root / "existing").is_dir() # 已有记录不受影响 diff --git a/tests/test_realtime/test_languages.py b/tests/test_realtime/test_languages.py new file mode 100644 index 00000000..f9c86739 --- /dev/null +++ b/tests/test_realtime/test_languages.py @@ -0,0 +1,88 @@ +"""实时转录各 provider 识别语言集的单一事实来源契约(core/realtime/languages.py)。 + +钉死「每个提供商区分好、命名好、都带自动识别」:voxgate 仅中/英、Fun-ASR 多语种 7、 +Qwen-ASR 27,三家首项都是 auto;校验器用并集;UI 标签按 provider 取子集组装。 +""" + +from videocaptioner.core.realtime.backends.languages import ( + AUTO, + FUN_ASR_LANGS, + FUN_ASR_MTL_LANGS, + FUN_ASR_REALTIME_LANGS, + PROVIDER_SOURCE_LANGS, + QWEN_ASR_LANGS, + VOXGATE_LANGS, + all_source_lang_codes, + source_lang_codes, +) + + +def test_every_provider_starts_with_auto(): + # 三家都支持自动识别,且约定 auto 永远是第一项 + for codes in PROVIDER_SOURCE_LANGS.values(): + assert codes[0] == AUTO + + +def test_voxgate_is_chinese_english_only(): + # voxgate 豆包双语:仅中/英(+ auto),不再误用 Fun-ASR 的 7 语言集 + assert VOXGATE_LANGS == ("auto", "zh", "en") + assert source_lang_codes("voxgate") == ("auto", "zh", "en") + + +def test_fun_asr_is_multilingual_seven(): + # Fun-ASR 下拉用多语种模型集(中/粤/英/日/泰/越/印尼) + assert FUN_ASR_MTL_LANGS == ("zh", "yue", "en", "ja", "th", "vi", "id") + assert FUN_ASR_REALTIME_LANGS == ("zh", "en", "ja") + assert FUN_ASR_LANGS == (AUTO, *FUN_ASR_MTL_LANGS) + assert len(source_lang_codes("fun-asr")) == 8 + + +def test_qwen_has_27_languages_incl_spanish(): + assert source_lang_codes("qwen-asr") is QWEN_ASR_LANGS + assert len(QWEN_ASR_LANGS) == 28 # auto + 27 + assert "es" in QWEN_ASR_LANGS # 西班牙语(Fun-ASR 没有,看西语视频选 Qwen) + assert "de" in QWEN_ASR_LANGS and "ko" in QWEN_ASR_LANGS + + +def test_unknown_provider_falls_back_to_voxgate(): + assert source_lang_codes("nope") == VOXGATE_LANGS + + +def test_union_dedupes_and_preserves_order(): + union = all_source_lang_codes() + assert union[0] == AUTO + assert len(union) == len(set(union)) # 去重 + # 并集 = qwen 的 28 种(它是各家的超集;voxgate/fun-asr 的码都在其中) + assert set(union) == set(QWEN_ASR_LANGS) + for codes in PROVIDER_SOURCE_LANGS.values(): + assert set(codes) <= set(union) + + +def test_ui_options_pair_codes_with_chinese_labels(): + from videocaptioner.ui.common.config import source_language_options + + assert source_language_options("voxgate") == [ + ("auto", "自动识别"), ("zh", "中文"), ("en", "英语")] + qwen = dict(source_language_options("qwen-asr")) + assert qwen["es"] == "西班牙语" and qwen["auto"] == "自动识别" + # 每个 code 都有标签(不漏译成 code 本身) + for code, label in source_language_options("fun-asr"): + assert label and label != code + + +def test_fun_asr_backend_uses_shared_sets(): + # 后端 _language_hints 取自同一份常量:auto → 按模型给整套;显式 → 单一语言 + from videocaptioner.core.realtime.backends.fun_asr import FunAsrBackend + + noop = lambda *a, **k: None # noqa: E731 + mtl = FunAsrBackend(api_key="k", model="fun-asr-mtl-realtime", language="auto", + on_segment=noop, on_state=noop, on_error=noop) + assert tuple(mtl._language_hints()) == FUN_ASR_MTL_LANGS + + rt = FunAsrBackend(api_key="k", model="fun-asr-realtime", language="auto", + on_segment=noop, on_state=noop, on_error=noop) + assert tuple(rt._language_hints()) == FUN_ASR_REALTIME_LANGS + + explicit = FunAsrBackend(api_key="k", model="fun-asr-mtl-realtime", language="ja", + on_segment=noop, on_state=noop, on_error=noop) + assert explicit._language_hints() == ["ja"] diff --git a/tests/test_realtime/test_qwen_asr_backend.py b/tests/test_realtime/test_qwen_asr_backend.py new file mode 100644 index 00000000..653f8f06 --- /dev/null +++ b/tests/test_realtime/test_qwen_asr_backend.py @@ -0,0 +1,211 @@ +"""Qwen-ASR 实时后端协议映射契约(离线,mock WebSocket,OpenAI-realtime 风格)。 + +锁住(均依据真实抓包):连上即等 session.created → 回发 session.update(auto 省略 language); +``.text`` 流式取 ``text+stash``(实测全文在 stash、text 恒空)→ 中间结果;``.completed`` 的 +``transcript`` 定稿;每个 item_id = 一句独立 seg_id;中途 session.finished(非停止)→ 透明重连续录。 +""" + +import json +import threading +import time + +import websocket + +from videocaptioner.core.realtime.backends.qwen_asr import QwenAsrBackend + + +class _FakeWS: + """脚本化假 WebSocket:recv 依次吐预设服务端事件,send 记录下来。""" + + def __init__(self, scripted): + self._scripted = list(scripted) + self.sent = [] + self._closed = threading.Event() + + def settimeout(self, _t): + pass + + def send(self, data, opcode=None): # noqa: ARG002 + self.sent.append(json.loads(data)) + + def recv(self): + if self._scripted: + return self._scripted.pop(0) + self._closed.wait(timeout=2) + raise websocket.WebSocketConnectionClosedException() + + def close(self): + self._closed.set() + + +def _ev(type_, **fields): + return json.dumps({"event_id": "e", "type": type_, **fields}) + + +def _text(item, stash, text=""): + return _ev("conversation.item.input_audio_transcription.text", + item_id=item, text=text, stash=stash, language="es") + + +def _done(item, transcript): + return _ev("conversation.item.input_audio_transcription.completed", + item_id=item, transcript=transcript, language="es") + + +def _wait(pred, timeout=5): + deadline = time.time() + timeout + while time.time() < deadline and not pred(): + time.sleep(0.02) + + +def test_protocol_mapping_es(monkeypatch): + scripted = [ + _ev("session.created"), + _text("A", "Hola"), + _text("A", "Hola mundo"), + _done("A", "Hola mundo."), + ] + fake = _FakeWS(scripted) + monkeypatch.setattr(websocket, "create_connection", lambda *a, **k: fake) + + segs = [] + be = QwenAsrBackend(api_key="sk-x", language="es", on_segment=segs.append) + be.start() + be.feed(b"\x00\x01" * 100) + _wait(lambda: any(s.is_final for s in segs)) + be.stop() + + # 连上回发 session.update,配 language=es + server_vad + upd = next(m for m in fake.sent if m["type"] == "session.update") + assert upd["session"]["input_audio_transcription"]["language"] == "es" + assert upd["session"]["turn_detection"]["type"] == "server_vad" + assert upd["session"]["input_audio_format"] == "pcm" + # 喂音频以 input_audio_buffer.append + base64 发出 + assert any(m["type"] == "input_audio_buffer.append" and m.get("audio") for m in fake.sent) + # 每句一个 seg_id;流式取 stash;completed 定稿 + assert {s.seg_id for s in segs} == {"qwen#A"} + interim = [s.text for s in segs if not s.is_final] + assert interim == ["Hola", "Hola mundo"] + final = [s for s in segs if s.is_final] + assert final and final[-1].text == "Hola mundo." + + +def test_auto_omits_language(monkeypatch): + """source_language=auto → session.update 不带 language(服务端自动检测)。""" + fake = _FakeWS([_ev("session.created"), _done("A", "hola")]) + monkeypatch.setattr(websocket, "create_connection", lambda *a, **k: fake) + be = QwenAsrBackend(api_key="sk-x", language="auto", on_segment=lambda s: None) + be.start() + be.stop() + upd = next(m for m in fake.sent if m["type"] == "session.update") + assert upd["session"]["input_audio_transcription"] == {} # 无 language + + +def test_midsession_session_finished_reconnects(monkeypatch): + """服务端中途发 session.finished(实时单 session 时长上限)→ 不当结束,重连续录。 + + 「转录到一半突然没了」的同类回归:旧式只收尾不重连,音频还在喂却再无结果。""" + ws1 = _FakeWS([_ev("session.created"), _done("A", "uno"), _ev("session.finished")]) + ws2 = _FakeWS([_ev("session.created"), _done("B", "dos")]) + pending = [ws1, ws2] + monkeypatch.setattr(websocket, "create_connection", + lambda *a, **k: pending.pop(0) if pending else _FakeWS([])) + + segs = [] + be = QwenAsrBackend(api_key="sk-x", language="es", on_segment=segs.append) + be.start() + be.feed(b"\x00\x01" * 100) # 非空、非 stopping + _wait(lambda: any(s.seg_id == "qwen#B" for s in segs)) + be.stop() + + seg_ids = {s.seg_id for s in segs} + assert "qwen#A" in seg_ids # 重连前 + assert "qwen#B" in seg_ids # 中途 session.finished 后重起续出 + assert len(pending) == 0 # 确实建立了第二个连接 + + +def test_feed_gated_until_session_ready(): + """会话未配置好(没发出 session.update)时不喂音频——避免重连后「session already started」。""" + fake = _FakeWS([]) # 不吐 session.created → 永不就绪 + be = QwenAsrBackend(api_key="sk-x", on_segment=lambda s: None) + be._ws = fake # 直接装连接,跳过 start()(不阻塞等就绪) + be.feed(b"\x00\x01" * 100) + assert not any(m.get("type") == "input_audio_buffer.append" for m in fake.sent) # 未就绪不发 + be._session_ready.set() + be.feed(b"\x00\x01" * 100) + assert any(m.get("type") == "input_audio_buffer.append" for m in fake.sent) # 就绪后才发 + + +def test_error_session_already_is_non_fatal(monkeypatch): + """重连后迟到的 session.update 被拒(session already …)是非致命的:不上报、会话继续出字幕。""" + fake = _FakeWS([ + _ev("session.created"), + _ev("error", error={"message": + "Session update error: session already started or finished or failed."}), + _done("A", "hola"), + ]) + monkeypatch.setattr(websocket, "create_connection", lambda *a, **k: fake) + segs, errs = [], [] + be = QwenAsrBackend(api_key="sk-x", language="es", + on_segment=segs.append, on_error=errs.append) + be.start() + _wait(lambda: any(s.seg_id == "qwen#A" for s in segs)) + be.stop() + assert errs == [] # 非致命:不上报错误(不杀会话) + assert any(s.seg_id == "qwen#A" for s in segs) # 会话继续、照常出字幕 + + +def test_missing_api_key_raises(): + import pytest + with pytest.raises(Exception): + QwenAsrBackend(api_key="") + + +def test_error_event_tolerates_non_dict_error_field(): + # 服务端 error 字段非 dict(字符串/None/缺失)时 _dispatch 不得抛 AttributeError 杀接收线程。 + be = QwenAsrBackend(api_key="sk-x", language="es", on_segment=lambda s: None) + for ev in ({"type": "error", "error": "boom"}, + {"type": "error", "error": None}, + {"type": "error"}): + be._dispatch(ev) # 不抛异常即通过 + + +def test_new_item_finalizes_previous_open(): + # 上一句 .completed 丢失/乱序时,新 item 的到来兜底定稿上一句,不丢句。 + segs = [] + be = QwenAsrBackend(api_key="sk-x", language="es", on_segment=segs.append) + be._dispatch(json.loads(_text("i1", "hola"))) # interim i1 + be._dispatch(json.loads(_text("i2", "mundo"))) # 新 item → 兜底定稿 i1 + assert ("qwen#i1", "hola") in [(s.seg_id, s.text) for s in segs if s.is_final] + + +def test_reconnect_backoff_gives_up_after_repeated_failures(monkeypatch): + """连不上时退避重试,连续失败超上限 → 上报致命并放弃(不再一次失败就静默退接收线程)。""" + def boom(*a, **k): + raise OSError("network down") + monkeypatch.setattr(websocket, "create_connection", boom) + errs = [] + be = QwenAsrBackend(api_key="sk-x", language="es", + on_segment=lambda s: None, on_error=errs.append) + be._RECONNECT_MAX_FAILS = 3 + be._RECONNECT_MAX_DELAY_S = 0.0 # 退避归零,测试不阻塞 + assert be._try_reconnect("boom") is False + assert errs and "网络持续不可用" in errs[0] + + +def test_reconnect_terminates_on_accept_then_drop(monkeypatch): + """抖动期(配额满 / 限流):建连每次都成功但连接秒断,attempt 不抛 → fails 永不累加。 + + 这种 "accept-then-drop" 必须靠跨调用累计的 _reconnect_streak 兜底终止,否则会每 0.5s 无限 + 重连、永不出字也永不报错(连不上的 fails 上限拦不住它,因为每次 attempt 都成功)。""" + monkeypatch.setattr(websocket, "create_connection", + lambda *a, **k: _FakeWS([_ev("session.created")])) + errs = [] + be = QwenAsrBackend(api_key="sk-x", language="es", + on_segment=lambda s: None, on_error=errs.append) + be._RECONNECT_MAX_FAILS = 3 + be._note_alive() # 最近确认存活 → 后续断连判为抖动期,streak 累计 + results = [be._try_reconnect("drop") for _ in range(5)] + assert results[:3] == [True, True, True] # 抖动期内每次都建连成功(fails 拦不住) + assert results[3] is False # streak 超上限 → 放弃续录 + assert errs and "反复连上即断" in errs[0] diff --git a/tests/test_realtime/test_recorder.py b/tests/test_realtime/test_recorder.py new file mode 100644 index 00000000..0b2cff02 --- /dev/null +++ b/tests/test_realtime/test_recorder.py @@ -0,0 +1,109 @@ +"""录制器时间轴契约(无 Qt,写真实临时 WAV)。 + +锁住:段落起点用**已录音频位置**(WAV 偏移)而非后端 start_time / 墙钟—— + +- 后端 start_time 不可靠(Fun-ASR 重连后 begin_time 归零/漂移)→ 不采信。 +- WAV 偏移天生暂停感知(暂停期不录、frames 不前进),恢复后段落不把暂停时长算进去; + 用墙钟(started_at-t0)则会把暂停时长算进去、超过总时长被钳到末尾 → 多段重叠、点句失效。 +- start/end 钳进 [0, dur] 且 end=下一段起点 → 单调、不重叠、不越界(SRT 导出也据此)。 +""" + +from pathlib import Path + +import pytest + +from videocaptioner.core.realtime.backends.base import SAMPLE_RATE +from videocaptioner.core.realtime.events import CaptionEntry +from videocaptioner.core.realtime.recording.history import LiveCaptionStore +from videocaptioner.core.realtime.recording.recorder import SessionRecorder + + +def _entry(seg_id, seq, src, tgt="", started_at=0.0): + return CaptionEntry( + seg_id=seg_id, seq=seq, source_text=src, source_stable_len=len(src), + target_text=tgt, is_final=False, started_at=started_at, + ) + + +def _pcm(seconds): + return b"\x00\x00" * int(SAMPLE_RATE * seconds) # 16k/mono/s16le 静音 + + +def _rec(tmp_path): + return SessionRecorder(LiveCaptionStore(root=Path(tmp_path)), "麦克风", "微软翻译", when=1000.0) + + +def test_start_is_wav_offset_not_backend_time(tmp_path): + """段落起点 = 该句首现时已录音频的秒数(WAV 偏移),与回放对齐。""" + r = _rec(tmp_path) + r.write_pcm(_pcm(3)) + r.note_caption(_entry("s1", 0, "hello")) # 已录 3s + r.write_pcm(_pcm(5)) + r.note_caption(_entry("s2", 1, "world")) # 已录 8s + r.write_pcm(_pcm(2)) # 共 10s + rec = r.finalize() + assert rec is not None + assert [round(s.start, 1) for s in rec.segments] == [3.0, 8.0] + assert rec.segments[0].end == pytest.approx(8.0, abs=0.05) # = 下一段起点 + assert rec.segments[-1].end == pytest.approx(rec.duration, abs=0.05) # 末段 → 总时长 + assert all(0.0 <= s.start <= s.end <= rec.duration + 1e-6 for s in rec.segments) + + +def test_pause_does_not_inflate_timeline(tmp_path): + """暂停后段落起点不含暂停时长,不会被钳到末尾、不重叠(审查发现的真 bug 的回归测试)。""" + r = _rec(tmp_path) + r.write_pcm(_pcm(3)) + r.note_caption(_entry("s1", 0, "before", started_at=1003.0)) + # —— 暂停 100s:不写 PCM、无新 caption(无音频喂入)—— + r.write_pcm(_pcm(2)) # 恢复后再录 2s → 共 5s + # started_at 含了暂停的 100s(墙钟),但起点应取 WAV 偏移 5s + r.note_caption(_entry("s2", 1, "after", started_at=1105.0)) + r.write_pcm(_pcm(2)) # 共 7s,给末段留区间 + rec = r.finalize() + starts = [round(s.start, 1) for s in rec.segments] + assert starts == [3.0, 5.0] # 5.0 而非 105.0/被钳到 7.0 + assert starts[0] < starts[1] # 不重叠、不倒退、不堆叠 + assert all(s.start <= rec.duration for s in rec.segments) + assert rec.segments[0].end <= rec.duration # SRT 不越界 + + +def test_end_covers_to_next_start_no_overlap(tmp_path): + """每段终点 = 下一段起点;空段被跳过但仍是合法前向时间点,不产生重叠/倒退。""" + r = _rec(tmp_path) + r.write_pcm(_pcm(2)) + r.note_caption(_entry("a", 0, "first")) # 2.0 + r.write_pcm(_pcm(3)) + r.note_caption(_entry("blank", 1, " ")) # 5.0 但内容为空 → finalize 跳过 + r.write_pcm(_pcm(4)) + r.note_caption(_entry("c", 2, "third")) # 9.0 + r.write_pcm(_pcm(1)) # 共 10s + rec = r.finalize() + assert [s.source for s in rec.segments] == ["first", "third"] + # a 的终点覆盖到被跳过的 blank 起点(5.0),仍 < c 起点;全程单调 + assert rec.segments[0].end == pytest.approx(5.0, abs=0.05) + assert all(rec.segments[i].start <= rec.segments[i + 1].start + for i in range(len(rec.segments) - 1)) + + +def test_no_audio_falls_back_to_wallclock(tmp_path): + """没录到音频(WAV 建失败)时回退墙钟相对秒,SRT 时间轴仍可用、单调。""" + r = _rec(tmp_path) + r._wav = None # 模拟录音不可用 + r.note_caption(_entry("s1", 0, "a", started_at=1002.0)) + r.note_caption(_entry("s2", 1, "b", started_at=1005.0)) + rec = r.finalize() + assert [round(s.start, 1) for s in rec.segments] == [2.0, 5.0] + assert rec.audio == "" # 无音频 + + +def test_translation_backfill_keeps_first_start(tmp_path): + """译文/改写以同 seg_id 回填时,起点保持首现值不变。""" + r = _rec(tmp_path) + r.write_pcm(_pcm(4)) + r.note_caption(_entry("s1", 0, "hello")) # 首现 → 4.0 + r.write_pcm(_pcm(6)) # 又录 6s + r.note_caption(_entry("s1", 0, "hello world", tgt="你好世界")) # 回填,不应改起点 + rec = r.finalize() + assert rec.segments[0].start == pytest.approx(4.0, abs=0.05) + assert rec.segments[0].source == "hello world" + assert rec.segments[0].target == "你好世界" diff --git a/tests/test_realtime/test_session.py b/tests/test_realtime/test_session.py new file mode 100644 index 00000000..93d7e4b4 --- /dev/null +++ b/tests/test_realtime/test_session.py @@ -0,0 +1,79 @@ +"""LiveCaptionSession 终态契约:后端致命失败 latch 后音频泵立即退出。 + +锁住"并发配额满之类的硬失败 → 停链路(不再挂在监听中空转)"的行为。 +""" + +from videocaptioner.core.realtime.config import LiveCaptionConfig +from videocaptioner.core.realtime.session import LiveCaptionSession + + +class _StubAudio: + def __init__(self): + self.stopped = 0 + + def read(self, timeout=0.1): + return None + + def stop(self): + self.stopped += 1 + + +class _StubBackend: + def __init__(self): + self.stopped = 0 + + def feed(self, pcm): # noqa: ANN001 + pass + + def stop(self): + self.stopped += 1 + + +class _StubAssembler: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + +def test_fatal_error_latches_and_pump_exits(): + session = LiveCaptionSession(LiveCaptionConfig(), on_caption=lambda e: None) + session._audio = _StubAudio() + session._backend = _StubBackend() + + assert session.fatal_error is None + session._forward_error("StartSession failed (code=40200011): concurrency quota exceeded") + assert session.fatal_error is not None + + # latch 已置位 → pump 不应阻塞,立即返回 + session.pump(cancel_check=lambda: None) + + +def test_no_fatal_error_means_clean_state(): + session = LiveCaptionSession(LiveCaptionConfig(), on_caption=lambda e: None) + assert session.fatal_error is None + + +def test_stop_tears_down_even_after_request_stop(): + """崩溃根因回归:request_stop 让 pump 退出后,stop() 仍必须真正收尾。 + + 曾经 stop() `if self._stopped: return`,而 request_stop 已置 _stopped=True,导致音频/ + 后端/装配器永不关闭——接收线程+音频回调+翻译池继续向已销毁浮窗 emit → 整程序崩。 + """ + session = LiveCaptionSession(LiveCaptionConfig(), on_caption=lambda e: None) + audio, backend, asm = _StubAudio(), _StubBackend(), _StubAssembler() + session._audio = audio + session._backend = backend + session._assembler = asm + + session.request_stop() # 用户点停止:只让 pump 退出 + assert session._stopped is True + + session.stop() # _work finally 里在工作线程上真正收尾 + assert audio.stopped == 1 and backend.stopped == 1 and asm.closed == 1 + + session.stop() # 幂等:再调不重复收尾 + assert audio.stopped == 1 and backend.stopped == 1 and asm.closed == 1 + + diff --git a/tests/test_realtime/test_system_audio_mac.py b/tests/test_realtime/test_system_audio_mac.py new file mode 100644 index 00000000..972d99da --- /dev/null +++ b/tests/test_realtime/test_system_audio_mac.py @@ -0,0 +1,83 @@ +"""macOS 原生系统声音采集(MacSystemAudioCapture)契约:与 helper 子进程的 stdio 协议。 + +用「假 helper」脚本替身验证启动握手/读 PCM/EOF 停止/权限错误,不依赖真实 ScreenCaptureKit, +故跨平台可跑(真机 SCK 验证另行人工)。 +""" + +import os +import sys +import time + +import pytest + +import videocaptioner.core.realtime.audio.system_mac as sam +from videocaptioner.core.realtime.audio.capture import AudioSourceError +from videocaptioner.core.realtime.audio.system_mac import ( + MacSystemAudioCapture, + MacSystemAudioPermissionError, + find_macsysaudio_binary, +) + +_HAPPY = """import sys, time +sys.stderr.write("STARTED\\n"); sys.stderr.flush() +end = time.time() + 2.0 +while time.time() < end: + sys.stdout.buffer.write(b"\\x01\\x02" * 800) # 1600B ≈ 50ms @16k/mono/s16le + sys.stdout.buffer.flush() + time.sleep(0.05) +sys.stdin.read() # 等父进程关 stdin(EOF) +""" + +_PERMISSION = """import sys +sys.stderr.write("PERMISSION: user declined screen recording\\n"); sys.stderr.flush() +sys.exit(2) +""" + + +def _fake(tmp_path, name, body): + # venv 路径含空格 → 不能用 #! shebang(内核按空格切断解释器路径)。改用 /bin/sh + # 启动器把带空格的 python 路径加引号 exec。 + impl = tmp_path / (name + "_impl.py") + impl.write_text(body) + launcher = tmp_path / name + launcher.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{impl}"\n') + launcher.chmod(0o755) + return str(launcher) + + +def test_missing_binary_raises(monkeypatch): + monkeypatch.setattr(sam, "find_macsysaudio_binary", lambda *a, **k: None) + with pytest.raises(AudioSourceError): + MacSystemAudioCapture().start() + + +def test_permission_denied_raises_friendly(tmp_path): + cap = MacSystemAudioCapture(binary=_fake(tmp_path, "perm", _PERMISSION)) + with pytest.raises(MacSystemAudioPermissionError): + cap.start() + + +def test_happy_path_reads_pcm_then_stops(tmp_path): + cap = MacSystemAudioCapture(binary=_fake(tmp_path, "happy", _HAPPY)) + cap.start() + got = b"" + t0 = time.time() + while time.time() - t0 < 1.0: + chunk = cap.read(timeout=0.2) + if chunk: + got += chunk + cap.stop() # 关 stdin → 假 helper 退出 + assert len(got) > 0 # 读到 PCM + + +def test_find_binary_prefers_configured(tmp_path): + fake = _fake(tmp_path, "macsysaudio", _HAPPY) + assert find_macsysaudio_binary(fake) == fake + _ = find_macsysaudio_binary("") # 配置路径空 → 回退发现,确保不抛 + + +@pytest.mark.skipif(sys.platform != "darwin", reason="仅 macOS 有真 helper") +def test_real_binary_discoverable_if_built(): + found = find_macsysaudio_binary() + if found: # 未构建则不强求,构建过就应可执行 + assert os.access(found, os.X_OK) diff --git a/tests/test_realtime/test_translate_fn.py b/tests/test_realtime/test_translate_fn.py new file mode 100644 index 00000000..12b82a1a --- /dev/null +++ b/tests/test_realtime/test_translate_fn.py @@ -0,0 +1,190 @@ +"""实时翻译装配(factory._LazyTranslator)契约。 + +两道防线让「目标==所说语言」时不显冗余译文: +① **语言级跳过**(主):按**每句文本的实际字符构成**判主语言,== 目标语言大类(如中文文本→简体中文) + → 跳过、不联网、不显近似重复的同语言「译文」。**不依赖配置的识别语言**(用户可能设错、或多语模型 + 自动识别成别的语言)——修了「识别语言设中文、实际说英文、目标中文 → 被误判同语言而整段不翻译」。 +② 逐句兜底:跨语言时若引擎返回的译文恰好≈原文,也判为无译文。 +跨语言(如英→中)正常返回译文。 +""" + +from videocaptioner.core.entities import SubtitleProcessData +from videocaptioner.core.realtime.config import LiveCaptionConfig +from videocaptioner.core.realtime.factory import _LazyTranslator, build_translate_fn +from videocaptioner.core.translate.types import TargetLanguage + + +class _FakeTranslator: + """按预设把 original_text 映射成 translated_text,不联网。""" + + def __init__(self, mapping): + self._mapping = mapping + self.calls = 0 + + def _translate_chunk(self, data: list[SubtitleProcessData]) -> None: + self.calls += 1 + for d in data: + d.translated_text = self._mapping(d.original_text) + + def stop(self): + pass + + +def _lazy(mapping, source="en", target=TargetLanguage.SIMPLIFIED_CHINESE) -> _LazyTranslator: + lt = _LazyTranslator(LiveCaptionConfig(source_language=source, target_language=target)) + lt._tr = _FakeTranslator(mapping) # 注入,跳过联网构造 + return lt + + +def test_same_language_skips_by_text(): + # 中文文本 + 目标中文:按文本判定同语言 → 跳过,连引擎都不调 + lt = _lazy(lambda s: "x", source="zh", target=TargetLanguage.SIMPLIFIED_CHINESE) + assert lt.translate("今天天气不错") == "" + assert lt._tr.calls == 0 # 没联网/没调引擎 + + +def test_wrong_source_lang_still_translates_english(): + # 关键回归:识别语言设成中文(source=zh)、目标中文、但实际说英文 → 必须翻译。 + # 旧逻辑按配置 source==target 整段跳过会漏翻(线上「开了翻译但全是英文」真因)。 + lt = _lazy(lambda s: "你好", source="zh", target=TargetLanguage.SIMPLIFIED_CHINESE) + assert lt.translate("I think the weather is nice") == "你好" + + +def test_same_language_by_text_en_ja(): + # 按文本主语言判定:英文文本→目标英文 跳过;日文文本→目标日文 跳过;跨语言不跳。 + assert _lazy(lambda s: s, target=TargetLanguage.ENGLISH)._looks_same_language("hello world") + assert _lazy(lambda s: s, target=TargetLanguage.JAPANESE)._looks_same_language("こんにちは、元気") + assert not _lazy(lambda s: s, target=TargetLanguage.SIMPLIFIED_CHINESE)._looks_same_language("hello") + + +def test_cross_language_passes_through(): + # 英→中:译文≠原文 → 正常返回译文 + lt = _lazy(lambda s: "你好", source="en", target=TargetLanguage.SIMPLIFIED_CHINESE) + assert lt.translate("hello") == "你好" + + +def test_cross_language_identical_output_dropped(): + # 跨语言但引擎恰好返回≈原文 → 逐句兜底判空 + lt = _lazy(lambda s: f" {s} ", source="en", target=TargetLanguage.SIMPLIFIED_CHINESE) + assert lt.translate("hello world") == "" + + +def test_empty_input_returns_empty(): + lt = _lazy(lambda s: "x", source="en", target=TargetLanguage.SIMPLIFIED_CHINESE) + assert lt.translate(" ") == "" + + +def test_build_translate_fn_disabled_returns_none(): + cfg = LiveCaptionConfig(translate_enabled=False) + assert build_translate_fn(cfg) is None + + +def test_openai_translator_sets_llm_env_from_config(monkeypatch): + """LLM 翻译(OPENAI)建翻译器前,从配置把 key/base 写进 OPENAI_API_KEY/BASE_URL 环境变量。 + + 实时字幕之前漏了这步 → 选「大模型翻译」报「环境变量未设置」。""" + import os + + from videocaptioner.core.translate import factory as tf + from videocaptioner.core.translate.types import TranslatorType + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + class _FakeLLMTr: + def _translate_chunk(self, data): + for d in data: + d.translated_text = "<译>" + + def stop(self): + pass + + monkeypatch.setattr(tf.TranslatorFactory, "create_translator", + staticmethod(lambda **k: _FakeLLMTr())) + + lt = _LazyTranslator(LiveCaptionConfig( + translate_enabled=True, translator_type=TranslatorType.OPENAI, + llm_api_key="sk-test", llm_api_base="https://example.com/v1", llm_model="m", + target_language=TargetLanguage.SIMPLIFIED_CHINESE)) + out = lt.translate("hola mundo") # 英文/西文 latin → 非中文,照常翻 + + assert os.environ.get("OPENAI_API_KEY") == "sk-test" + assert os.environ.get("OPENAI_BASE_URL") == "https://example.com/v1" + assert out == "<译>" + + +def test_openai_translator_without_key_disabled(monkeypatch): + """LLM 翻译但没配 key/base → 禁用译文(不报错刷屏),不抛异常。""" + from videocaptioner.core.translate.types import TranslatorType + lt = _LazyTranslator(LiveCaptionConfig( + translate_enabled=True, translator_type=TranslatorType.OPENAI, + llm_api_key="", llm_api_base="", target_language=TargetLanguage.SIMPLIFIED_CHINESE)) + assert lt.translate("hola mundo") == "" # 无 key → 空译文、禁用 + + +def test_llm_translator_disable_thinking_extra_body(): + """disable_thinking=True 时翻译请求带 extra_body={'enable_thinking': False};否则不带。""" + from videocaptioner.core.translate.llm_translator import LLMTranslator + common = dict(thread_num=1, batch_num=1, target_language=TargetLanguage.SIMPLIFIED_CHINESE, + model="m", custom_prompt="", is_reflect=False, update_callback=None) + assert LLMTranslator(**common, disable_thinking=True)._llm_extra == { + "extra_body": {"enable_thinking": False}} + assert LLMTranslator(**common)._llm_extra == {} + + +def test_realtime_llm_translation_disables_thinking(monkeypatch): + """实时字幕的 LLM 翻译建翻译器时带 disable_thinking=True(关思考求快)。""" + from videocaptioner.core.translate import factory as tf + from videocaptioner.core.translate.types import TranslatorType + captured = {} + + class _FakeTr: + def _translate_chunk(self, data): + for d in data: + d.translated_text = "" + + def stop(self): + pass + + def spy(**kw): + captured.update(kw) + return _FakeTr() + + monkeypatch.setattr(tf.TranslatorFactory, "create_translator", staticmethod(spy)) + lt = _LazyTranslator(LiveCaptionConfig( + translate_enabled=True, translator_type=TranslatorType.OPENAI, + llm_api_key="k", llm_api_base="https://x/v1", llm_model="m", + target_language=TargetLanguage.SIMPLIFIED_CHINESE)) + lt.translate("hola mundo") + assert captured.get("disable_thinking") is True + + +def test_call_llm_api_strips_enable_thinking_on_400(monkeypatch): + """端点不认 enable_thinking(400)→ 去掉该参数重试成功,并记住该端点。""" + from videocaptioner.core.llm import client as cli + cli._thinking_unsupported.clear() + seen = [] + + class _Resp: + choices = [] + + def fake_create(**kw): + has = "enable_thinking" in (kw.get("extra_body") or {}) + seen.append(has) + if has: + err = Exception("Error code: 400 - unknown parameter: enable_thinking") + err.status_code = 400 + raise err + return _Resp() + + fake_client = type("C", (), {"chat": type("ch", (), { + "completions": type("co", (), {"create": staticmethod(fake_create)})})})() + monkeypatch.setattr(cli, "get_llm_client", lambda: fake_client) + monkeypatch.setattr(cli, "log_llm_response", lambda r: None) + monkeypatch.setenv("OPENAI_BASE_URL", "https://official.example/v1") + + r = cli._call_llm_api([{"role": "user", "content": "hi"}], "m", + extra_body={"enable_thinking": False}) + assert r is not None + assert seen == [True, False] # 先带→400,去掉后成功 + assert ("https://official.example/v1", "m") in cli._thinking_unsupported # 按(端点,模型)记住 diff --git a/tests/test_realtime/test_voxgate_backend.py b/tests/test_realtime/test_voxgate_backend.py new file mode 100644 index 00000000..71d3178d --- /dev/null +++ b/tests/test_realtime/test_voxgate_backend.py @@ -0,0 +1,137 @@ +"""VoxgateBackend 原生 protocol 解析契约(离线,直接喂 _dispatch)。 + +锁住新协议(实测取证 docs/dev/voxgate-protocol.md):每条 result 自带 index(句号)= 稳定 +seg_id;text 是该句从头累积的全文;**定稿信号是 is_vad_finished(真·停顿)**,is_force_finished +是同句 twopass 二次冲刷、其后同 index 仍生长,绝不据它定稿;VAD 收尾帧带的 text:"" 空占位跳过; +末句没等到自己的 VAD 交装配器 close() 兜底。 +""" + +from videocaptioner.core.realtime.backends.voxgate import VoxgateBackend + + +def _msg(results, **kw): + return {"direction": "recv", "status_code": 20000000, + "result_json": {"results": results}, **kw} + + +def _r(text, index, start, end, interim=True, vad=False, force=False): + d = {"index": index, "start_time": start, "end_time": end, + "text": text, "is_interim": interim} + if vad: + d["is_vad_finished"] = True + if force: + d["is_force_finished"] = True + return d + + +def _backend(): + segs = [] + be = VoxgateBackend(binary="x") # 构造不 spawn,仅 start() 才起子进程 + be.on_segment = segs.append + return be, segs + + +def _finals(segs): + return [s for s in segs if s.is_final] + + +def test_index_is_seg_id_and_vad_finalizes(): + be, segs = _backend() + be._dispatch(_msg([_r("你好", 0, 1.88, 3.2)])) # 生长(活动) + be._dispatch(_msg([_r("你好今天天气", 0, 1.88, 5.1, interim=False, vad=True)])) # 真停顿定稿 + f = _finals(segs) + assert f and f[-1].seg_id == "voxgate#0" and f[-1].text == "你好今天天气" + assert f[-1].start_time == 1.88 and f[-1].end_time == 5.1 + + +def test_force_finished_does_not_finalize(): + # is_force_finished(twopass 冲刷)其后同 index 继续长出英文 → 绝不能据它提前定稿。 + be, segs = _backend() + be._dispatch(_msg([_r("你好今天的天气", 0, 1.88, 8.34)])) # 生长 + be._dispatch(_msg([_r("你好今天的天气非常的不错", 0, 1.88, 10.26, + interim=False, force=True)])) # 二次冲刷(非定稿) + assert _finals(segs) == [] # 还没真停顿 + be._dispatch(_msg([_r("你好今天的天气非常的不错。i think the weather is nice。", + 0, 1.88, 14.69, interim=False, vad=True)])) # 真 VAD 定稿 + f = _finals(segs) + assert len(f) == 1 and f[-1].seg_id == "voxgate#0" + assert f[-1].text.endswith("nice。") + + +def test_second_index_distinct_seg(): + be, segs = _backend() + be._dispatch(_msg([_r("第一句。", 0, 1.88, 14.69, interim=False, vad=True)])) + be._dispatch(_msg([_r("还有一点", 1, 19.76, 21.1)])) + be._dispatch(_msg([_r("还有一点就是这样。", 1, 19.76, 31.26, interim=False, vad=True)])) + assert {s.seg_id for s in _finals(segs)} == {"voxgate#0", "voxgate#1"} + + +def test_vad_frame_empty_placeholder_skipped(): + # VAD 收尾帧除了本句还会多带一条 text:"" 的下一句占位 → 跳过空文本,不多出一段。 + be, segs = _backend() + be._dispatch(_msg([ + _r("整句。", 1, 19.76, 31.26, interim=False, vad=True), + _r("", 1, -0.001, -0.001, interim=False), + ])) + f = _finals(segs) + assert len(f) == 1 and f[-1].text == "整句。" + + +def test_growing_interim_upserts_same_seg(): + be, segs = _backend() + be._dispatch(_msg([_r("我", 0, 1.88, 2.0)])) + be._dispatch(_msg([_r("我觉得今天", 0, 1.88, 3.0)])) + assert all(s.seg_id == "voxgate#0" for s in segs) + assert not any(s.is_final for s in segs) + assert segs[-1].text == "我觉得今天" + + +def test_error_status_forwards(): + errs = [] + be = VoxgateBackend(binary="x") + be.on_error = errs.append + be._dispatch({"direction": "recv", "status_code": 45000001, "status_message": "bad key"}) + assert errs and "45000001" in errs[0] + + +def test_heartbeat_and_send_ignored(): + be, segs = _backend() + be._dispatch({"direction": "recv", "status_code": 20000000, "result_json": {"results": None}}) + be._dispatch({"direction": "send", "method_name": "StartTask"}) + assert segs == [] + + +def test_rolling_reset_splits_continuous_speech(): + # 连续语音(看视频/会议):豆包同 index、无 VAD,到上限把 text 截短重来(骤降到很短)。 + # 须当句边界切句,否则整场被覆盖成最后残段(真实 118s 会话曾只剩 1 句)。 + be, segs = _backend() + long_text = "一二三四五六七八九十甲乙丙丁戊己庚辛壬癸" # 20 字 + be._dispatch(_msg([_r("一二三四五六七八", 0, 0, 5)])) + be._dispatch(_msg([_r(long_text, 0, 0, 6)])) # 同句生长到 20 字 + be._dispatch(_msg([_r("完全重来了", 0, 6, 9)])) # 5 字,腰斩(5*2<20)=滚动重置 + f = _finals(segs) + assert any(s.text == long_text for s in f) # 重置处定稿上一段(不丢) + assert len({s.seg_id for s in segs}) >= 2 + assert any(s.text == "完全重来了" for s in segs) # 新句不覆盖旧句 + + +def test_twopass_rewrite_does_not_false_split(): + # twopass 改写(去口水词/补标点)前缀大体不变、长度相近 → 不得误判成重置而切句。 + be, segs = _backend() + be._dispatch(_msg([_r("今天天气不错呃", 0, 0, 5)])) + be._dispatch(_msg([_r("今天天气不错。", 0, 0, 5, force=True)])) # 同长度小改写 + be._dispatch(_msg([_r("今天天气不错,挺好。", 0, 0, 6, vad=True)])) + assert {s.seg_id for s in segs} == {"voxgate#0"} # 始终同一句 + f = _finals(segs) + assert f and f[-1].text == "今天天气不错,挺好。" + + +def test_twopass_shorter_rewrite_no_false_split(): + # twopass 改写让文本「略变短 + 前半句改写」(whose fault→who spots),长度不腰斩 → + # 绝不能误判成滚动重置切句(线上「开头 3 句重复」真因,实测 dump 45→43→…)。 + be, segs = _backend() + be._dispatch(_msg([_r("Okay. Well, whose fault is that? Yours. What?", 0, 0, 5)])) # 45 + be._dispatch(_msg([_r("Okay. Well, who spots? Is that yours? What?", 0, 0, 5)])) # 43 略短+前缀改 + be._dispatch(_msg([_r("Okay. Well, whose fault is that? Yours. What? Let me.", 0, 0, 6)])) # 续长 + assert {s.seg_id for s in segs} == {"voxgate#0"} # 始终同一句,不切 + assert _finals(segs) == [] # 无 vad/边界,全程在途(末句交 close 兜底) diff --git a/tests/test_subtitle/test_preview_cache.py b/tests/test_subtitle/test_preview_cache.py new file mode 100644 index 00000000..aaaf254b --- /dev/null +++ b/tests/test_subtitle/test_preview_cache.py @@ -0,0 +1,46 @@ +"""字幕预览内容寻址缓存:同输入只渲染一次,按数量上限滚动清理。 + +预览渲染是输入的纯函数;ASS 走 ffmpeg(~250ms)、圆角走 PIL(~70ms),来回切换 +或重复编辑同一组样式时命中缓存(~1ms)。这里用圆角渲染器(不依赖 ffmpeg)验证缓存 +命中不重渲染,并单测路径确定性与清理上限。 +""" + +import videocaptioner.core.subtitle.preview_cache as pc +from videocaptioner.core.subtitle import render_preview +from videocaptioner.core.subtitle.styles import RoundedBgStyle + + +def test_preview_path_is_deterministic_and_distinct(tmp_path, monkeypatch): + monkeypatch.setattr(pc, "_PREVIEW_DIR", tmp_path) + assert pc.preview_path("abc") == pc.preview_path("abc") + assert pc.preview_path("abc") != pc.preview_path("xyz") + assert pc.preview_path("abc").parent == tmp_path + + +def test_prune_keeps_only_recent(tmp_path, monkeypatch): + monkeypatch.setattr(pc, "_PREVIEW_DIR", tmp_path) + for i in range(30): + (tmp_path / f"{i:02d}.png").write_bytes(b"x") + pc.prune(keep=24) + assert len(list(tmp_path.glob("*.png"))) == 24 + + +def test_render_preview_hits_cache(tmp_path, monkeypatch): + monkeypatch.setattr(pc, "_PREVIEW_DIR", tmp_path) + style = RoundedBgStyle(font_name="Noto Sans SC", font_size=52) + kwargs = dict(primary_text="Hello", secondary_text="你好", style=style, bg_image_path=None) + + first = render_preview(**kwargs) + assert "subtitle_preview" not in first # 已被 monkeypatch 到 tmp_path + assert first.endswith(".png") + mtime = (tmp_path / first.split("/")[-1]).stat().st_mtime_ns + + # 同输入再渲染:返回同一路径,且文件未被重写(命中缓存,未重渲染) + second = render_preview(**kwargs) + assert second == first + assert (tmp_path / second.split("/")[-1]).stat().st_mtime_ns == mtime + + # 改一个参数:另一个缓存文件 + third = render_preview(**{**kwargs, "primary_text": "World"}) + assert third != first + assert len(list(tmp_path.glob("*.png"))) == 2 diff --git a/tests/test_subtitle/test_style_manager.py b/tests/test_subtitle/test_style_manager.py new file mode 100644 index 00000000..bf423739 --- /dev/null +++ b/tests/test_subtitle/test_style_manager.py @@ -0,0 +1,102 @@ +from videocaptioner.core.subtitle.style_manager import ( + AssSecondaryStyle, + AssSubtitleStyle, + RoundedSubtitleStyle, + StyleSource, + SubtitleRenderer, + SubtitleStylePreset, + list_styles, + load_style, + normalize_style_id, + preset_from_json, + save_user_style, +) + + +def _ass_bold_flags(style: AssSubtitleStyle) -> tuple[str, str]: + """返回 (主字幕 bold, 副字幕 bold) 两个 ASS Style 行的 Bold 字段。""" + lines = style.to_ass_string().splitlines() + default = next(line for line in lines if line.startswith("Style: Default,")) + secondary = next(line for line in lines if line.startswith("Style: Secondary,")) + return default.split(",")[7], secondary.split(",")[7] + + +def test_primary_and_secondary_bold_are_independent(): + style = AssSubtitleStyle(bold=True, secondary=AssSecondaryStyle(bold=False)) + assert _ass_bold_flags(style) == ("-1", "0") + style = AssSubtitleStyle(bold=False, secondary=AssSecondaryStyle(bold=True)) + assert _ass_bold_flags(style) == ("0", "-1") + + +def test_legacy_style_secondary_bold_inherits_primary(): + # 老样式只存主字幕 bold(当时主副共用);副字幕缺省时应沿用主字幕,渲染不变 + preset = preset_from_json( + {"renderer": "ass", "bold": False, "secondary": {"font_size": 20}}, + source=StyleSource.USER, + ) + assert preset.style.bold is False + assert preset.style.secondary is not None + assert preset.style.secondary.bold is False + + +def test_secondary_bold_round_trips_through_json(): + preset = preset_from_json( + {"renderer": "ass", "bold": False, "secondary": {"font_size": 20, "bold": True}}, + source=StyleSource.USER, + ) + assert preset.style.secondary.bold is True + assert preset.to_json_dict()["secondary"]["bold"] is True + + +def test_builtin_styles_are_typed_and_grouped_by_renderer(): + ass_styles = list_styles(renderer="ass", include_user=False) + rounded_styles = list_styles(renderer="rounded", include_user=False) + + assert {style.id for style in ass_styles} >= { + "ass/default", + "ass/anime", + "ass/vertical", + } + assert {style.id for style in rounded_styles} >= {"rounded/default"} + assert all(isinstance(style.style, AssSubtitleStyle) for style in ass_styles) + assert all(isinstance(style.style, RoundedSubtitleStyle) for style in rounded_styles) + + +def test_load_style_uses_renderer_to_disambiguate_default(): + ass = load_style("default", renderer=SubtitleRenderer.ASS) + rounded = load_style("default", renderer=SubtitleRenderer.ROUNDED) + + assert ass is not None + assert rounded is not None + assert ass.id == "ass/default" + assert rounded.id == "rounded/default" + assert isinstance(ass.style, AssSubtitleStyle) + assert isinstance(rounded.style, RoundedSubtitleStyle) + + +def test_save_user_style_does_not_modify_builtin_styles(tmp_path): + user_preset = SubtitleStylePreset( + id="rounded/my-style", + name="my-style", + renderer=SubtitleRenderer.ROUNDED, + source=StyleSource.USER, + style=RoundedSubtitleStyle(font_name="Noto Sans SC", font_size=44), + ) + + saved = save_user_style(user_preset, styles_dir=tmp_path) + loaded = load_style("rounded/my-style", styles_dir=tmp_path, renderer="rounded") + builtin = load_style("rounded/default", styles_dir=tmp_path, renderer="rounded") + + assert saved == tmp_path / "rounded" / "my-style.json" + assert loaded is not None + assert loaded.source == StyleSource.USER + assert loaded.to_rounded_dict()["font_size"] == 44 + assert builtin is not None + assert builtin.source == StyleSource.BUILTIN + + +def test_normalize_style_id_accepts_legacy_names(): + assert normalize_style_id("default", "ass") == "ass/default" + assert normalize_style_id("ass-default", "rounded") == "ass/default" + assert normalize_style_id("rounded-default", "ass") == "rounded/default" + assert normalize_style_id("rounded/default", "ass") == "rounded/default" diff --git a/tests/test_subtitle/test_subtitle_thread.py b/tests/test_subtitle/test_subtitle_thread.py index 731a405b..d09aa54e 100644 --- a/tests/test_subtitle/test_subtitle_thread.py +++ b/tests/test_subtitle/test_subtitle_thread.py @@ -233,6 +233,11 @@ def test_translate_google(self, subtitle_file, output_dir, base_config): assert len(results["updates"]) > 0 @pytest.mark.integration + @pytest.mark.skipif( + os.getenv("RUN_LIVE_TRANSLATION_TESTS") != "1" + and os.getenv("RUN_BING_TRANSLATOR_TESTS") != "1", + reason="Bing free translation endpoint is network-dependent; set RUN_BING_TRANSLATOR_TESTS=1 to run.", + ) def test_translate_bing(self, subtitle_file, output_dir, base_config): """Test Bing Translate (free API).""" config = base_config diff --git a/tests/test_thread/test_dubbing_thread.py b/tests/test_thread/test_dubbing_thread.py new file mode 100644 index 00000000..89993f6f --- /dev/null +++ b/tests/test_thread/test_dubbing_thread.py @@ -0,0 +1,70 @@ +import os +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from videocaptioner.core.dubbing import DubbingResult +from videocaptioner.core.dubbing.models import DubbingConfig +from videocaptioner.core.entities import DubbingTask, DubbingUIConfig +from videocaptioner.ui.thread.dubbing_thread import DubbingThread + +from .conftest import run_thread_with_timeout + + +class FakeDubbingPipeline: + def __init__(self, config: DubbingConfig): + self.config = config + + def run( + self, + subtitle_path, + output_audio_path, + *, + video_path=None, + output_video_path=None, + text_track="auto", + work_dir=None, + callback=None, + ): + if callback: + callback(50, "fake dubbing") + audio = Path(output_audio_path) + audio.parent.mkdir(parents=True, exist_ok=True) + audio.write_bytes(b"fake-audio") + video = Path(output_video_path) if output_video_path else None + if video: + video.write_bytes(b"fake-video") + return DubbingResult( + audio_path=audio, + video_path=video, + segments=[], + duration_ms=1000, + warnings=[], + ) + + +def test_dubbing_thread_finishes_with_mock_pipeline(tmp_path, monkeypatch, qapp): + subtitle = tmp_path / "input.srt" + subtitle.write_text("1\n00:00:00,000 --> 00:00:01,000\n你好\n", encoding="utf-8") + output_audio = tmp_path / "dub.wav" + output_video = tmp_path / "dub.mp4" + + monkeypatch.setattr( + "videocaptioner.ui.thread.dubbing_thread.DubbingPipeline", + FakeDubbingPipeline, + ) + + task = DubbingTask( + subtitle_path=str(subtitle), + output_audio_path=str(output_audio), + output_video_path=str(output_video), + task_dir=str(tmp_path / "tasks" / "demo"), + dubbing_config=DubbingUIConfig(enabled=True), + ) + thread = DubbingThread(task) + result = run_thread_with_timeout(thread, timeout_ms=5000) + + assert result["error"] is None + assert result["finished"] + assert output_audio.exists() + assert output_video.exists() diff --git a/tests/test_thread/test_subtitle_pipeline_thread.py b/tests/test_thread/test_subtitle_pipeline_thread.py deleted file mode 100644 index 155af122..00000000 --- a/tests/test_thread/test_subtitle_pipeline_thread.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Tests for SubtitlePipelineThread (simplified for basic validation).""" - -import pytest - - -@pytest.mark.integration -class TestSubtitlePipelineThread: - """Test suite for SubtitlePipelineThread (simplified).""" - - def test_pipeline_placeholder(self, qapp): - """Placeholder test - full pipeline tests require all dependencies.""" - # Full pipeline tests would require: - # - FasterWhisper model downloaded - # - LLM API configured - # - Video files available - # These are better suited for manual integration testing - assert True, "Pipeline thread exists and can be imported" diff --git a/tests/test_thread/test_transcript_thread.py b/tests/test_thread/test_transcript_thread.py index 1cd6f1d3..54525b82 100644 --- a/tests/test_thread/test_transcript_thread.py +++ b/tests/test_thread/test_transcript_thread.py @@ -109,3 +109,34 @@ def test_transcribe_empty_path( results = run_thread_with_timeout(thread, timeout_ms=5000) assert results["error"] is not None, "Expected error for empty path" + + +class TestDownloadedSubtitleValidation: + """下载字幕直通的校验(在首页流程里做):只认可解析且非空的真字幕。 + + 字幕路径由下载线程显式传递,转录线程不再按文件名扫描。 + """ + + @staticmethod + def _usable(path) -> bool: + from videocaptioner.ui.view.home_interface import HomeInterface + + return HomeInterface._subtitle_usable(str(path)) + + def test_missing_file_rejected(self, tmp_path, qapp): + assert self._usable(tmp_path / "gone.srt") is False + + def test_danmaku_and_empty_rejected(self, tmp_path, qapp): + danmaku = tmp_path / "video.danmaku.xml" + danmaku.write_text("") + empty = tmp_path / "video.zh.srt" + empty.write_text("") + assert self._usable(danmaku) is False + assert self._usable(empty) is False + + def test_valid_srt_accepted(self, tmp_path, qapp): + srt = tmp_path / "video.zh.srt" + srt.write_text( + "1\n00:00:00,000 --> 00:00:01,000\nhello\n", encoding="utf-8" + ) + assert self._usable(srt) is True diff --git a/tests/test_translate/test_bing_translator.py b/tests/test_translate/test_bing_translator.py index 7d388abc..2812d7f5 100644 --- a/tests/test_translate/test_bing_translator.py +++ b/tests/test_translate/test_bing_translator.py @@ -1,5 +1,6 @@ """Bing Translator integration tests.""" +import os from typing import Dict, List import pytest @@ -11,6 +12,11 @@ @pytest.mark.integration +@pytest.mark.skipif( + os.getenv("RUN_LIVE_TRANSLATION_TESTS") != "1" + and os.getenv("RUN_BING_TRANSLATOR_TESTS") != "1", + reason="Bing free translation endpoint is network-dependent; set RUN_BING_TRANSLATOR_TESTS=1 to run.", +) class TestBingTranslator: """Test suite for BingTranslator using public API endpoints.""" diff --git a/tests/test_translate/test_google_translator.py b/tests/test_translate/test_google_translator.py index c9408e9d..f086b4a4 100644 --- a/tests/test_translate/test_google_translator.py +++ b/tests/test_translate/test_google_translator.py @@ -1,5 +1,6 @@ """Google Translator integration tests.""" +import os from typing import Dict, List import pytest @@ -11,6 +12,11 @@ @pytest.mark.integration +@pytest.mark.skipif( + os.getenv("RUN_LIVE_TRANSLATION_TESTS") != "1" + and os.getenv("RUN_GOOGLE_TRANSLATOR_TESTS") != "1", + reason="Google free translation endpoint is network-dependent; set RUN_GOOGLE_TRANSLATOR_TESTS=1 to run.", +) class TestGoogleTranslator: """Test suite for GoogleTranslator using public API endpoints.""" diff --git a/tests/test_ui/__init__.py b/tests/test_ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_ui/conftest.py b/tests/test_ui/conftest.py new file mode 100644 index 00000000..127f5bd9 --- /dev/null +++ b/tests/test_ui/conftest.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""test_ui 共享 Qt 夹具。 + +qfluentwidgets 的全局 ``qconfig`` 单例在进程内只创建一次;若某个测试模块结束时 +其 ``QApplication`` 的最后一个 Python 引用被回收,sip 会把 C++ 对象连同 qconfig +一起删掉,导致后续模块构造 qfluent 控件时报 "QConfig has been deleted"。 + +这里用 session 作用域、autouse 的夹具长期持有同一个 QApplication,杜绝跨模块的 +单例被提前销毁。各模块自带的 ``app`` 夹具仍可用(拿到的是同一个实例)。 +""" + +import os +import sys + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt5.QtWidgets import QApplication # noqa: E402 + +from videocaptioner.config import I18N_PATH # noqa: E402 +from videocaptioner.ui.i18n import init as _init_i18n # noqa: E402 + +# 与 ui/main.py 一致装载 UI 翻译;缺了页面会显示 tr key 而非中文,断言会失败。 +_init_i18n(I18N_PATH, "zh_CN") + +_HELD_APP = None # 进程级强引用:阻止 QApplication 被 GC + + +@pytest.fixture(scope="session", autouse=True) +def _shared_qapp(): + global _HELD_APP + _HELD_APP = QApplication.instance() or QApplication(sys.argv) + yield _HELD_APP + # 不主动销毁:留给进程退出,避免触发 qfluent 单例的提前删除 + + +@pytest.fixture() +def app(): + """共享 QApplication 的具名别名(模块未自带 app 夹具时取用)。""" + return QApplication.instance() or QApplication(sys.argv) diff --git a/tests/test_ui/test_app_dialog.py b/tests/test_ui/test_app_dialog.py new file mode 100644 index 00000000..9d99e7ac --- /dev/null +++ b/tests/test_ui/test_app_dialog.py @@ -0,0 +1,117 @@ +"""AppDialog 壳契约:居中提升 + 确认/取消返回值。 + +历史 bug:弹窗 parent 传子页面(tab interface)时,qfluent MessageBoxBase +基于该子页面遮罩与居中,弹窗偏到窗口一角。AppDialog 构造时强制提升到 +parent.window(),这里锁住该契约,防止以后新弹窗再退化。 +""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("VIDEOCAPTIONER_CONFIG_FILE", "/tmp/vc-test-app-dialog.toml") + +from PyQt5.QtTest import QTest # noqa: E402 +from PyQt5.QtWidgets import QApplication, QFrame, QHBoxLayout, QWidget # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + yield instance + + +@pytest.fixture() +def host(app): + """主窗口 + 偏左子面板(模拟 tab interface)。""" + window = QWidget() + window.resize(1000, 700) + row = QHBoxLayout(window) + row.setContentsMargins(0, 0, 0, 0) + tab = QFrame(window) + tab.setFixedWidth(300) + row.addWidget(tab) + row.addStretch(1) + window.show() + app.processEvents() + yield window, tab + window.close() + + +class TestAppDialogShell: + def test_parent_promoted_to_window(self, host): + from videocaptioner.ui.components.app_dialog import AppDialog + + window, tab = host + dialog = AppDialog("测试", parent=tab) + assert dialog.parent() is window + dialog.deleteLater() + + def test_card_centered_on_window_not_tab(self, app, host): + from videocaptioner.ui.components.app_dialog import ConfirmDialog + + window, tab = host + dialog = ConfirmDialog("标题", "正文", tab) + dialog.show() + app.processEvents() + card_center = dialog.widget.mapTo(dialog, dialog.widget.rect().center()) + assert abs(card_center.x() - dialog.rect().center().x()) <= 2 + assert abs(card_center.y() - dialog.rect().center().y()) <= 2 + dialog.done(0) + dialog.deleteLater() + + +class TestConfirmDialogResult: + def test_confirm_returns_1(self, app, host): + from videocaptioner.ui.components.app_dialog import ConfirmDialog + + _, tab = host + dialog = ConfirmDialog("标题", "正文", tab) + results = [] + dialog.finished.connect(results.append) + dialog.show() + app.processEvents() + dialog.confirmButton.clicked.emit() + QTest.qWait(500) # MaskDialogBase.done 经淡出动画后才发 finished + assert results == [1] + dialog.deleteLater() + + def test_cancel_returns_0(self, app, host): + from videocaptioner.ui.components.app_dialog import ConfirmDialog + + _, tab = host + dialog = ConfirmDialog("标题", "正文", tab) + results = [] + dialog.finished.connect(results.append) + dialog.show() + app.processEvents() + assert dialog.cancelButton is not None + dialog.cancelButton.clicked.emit() + QTest.qWait(500) # MaskDialogBase.done 经淡出动画后才发 finished + assert results == [0] + dialog.deleteLater() + + def test_notice_mode_has_no_cancel(self, host): + from videocaptioner.ui.components.app_dialog import ConfirmDialog + + _, tab = host + dialog = ConfirmDialog("公告", "正文", tab, cancel_text=None) + assert dialog.cancelButton is None + dialog.deleteLater() + + +class TestStyleNameDialog: + def test_confirm_disabled_until_text(self, app, host): + from videocaptioner.ui.view.subtitle_style_interface import StyleNameDialog + + _, tab = host + dialog = StyleNameDialog(parent=tab) + assert not dialog.confirmButton.isEnabled() + dialog.nameLineEdit.setText(" ") + app.processEvents() + assert not dialog.confirmButton.isEnabled() + dialog.nameLineEdit.setText("我的样式") + app.processEvents() + assert dialog.confirmButton.isEnabled() + dialog.deleteLater() diff --git a/tests/test_ui/test_app_icons.py b/tests/test_ui/test_app_icons.py new file mode 100644 index 00000000..be512678 --- /dev/null +++ b/tests/test_ui/test_app_icons.py @@ -0,0 +1,35 @@ +"""应用图标规范测试。 + +主题上色管线(app_icons.render_svg_pixmap)靠把 SVG 里的 currentColor +占位符替换成主题色;硬编码颜色的图标会以原始颜色渲染、主题失效 +(曾导致“等待字幕”按钮图标在深色主题下渲染成黑色)。 +此测试强制 resource/assets/icons 下所有 SVG 遵守占位符约定。 +""" + +import re +from pathlib import Path + +import pytest + +ICON_DIR = Path(__file__).resolve().parents[2] / "resource" / "assets" / "icons" + +# fill/stroke 只允许 currentColor 或 none +_HARDCODED_PAINT = re.compile(r'(?:fill|stroke)="(?!currentColor|none)[^"]+"') + + +def _icon_files() -> list[Path]: + files = sorted(ICON_DIR.glob("*.svg")) + assert files, f"icon directory missing or empty: {ICON_DIR}" + return files + + +@pytest.mark.parametrize("svg_path", _icon_files(), ids=lambda p: p.name) +def test_icon_uses_current_color_placeholder(svg_path: Path): + content = svg_path.read_text(encoding="utf-8") + assert "currentColor" in content, ( + f"{svg_path.name} 缺少 currentColor 占位符,主题上色会失效" + ) + hardcoded = _HARDCODED_PAINT.findall(content) + assert not hardcoded, ( + f"{svg_path.name} 存在硬编码颜色 {hardcoded},请改为 currentColor / none" + ) diff --git a/tests/test_ui/test_batch_queue.py b/tests/test_ui/test_batch_queue.py new file mode 100644 index 00000000..9789ecd3 --- /dev/null +++ b/tests/test_ui/test_batch_queue.py @@ -0,0 +1,199 @@ +"""批量处理页纯逻辑测试:文件收集、阶段链、任务数据、队列调度边界。""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt5.QtCore import QObject, pyqtSignal # noqa: E402 + +import videocaptioner.ui.view.batch_process_interface as batch_module # noqa: E402 +from videocaptioner.ui.view.batch_process_interface import ( # noqa: E402 + BatchController, + BatchJob, + JobStatus, + collect_files, + mode_by_key, + stages_for_mode, +) + + +class TestStagesForMode: + def test_transcribe_only(self): + assert stages_for_mode("transcribe", dubbing_enabled=True) == ["transcribe"] + + def test_subtitle_only(self): + assert stages_for_mode("subtitle", dubbing_enabled=True) == ["subtitle"] + + def test_trans_sub(self): + assert stages_for_mode("trans_sub", dubbing_enabled=True) == [ + "transcribe", + "subtitle", + ] + + def test_full_without_dubbing(self): + assert stages_for_mode("full", dubbing_enabled=False) == [ + "transcribe", + "subtitle", + "synthesis", + ] + + def test_full_with_dubbing(self): + assert stages_for_mode("full", dubbing_enabled=True) == [ + "transcribe", + "subtitle", + "dubbing", + "synthesis", + ] + + +class TestModeByKey: + def test_known_key(self): + assert mode_by_key("subtitle").accepts_media is False + + def test_unknown_key_falls_back_to_full(self): + assert mode_by_key("nope").key == "full" + + +class TestCollectFiles: + @pytest.fixture + def tree(self, tmp_path): + (tmp_path / "a.mp4").write_bytes(b"\0") + (tmp_path / "b.txt").write_text("x") + deep = tmp_path / "l1" / "l2" + deep.mkdir(parents=True) + (deep / "c.mp3").write_bytes(b"\0") + too_deep = deep / "l3" / "l4" + too_deep.mkdir(parents=True) + (too_deep / "d.mp4").write_bytes(b"\0") + return tmp_path + + def test_expands_folder_and_filters(self, tree): + valid, ignored = collect_files([str(tree)], {".mp4", ".mp3"}) + names = sorted(os.path.basename(path) for path in valid) + assert names == ["a.mp4", "c.mp3"] # 第 4 层不展开 + assert ignored == 1 # b.txt + + def test_single_files(self, tree): + valid, ignored = collect_files( + [str(tree / "a.mp4"), str(tree / "b.txt"), str(tree / "missing.mp4")], + {".mp4"}, + ) + assert valid == [str(tree / "a.mp4")] + assert ignored == 2 + + def test_extension_case_insensitive(self, tmp_path): + upper = tmp_path / "UP.MP4" + upper.write_bytes(b"\0") + valid, _ = collect_files([str(upper)], {".mp4"}) + assert valid == [str(upper)] + + +class FakeRunner(QObject): + """替身 JobRunner:由测试手动触发完成/失败。""" + + progressChanged = pyqtSignal(int, str, str) + completed = pyqtSignal(list) + failed = pyqtSignal(str) + instances: list["FakeRunner"] = [] + + def __init__(self, file_path, stages, parent=None): + super().__init__(parent) + self.file_path = file_path + self.cancelled = False + FakeRunner.instances.append(self) + + def start(self): + pass + + def cancel(self): + self.cancelled = True + + def release(self): + pass + + +@pytest.fixture +def controller(monkeypatch): + from PyQt5.QtWidgets import QApplication + + QApplication.instance() or QApplication([]) + monkeypatch.setattr(batch_module, "JobRunner", FakeRunner) + FakeRunner.instances.clear() + ctrl = BatchController(concurrency=lambda: 2) + finished = [] + ctrl.batchFinished.connect(lambda: finished.append(True)) + ctrl.finished_events = finished + return ctrl + + +class TestControllerScheduling: + def test_pause_drain_announces_finish(self, controller): + """暂停后排空到底应宣布批次结束(历史 bug:永不收尾)。""" + controller.add_paths(["/tmp/a.mp4", "/tmp/b.mp4"]) + controller.start(["transcribe"]) + assert len(FakeRunner.instances) == 2 # 并发 2 全部起跑 + controller.pause() + for runner in list(FakeRunner.instances): + runner.completed.emit(["/tmp/out.srt"]) + assert controller.finished_events == [True] + assert not controller.is_active() + + def test_pause_with_waiting_jobs_does_not_finish(self, controller): + controller.add_paths(["/tmp/a.mp4", "/tmp/b.mp4", "/tmp/c.mp4"]) + controller.start(["transcribe"]) + controller.pause() + for runner in list(FakeRunner.instances): + runner.completed.emit([]) + assert controller.finished_events == [] # 还有等待任务,是暂停不是结束 + assert controller.count(JobStatus.WAITING) == 1 + + def test_add_files_while_running_gets_dispatched(self, controller): + controller.add_paths(["/tmp/a.mp4"]) + controller.start(["transcribe"]) + controller.add_paths(["/tmp/b.mp4"]) + assert len(FakeRunner.instances) == 2 # 运行中加入的文件自动派发 + + def test_remove_last_waiting_finishes_batch(self, controller): + controller.add_paths(["/tmp/a.mp4", "/tmp/b.mp4", "/tmp/c.mp4"]) + controller.start(["transcribe"]) + waiting = next(j for j in controller.jobs if j.status == JobStatus.WAITING) + controller.remove(waiting) + for runner in list(FakeRunner.instances): + if not runner.cancelled: + runner.completed.emit([]) + assert controller.finished_events == [True] + + def test_finish_announced_only_once(self, controller): + controller.add_paths(["/tmp/a.mp4"]) + controller.start(["transcribe"]) + FakeRunner.instances[0].completed.emit([]) + controller.remove(controller.jobs[0]) # 结束后再操作不应重复宣布 + assert controller.finished_events == [True] + + def test_retry_reopens_dispatch_and_finishes_again(self, controller): + controller.add_paths(["/tmp/a.mp4"]) + controller.start(["transcribe"]) + FakeRunner.instances[0].failed.emit("boom") + assert controller.finished_events == [True] + job = controller.jobs[0] + controller.retry(job, ["transcribe"]) + assert job.status == JobStatus.RUNNING # 重试即跑 + assert len(FakeRunner.instances) == 2 + FakeRunner.instances[1].completed.emit([]) + assert controller.finished_events == [True, True] + + +class TestBatchJob: + def test_defaults(self, tmp_path): + job = BatchJob(path=str(tmp_path / "视频.mp4")) + assert job.status == JobStatus.WAITING + assert job.progress == 0 + assert job.name == "视频.mp4" + assert job.outputs == [] + + def test_folder_abbreviates_home(self): + home = os.path.expanduser("~") + job = BatchJob(path=os.path.join(home, "Movies", "a.mp4")) + assert job.folder == os.path.join("~", "Movies") diff --git a/tests/test_ui/test_caption_overlay.py b/tests/test_ui/test_caption_overlay.py new file mode 100644 index 00000000..7ccc858c --- /dev/null +++ b/tests/test_ui/test_caption_overlay.py @@ -0,0 +1,360 @@ +"""CaptionOverlay 交互契约:按 seg_id upsert、工具条按钮切换形态/显示/暂停。 + +锁住"点击各按钮后状态正确翻转"的行为,防止以后改 toolbar 接线时退化。 +""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("VIDEOCAPTIONER_CONFIG_FILE", "/tmp/vc-test-overlay.toml") + +from PyQt5.QtWidgets import QApplication # noqa: E402 + +from videocaptioner.core.realtime.events import CaptionEntry # noqa: E402 +from videocaptioner.ui.components import caption_overlay as co # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def overlay(app): + ov = co.CaptionOverlay() + yield ov + ov.close() + ov.deleteLater() + + +def _entry(seg_id="s#0", seq=0, source="hello world", stable=5, target="", final=False): + return CaptionEntry( + seg_id=seg_id, seq=seq, source_text=source, source_stable_len=stable, + target_text=target, is_final=final, started_at=1_700_000_000.0, + ) + + +class TestUpsert: + def test_renders_source_and_target(self, overlay): + # 标准/转录态当前段落都落到 _cur_item 卡片(标准 bare),不再用独立 _src/_tgt 标签 + overlay.upsert_caption(_entry(source="今天天气", stable=2, target="weather", final=True)) + row = overlay._current_row() + assert row.source_text == "今天天气" + assert row.target_text == "weather" + item = overlay._cur_item + assert "span" in item._src.text() # 原文双色 span + # 当前段原文/译文都逐字打字(_tgt.text() 是动画中的可见串,会从空打起); + # 译文是否落地看 _content_tgt(整段,set_content 即登记,供定高)。 + assert item._content_tgt == "weather" + + def test_same_seg_id_updates_in_place(self, overlay): + overlay.upsert_caption(_entry(seg_id="s#0", source="我相信", target="")) + overlay.upsert_caption(_entry(seg_id="s#0", source="我相信美国", target="i believe")) + assert len(overlay._rows) == 1 + assert overlay._current_row().source_text == "我相信美国" + assert overlay._current_row().target_text == "i believe" + + def test_current_is_highest_seq(self, overlay): + overlay.upsert_caption(_entry(seg_id="s#0", seq=0, source="first", final=True)) + overlay.upsert_caption(_entry(seg_id="s#1", seq=1, source="second")) + assert overlay._current_row().seg_id == "s#1" + + def test_current_target_row_is_content_tight_no_reserve(self, overlay): + """当前段落不预留空译文行(预留会留一大块空白=用户反馈的「巨大空卡片」):无译文 + 时 tgt 隐藏、卡片贴合内容;译文到达才显示。固定窗高 + 保留上次译文 → 不闪不缩窗。""" + overlay.upsert_caption(_entry(seg_id="s", source="你好世界", stable=4, target="")) + item = overlay._cur_item + assert item._tgt.isHidden() is True # 无译文 → 不占空行(内容紧凑) + assert item._src.isHidden() is False + # 译文到达 → 显示(_content_tgt 是去标签纯文本,双色 span 不影响断言) + overlay.upsert_caption(_entry(seg_id="s", source="你好世界", stable=4, target="hello")) + assert item._tgt.isHidden() is False and item._content_tgt == "hello" + # 后端改写帧 target 空:upsert 保留上次译文 → tgt 仍显示,不在「有↔无」之间闪 + overlay.upsert_caption(_entry(seg_id="s", source="你好世界啊", stable=4, target="")) + assert item._tgt.isHidden() is False and item._content_tgt == "hello" + + def test_empty_target_keeps_previous_translation(self, overlay): + """防闪:新一帧还没翻译(target 空)就保留上次译文,不在空↔有之间闪。""" + overlay.upsert_caption(_entry(seg_id="s#0", source="你好世界", target="hello")) + overlay.upsert_caption(_entry(seg_id="s#0", source="你好世界啊", target="")) + assert overlay._current_row().target_text == "hello" + # 新译文到达才覆盖 + overlay.upsert_caption(_entry(seg_id="s#0", source="你好世界啊", target="hello world")) + assert overlay._current_row().target_text == "hello world" + + +class TestHistoryFollow: + def test_follow_flag_tracks_bottom(self, overlay): + """历史跟随:贴底=跟随,上翻=停跟。""" + sb = overlay._history.verticalScrollBar() + sb.setRange(0, 100) + overlay._on_history_scroll(100) # 在底 + assert overlay._history_follow is True + overlay._on_history_scroll(40) # 上翻 + assert overlay._history_follow is False + overlay._on_history_scroll(100) # 回底 + assert overlay._history_follow is True + + def test_range_grow_scrolls_to_bottom_when_following(self, overlay): + sb = overlay._history.verticalScrollBar() + overlay._history_follow = True + sb.setRange(0, 200) + overlay._on_history_range(0, 200) + assert sb.value() == 200 # 跟随时自动贴底 + overlay._history_follow = False + sb.setValue(50) + overlay._on_history_range(0, 300) + assert sb.value() == 50 # 停跟时不被拽走 + + def test_transcript_bottom_anchored(self, overlay): + """底对齐时间线:stretch 顶在最上,历史段落卡片 + 常驻的当前段落卡片在其后。""" + overlay.set_mode(co.MODE_TALL) + for i in range(3): + overlay.upsert_caption(_entry(seg_id=f"h{i}", seq=i, source=f"line {i}", + target=f"行 {i}", final=True)) + overlay.upsert_caption(_entry(seg_id="cur", seq=9, source="current", target="当前")) + overlay._flush_layout() # 同步触发转录重建(绕开 33ms 合批定时器) + layout = overlay._history_layout + assert layout.itemAt(0).widget() is None # 顶端 stretch + widgets = [layout.itemAt(i).widget() for i in range(layout.count()) + if layout.itemAt(i).widget() is not None] + assert len(widgets) == 4 # 3 历史段落 + 1 当前段落卡片 + assert widgets[-1] is overlay._cur_item # 当前段落常驻在最底 + + +class TestShellBehaviors: + def test_show_is_offscreen_safe_and_installs_edge_filter(self, overlay): + # showEvent 关原生阴影 + 装边缘过滤器;离屏平台必须不崩(曾因 winId objc 段错误) + overlay.show() + app = overlay.window() + assert app is not None + assert overlay._edge_filter_installed is True + + def test_no_autofade_no_resize_grip(self, overlay): + # 自动淡隐与右下角缩放角标都已移除(用户嫌打扰/多余);缩放靠拖边缘 + assert not hasattr(overlay, "_autofade") + assert not hasattr(overlay, "_rz") + + def test_edge_press_over_child_resizes(self, overlay): + from PyQt5.QtCore import QEvent, QPoint, Qt + from PyQt5.QtGui import QMouseEvent + + overlay.set_mode(co.MODE_TALL) + overlay.show() + calls = [] + overlay._begin_drag_or_resize = lambda pos: calls.append(pos) # 不触发真 resize + vp = overlay._history.viewport() + # 历史滚动区左边缘按下 → 过滤器吃掉并触发缩放 + edge = QPoint(1, max(2, vp.height() // 2)) + ev = QMouseEvent( + QEvent.MouseButtonPress, QPoint(edge), + Qt.LeftButton, Qt.LeftButton, Qt.NoModifier, + ) + consumed = overlay.eventFilter(vp, ev) + assert consumed is True and len(calls) == 1 + + def test_body_press_over_child_moves_window(self, overlay): + # 本体(非边缘)按下也转交浮窗 → 拖动整窗。转录文字铺满浮窗,不转交就「按在 + # 文字上拖不动、缩放不了」(用户反馈浮窗大小调不了的根因)。 + from PyQt5.QtCore import QEvent, QPoint, Qt + from PyQt5.QtGui import QMouseEvent + + overlay.set_mode(co.MODE_TALL) + overlay.show() + calls = [] + overlay._begin_drag_or_resize = lambda pos: calls.append(pos) + vp = overlay._history.viewport() + center = QPoint(max(40, vp.width() // 2), max(40, vp.height() // 2)) # 远离边缘 + ev = QMouseEvent( + QEvent.MouseButtonPress, QPoint(center), + Qt.LeftButton, Qt.LeftButton, Qt.NoModifier, + ) + consumed = overlay.eventFilter(vp, ev) + assert consumed is True and len(calls) == 1 + + +class TestCursor: + def test_body_grab_edges_resize(self, overlay): + from PyQt5.QtCore import Qt + + card = overlay._card.geometry() + # 本体 → 抓手(表明可拖);四角/边 → 缩放 + assert overlay._cursor_for(overlay._edge_at(card.center())) == Qt.OpenHandCursor + assert overlay._cursor_for(overlay._edge_at(card.topLeft())) == Qt.SizeFDiagCursor + # 卡片默认就是抓手(悬浮即提示可拖,不必等 hover 事件) + assert overlay._card.cursor().shape() == Qt.OpenHandCursor + + +class TestToolbarButtons: + def test_pause_button_toggles_state_and_signal(self, overlay): + seen = [] + overlay.pauseToggled.connect(seen.append) + overlay._btn_pause.click() + assert overlay._paused is True and seen == [True] + overlay._btn_pause.click() + assert overlay._paused is False and seen == [True, False] + + def test_pause_swaps_play_pause_icon(self, overlay): + # 暂停态用"播放"图标(点击=恢复),而不是只给一个高亮——符合正常认知 + assert overlay._btn_pause._key == "pause" + overlay.set_paused(True) + assert overlay._btn_pause._key == "play" + overlay.set_paused(False) + assert overlay._btn_pause._key == "pause" + + def test_history_button_toggles_tall(self, overlay): + overlay._btn_hist.click() + assert overlay.mode == co.MODE_TALL + overlay._btn_hist.click() + assert overlay.mode == co.MODE_STANDARD + + def test_display_mode_set_via_api(self, overlay): + # 双语/仅译文/仅原文改由设置弹层(API),工具条不再放双语按钮 + assert not hasattr(overlay, "_btn_bi") + overlay.set_display_mode(co.DISPLAY_TARGET) + assert overlay._display == co.DISPLAY_TARGET + + def test_pin_via_control_page_api(self, overlay): + # 鼠标穿透由控制页开关驱动(set_pinned),浮窗工具条不再放钉住按钮 + from PyQt5.QtCore import Qt + + assert not hasattr(overlay, "_btn_pin") + overlay.set_pinned(True) + assert overlay._pinned is True + assert overlay.testAttribute(Qt.WA_TransparentForMouseEvents) + + def test_removed_toolbar_extras_absent(self, overlay): + # 语言胶囊、复制/收藏、收起(胶囊态已删)都已去掉 + for attr in ("_lang_pill", "_btn_copy", "_btn_star", "_line_acts", "_btn_min"): + assert not hasattr(overlay, attr), attr + + def test_compact_mode_removed(self, overlay): + # COMPACT 胶囊态已彻底删除:常量/方法/波形都不应存在 + assert not hasattr(co, "MODE_COMPACT") + assert not hasattr(co, "_Waveform") + assert not hasattr(overlay, "_render_compact") + assert not hasattr(overlay, "_wave") + + +class TestScrollbarPolicy: + def test_standard_never_shows_scrollbar(self, overlay): + """标准态紧凑单段视图,滚动条永远关(杜绝右侧诡异滚动条);转录态按需。""" + from PyQt5.QtCore import Qt + + overlay.set_mode(co.MODE_STANDARD) + assert overlay._history.verticalScrollBarPolicy() == Qt.ScrollBarAlwaysOff + overlay.set_mode(co.MODE_TALL) + assert overlay._history.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded + + +class TestModeWidths: + @pytest.mark.parametrize( + "mode,width", + [ + (co.MODE_STANDARD, 540), + (co.MODE_TALL, 540), # 两态同宽 → 切历史只变高不左右抖 + ], + ) + def test_card_width_per_mode(self, overlay, mode, width): + overlay.set_mode(mode) + assert overlay._card.width() == width + + +class TestStatePreservation: + def test_user_size_kept_across_mode_toggle(self, overlay): + """用户在标准态拖大后,切到展开再切回,标准态尺寸应保持(不再每次切形态清空)。""" + overlay.set_mode(co.MODE_STANDARD) + overlay._user_sizes[co.MODE_STANDARD] = (640, 200) + overlay._relayout() + assert overlay._card.width() == 640 + overlay.set_mode(co.MODE_TALL) + overlay.set_mode(co.MODE_STANDARD) + assert overlay._card.width() == 640 # 切换往返后仍是用户尺寸 + + def test_tall_height_is_fixed_not_content_driven(self, overlay): + """根治高度抖动:转录态默认高度固定(_TALL_H),不随历史内容增减——内容多了在 + 转录区内部滚动到底,窗口高度只由用户拖边决定。""" + overlay.set_mode(co.MODE_TALL) + _, h0 = overlay._card_size() + assert h0 == co._TALL_H + for i in range(6): + overlay.upsert_caption( + _entry(seg_id=f"h#{i}", seq=i, source=f"line {i}", target=f"行{i}", final=True) + ) + _, h1 = overlay._card_size() + assert h1 == co._TALL_H # 加一堆历史后高度仍固定,不被内容撑动 + + def test_standard_height_adapts_to_content_no_clip(self, overlay): + """标准态卡高随当前段落行数自适应(根治多行时首行顶部被裁半字):单行=_STANDARD_H, + 多行长内容在上限内长高;不再固定 140 裁字。短内容仍回基准高、不留大空白。""" + overlay.set_mode(co.MODE_STANDARD) + _, h0 = overlay._card_size() + assert h0 == co._STANDARD_H # 空态/占位 = 基准高 + overlay.upsert_caption(_entry(seg_id="s", source="hi", stable=2, target="")) + _, h1 = overlay._card_size() + assert h1 == co._STANDARD_H # 一句短话仍是基准高 + overlay.upsert_caption( + _entry( + seg_id="s", + source="这是一段相当长的当前句用来测试自动换行会不会被裁切掉首行的顶部" * 2, + stable=4, + target="A fairly long current paragraph used to verify the card grows to fit " * 2, + ) + ) + _, h2 = overlay._card_size() + assert h2 > co._STANDARD_H # 多行长内容 → 长高(不再固定裁字) + assert h2 <= co._STANDARD_MAX_H # 但不超上限(再长则内部滚动) + + def test_manual_resize_changes_geometry_and_clamps(self, overlay): + """macOS startSystemResize 返回 False → 手动缩放兜底:拖右下角改窗几何,夹住最小尺寸。""" + from PyQt5.QtCore import QPoint, QRect + + overlay.set_mode(co.MODE_TALL) + before = overlay.geometry() + overlay._drag_mode = "resize" + overlay._resize_edge = co._RIGHT | co._BOTTOM + overlay._drag_geo = QRect(overlay.geometry()) + overlay._apply_manual_resize(QPoint(70, 50)) + after = overlay.geometry() + assert after.width() - before.width() == 70 + assert after.height() - before.height() == 50 + overlay._drag_geo = QRect(after) # 缩到极小 → 被夹住 + overlay._apply_manual_resize(QPoint(-9999, -9999)) + assert overlay.width() >= 260 + 2 * co.MARGIN + assert overlay.height() >= 56 + 2 * co.MARGIN + + def test_standard_centers_when_dragged_taller(self, overlay): + """标准态拖大后当前单句垂直居中(不再被顶 stretch 推到底沿留大块空白):底部 + stretch 因子=1 与顶部对称;转录态有内容时=0 保持底对齐时间线。""" + # 有内容,避开空态占位(空态两态都居中,是 #97 行为) + overlay.upsert_caption(_entry(seg_id="s", source="hi", target="你好", final=True)) + overlay.set_mode(co.MODE_STANDARD) + last = overlay._history_layout.count() - 1 + assert overlay._history_layout.itemAt(last).widget() is None # 末尾是 stretch + assert overlay._history_layout.stretch(last) == 1 # 标准态底 stretch=1 → 居中 + overlay.set_mode(co.MODE_TALL) + last = overlay._history_layout.count() - 1 + assert overlay._history_layout.stretch(last) == 0 # 转录态底 stretch=0 → 底对齐 + + def test_empty_placeholder_centers_in_both_modes(self, overlay): + """空态「监听中…」两态都垂直居中(#97):底部 stretch=1 与顶部对称。""" + for mode in (co.MODE_STANDARD, co.MODE_TALL): + overlay.set_mode(mode) + last = overlay._history_layout.count() - 1 + assert overlay._history_layout.stretch(last) == 1 + + def test_set_mode_emits_mode_changed(self, overlay): + seen = [] + overlay.modeChanged.connect(seen.append) + overlay.set_mode(co.MODE_TALL) + assert seen == [co.MODE_TALL] + overlay.set_mode(co.MODE_TALL) # 同形态不重复发 + assert seen == [co.MODE_TALL] + + def test_restore_size_seeds_user_size(self, overlay): + overlay.set_mode(co.MODE_TALL) + overlay.restore_size(co.MODE_TALL, 700, 520) + assert overlay._user_sizes[co.MODE_TALL] == (700, 520) + assert overlay._card.width() == 700 diff --git a/tests/test_ui/test_dependency_dialog.py b/tests/test_ui/test_dependency_dialog.py new file mode 100644 index 00000000..1a38f687 --- /dev/null +++ b/tests/test_ui/test_dependency_dialog.py @@ -0,0 +1,135 @@ +"""依赖下载弹窗的行为契约。 + +锁住:已安装→绿胶囊无按钮;缺失→可下载;不支持平台→不可用禁用;一键安装仅在有缺失时 +可点;下载流程经替身线程:progress 刷行、completed 发 depsChanged。 +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("VIDEOCAPTIONER_CONFIG_FILE", "/tmp/vc-test-depdlg.toml") + +import pytest # noqa: E402 +from PyQt5.QtWidgets import QWidget # noqa: E402 + +from videocaptioner.ui.components import dependency_download_dialog as ddl # noqa: E402 + + +@pytest.fixture() +def host(app): + # AppDialog 需要一个有 window() 的父控件(遮罩盖整个程序窗口)。 + # resize 即可满足 MaskDialogBase 取 parent.width/height,无需真的 show()。 + # 与 test_app_dialog 一致:弹窗各自 deleteLater,host 只 close(不删、不 flush), + # 避免 MaskDialogBase 在离屏下被提前删触发段错误。 + w = QWidget() + w.resize(900, 640) + yield w + w.close() + + +class _Signal: + def __init__(self): + self._slots = [] + + def connect(self, slot): + self._slots.append(slot) + + def emit(self, *a): + for s in list(self._slots): + s(*a) + + +class _FakeThread: + def __init__(self): + self.started = False + self.stopped = False + for name in ("progress", "completed", "error", "finished"): + setattr(self, name, _Signal()) + + def start(self): + self.started = True + + def stop(self, *a, **k): + self.stopped = True + + def deleteLater(self): + pass + + +@pytest.fixture() +def installed_map(monkeypatch): + """可变的「已安装」表:测试按 key 控制每个依赖装没装。""" + state = {"ffmpeg": True, "voxgate": False} + monkeypatch.setattr(ddl, "is_installed", lambda spec: state.get(spec.key, False)) + return state + + +def test_rows_reflect_install_state(host, installed_map): + dlg = ddl.DependencyDownloadDialog(parent=host) + rows = {r.spec.key: r for r in dlg._rows} + assert set(rows) == {"ffmpeg", "voxgate"} + # ffmpeg 已装:绿胶囊、操作按钮隐藏 + assert rows["ffmpeg"].status.text() == "已安装" + assert rows["ffmpeg"].status.isHidden() is False + assert rows["ffmpeg"].actionButton.isHidden() is True + # voxgate 缺失:右侧单槽只放下载按钮,状态胶囊隐藏(不再叠一个「待安装」胶囊) + assert rows["voxgate"].actionButton.isHidden() is False + assert rows["voxgate"].status.isHidden() is True + assert dlg.installAllButton.isEnabled() is True + dlg.done(0) + dlg.deleteLater() + + +def test_install_all_disabled_when_all_installed(host, monkeypatch): + monkeypatch.setattr(ddl, "is_installed", lambda spec: True) + dlg = ddl.DependencyDownloadDialog(parent=host) + assert dlg.installAllButton.isEnabled() is False + dlg.done(0) + dlg.deleteLater() + + +def test_unsupported_platform_disables_action(host, installed_map, monkeypatch): + monkeypatch.setattr(ddl, "asset_for", lambda spec: None) # 平台无件 + dlg = ddl.DependencyDownloadDialog(parent=host) + vox = next(r for r in dlg._rows if r.spec.key == "voxgate") + # 不支持的平台只显示「暂不支持」胶囊,不再放一个禁用按钮(右侧单槽只放一件事) + assert vox.status.text() == "暂不支持" + assert vox.status.isHidden() is False + assert vox.actionButton.isHidden() is True + dlg.done(0) + dlg.deleteLater() + + +def test_download_flow_emits_deps_changed(host, installed_map, monkeypatch): + fake = _FakeThread() + monkeypatch.setattr(ddl, "dependency_download_thread", lambda spec, parent=None: fake) + dlg = ddl.DependencyDownloadDialog(parent=host) + changed = [] + dlg.depsChanged.connect(lambda: changed.append(True)) + + vox = next(r for r in dlg._rows if r.spec.key == "voxgate") + vox.actionButton.clicked.emit() + assert fake.started is True + assert dlg._busy is True + + fake.progress.emit(42, "voxgate · 4.2 MB / 10 MB") + assert "42%" in vox.percentLabel.text() + + # 安装完成 → 标记已装 → 完成回调发 depsChanged,结束后回到非忙 + installed_map["voxgate"] = True + fake.completed.emit("/x/voxgate") + fake.finished.emit() + assert changed == [True] + assert dlg._busy is False + dlg.done(0) + dlg.deleteLater() + + +def test_close_cancels_active_download(host, installed_map, monkeypatch): + fake = _FakeThread() + monkeypatch.setattr(ddl, "dependency_download_thread", lambda spec, parent=None: fake) + dlg = ddl.DependencyDownloadDialog(parent=host) + next(r for r in dlg._rows if r.spec.key == "voxgate").actionButton.clicked.emit() + dlg.done(0) # 关闭即取消 + dlg.deleteLater() + assert fake.stopped is True diff --git a/tests/test_ui/test_doctor_status.py b/tests/test_ui/test_doctor_status.py new file mode 100644 index 00000000..404b583c --- /dev/null +++ b/tests/test_ui/test_doctor_status.py @@ -0,0 +1,45 @@ +"""诊断页状态映射契约:warn 与 error 必须区分(琥珀 vs 红)。 + +锁住「可选项缺失 = 琥珀 WARNING,不是红 ERROR」这条——live_caption 缺 voxgate、 +dubbing 缺 Key 都属此类,不应像硬错误一样吓人,也不计入「未通过」。 +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("VIDEOCAPTIONER_CONFIG_FILE", "/tmp/vc-test-doctor-status.toml") + +from videocaptioner.cli.commands.doctor import Check # noqa: E402 +from videocaptioner.ui.view import doctor_interface as di # noqa: E402 + + +def test_combined_status_distinguishes_warn_from_error(): + assert di._combined_status([Check("a", "ok", "")]) == di.ItemStatus.OK + assert di._combined_status([Check("a", "warn", "")]) == di.ItemStatus.WARNING + assert di._combined_status([Check("a", "error", "")]) == di.ItemStatus.ERROR + # error 优先于 warn:同时存在按最严重算 + assert di._combined_status( + [Check("a", "warn", ""), Check("b", "error", "")] + ) == di.ItemStatus.ERROR + assert di._combined_status([]) == di.ItemStatus.OK + + +def test_status_level_and_text_have_warning(): + assert di._status_level(di.ItemStatus.WARNING) == "warning" + assert di._status_text(di.ItemStatus.WARNING) # 有文案 + + +def test_live_caption_missing_voxgate_is_amber_warning(): + # voxgate 缺失 → warn → 卡片 WARNING(琥珀),描述强调「可选」,而非红色「不可用」 + checks = [Check("live_caption.voxgate", "warn", "未找到 voxgate 转录程序")] + items = di._items_from_checks(checks) + card = next(i for i in items if i.key == "live_caption") + assert card.status == di.ItemStatus.WARNING + assert "可选" in card.description + + +def test_live_caption_ready_is_ok(): + checks = [Check("live_caption.voxgate", "ok", "voxgate 已就绪:/usr/bin/voxgate")] + items = di._items_from_checks(checks) + card = next(i for i in items if i.key == "live_caption") + assert card.status == di.ItemStatus.OK diff --git a/tests/test_ui/test_live_caption_control.py b/tests/test_ui/test_live_caption_control.py new file mode 100644 index 00000000..ac3ad6ec --- /dev/null +++ b/tests/test_ui/test_live_caption_control.py @@ -0,0 +1,186 @@ +"""实时字幕页启停 + 崩溃防线契约(三视图宿主版)。 + +锁住:点了即时切到录制态、停止非阻塞且异步退役线程、销毁浮窗前先断开 caption +信号(否则收尾期排队信号投递到已释放浮窗 → 硬 abort)、暂停真停喂音频、退出阻塞收尾。 +""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("VIDEOCAPTIONER_CONFIG_FILE", "/tmp/vc-test-livectl.toml") + +from PyQt5.QtWidgets import QApplication # noqa: E402 + +from videocaptioner.ui.view import live_caption_interface as lci # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +class _Signal: + def __init__(self): + self.slots = [] + + def connect(self, slot): + self.slots.append(slot) + + def disconnect(self, slot=None): + if slot is None: + self.slots.clear() + return + if slot in self.slots: + self.slots.remove(slot) + else: + raise TypeError("not connected") + + def emit(self, *a): + for s in list(self.slots): + s(*a) + + +class _FakeThread: + """替身线程:记录调用,永远"在运行",不真起后端。""" + + def __init__(self, config, store=None, parent=None): + self.config = config + self.store = store + self.started = False + self.cancelled = False + self.stopped = False + self.paused = None + self._running = False + for name in ("caption", "stateChanged", "error", "recorded", "finished", "level"): + setattr(self, name, _Signal()) + + def start(self): + self.started = True + self._running = True + + def isRunning(self): + return self._running + + def request_cancel(self): + self.cancelled = True + self._running = False + + def stop(self, *a, **k): + self.stopped = True + self._running = False + + def set_paused(self, paused): + self.paused = paused + + def deleteLater(self): + self.deleted = True + + +@pytest.fixture() +def page(app, monkeypatch): + monkeypatch.setattr(lci, "LiveCaptionThread", _FakeThread) + p = lci.LiveCaptionInterface() + yield p + p._finish() + p.deleteLater() + + +def test_start_switches_to_live_immediately_then_launches(page): + page._start() + assert page.session._mode == lci.MODE_LIVE # 当帧就切录制态 + assert page._starting is True + assert page._thread is None # 线程构造推迟到下一轮事件循环 + QApplication.processEvents() + assert page._starting is False + assert page._thread is not None and page._thread.started is True + + +def test_pause_signal_is_wired_to_thread(page): + page._start() + QApplication.processEvents() + overlay = page._overlay + assert overlay is not None + overlay._toggle_pause() # 浮窗暂停 → 真停喂音频 + assert page._thread.paused is True + overlay._toggle_pause() + assert page._thread.paused is False + + +def test_finish_is_nonblocking_and_retires_thread(page): + page._start() + QApplication.processEvents() + thread = page._thread + page._finish() + assert page._thread is None + assert thread.cancelled is True and thread.stopped is False # 非阻塞取消 + assert thread in page._retiring + thread.finished.emit() + QApplication.processEvents() + assert thread not in page._retiring + + +def test_finish_disconnects_signals_before_teardown(page): + page._start() + QApplication.processEvents() + thread = page._thread + assert page._on_caption in thread.caption.slots + + page._finish() + + assert page._on_caption not in thread.caption.slots + # 停止后后端线程仍可能 emit:现在落空、不触达已销毁浮窗、不崩 + from videocaptioner.core.realtime.events import CaptionEntry + + thread.caption.emit(CaptionEntry("s", 0, "x", 1, "", False, 0.0)) + + +def test_restart_ignored_while_starting(page): + page._start() + assert page._starting is True + page._start() # 构造窗口内再点:忽略 + QApplication.processEvents() + assert page._thread is not None and page._thread.started is True + + +def test_finish_during_starting_window_resets_to_ready(page): + # 启动窗口期(singleShot 已排队、线程还没建)就点结束:必须复位回就绪, + # 不能走 set_saving——否则没有线程触发 finished/recorded,会永久卡「保存中…」。 + page._start() + assert page._starting is True and page._thread is None + page._finish() + assert page._starting is False + assert page.session._mode == lci.MODE_READY + QApplication.processEvents() # 排队的 _launch 应空跑、不起线程 + assert page._thread is None + assert page.session._mode == lci.MODE_READY + + +def test_close_event_blocks_for_clean_shutdown(page): + page._start() + QApplication.processEvents() + thread = page._thread + from PyQt5.QtGui import QCloseEvent + + page.closeEvent(QCloseEvent()) + assert thread.stopped is True # 退出走阻塞 stop() + assert page._thread is None + + +def test_finish_when_idle_is_safe(page): + page._finish() # 未运行就停:幂等、不崩 + assert page._thread is None + + +def test_recorded_signal_switches_to_ended(page, tmp_path): + from videocaptioner.core.realtime.recording.history import LiveCaptionRecord + from videocaptioner.ui.components.live_caption.views import MODE_ENDED + + page._start() + QApplication.processEvents() + rec = LiveCaptionRecord(id="x", name="记录", source="麦克风", translate="原文记录", + created_at=1.0, duration=12.0, audio="", segments=[]) + page._on_recorded(rec) + assert page.session._mode == MODE_ENDED + assert page._last_record is rec diff --git a/tests/test_ui/test_live_caption_player.py b/tests/test_ui/test_live_caption_player.py new file mode 100644 index 00000000..8547aac9 --- /dev/null +++ b/tests/test_ui/test_live_caption_player.py @@ -0,0 +1,50 @@ +"""AudioPlayerBar seek 时序契约:媒体未加载完时 setPosition 会被 AVFoundation 丢弃, +必须暂存目标位置并在 LoadedMedia 时补跳——否则进入详情后「第一次点句子不跳、之后才跳」。 +""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("VIDEOCAPTIONER_CONFIG_FILE", "/tmp/vc-test-lc-player.toml") + +from PyQt5.QtMultimedia import QMediaPlayer # noqa: E402 +from PyQt5.QtWidgets import QApplication # noqa: E402 + +from videocaptioner.ui.components.live_caption.player import AudioPlayerBar # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def player(app): + bar = AudioPlayerBar() + bar._starts = [0.0, 3.0, 7.5] + bar._duration_ms = 12_000 + yield bar + bar.deleteLater() + + +def test_seek_before_loaded_stashes_pending(player): + # 未加载完(NoMedia)→ setPosition 被丢弃 → 暂存目标,等加载完成 + assert player._player.mediaStatus() != QMediaPlayer.LoadedMedia + player.seek_sentence(1) + assert player._pending_seek_ms == 3000 # 第 2 句起点暂存 + + +def test_loaded_status_replays_pending(player): + player.seek_sentence(2) + assert player._pending_seek_ms == 7500 + # 模拟媒体加载完成 → 补跳并清空暂存 + player._on_media_status(QMediaPlayer.LoadedMedia) + assert player._pending_seek_ms is None + + +def test_load_clears_stale_pending(player): + player._pending_seek_ms = 5000 + player.load(None, [0.0, 2.0], 5.0) # 换记录后旧的待跳必须清掉 + assert player._pending_seek_ms is None diff --git a/tests/test_ui/test_style_contracts.py b/tests/test_ui/test_style_contracts.py new file mode 100644 index 00000000..70aeec75 --- /dev/null +++ b/tests/test_ui/test_style_contracts.py @@ -0,0 +1,90 @@ +"""UI 样式契约:防止“组件定义了 syncStyle 但构造时没应用样式”。 + +历史 bug:批量处理页 TaskRow 定义了 syncStyle(内含 setStyleSheet)却没在 +__init__ 调用,行内 QLabel 落到 Qt 默认黑字(深色主题下不可读)。截图回归 +只能逐页发现,这里用 AST 做全量静态检查。 + +规则:类的 syncStyle 自身包含 setStyleSheet(说明该类对自己的样式负责)时, +__init__ 必须能(沿 self.xxx() 调用链传递地)到达一次 setStyleSheet 或 +syncStyle 调用;没有 __init__ 的子类视为父类构造负责。 + +确认过“由外部在构造后统一调用 syncStyle”的类进白名单并注明原因。 +""" + +import ast +from pathlib import Path + +UI_ROOT = Path(__file__).resolve().parents[2] / "videocaptioner" / "ui" + +# 构造后由持有方立即调用 syncStyle 的类(运行时已逐一核实有样式)。 +EXEMPT: dict[str, set[str]] = {} + + +def _method_calls(func: ast.FunctionDef) -> set[str]: + """func 体内 self.xxx(...) 形式的方法调用名集合。""" + calls = set() + for node in ast.walk(func): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "self" + ): + calls.add(node.func.attr) + return calls + + +def _applies_style(func: ast.FunctionDef) -> bool: + src = ast.dump(func) + return "setStyleSheet" in src or "syncStyle" in src + + +def _class_violations(tree: ast.Module) -> list[str]: + violations = [] + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + methods = { + item.name: item for item in node.body if isinstance(item, ast.FunctionDef) + } + sync = methods.get("syncStyle") + init = methods.get("__init__") + if sync is None or init is None: + continue + if "setStyleSheet" not in ast.dump(sync): + continue # syncStyle 只转发子控件,无自我样式职责 + # 从 __init__ 沿 self 方法调用做可达性搜索 + seen, queue = set(), ["__init__"] + reached_style = False + while queue: + name = queue.pop() + if name in seen or name not in methods: + continue + seen.add(name) + if _applies_style(methods[name]): + reached_style = True + break + queue.extend(_method_calls(methods[name])) + if not reached_style: + violations.append(node.name) + return violations + + +def test_widgets_apply_styles_on_construction(): + assert UI_ROOT.exists() + all_violations: dict[str, list[str]] = {} + for path in sorted(UI_ROOT.rglob("*.py")): + relative = str(path.relative_to(UI_ROOT)) + tree = ast.parse(path.read_text(encoding="utf-8")) + violations = [ + name + for name in _class_violations(tree) + if name not in EXEMPT.get(relative, set()) + ] + if violations: + all_violations[relative] = violations + assert not all_violations, ( + "以下类定义了带 setStyleSheet 的 syncStyle,但 __init__ 未应用样式" + "(QLabel 会落到默认黑字):\n" + + "\n".join(f" {file}: {names}" for file, names in all_violations.items()) + ) diff --git a/tests/test_ui/test_subtitle_editor.py b/tests/test_ui/test_subtitle_editor.py new file mode 100644 index 00000000..c18505ef --- /dev/null +++ b/tests/test_ui/test_subtitle_editor.py @@ -0,0 +1,51 @@ +"""字幕行内编辑器回归:双击进入编辑不再全选整格(用户反馈:双击直接全选不合理)。""" + +import pytest +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QApplication, QWidget + +from videocaptioner.ui.view.subtitle_interface import SubtitleLineEditor + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def test_focus_does_not_select_all(app): + host = QWidget() + host.resize(400, 80) + host.show() + editor = SubtitleLineEditor(host) + editor.setText("欢迎报考百年名校华南师范大学") + editor.setGeometry(10, 10, 360, 30) + editor.show() + app.processEvents() + # OtherFocusReason 正是单元格进入编辑时的聚焦方式,Qt 默认会 selectAll + editor.setFocus(Qt.OtherFocusReason) + app.processEvents() + assert editor.hasSelectedText() is False + # 无点击坐标时光标落到末尾,可直接续编 + assert editor.cursorPosition() == len(editor.text()) + host.close() + + +def test_click_point_positions_cursor(app): + host = QWidget() + host.resize(400, 80) + host.show() + editor = SubtitleLineEditor(host) + editor.setText("abcdefghijklmnop") + editor.setGeometry(10, 10, 360, 30) + editor.show() + app.processEvents() + # 给一个落在文本左段的全局点,光标应落在靠前位置而非末尾/全选 + left_global = editor.mapToGlobal(editor.rect().topLeft()) + left_global.setX(left_global.x() + 6) + editor.setClickPoint(left_global) + editor.setFocus(Qt.OtherFocusReason) + app.processEvents() + # 关键保证:带点击坐标聚焦也不全选;精确光标列依赖真实几何,offscreen 不强校验 + assert editor.hasSelectedText() is False + assert 0 <= editor.cursorPosition() <= len(editor.text()) + host.close() diff --git a/tests/test_ui/test_subtitle_style_persistence.py b/tests/test_ui/test_subtitle_style_persistence.py new file mode 100644 index 00000000..54ba33c0 --- /dev/null +++ b/tests/test_ui/test_subtitle_style_persistence.py @@ -0,0 +1,95 @@ +"""字幕样式页:编辑内置样式不应每次新建一个,避免样式越积越多。 + +历史 bug:关闭/打开程序后字幕样式列表里突然多出一堆 `默认描边 · 自定义`、 +`…-custom-2`、`…-custom-3`…… 用户并没有手动建这么多。 + +根因(两处耦合): +1. `subtitle_style_name` 只存一份,却被 ASS / 圆角两种渲染模式共用。切到另一 + 模式再切回时,`_refresh_style_list` 解析不到本模式的选择,回退到内置默认 + 并把它写回配置——本模式的选择被悄悄重置成内置。 +2. `_auto_save` 编辑内置时用递增 id(`-custom-N`)fork,每次重置回内置后再 + 编辑就又新建一个。 + +修复:每个模式各记一份最近选择(切模式往返可还原);编辑内置时 fork 到该内置 +「唯一」的确定性 id(已存在就复用更新)。本测试锁死“无论怎么编辑/切模式/重启, +同一个内置至多派生一个用户样式”。 +""" + +import os +import sys + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 +from PyQt5.QtWidgets import QApplication # noqa: E402 + +import videocaptioner.config as vc_config # noqa: E402 +import videocaptioner.core.application.config_store as config_store # noqa: E402 +from videocaptioner.ui.common.config import cfg # noqa: E402 + + +@pytest.fixture +def qapp(): + return QApplication.instance() or QApplication(sys.argv) + + +@pytest.fixture +def isolated_styles(tmp_path, monkeypatch): + """把用户样式目录与配置文件都指到临时路径,互不污染真实环境。""" + styles_dir = tmp_path / "subtitle_styles" + styles_dir.mkdir() + monkeypatch.setattr(vc_config, "USER_SUBTITLE_STYLE_PATH", styles_dir) + monkeypatch.setattr(config_store, "CONFIG_FILE", tmp_path / "config.toml") + return styles_dir + + +def _user_style_files(styles_dir): + return sorted(p.relative_to(styles_dir).as_posix() for p in styles_dir.rglob("*.json")) + + +def _make_page(monkeypatch): + from videocaptioner.ui.view.subtitle_style_interface import SubtitleStyleInterface + + # 预览会起 QThread,本测试只关心 fork 逻辑,置空避免线程残留。 + monkeypatch.setattr(SubtitleStyleInterface, "update_preview", lambda self: None) + page = SubtitleStyleInterface() + page._on_mode_changed("ass") + return page + + +def test_editing_builtin_forks_only_once(qapp, isolated_styles, monkeypatch): + # 起始:当前是内置 ASS 默认样式 + cfg.set(cfg.subtitle_style_name, "ass/default", save=False) + + page = _make_page(monkeypatch) + assert _user_style_files(isolated_styles) == [] + + # 1) 编辑内置 → 派生唯一 fork + page._ass["size"].setValue(41) + page._on_edit() + assert _user_style_files(isolated_styles) == ["ass/default-custom.json"] + assert cfg.subtitle_style_name.value == "ass/default-custom" + + # 2) 切到圆角再切回 ASS(往返)——选择必须被记住、还原,而非重置回内置 + page._on_mode_changed("rounded") + page._on_mode_changed("ass") + assert cfg.subtitle_style_name.value == "ass/default-custom" + + # 3) 往返后再次编辑 → 更新同一个 fork,绝不新建 + page._ass["size"].setValue(46) + page._on_edit() + assert _user_style_files(isolated_styles) == ["ass/default-custom.json"] + + +def test_no_accumulation_across_restarts(qapp, isolated_styles, monkeypatch): + cfg.set(cfg.subtitle_style_name, "ass/default", save=False) + + # 连续三次“启动→编辑内置→关闭”,模拟用户多日使用 + for size in (40, 44, 50): + page = _make_page(monkeypatch) + page._ass["size"].setValue(size) + page._on_edit() + page.deleteLater() + + # 三轮下来仍只有一个用户样式,而不是 3 个 -custom-N + assert _user_style_files(isolated_styles) == ["ass/default-custom.json"] diff --git a/tests/test_ui/test_typing.py b/tests/test_ui/test_typing.py new file mode 100644 index 00000000..17cc4f4b --- /dev/null +++ b/tests/test_ui/test_typing.py @@ -0,0 +1,64 @@ +"""译文逐字动画步进 next_visible 契约:只前进、不回退、不缩短。 + +可见长度单调不减:公共前缀原位不动,改写过的尾字在原位被新值覆盖(不先删再打),新增字逐字 +补出。这样行数只增不减、高度不会先塌再涨(删字回退会缩行、看着卡)。整句重译变短才直接对齐。 +""" + +from videocaptioner.ui.components.live_caption.typing import common_prefix_len, next_visible + + +def _converge(visible: str, full: str, max_steps: int = 500): + seq = [visible] + for _ in range(max_steps): + if visible == full: + break + visible = next_visible(visible, full) + seq.append(visible) + assert visible == full # 终会收敛到目标(不死循环) + return seq + + +def _lengths_nondecreasing(seq): + return all(len(seq[i]) <= len(seq[i + 1]) for i in range(len(seq) - 1)) + + +def test_pure_append_types_forward(): + seq = _converge("", "你好世界") + assert seq[0] == "" and seq[-1] == "你好世界" + for s in seq: # 全程是目标的前缀,逐字增长 + assert "你好世界".startswith(s) + assert _lengths_nondecreasing(seq) + + +def test_tail_rewrite_overwrites_in_place_no_shrink(): + # 改写尾字:可见长度只增不减(不先删再打),公共前缀「我觉得」原位不动 → 不抖 + seq = _converge("我觉得很难", "我觉得非常困难") + assert seq[0] == "我觉得很难" and seq[-1] == "我觉得非常困难" + assert _lengths_nondecreasing(seq) # 关键:全程不缩短 + assert all(s.startswith("我觉得") for s in seq) # 公共前缀不动 + + +def test_same_length_rewrite_is_in_place_one_step(): + # 等长改写:一步原位覆盖末尾,长度不变、不闪 + assert _converge("今天天气不错", "今天天气很好") == ["今天天气不错", "今天天气很好"] + + +def test_shorter_retranslation_aligns_directly(): + # 整句重译变短(少见):直接对齐到更短目标(这一步会变短,容器由高度地板兜住不抖) + assert _converge("你好世界啊哈", "你好世界") == ["你好世界啊哈", "你好世界"] + + +def test_big_rewrite_forward_only_accelerated(): + old = "这是上一句完全不同的旧内容" * 3 + new = "这是" + "全新改写后的另一段更长的内容" * 3 + seq = _converge(old, new) + assert seq[-1] == new + assert _lengths_nondecreasing(seq) # 全程不缩短(new 比 old 长) + assert len(seq) < len(new) # 加速补 → 步数远少于纯逐字 + + +def test_common_prefix_len(): + assert common_prefix_len("abc", "abd") == 2 + assert common_prefix_len("", "x") == 0 + assert common_prefix_len("abc", "abc") == 3 + assert common_prefix_len("abc", "abcd") == 3 diff --git a/tests/test_update/__init__.py b/tests/test_update/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_update/test_client.py b/tests/test_update/test_client.py new file mode 100644 index 00000000..a69b89e3 --- /dev/null +++ b/tests/test_update/test_client.py @@ -0,0 +1,137 @@ +"""更新检查客户端:解析后端 /api/update/check 响应(确定性单测,不打网络)。 + +真机联调(真实后端)见手动验证;这里只锁解析、容错、渠道判定、镜像兜底逻辑。 +""" + +import os + +import pytest + +from videocaptioner.core.update.client import ( + Announcement, + UpdateInfo, + app_channel, + check_update, +) + + +class _Resp: + def __init__(self, payload, status=200): + self._payload = payload + self.status_code = status + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + +class _Session: + def __init__(self, payload, status=200): + self._payload = payload + self._status = status + self.captured = {} + + def get(self, url, headers=None, timeout=None): + self.captured["url"] = url + self.captured["headers"] = headers or {} + return _Resp(self._payload, self._status) + + +def _check(payload): + sess = _Session(payload) + return check_update("https://x/api/update/check", session=sess), sess + + +def test_parses_full_response(): + res, _ = _check( + { + "block": "此版本已停用", + "update": { + "version": "2.3.0", + "notes": "修复…", + "url": "https://github.com/x/y.zip", + "sha256": "abc", + "size": 123, + }, + "announcement": {"id": "a1", "title": "标题", "content": "正文"}, + } + ) + assert res.block == "此版本已停用" + assert isinstance(res.update, UpdateInfo) and res.update.version == "2.3.0" + assert res.update.size == 123 and res.update.sha256 == "abc" + assert isinstance(res.announcement, Announcement) and res.announcement.id == "a1" + # 镜像兜底:原直链在末尾 + assert res.update.urls[-1] == "https://github.com/x/y.zip" and len(res.update.urls) == 3 + + +def test_all_null(): + res, _ = _check({"block": None, "update": None, "announcement": None}) + assert res.block is None and res.update is None and res.announcement is None + + +def test_sends_required_headers(): + _, sess = _check({"block": None, "update": None, "announcement": None}) + h = sess.captured["headers"] + for key in ("X-App-Version", "X-App-Platform", "X-App-Channel", "X-Client-Id", "User-Agent"): + assert h.get(key) + assert h["X-App-Channel"] in ("desktop", "dev", "pip") + + +def test_blank_block_is_none(): + res, _ = _check({"block": " ", "update": None, "announcement": None}) + assert res.block is None # 空白字符串视为未封禁 + + +def test_update_missing_url_dropped(): + res, _ = _check({"update": {"version": "2.3.0"}}) # 缺 url → 不冒进 + assert res.update is None + + +def test_update_bad_size_degrades_to_zero(): + res, _ = _check({"update": {"version": "2.3.0", "url": "https://x/y.zip", "size": "oops"}}) + assert res.update is not None and res.update.size == 0 + + +def test_announcement_without_id_dropped(): + res, _ = _check({"announcement": {"content": "无 id 无法去重"}}) + assert res.announcement is None # 无 id 不弹(没法去重) + + +def test_error_response_returns_none(): + res, _ = _check({"ok": False, "code": "invalid_request", "error": "缺少 X-App-Version"}) + assert res is None + + +def test_non_dict_response_returns_none(): + res, _ = _check(["not", "a", "dict"]) + assert res is None + + +def test_network_error_returns_none(): + import requests + + class _BoomSession: + def get(self, *a, **k): + raise requests.ConnectionError("down") + + res = check_update("https://x", session=_BoomSession()) + assert res is None # 失败静默,不抛给调用方 + + +def test_app_channel_value(): + # 源码/editable 运行 = dev;本仓库测试环境包不在 site-packages + assert app_channel() in ("desktop", "dev", "pip") + + +@pytest.mark.integration +@pytest.mark.skipif( + os.getenv("RUN_UPDATE_CHECK_LIVE") != "1", + reason="真实后端联调;设 RUN_UPDATE_CHECK_LIVE=1 运行。", +) +def test_live_backend(): + from videocaptioner.config import UPDATE_CHECK_URL + + res = check_update(UPDATE_CHECK_URL, timeout=20) + assert res is not None # 后端可达且返回合法 JSON diff --git a/tests/test_update/test_installer.py b/tests/test_update/test_installer.py new file mode 100644 index 00000000..35848c9a --- /dev/null +++ b/tests/test_update/test_installer.py @@ -0,0 +1,130 @@ +"""更新安装器:helper 脚本生成 / 解压 / 安装根定位 / 自更新可行性 的可测部分。 + +「替换运行中的自己」无法在单测里安全跑(会动到当前进程目录),只验证脚本内容、 +解压结构、以及非 frozen 时的安全退路(不能自更新、apply_update 抛错)。 +""" + +import zipfile + +import pytest + +from videocaptioner.core.update import installer + + +def test_install_root_none_when_not_frozen(monkeypatch): + monkeypatch.setattr(installer, "is_frozen", lambda: False) + assert installer.install_root() is None + + +def test_can_self_update_false_when_not_frozen(monkeypatch): + monkeypatch.setattr(installer, "is_frozen", lambda: False) + assert installer.can_self_update() is False + + +def test_apply_update_raises_when_not_frozen(monkeypatch, tmp_path): + monkeypatch.setattr(installer, "install_root", lambda: None) + zip_path = tmp_path / "x.zip" + zip_path.write_bytes(b"") + with pytest.raises(RuntimeError): + installer.apply_update(zip_path) + + +def test_win_helper_waits_for_pid_then_swaps(tmp_path): + new_dir = tmp_path / "new" / "VideoCaptioner" + target = tmp_path / "app" / "VideoCaptioner" + script = installer._win_helper(new_dir, target, 4321) + assert "PID eq 4321" in script # 等本进程退出 + assert f'rmdir /s /q "{target}"' in script + assert f'move "{new_dir}" "{target}"' in script + assert "VideoCaptioner.exe" in script # 重启 + assert "del " in script # 自删除 + # Inno 安装副本的卸载器要保留:rmdir 前先挪进新目录,move 时带回 + assert "unins000" in script + assert f'if exist "{target}\\unins000.exe"' in script + + +def test_unix_helper_mac_clears_quarantine_and_reopens(tmp_path): + new_app = tmp_path / "new" / "VideoCaptioner.app" + target = tmp_path / "Applications" / "VideoCaptioner.app" + script = installer._unix_helper(new_app, target, 99, is_mac=True) + assert "kill -0 99" in script # 等本进程退出 + assert f'rm -rf "{target}"' in script + assert f'mv "{new_app}" "{target}"' in script + assert "xattr -dr com.apple.quarantine" in script # 清隔离属性 + assert f'open "{target}"' in script # 重启 + + +def test_unix_helper_linux_no_quarantine(tmp_path): + new_dir = tmp_path / "new" / "VideoCaptioner" + target = tmp_path / "opt" / "VideoCaptioner" + script = installer._unix_helper(new_dir, target, 7, is_mac=False) + assert "xattr" not in script + assert f'"{target}/VideoCaptioner"' in script # 直接启动可执行文件 + + +def test_extract_finds_named_top_level(tmp_path, monkeypatch): + monkeypatch.setattr(installer.platform, "system", lambda: "Windows") + src = tmp_path / "VideoCaptioner.zip" + with zipfile.ZipFile(src, "w") as zf: + zf.writestr("VideoCaptioner/VideoCaptioner.exe", "binary") + zf.writestr("VideoCaptioner/resource/x.txt", "data") + out = installer._extract(src, tmp_path / "staging") + assert out.name == "VideoCaptioner" + assert (out / "VideoCaptioner.exe").exists() + + +def test_extract_falls_back_to_single_top_dir(tmp_path, monkeypatch): + # 非 Darwin → zipfile 分支(不依赖 ditto,可在任意 CI 主机跑);测的是顶层目录兜底逻辑。 + monkeypatch.setattr(installer.platform, "system", lambda: "Linux") + src = tmp_path / "pkg.zip" + with zipfile.ZipFile(src, "w") as zf: + zf.writestr("Renamed/Contents/x", "binary") + out = installer._extract(src, tmp_path / "staging") + assert out.name == "Renamed" + + +def test_extract_uses_ditto_on_macos(tmp_path, monkeypatch): + """macOS 走 ditto(保留 .app 软链/可执行位);用 stub 验证分发,不依赖真 ditto。""" + monkeypatch.setattr(installer.platform, "system", lambda: "Darwin") + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + # 模拟 ditto 解压出 VideoCaptioner.app + (tmp_path / "staging" / "VideoCaptioner.app").mkdir(parents=True, exist_ok=True) + import subprocess as _sp + + return _sp.CompletedProcess(cmd, 0) + + monkeypatch.setattr(installer.subprocess, "run", fake_run) + out = installer._extract(tmp_path / "x.zip", tmp_path / "staging") + assert calls and calls[0][:3] == ["ditto", "-x", "-k"] + assert out.name == "VideoCaptioner.app" + + +def test_download_update_passes_sha256(monkeypatch, tmp_path): + from videocaptioner.core.update.client import UpdateInfo + + captured = {} + + def fake_download_file(urls, dest, *, sha256=None, on_progress=None, should_cancel=None): + captured["urls"] = list(urls) + captured["sha256"] = sha256 + dest.write_bytes(b"x") + return dest + + monkeypatch.setattr(installer, "download_file", fake_download_file) + info = UpdateInfo( + version="3.0.0", + notes="", + url="https://github.com/x/y.zip", + sha256="deadbeef", + size=1, + ) + out = installer.download_update(info, tmp_path) + assert out.name == "VideoCaptioner-3.0.0.zip" + assert captured["sha256"] == "deadbeef" + # 镜像兜底:原直链在列表末尾,前面是 ghproxy 镜像 + assert captured["urls"][-1] == "https://github.com/x/y.zip" + assert len(captured["urls"]) == 3 + assert captured["urls"][-1] == "https://github.com/x/y.zip" # 直连兜底在末位 diff --git a/tests/test_utils/test_debug.py b/tests/test_utils/test_debug.py new file mode 100644 index 00000000..8abdac40 --- /dev/null +++ b/tests/test_utils/test_debug.py @@ -0,0 +1,40 @@ +"""通用调试开关 VC_DEBUG(core/utils/debug.debug_enabled)。""" + +import pytest + +from videocaptioner.core.utils.debug import debug_enabled + + +def test_unset_is_off(monkeypatch): + monkeypatch.delenv("VC_DEBUG", raising=False) + assert debug_enabled() is False + assert debug_enabled("live") is False + + +@pytest.mark.parametrize("val", ["", "0", "false", "no", "off", "OFF", "False"]) +def test_off_values_disable(monkeypatch, val): + monkeypatch.setenv("VC_DEBUG", val) + assert debug_enabled() is False + assert debug_enabled("live") is False + + +@pytest.mark.parametrize("val", ["1", "true", "yes", "on", "all", "ALL", "True"]) +def test_all_values_enable_everything(monkeypatch, val): + monkeypatch.setenv("VC_DEBUG", val) + assert debug_enabled() is True + assert debug_enabled("live") is True + assert debug_enabled("download") is True + + +def test_subsystem_filter(monkeypatch): + monkeypatch.setenv("VC_DEBUG", "live") + assert debug_enabled() is True # 总开关:开着 + assert debug_enabled("live") is True # 命中 + assert debug_enabled("download") is False # 未列出 → 不开 + + +def test_multi_subsystem_with_spaces(monkeypatch): + monkeypatch.setenv("VC_DEBUG", "download, live") # 逗号分隔、含空格 + assert debug_enabled("live") is True + assert debug_enabled("download") is True + assert debug_enabled("other") is False diff --git a/tests/test_utils/test_media_info.py b/tests/test_utils/test_media_info.py new file mode 100644 index 00000000..e20ea08e --- /dev/null +++ b/tests/test_utils/test_media_info.py @@ -0,0 +1,70 @@ +"""media_info:ffmpeg -i stderr 解析器的单元测试(纯文本解析,无需真 ffmpeg)。""" + +from videocaptioner.core.utils.media_info import _parse_ffmpeg_output, probe_media + +_VIDEO_OUTPUT = """\ +Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'video.mp4': + Metadata: + title : 测试视频【中文】 + Duration: 00:08:48.51, start: 0.000000, bitrate: 2886 kb/s + Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main) (av01 / 0x31307661), yuv420p(tv, bt709), 3840x2160 [SAR 1:1 DAR 16:9], 2752 kb/s, 23.98 fps, 23.98 tbr, 90k tbn (default) + Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6D6134), 44100 Hz, stereo, fltp, 129 kb/s (default) + Stream #0:2[0x3](eng): Audio: ac3 (ac-3 / 0x332D6361), 48000 Hz, 5.1(side), fltp, 448 kb/s +""" + +_AUDIO_WITH_COVER_OUTPUT = """\ +Input #0, mp3, from 'song.mp3': + Duration: 00:03:21.60, start: 0.025057, bitrate: 320 kb/s + Stream #0:0: Audio: mp3 (mp3float), 44100 Hz, stereo, fltp, 320 kb/s + Stream #0:1: Video: mjpeg (Progressive), yuvj420p(pc, bt470bg/unknown/unknown), 500x500 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn (attached pic) +""" + +_INVALID_OUTPUT = """\ +[in#0 @ 000001] Error opening input: Invalid data found when processing input +Error opening input file not_media.txt. +""" + + +def test_parse_video_with_multiple_audio_tracks(): + info = _parse_ffmpeg_output(_VIDEO_OUTPUT) + assert info.resolution == (3840, 2160) + assert info.video_codec == "av1" + assert info.fps == 23.98 + assert abs(info.duration_seconds - 528.51) < 0.01 + assert info.bitrate_kbps == 2886 + assert info.has_video and info.has_audio + assert [s.index for s in info.audio_streams] == [1, 2] + assert info.audio_streams[1].language == "eng" + assert info.audio_codec == "aac" and info.audio_sampling_rate == 44100 + + +def test_attached_cover_art_is_not_video(): + """mp3 内嵌封面是一条 mjpeg Video 流,不能把音频文件误判成视频。""" + info = _parse_ffmpeg_output(_AUDIO_WITH_COVER_OUTPUT) + assert not info.has_video + assert info.has_audio + assert info.width == 0 and info.height == 0 + assert abs(info.duration_seconds - 201.60) < 0.01 + + +def test_fps_falls_back_to_tbr_not_tbn(): + """裸流只报 tbr 时取 tbr;tbn 是时基(90k)绝不能当帧率。""" + out = ( + "Input #0, h264, from 'raw.264':\n" + " Duration: N/A, bitrate: N/A\n" + " Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080, 60 tbr, 1200k tbn\n" + ) + info = _parse_ffmpeg_output(out) + assert info.fps == 60.0 + assert info.resolution == (1920, 1080) + + +def test_no_streams_parses_empty(): + info = _parse_ffmpeg_output(_INVALID_OUTPUT) + assert not info.has_video and not info.has_audio + + +def test_probe_media_rejects_non_media(tmp_path): + bad = tmp_path / "not_media.txt" + bad.write_text("hello", encoding="utf-8") + assert probe_media(str(bad)) is None diff --git a/uv.lock b/uv.lock index 3641d53a..fa02dcb2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.13" resolution-markers = [ - "sys_platform == 'win32'", - "sys_platform == 'darwin'", - "sys_platform == 'linux'", + "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'linux'", ] supported-markers = [ "sys_platform == 'win32'", @@ -29,7 +32,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -39,61 +42,65 @@ dependencies = [ { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" }, - { url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" }, - { url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" }, - { url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" }, - { url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" }, - { url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" }, - { url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" }, - { url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" }, - { url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" }, - { url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" }, - { url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" }, - { url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" }, - { url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, + { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, + { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, + { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, + { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, ] [[package]] @@ -118,6 +125,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + [[package]] name = "anyio" version = "4.12.1" @@ -150,6 +163,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -164,10 +186,12 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy' and sys_platform == 'linux'" }, + { name = "pycparser", marker = "(implementation_name != 'PyPy' and sys_platform == 'darwin') or (implementation_name != 'PyPy' and sys_platform == 'linux') or (implementation_name != 'PyPy' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, @@ -176,6 +200,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, @@ -184,6 +212,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, @@ -191,6 +224,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] [[package]] @@ -259,6 +295,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, +] + [[package]] name = "darkdetect" version = "0.8.0" @@ -306,7 +379,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -314,45 +387,44 @@ wheels = [ ] [[package]] -name = "filelock" -version = "3.20.3" +name = "flatbuffers" +version = "25.12.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] name = "fonttools" -version = "4.61.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/94/8a28707adb00bed1bf22dac16ccafe60faf2ade353dcb32c3617ee917307/fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24", size = 2854799, upload-time = "2025-12-12T17:29:27.5Z" }, - { url = "https://files.pythonhosted.org/packages/94/93/c2e682faaa5ee92034818d8f8a8145ae73eb83619600495dcf8503fa7771/fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958", size = 2403032, upload-time = "2025-12-12T17:29:30.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/62/1748f7e7e1ee41aa52279fd2e3a6d0733dc42a673b16932bad8e5d0c8b28/fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da", size = 4897863, upload-time = "2025-12-12T17:29:32.535Z" }, - { url = "https://files.pythonhosted.org/packages/69/69/4ca02ee367d2c98edcaeb83fc278d20972502ee071214ad9d8ca85e06080/fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6", size = 4859076, upload-time = "2025-12-12T17:29:34.907Z" }, - { url = "https://files.pythonhosted.org/packages/8c/f5/660f9e3cefa078861a7f099107c6d203b568a6227eef163dd173bfc56bdc/fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1", size = 4875623, upload-time = "2025-12-12T17:29:37.33Z" }, - { url = "https://files.pythonhosted.org/packages/63/d1/9d7c5091d2276ed47795c131c1bf9316c3c1ab2789c22e2f59e0572ccd38/fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881", size = 4993327, upload-time = "2025-12-12T17:29:39.781Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2d/28def73837885ae32260d07660a052b99f0aa00454867d33745dfe49dbf0/fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47", size = 1502180, upload-time = "2025-12-12T17:29:42.217Z" }, - { url = "https://files.pythonhosted.org/packages/63/fa/bfdc98abb4dd2bd491033e85e3ba69a2313c850e759a6daa014bc9433b0f/fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6", size = 1550654, upload-time = "2025-12-12T17:29:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] [[package]] @@ -529,11 +601,11 @@ wheels = [ [[package]] name = "json-repair" -version = "0.55.1" +version = "0.60.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/de/71d6bb078d167c0d0959776cee6b6bb8d2ad843f512a5222d7151dde4955/json_repair-0.55.1.tar.gz", hash = "sha256:b27aa0f6bf2e5bf58554037468690446ef26f32ca79c8753282adb3df25fb888", size = 39231, upload-time = "2026-01-23T09:37:20.93Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a6/d69888cb4ffde30e80db1e6c32caaadd2f984a80067d5ea72c2cb3f61c3f/json_repair-0.60.1.tar.gz", hash = "sha256:841661cdd2df507c9a4e189097f38ca6bc372e06d4b4e36d72e590f68176c290", size = 49451, upload-time = "2026-06-03T17:28:44.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/da/289ba9eb550ae420cfc457926f6c49b87cacf8083ee9927e96921888a665/json_repair-0.55.1-py3-none-any.whl", hash = "sha256:a1bcc151982a12bc3ef9e9528198229587b1074999cfe08921ab6333b0c8e206", size = 29743, upload-time = "2026-01-23T09:37:19.404Z" }, + { url = "https://files.pythonhosted.org/packages/32/1f/2a2b5eea8ef5762a86ad3f8fddddaaba2c0d76dd44e644b9158900868bec/json_repair-0.60.1-py3-none-any.whl", hash = "sha256:ba6ff974f2a8bef2f7768144a7f03f870a816443f03da27a49cdd0ec31a78049", size = 48045, upload-time = "2026-06-03T17:28:43.038Z" }, ] [[package]] @@ -546,19 +618,33 @@ dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } [[package]] -name = "modelscope" -version = "1.34.0" +name = "markdown-it-py" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "setuptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mdurl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/89/7a13bf70090e631f81a943797e875272d19d47e0a6984eda0a5a3f04e7e3/modelscope-1.34.0.tar.gz", hash = "sha256:c3041af301334aa9ca3f66f5b23e11ca33a2bdf28cc415dcceb75f68e4732aac", size = 4560273, upload-time = "2026-01-19T02:50:23.274Z" } + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/df/2c112a7c4160aa5e74dad87060019be5eca197d910af3f5b12e68ec090a9/modelscope-1.34.0-py3-none-any.whl", hash = "sha256:4629ace145972520b71b0ad02e4604282426c0cfae6a4b0922509898f3b269c8", size = 6050825, upload-time = "2026-01-19T02:50:20.018Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] [[package]] @@ -636,9 +722,168 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and sys_platform == 'linux'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "omegaconf" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a", size = 3298472, upload-time = "2026-06-11T05:05:12.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/0e/152509871bf30df6fc38569f52a2db9b55dd41aae957adae50a053ac7778/omegaconf-2.3.1-py3-none-any.whl", hash = "sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0", size = 79502, upload-time = "2026-06-11T05:05:09.954Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.24.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", +] +dependencies = [ + { name = "flatbuffers", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "packaging", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "protobuf", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "sympy", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/3253db975a90c3ce1d475e2a230773a21cd7998537f0657947df6fb79861/onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c", size = 17332766, upload-time = "2026-03-05T17:18:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c5/3af6b325f1492d691b23844d88ed26844c1164620860c5efe95c0e22782d/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978", size = 15130330, upload-time = "2026-03-05T16:34:53.831Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/f96b46c1866a293ed23ca2cf5e5a63d413ad3a951da60dd877e3c56cbbca/onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb56575d7794bf0781156955610c9e651c9504c64d42ec880784b6106244882d", size = 17213247, upload-time = "2026-03-05T17:17:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/36/13/27cf4d8df2578747584e8758aeb0b673b60274048510257f1f084b15e80e/onnxruntime-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:c958222ef9eff54018332beecd32d5d94a3ab079d8821937b333811bf4da0d39", size = 12595530, upload-time = "2026-03-05T17:18:49.356Z" }, + { url = "https://files.pythonhosted.org/packages/19/8c/6d9f31e6bae72a8079be12ed8ba36c4126a571fad38ded0a1b96f60f6896/onnxruntime-1.24.3-cp311-cp311-win_arm64.whl", hash = "sha256:a8f761857ebaf58a85b9e42422d03207f1d39e6bb8fecfdbf613bac5b9710723", size = 12261715, upload-time = "2026-03-05T17:18:39.699Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7f/dfdc4e52600fde4c02d59bfe98c4b057931c1114b701e175aee311a9bc11/onnxruntime-1.24.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0d244227dc5e00a9ae15a7ac1eba4c4460d7876dfecafe73fb00db9f1d914d91", size = 17342578, upload-time = "2026-03-05T17:19:02.403Z" }, + { url = "https://files.pythonhosted.org/packages/1c/dc/1f5489f7b21817d4ad352bf7a92a252bd5b438bcbaa7ad20ea50814edc79/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a9847b870b6cb462652b547bc98c49e0efb67553410a082fde1918a38707452", size = 15150105, upload-time = "2026-03-05T16:34:56.897Z" }, + { url = "https://files.pythonhosted.org/packages/28/7c/fd253da53594ab8efbefdc85b3638620ab1a6aab6eb7028a513c853559ce/onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b354afce3333f2859c7e8706d84b6c552beac39233bcd3141ce7ab77b4cabb5d", size = 17237101, upload-time = "2026-03-05T17:18:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/71/5f/eaabc5699eeed6a9188c5c055ac1948ae50138697a0428d562ac970d7db5/onnxruntime-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:44ea708c34965439170d811267c51281d3897ecfc4aa0087fa25d4a4c3eb2e4a", size = 12597638, upload-time = "2026-03-05T17:18:52.141Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5c/d8066c320b90610dbeb489a483b132c3b3879b2f93f949fb5d30cfa9b119/onnxruntime-1.24.3-cp312-cp312-win_arm64.whl", hash = "sha256:48d1092b44ca2ba6f9543892e7c422c15a568481403c10440945685faf27a8d8", size = 12270943, upload-time = "2026-03-05T17:18:42.006Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and sys_platform == 'linux'", +] +dependencies = [ + { name = "flatbuffers", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "packaging", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "protobuf", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1f/a2117aa3f144fce88774efa37440d0ca72d0c9144854dfc0961f2b04c6fc/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb083321af8a236a84c7c140a7f4cecbfa2a987a18c07c78db471c20cd390ef", size = 16419330, upload-time = "2026-06-15T22:42:37.58Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cd/74bb804170ceb622fda9111df31a07b3024f7491472256d3a90b5391a4d2/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4f7b0e90d2d212e2c2deaa6c8291616183ab815d3ec558ea12d3ac8b26d36f4", size = 18636930, upload-time = "2026-06-15T22:43:01.584Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8f/5b8e2b85e81735696887175dbaf6409f215683f5ca9d4928fbb038211d32/onnxruntime-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:ff050e4f6bf7f12918fa14dcb047c0b02e295f35e86d42532552be4b3d54e977", size = 13356110, upload-time = "2026-06-15T22:43:32.172Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/4f568de678126b6a371a93862f015a82138359decd97fcac61fc84b5b774/onnxruntime-1.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:75fbc1e1fb43a39a856c8209c544cca7817b5de7ac16b15b1bdf55d1cc67b9df", size = 13098635, upload-time = "2026-06-15T22:43:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/dd3a524ed93a820dff1af902d0412957ab12499953333e9daa01af5bc480/onnxruntime-1.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a14c2ce45312def86b77aea651f46565e45960cf5f0721bfdff449165086ab76", size = 18433506, upload-time = "2026-06-15T22:43:47.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/c3b6b17745a1997d784dadc9bd88d713d2e6721139a5a0e885b28cfb79b1/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fddce0539a4898c7bef35b052ffd37935b2190e35488eab99ce91887743ea1", size = 16438140, upload-time = "2026-06-15T22:42:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/26/81/24dd9b31b0fb912ee19ca53ac1c9764bfd79d58a2ccef564eb693be831a5/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c65a7438632d55dfbc8a02ee60bd6cf7dd9d1ba05a43d4b851452f32338e194", size = 18658316, upload-time = "2026-06-15T22:43:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/8ec9db1a4d126bb8b758992beb40d1249df171917d75f44a327eb5f20dda/onnxruntime-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:20c321cf187ba496e648acf6b4cf90b4d398b0d17c2a77fdaeba365b908cc1c1", size = 13358769, upload-time = "2026-06-15T22:43:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/fdad359dfcba7e7cd8815569b304a596531d4efa77a75d77f8b4981891a2/onnxruntime-1.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:d0d1f68868e2ef30ef70998ba9bbbc5c305e9b17041e3936751c1b8aa6aade06", size = 13104440, upload-time = "2026-06-15T22:43:22.893Z" }, +] + [[package]] name = "openai" -version = "2.15.0" +version = "2.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -650,9 +895,28 @@ dependencies = [ { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/36/4c926a91554483977608951360c18c2e911592785eb87a6437813f6123f7/openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57", size = 783584, upload-time = "2026-06-10T16:10:37.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/925d7b3892927e9804aaf58d374a45dc28e4420ff90e992272b77286343e/openai-2.41.1-py3-none-any.whl", hash = "sha256:a939565f350cb7443cb843b801b88c716ac8024b492fb94ca269d5f6b1bbefd6", size = 1353380, upload-time = "2026-06-10T16:10:35.756Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] [[package]] @@ -666,59 +930,59 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, - { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, - { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, - { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, - { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, - { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, - { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, - { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -790,20 +1054,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + [[package]] name = "psutil" -version = "7.2.1" +version = "7.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" }, - { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" }, - { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" }, - { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" }, - { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyclipper" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/21/3c06205bb407e1f79b73b7b4dfb3950bd9537c4f625a68ab5cc41177f5bc/pyclipper-1.4.0.tar.gz", hash = "sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1", size = 54489, upload-time = "2025-12-01T13:15:35.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/9f/a10173d32ecc2ce19a04d018163f3ca22a04c0c6ad03b464dcd32f9152a8/pyclipper-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bafad70d2679c187120e8c44e1f9a8b06150bad8c0aecf612ad7dfbfa9510f73", size = 264510, upload-time = "2025-12-01T13:14:46.551Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/5490ddc4a1f7ceeaa0258f4266397e720c02db515b2ca5bc69b85676f697/pyclipper-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b74a9dd44b22a7fd35d65fb1ceeba57f3817f34a97a28c3255556362e491447", size = 139498, upload-time = "2025-12-01T13:14:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/3b/0a/bea9102d1d75634b1a5702b0e92982451a1eafca73c4845d3dbe27eba13d/pyclipper-1.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a4d2736fb3c42e8eb1d38bf27a720d1015526c11e476bded55138a977c17d9d", size = 970974, upload-time = "2025-12-01T13:14:49.799Z" }, + { url = "https://files.pythonhosted.org/packages/8b/1b/097f8776d5b3a10eb7b443b632221f4ed825d892e79e05682f4b10a1a59c/pyclipper-1.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3b3630051b53ad2564cb079e088b112dd576e3d91038338ad1cc7915e0f14dc", size = 943315, upload-time = "2025-12-01T13:14:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/17d6a3f1abf0f368d58f2309e80ee3761afb1fd1342f7780ab32ba4f0b1d/pyclipper-1.4.0-cp310-cp310-win32.whl", hash = "sha256:8d42b07a2f6cfe2d9b87daf345443583f00a14e856927782fde52f3a255e305a", size = 95286, upload-time = "2025-12-01T13:14:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/53/ca/b30138427ed122ec9b47980b943164974a2ec606fa3f71597033b9a9f9a6/pyclipper-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:6a97b961f182b92d899ca88c1bb3632faea2e00ce18d07c5f789666ebb021ca4", size = 104227, upload-time = "2025-12-01T13:14:54.013Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/64cf7794319b088c288706087141e53ac259c7959728303276d18adc665d/pyclipper-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:adcb7ca33c5bdc33cd775e8b3eadad54873c802a6d909067a57348bcb96e7a2d", size = 264281, upload-time = "2025-12-01T13:14:55.47Z" }, + { url = "https://files.pythonhosted.org/packages/34/cd/44ec0da0306fa4231e76f1c2cb1fa394d7bde8db490a2b24d55b39865f69/pyclipper-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fd24849d2b94ec749ceac7c34c9f01010d23b6e9d9216cf2238b8481160e703d", size = 139426, upload-time = "2025-12-01T13:14:56.683Z" }, + { url = "https://files.pythonhosted.org/packages/ad/88/d8f6c6763ea622fe35e19c75d8b39ed6c55191ddc82d65e06bc46b26cb8e/pyclipper-1.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b6c8d75ba20c6433c9ea8f1a0feb7e4d3ac06a09ad1fd6d571afc1ddf89b869", size = 989649, upload-time = "2025-12-01T13:14:58.28Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e9/ea7d68c8c4af3842d6515bedcf06418610ad75f111e64c92c1d4785a1513/pyclipper-1.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58e29d7443d7cc0e83ee9daf43927730386629786d00c63b04fe3b53ac01462c", size = 962842, upload-time = "2025-12-01T13:15:00.044Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/0b4a272d8726e51ab05e2b933d8cc47f29757fb8212e38b619e170e6015c/pyclipper-1.4.0-cp311-cp311-win32.whl", hash = "sha256:a8d2b5fb75ebe57e21ce61e79a9131edec2622ff23cc665e4d1d1f201bc1a801", size = 95098, upload-time = "2025-12-01T13:15:01.359Z" }, + { url = "https://files.pythonhosted.org/packages/3a/76/4901de2919198bb2bd3d989f86d4a1dff363962425bb2d63e24e6c990042/pyclipper-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:e9b973467d9c5fa9bc30bb6ac95f9f4d7c3d9fc25f6cf2d1cc972088e5955c01", size = 104362, upload-time = "2025-12-01T13:15:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/90/1b/7a07b68e0842324d46c03e512d8eefa9cb92ba2a792b3b4ebf939dafcac3/pyclipper-1.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140", size = 265676, upload-time = "2025-12-01T13:15:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/8bd622521c05d04963420ae6664093f154343ed044c53ea260a310c8bb4d/pyclipper-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6", size = 140458, upload-time = "2025-12-01T13:15:05.76Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/6e3e241882bf7d6ab23d9c69ba4e85f1ec47397cbbeee948a16cf75e21ed/pyclipper-1.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca", size = 978235, upload-time = "2025-12-01T13:15:06.993Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f4/3418c1cd5eea640a9fa2501d4bc0b3655fa8d40145d1a4f484b987990a75/pyclipper-1.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872", size = 961388, upload-time = "2025-12-01T13:15:08.467Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/c85401d24be634af529c962dd5d781f3cb62a67cd769534df2cb3feee97a/pyclipper-1.4.0-cp312-cp312-win32.whl", hash = "sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4", size = 95169, upload-time = "2025-12-01T13:15:10.098Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/dfea08e3b230b82ee22543c30c35d33d42f846a77f96caf7c504dd54fab1/pyclipper-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037", size = 104619, upload-time = "2025-12-01T13:15:11.592Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/81050abdc9e5b90ffc2c765738c5e40e9abd8e44864aaa737b600f16c562/pyclipper-1.4.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98b2a40f98e1fc1b29e8a6094072e7e0c7dfe901e573bf6cfc6eb7ce84a7ae87", size = 126495, upload-time = "2025-12-01T13:15:33.743Z" }, ] [[package]] @@ -3487,7 +3793,8 @@ name = "pyqt5-qt5" version = "5.15.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "sys_platform == 'win32'", + "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform == 'win32'", ] wheels = [ { url = "https://files.pythonhosted.org/packages/1c/7e/ce7c66a541a105fa98b41d6405fe84940564695e29fc7dccf6d9e8c5f898/PyQt5_Qt5-5.15.2-py3-none-win32.whl", hash = "sha256:9cc7a768b1921f4b982ebc00a318ccb38578e44e45316c7a4a850e953e1dd327", size = 43447358, upload-time = "2021-03-10T14:01:17.827Z" }, @@ -3499,8 +3806,10 @@ name = "pyqt5-qt5" version = "5.15.18" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "sys_platform == 'darwin'", - "sys_platform == 'linux'", + "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'linux'", ] wheels = [ { url = "https://files.pythonhosted.org/packages/46/90/bf01ac2132400997a3474051dd680a583381ebf98b2f5d64d4e54138dc42/pyqt5_qt5-5.15.18-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:8bb997eb903afa9da3221a0c9e6eaa00413bbeb4394d5706118ad05375684767", size = 39715743, upload-time = "2025-11-09T12:56:42.936Z" }, @@ -3578,9 +3887,113 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/b1/d6d6e7737fe3d0eb2ac2ac337686420d538f83f28495acc3cc32201c0dbf/rapidfuzz-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:071d96b957a33b9296b9284b6350a0fb6d030b154a04efd7c15e56b98b79a517", size = 1953508, upload-time = "2026-04-07T11:13:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7b/94c1c953ac818bdd88b43213a9d38e4a41e953b786af3c3b2444d4a8f96d/rapidfuzz-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667f40fe9c81ad129b198d236881b00dd9e8314d9cc72d03c3e16bdfe5879051", size = 1160895, upload-time = "2026-04-07T11:13:39.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/60/a67a7ca7c2532c6c1a4b5cd797917780eed43798b82c98b6df734a086c95/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9fff308486bbd2c8c24f25e8e152c7594d3fe8db265a2d6a1ce24d58671127f", size = 1382245, upload-time = "2026-04-07T11:13:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/95/ff/a42c9ce9f9e90ceb5b51136e0b8e8e6e5113ba0b45d986effbd671e7dddf/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dfa552338f51aec280f17b02d28bace1e162d1a84ccd80e3339a57f98aedb56b", size = 3163974, upload-time = "2026-04-07T11:13:42.662Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/11e2d41075e6e48b7dad373631b379b7e40491f71d5412c5a98d3c58f60f/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:068b3e965ca9d9ee4debe40001ae7c3938ba646308afd33cf0c66618147db65c", size = 1475540, upload-time = "2026-04-07T11:13:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/29/fa/09be143dcc22c79f09cf90168a574725dbda49f02cbbd55d0447da8bec86/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88b7d31ff1cc5e9bc0e4406e6b1fa00b6d37163d50bb58091e9b976ff1129faa", size = 2404128, upload-time = "2026-04-07T11:13:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/32/f9/1aeb504cdcfde42881825e9c86f48238d4e01ba8a1530491e82eb17e5689/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eacb434410b8d9ca99a8d42352ef085cf423e3c76c1f0b86be2fcba3bff2952c", size = 2508455, upload-time = "2026-04-07T11:13:48.726Z" }, + { url = "https://files.pythonhosted.org/packages/10/8e/b1b5eed8d887a29b0e18fd3222c46ca60fddfb528e7e1c41267ce42d5522/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:649712823f3abcdc48427147a5384fac15623ba435d0013959b52e6462521397", size = 4274060, upload-time = "2026-04-07T11:13:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/7e5b0353693d4f47b8b0f96e941efc377cfb2034b67ef92d082ac4441a0f/rapidfuzz-3.14.5-cp310-cp310-win32.whl", hash = "sha256:13cb79c23ef5516e4c4e3830877be8b19aa75203636be1163d690d37803f6504", size = 1727457, upload-time = "2026-04-07T11:13:52.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6e/f530a39b946fa71c009bc9c81fdb6b48a77bbc57ee8572ac0302b3bf6308/rapidfuzz-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:f2073495a7f9b75e57e600747ac09510d67683fd64d3228e009740b7ef88f9fe", size = 1544657, upload-time = "2026-04-07T11:13:54.952Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/02fa075f9f59ff766d374fecbd042b3ac9782dcd5abc52d909a54f587eeb/rapidfuzz-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:8166efddea49fdbc61185559f47593239e4794fd7c9044dd5a789d1a90af852d", size = 816587, upload-time = "2026-04-07T11:13:56.418Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, +] + +[[package]] +name = "rapidocr" +version = "3.8.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorlog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "omegaconf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opencv-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyclipper", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "shapely", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/26/387eca0137a3507ea62acedd2b2903697223cbca2f8561f7c63b47ec3ad2/rapidocr-3.8.4-py3-none-any.whl", hash = "sha256:1a8400df99ea2348a6d1902d1c6fa64c29edcf56b3c58cecd4cf1e5ff51b7ae2", size = 15018555, upload-time = "2026-06-15T08:55:07.979Z" }, +] + [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3588,9 +4001,22 @@ dependencies = [ { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -3620,12 +4046,39 @@ wheels = [ ] [[package]] -name = "setuptools" -version = "80.10.2" +name = "shapely" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, + { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" }, + { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, + { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, ] [[package]] @@ -3646,6 +4099,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sounddevice" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + [[package]] name = "tabulate" version = "0.10.0" @@ -3657,11 +4138,11 @@ wheels = [ [[package]] name = "tenacity" -version = "9.1.2" +version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] @@ -3737,13 +4218,16 @@ wheels = [ name = "videocaptioner" source = { editable = "." } dependencies = [ + { name = "curl-cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "diskcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "edge-tts", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "gputil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "json-repair", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "langdetect", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "modelscope", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3752,15 +4236,25 @@ dependencies = [ { name = "pyqt-fluent-widgets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyqt5", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sounddevice", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "websocket-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "yt-dlp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] +[package.optional-dependencies] +ocr = [ + { name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "onnxruntime", version = "1.27.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "rapidfuzz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rapidocr", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + [package.dev-dependencies] dev = [ + { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "gputil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "modelscope", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyqt-fluent-widgets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyqt5", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3771,32 +4265,39 @@ dev = [ [package.metadata] requires-dist = [ + { name = "curl-cffi", specifier = ">=0.7.0" }, { name = "diskcache", specifier = ">=5.6.3" }, { name = "edge-tts", specifier = ">=7.2.8" }, - { name = "fonttools", specifier = ">=4.61.1" }, + { name = "fonttools", specifier = ">=4.63.0" }, { name = "gputil", specifier = ">=1.4.0" }, - { name = "json-repair", specifier = ">=0.49.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "json-repair", specifier = ">=0.60.1" }, { name = "langdetect", specifier = ">=1.0.9" }, - { name = "modelscope", specifier = ">=1.32.0" }, - { name = "openai", specifier = ">=1.97.1" }, - { name = "pillow", specifier = ">=12.0.0" }, - { name = "platformdirs", specifier = ">=4.0.0" }, - { name = "psutil", specifier = ">=7.0.0" }, + { name = "numpy", specifier = ">=1.26.0" }, + { name = "onnxruntime", marker = "extra == 'ocr'", specifier = ">=1.19" }, + { name = "openai", specifier = ">=2.41.1" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "platformdirs", specifier = ">=4.10.0" }, + { name = "psutil", specifier = ">=7.2.2" }, { name = "pydub", specifier = ">=0.25.1" }, { name = "pyqt-fluent-widgets", specifier = "==1.8.4" }, { name = "pyqt5", specifier = "==5.15.11" }, - { name = "requests", specifier = ">=2.32.4" }, - { name = "tenacity", specifier = ">=8.2.0" }, + { name = "rapidfuzz", marker = "extra == 'ocr'", specifier = ">=3.0" }, + { name = "rapidocr", marker = "extra == 'ocr'", specifier = ">=3.0" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "sounddevice", specifier = ">=0.4.6" }, + { name = "tenacity", specifier = ">=9.1.4" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, - { name = "yt-dlp", specifier = ">=2025.7.21" }, + { name = "websocket-client", specifier = ">=1.7.0" }, + { name = "yt-dlp", specifier = ">=2026.6.9" }, ] -provides-extras = ["gui"] +provides-extras = ["gui", "ocr"] [package.metadata.requires-dev] dev = [ + { name = "babel", specifier = ">=2.14.0" }, { name = "gputil", specifier = ">=1.4.0" }, - { name = "modelscope", specifier = ">=1.32.0" }, - { name = "psutil", specifier = ">=7.0.0" }, + { name = "psutil", specifier = ">=7.2.2" }, { name = "pyqt-fluent-widgets", specifier = "==1.8.4" }, { name = "pyqt5", specifier = "==5.15.11" }, { name = "pyright", specifier = ">=1.1.0" }, @@ -3804,6 +4305,15 @@ dev = [ { name = "ruff", specifier = ">=0.4.0" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "xcffib" version = "1.12.0" @@ -3880,9 +4390,9 @@ wheels = [ [[package]] name = "yt-dlp" -version = "2025.12.8" +version = "2026.6.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/77/db924ebbd99d0b2b571c184cb08ed232cf4906c6f9b76eed763cd2c84170/yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f", size = 3088947, upload-time = "2025-12-08T00:16:01.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/a4/1b0979d28f87774bb67fbbc66bce44f9dd1aa0e547a99e22985fac945c33/yt_dlp-2026.6.9.tar.gz", hash = "sha256:d50fcb95f48d61bedde33e408c1881d4c279e51c31354a599ce09e96ba0f4b86", size = 3030590, upload-time = "2026-06-09T23:27:14.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/2f/98c3596ad923f8efd32c90dca62e241e8ad9efcebf20831173c357042ba0/yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065", size = 3291464, upload-time = "2025-12-08T00:15:58.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ee/188a3dadf9dfdac713243521f919feca1cd091d4358c9ea7e8ebb710a7cc/yt_dlp-2026.6.9-py3-none-any.whl", hash = "sha256:442ba4c75724b9496144c8434b617962ee08d0ee7c26ec663848fe9b78d5a3e4", size = 3169035, upload-time = "2026-06-09T23:27:12.58Z" }, ] diff --git a/videocaptioner/cli/commands/config_cmd.py b/videocaptioner/cli/commands/config_cmd.py index 59434f0c..c0cc9552 100644 --- a/videocaptioner/cli/commands/config_cmd.py +++ b/videocaptioner/cli/commands/config_cmd.py @@ -8,16 +8,16 @@ from videocaptioner.cli import exit_codes as EXIT from videocaptioner.cli import output -from videocaptioner.cli.config import ( +from videocaptioner.core.application.config_store import ( CONFIG_FILE, DEFAULTS, - _set_nested, - _write_toml, ensure_config_dir, format_config, get, load_config_file, save_config_value, + set_nested, + write_toml, ) @@ -70,6 +70,8 @@ def _set(key: str, value: str) -> int: output.error(str(e)) return EXIT.GENERAL_ERROR # Mask sensitive values in success message + if "key" in key or "token" in key or "secret" in key: + value = value.strip() display = f"{value[:4]}...{value[-4:]}" if ("key" in key) and len(value) > 8 else value output.success(f"{key} = {display}") return EXIT.SUCCESS @@ -129,23 +131,23 @@ def _prompt(msg: str, default: str = "") -> str: print() try: - _set_nested(config_data, "transcribe.asr", _prompt("ASR engine [bijian]: ", "bijian")) - _set_nested(config_data, "subtitle.optimize", _yes_no("Enable AI subtitle polish? It fixes obvious ASR errors and punctuation. [Y/n]: ", True)) - _set_nested(config_data, "subtitle.split", _yes_no("Enable subtitle re-segmentation? [Y/n]: ", True)) + set_nested(config_data, "transcribe.asr", _prompt("ASR engine [bijian]: ", "bijian")) + set_nested(config_data, "subtitle.optimize", _yes_no("Enable AI subtitle polish? It fixes obvious ASR errors and punctuation. [Y/n]: ", True)) + set_nested(config_data, "subtitle.split", _yes_no("Enable subtitle re-segmentation? [Y/n]: ", True)) translator = _prompt("Translator [bing] (bing/google/llm): ", "bing") - _set_nested(config_data, "translate.service", translator) + set_nested(config_data, "translate.service", translator) print() print("LLM config is used for AI subtitle polish, LLM translation, and --adapt-length.") - _set_nested(config_data, "llm.api_key", _prompt("LLM API key [skip]: ")) - _set_nested(config_data, "llm.api_base", _prompt(f"LLM API base [{DEFAULTS['llm']['api_base']}]: ", DEFAULTS["llm"]["api_base"])) - _set_nested(config_data, "llm.model", _prompt(f"LLM model [{DEFAULTS['llm']['model']}]: ", DEFAULTS["llm"]["model"])) + set_nested(config_data, "llm.api_key", _prompt("LLM API key [skip]: ")) + set_nested(config_data, "llm.api_base", _prompt(f"LLM API base [{DEFAULTS['llm']['api_base']}]: ", DEFAULTS["llm"]["api_base"])) + set_nested(config_data, "llm.model", _prompt(f"LLM model [{DEFAULTS['llm']['model']}]: ", DEFAULTS["llm"]["model"])) print() print("Dubbing config is used by 'dub' and 'process --dub-only'.") - _set_nested(config_data, "dubbing.preset", _prompt("Dubbing preset [edge-cn-female] (no API key): ", "edge-cn-female")) - _set_nested(config_data, "dubbing.api_key", _prompt("TTS API key [skip; only needed for SiliconFlow/Gemini]: ")) - _set_nested(config_data, "dubbing.voice", _prompt("Default voice [xiaoxiao]: ", "xiaoxiao")) - _set_nested(config_data, "dubbing.timing", _prompt("Timing [balanced] (balanced/strict/natural/none): ", "balanced")) - _set_nested(config_data, "dubbing.audio_mode", _prompt("Audio mode [replace] (replace/mix/duck): ", "replace")) + set_nested(config_data, "dubbing.preset", _prompt("Dubbing preset [edge-cn-female] (no API key): ", "edge-cn-female")) + set_nested(config_data, "dubbing.api_key", _prompt("TTS API key [skip; only needed for SiliconFlow/Gemini]: ")) + set_nested(config_data, "dubbing.voice", _prompt("Default voice [xiaoxiao]: ", "xiaoxiao")) + set_nested(config_data, "dubbing.timing", _prompt("Timing [balanced] (balanced/strict/natural/none): ", "balanced")) + set_nested(config_data, "dubbing.audio_mode", _prompt("Audio mode [replace] (replace/mix/duck): ", "replace")) except (EOFError, KeyboardInterrupt): return EXIT.USAGE_ERROR @@ -162,11 +164,11 @@ def _yes_no(prompt: str, default: bool) -> bool: def _build_onboarding_config(args: Namespace) -> dict: config_data = deepcopy(DEFAULTS) - _set_nested(config_data, "translate.service", "bing") - _set_nested(config_data, "dubbing.preset", "edge-cn-female") - _set_nested(config_data, "dubbing.voice", "xiaoxiao") - _set_nested(config_data, "dubbing.timing", "balanced") - _set_nested(config_data, "dubbing.audio_mode", "replace") + set_nested(config_data, "translate.service", "bing") + set_nested(config_data, "dubbing.preset", "edge-cn-female") + set_nested(config_data, "dubbing.voice", "xiaoxiao") + set_nested(config_data, "dubbing.timing", "balanced") + set_nested(config_data, "dubbing.audio_mode", "replace") mappings = { "llm_api_key": "llm.api_key", @@ -183,11 +185,11 @@ def _build_onboarding_config(args: Namespace) -> dict: for attr, key in mappings.items(): value = getattr(args, attr, None) if value is not None: - _set_nested(config_data, key, value) + set_nested(config_data, key, value) if getattr(args, "no_optimize", False): - _set_nested(config_data, "subtitle.optimize", False) + set_nested(config_data, "subtitle.optimize", False) if getattr(args, "no_split", False): - _set_nested(config_data, "subtitle.split", False) + set_nested(config_data, "subtitle.split", False) return config_data @@ -202,12 +204,13 @@ def _render_onboarding_template(config_data: dict) -> str: f.write("# Keep API keys private. This file is written with 0600 permissions on Unix.\n\n") f.write("# [llm] is used for AI subtitle polish, LLM translation, reflective translation, and dubbing length adaptation.\n") f.write("# [whisper_api] is only needed when transcribe.asr = \"whisper-api\".\n") + f.write("# [fun_asr] is only needed when transcribe.asr = \"fun-asr\".\n") f.write("# [transcribe] controls speech-to-text. bijian/jianying need no key; whisper-cpp needs a local binary/model.\n") f.write("# [subtitle] split and AI polish use LLM; [translate] can use bing/google/llm.\n") f.write("# [synthesize] controls subtitle embedding/burning.\n") f.write("# [dubbing] preset selects provider/model/voice defaults; edge-* presets need no API key but require network access.\n") f.write("# timing controls speech fitting; audio_mode controls original audio.\n\n") - _write_toml(f, template_data) + write_toml(f, template_data) f.write("\n# Optional multi-speaker example:\n") f.write("# [dubbing.speakers.Alice]\n# voice = \"anna\"\n# [dubbing.speakers.Bob]\n# voice = \"benjamin\"\n# clone_audio = \"bob-reference.wav\"\n# clone_text = \"Exact words spoken in the reference audio.\"\n\n") return f.getvalue() @@ -226,6 +229,11 @@ def _user_facing_config(config_data: dict) -> dict: "api_base": config_data["whisper_api"]["api_base"], "model": config_data["whisper_api"]["model"], }, + "fun_asr": { + "api_key": config_data["fun_asr"]["api_key"], + "api_base": config_data["fun_asr"]["api_base"], + "model": config_data["fun_asr"]["model"], + }, "transcribe": { "asr": config_data["transcribe"]["asr"], }, @@ -281,9 +289,9 @@ def _edit() -> int: if not CONFIG_FILE.exists(): ensure_config_dir() # Create with defaults - from videocaptioner.cli.config import _write_toml + from videocaptioner.core.application.config_store import write_toml with open(CONFIG_FILE, "w", encoding="utf-8") as f: - _write_toml(f, DEFAULTS) + write_toml(f, DEFAULTS) output.info(f"Created default config at {CONFIG_FILE}") editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "")) diff --git a/videocaptioner/cli/commands/doctor.py b/videocaptioner/cli/commands/doctor.py index a3280226..9d6c0da6 100644 --- a/videocaptioner/cli/commands/doctor.py +++ b/videocaptioner/cli/commands/doctor.py @@ -4,17 +4,26 @@ import shutil import subprocess import sys +import tempfile from argparse import Namespace from dataclasses import asdict, dataclass from datetime import date +from pathlib import Path from videocaptioner.cli import exit_codes as EXIT -from videocaptioner.cli.config import CONFIG_FILE, DEFAULTS, get +from videocaptioner.core.application.config_store import CONFIG_FILE, DEFAULTS, get +from videocaptioner.core.dubbing import build_dubbing_config from videocaptioner.core.dubbing.presets import ( get_dubbing_preset, normalize_dubbing_voice, validate_dubbing_voice, ) +from videocaptioner.core.speech import ( + SpeechProviderConfig, + SynthesisRequest, + create_speech_synthesizer, +) +from videocaptioner.core.subtitle.ass_renderer import ffmpeg_supports_ass_filter @dataclass @@ -26,7 +35,7 @@ class Check: def run(args: Namespace, config: dict) -> int: - checks = _run_checks(config, check_api=bool(getattr(args, "check_api", False))) + checks = run_diagnostics(config, check_api=bool(getattr(args, "check_api", False))) if getattr(args, "json", False): print(json.dumps({"checks": [asdict(c) for c in checks]}, ensure_ascii=False, indent=2)) else: @@ -34,21 +43,53 @@ def run(args: Namespace, config: dict) -> int: return EXIT.DEPENDENCY_MISSING if any(c.status == "error" for c in checks) else EXIT.SUCCESS -def _run_checks(config: dict, *, check_api: bool = False) -> list[Check]: +def run_diagnostics( + config: dict, *, check_api: bool = False, check_download: bool = False +) -> list[Check]: checks: list[Check] = [] checks.append(_check_python()) - checks.append(_check_command("ffmpeg", "Required for audio extraction, timing fit, muxing, and hard subtitles.")) - checks.append(_check_command("ffprobe", "Required for media duration checks.")) + checks.append(_check_command("ffmpeg", "Required for audio extraction, media probing, timing fit, muxing, and hard subtitles.")) + checks.append(_check_command("ffprobe", "Required by dubbing audio processing (pydub reads mp3 via ffprobe).")) checks.append(_check_ytdlp()) checks.append(_check_config_file()) checks.extend(_check_transcribe(config)) checks.extend(_check_subtitle(config)) checks.extend(_check_dubbing(config)) + checks.extend(_check_live_caption(config)) + if check_api or check_download: + checks.extend(_check_download_sources()) if check_api: checks.extend(_check_api(config)) return checks +def _check_download_sources() -> list[Check]: + """Resolve one stable public video per source (real network requests).""" + from videocaptioner.core.download import DOWNLOAD_SOURCES, check_download_sources + + hints = {source.key: source.fix_hint for source in DOWNLOAD_SOURCES} + checks = [] + for result in check_download_sources(): + if result.success: + checks.append(Check( + f"api.download.{result.key}", "ok", + f"{result.title}: 解析成功({result.detail[:48]})", + )) + else: + # friendly 文案可能已带站点主语(如「哔哩哔哩风控…」),避免重复前缀 + message = ( + result.detail + if result.detail.startswith(result.title) + else f"{result.title}: {result.detail}" + ) + checks.append(Check( + f"api.download.{result.key}", "error", + message, + hints.get(result.key, ""), + )) + return checks + + def _check_python() -> Check: version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" if (3, 10) <= sys.version_info[:2] < (3, 13): @@ -93,13 +134,13 @@ def _yt_dlp_version_is_old(version: str) -> bool: def _command_version(name: str) -> str: try: - result = subprocess.run([name, "-version"], capture_output=True, text=True, timeout=5) + result = subprocess.run([name, "-version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5) if result.returncode == 0 and result.stdout: return result.stdout.splitlines()[0][:100] except Exception: pass try: - result = subprocess.run([name, "--version"], capture_output=True, text=True, timeout=5) + result = subprocess.run([name, "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5) if result.returncode == 0 and result.stdout: return result.stdout.splitlines()[0][:100] except Exception: @@ -123,18 +164,67 @@ def _check_transcribe(config: dict) -> list[Check]: checks = [Check("transcribe.asr", "ok", f"default ASR: {asr}")] if asr == "whisper-api" and not get(config, "whisper_api.api_key", ""): checks.append(Check("whisper_api.api_key", "error", "Whisper API key is missing", "Run 'videocaptioner config set whisper_api.api_key '")) - if asr == "whisper-cpp" and not any(shutil.which(n) for n in ["whisper-cpp", "whisper", "whisper-cpp-main"]): - checks.append(Check("whisper-cpp", "error", "whisper.cpp binary not found", "Install whisper.cpp or choose --asr bijian/whisper-api")) + if asr == "fun-asr" and not get(config, "fun_asr.api_key", ""): + checks.append(Check("fun_asr.api_key", "error", "Bailian Fun-ASR API key is missing", "Run 'videocaptioner config set fun_asr.api_key '")) + if asr == "whisper-cpp": + checks.extend(_check_local_program("whisper-cpp")) + checks.extend(_check_local_model("whisper-cpp", get(config, "transcribe.whisper_cpp.model", "tiny"))) + if asr == "faster-whisper": + checks.extend(_check_local_program("faster-whisper")) + checks.extend(_check_local_model("faster-whisper", get(config, "transcribe.faster_whisper.model", "tiny"))) return checks +def _check_local_program(kind: str) -> list[Check]: + from videocaptioner.core.download import detect_program, program_install_plan + + status = detect_program(kind) + if status.installed: + return [Check(f"{kind}.program", "ok", f"{status.name} ({status.path})")] + plan = program_install_plan(kind) + hint = plan.command or plan.summary + return [Check(f"{kind}.program", "error", f"{kind} program not found", hint)] + + +def _check_local_model(kind: str, model_name: str) -> list[Check]: + from videocaptioner.config import MODEL_PATH + from videocaptioner.core.download import find_model, model_install_state + + spec = find_model(kind, str(model_name)) + if spec is None: + return [Check(f"{kind}.model", "warn", f"Unknown model name: {model_name}", "Run 'videocaptioner models list' to see available models")] + if model_install_state(spec, Path(MODEL_PATH)): + return [Check(f"{kind}.model", "ok", f"model '{spec.name}' installed in {MODEL_PATH}")] + return [ + Check( + f"{kind}.model", + "error", + f"model '{spec.name}' is not downloaded", + f"Run 'videocaptioner models download {kind} {spec.name}'", + ) + ] + + def _check_subtitle(config: dict) -> list[Check]: checks: list[Check] = [] optimize = bool(get(config, "subtitle.optimize", True)) split = bool(get(config, "subtitle.split", True)) + render_mode = str(get(config, "subtitle.render_mode", "ass")) translator = get(config, "translate.service", "bing") needs_llm = optimize or split or translator == "llm" checks.append(Check("subtitle.processing", "ok", f"ai_polish={optimize}, split={split}, translator={translator}")) + if _is_ass_render_mode(render_mode) and shutil.which("ffmpeg"): + if ffmpeg_supports_ass_filter(): + checks.append(Check("ffmpeg.ass_filter", "ok", "FFmpeg supports ASS hard-subtitle rendering")) + else: + checks.append( + Check( + "ffmpeg.ass_filter", + "error", + "当前 FFmpeg 不支持 ASS 硬字幕渲染", + "安装带 libass 的完整 FFmpeg,或在字幕样式里切换为圆角背景", + ) + ) if needs_llm and not get(config, "llm.api_key", ""): checks.append(Check("llm.api_key", "warn", "LLM API key is missing; AI polish/split/LLM translation will fail", "Run 'videocaptioner config set llm.api_key ' or disable AI polish/split")) if needs_llm and not get(config, "llm.model", ""): @@ -142,6 +232,11 @@ def _check_subtitle(config: dict) -> list[Check]: return checks +def _is_ass_render_mode(render_mode: str) -> bool: + normalized = render_mode.strip().lower() + return normalized in {"ass", "ass_style", "ass-style", "ass 样式", "ass样式"} + + def _check_dubbing(config: dict) -> list[Check]: checks: list[Check] = [] preset_name = get(config, "dubbing.preset", "") @@ -178,14 +273,126 @@ def _check_dubbing(config: dict) -> list[Check]: return checks +def _check_live_caption(config: dict) -> list[Check]: + """实时字幕(Live Caption)后端可用性。 + + 按 provider 分流:① fun-asr → 检查百炼 API Key 是否填了;② voxgate → 检测 voxgate + 二进制是否能找到(配置路径 → 自带 bin → 用户 bin → PATH,同 ``find_voxgate_binary`` + 的真实发现顺序;voxgate 走 ``voxgate transcribe`` 子进程 stdio,不再有本地服务)。 + + 实时字幕是可选功能,缺失只给 ``warn``(不影响主字幕流程,doctor 不因此失败)。 + """ + provider = get(config, "live_caption.provider", "voxgate") + if provider == "fun-asr": + # 云后端:检查百炼 API Key 是否填了(联网探活留给「测试转录」,doctor 不刷接口) + if get(config, "live_caption.api_key", "").strip(): + return [Check("live_caption.funasr", "ok", "Fun-ASR 实时:已配置百炼 API Key")] + return [ + Check( + "live_caption.funasr", + "warn", + "Fun-ASR 实时缺少百炼 API Key;实时字幕将无法启动(不影响其它功能)", + "在 设置 → 实时字幕配置 填入阿里云百炼 API Key", + ) + ] + from videocaptioner.core.realtime.backends.voxgate import find_voxgate_binary + + configured = get(config, "live_caption.voxgate_binary", "").strip() + binary = find_voxgate_binary(configured) + if binary: + return [Check("live_caption.voxgate", "ok", f"voxgate 转录程序已就绪:{binary}")] + return [ + Check( + "live_caption.voxgate", + "warn", + "未找到 voxgate 转录程序;实时字幕将无法启动(不影响其它功能)", + "在 设置 → 实时字幕配置 指定 voxgate 路径,或把 voxgate 放到 PATH / resource/bin", + ) + ] + + def _check_api(config: dict) -> list[Check]: checks: list[Check] = [] - if not get(config, "dubbing.api_key", ""): + checks.extend(_check_api_transcribe(config)) + provider = get(config, "dubbing.provider", "edge") + if provider != "edge" and not get(config, "dubbing.api_key", ""): + checks.append(Check("api.dubbing", "warn", "Skipped real TTS request because dubbing API key is missing", "Run 'videocaptioner config set dubbing.api_key '")) return checks - checks.append(Check("api.dubbing", "warn", "--check-api is currently limited to configuration validation", "Run a short 'videocaptioner dub sample.srt' to verify billing/provider access")) + try: + preset_name = get(config, "dubbing.preset", "edge-cn-female") + preset = get_dubbing_preset(preset_name) + core_config = build_dubbing_config( + provider=preset.provider, + preset=preset_name, + api_key=get(config, "dubbing.api_key", ""), + api_base=get(config, "dubbing.api_base", "") or preset.api_base, + model=get(config, "dubbing.model", "") or preset.model, + voice=get(config, "dubbing.voice", "") or preset.voice, + style_prompt=preset.style_prompt, + tts_workers=1, + ) + response_format = core_config.response_format + if core_config.provider == "gemini": + response_format = "wav" + elif core_config.provider == "edge": + response_format = "mp3" + synthesizer = create_speech_synthesizer( + SpeechProviderConfig( + provider=core_config.provider, + api_key=core_config.api_key, + base_url=core_config.base_url, + model=core_config.model, + default_voice=core_config.voice, + response_format=response_format, + sample_rate=core_config.sample_rate, + speed=core_config.speed, + gain=core_config.gain, + timeout=core_config.timeout, + style_prompt=core_config.style_prompt, + ) + ) + output = Path(tempfile.mkdtemp(prefix="videocaptioner-doctor-")) / "tts-preview.wav" + result = synthesizer.synthesize( + SynthesisRequest( + text="你好,这是卡卡字幕助手的配音诊断。", + output_path=str(output), + voice=core_config.voice, + style_prompt=core_config.style_prompt or None, + ) + ) + size = Path(result.output_path).stat().st_size + checks.append(Check("api.dubbing", "ok", f"Real TTS request succeeded: {core_config.provider}, {size} bytes")) + except Exception as exc: + checks.append(Check("api.dubbing", "error", f"Real TTS request failed: {exc}", "Open Settings > Dubbing and verify provider, API Key, Base URL, model, and voice")) return checks +def _check_api_transcribe(config: dict) -> list[Check]: + """Run a real short-audio transcription with the configured ASR provider. + + Shares core.asr.check.check_transcribe with the Settings page test button. + """ + asr = get(config, "transcribe.asr", "bijian") + if asr == "whisper-api" and not get(config, "whisper_api.api_key", ""): + return [Check("api.transcribe", "warn", "Skipped real ASR request because Whisper API key is missing", "Run 'videocaptioner config set whisper_api.api_key '")] + if asr == "fun-asr" and not get(config, "fun_asr.api_key", ""): + return [Check("api.transcribe", "warn", "Skipped real ASR request because Bailian Fun-ASR API key is missing", "Run 'videocaptioner config set fun_asr.api_key '")] + try: + from videocaptioner.cli.config_adapter import app_config_from_cli + from videocaptioner.core.application import TaskBuilder + from videocaptioner.core.asr.check import check_transcribe + + transcribe_config = TaskBuilder( + app_config_from_cli(config) + ).create_transcribe_config(need_word_timestamp=False) + result = check_transcribe(transcribe_config) + except Exception as exc: + return [Check("api.transcribe", "error", f"Real ASR request failed: {exc}", "Open Settings > Transcribe and verify the provider configuration")] + if result.success: + return [Check("api.transcribe", "ok", f"Real ASR request succeeded: {asr}, recognized: {result.detail[:60]}")] + return [Check("api.transcribe", "error", f"Real ASR request failed: {result.detail}", "Open Settings > Transcribe and verify the provider configuration")] + + def _print_checks(checks: list[Check]) -> None: for check in checks: prefix = {"ok": "OK", "warn": "WARN", "error": "ERROR"}.get(check.status, check.status.upper()) diff --git a/videocaptioner/cli/commands/download.py b/videocaptioner/cli/commands/download.py index d5ed04a7..3609e880 100644 --- a/videocaptioner/cli/commands/download.py +++ b/videocaptioner/cli/commands/download.py @@ -1,8 +1,11 @@ -"""download command — download online video via yt-dlp.""" +"""download command — download online video via the shared core engine. + +与 GUI 下载共用 core/download/media.py:代理分流、B 站 buvid、 +浏览器登录态兜底、同语言字幕 sidecar 全部一致。 +""" from argparse import Namespace from pathlib import Path -from typing import Any from videocaptioner.cli import exit_codes as EXIT from videocaptioner.cli import output @@ -12,36 +15,43 @@ def run(args: Namespace, config: dict) -> int: url = args.url out_dir = getattr(args, "output", None) or "." quiet = getattr(args, "quiet", False) + verbose = getattr(args, "verbose", False) - try: - import yt_dlp - except ImportError: - output.error("yt-dlp is not available") - output.hint("Install the official package with: pip install videocaptioner") - return EXIT.DEPENDENCY_MISSING + from videocaptioner.core.download.media import MediaDownloader Path(out_dir).mkdir(parents=True, exist_ok=True) - progress = None if quiet else output.ProgressLine(f"Downloading {url}").start() - try: - ydl_opts: dict[str, Any] = { - "format": "bestvideo+bestaudio/best", - "outtmpl": f"{out_dir}/%(title)s.%(ext)s", - "noplaylist": True, - "quiet": quiet, - "no_warnings": quiet, - } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[arg-type] - ydl.download([url]) - + def on_progress(value: int, message: str) -> None: if progress: - progress.finish(f"Downloaded to {out_dir}/") - return EXIT.SUCCESS + progress.update(value, message) - except Exception as e: + try: + video_path, subtitle_path = MediaDownloader( + url, + out_dir, + on_progress=on_progress, + ).run() + except Exception as exc: + message = output.clean_error(str(exc)) if progress: - progress.fail(str(e)) + progress.fail(message) else: - output.error(str(e)) + output.error(message) + if verbose: + import traceback + + traceback.print_exc() + return EXIT.RUNTIME_ERROR + + if not video_path: + if progress: + progress.fail("下载完成但未找到视频文件") return EXIT.RUNTIME_ERROR + if progress: + progress.finish(f"Done -> {video_path}") + if subtitle_path and not quiet: + output.info(f"Subtitle -> {subtitle_path}") + if quiet: + print(video_path) + return EXIT.SUCCESS diff --git a/videocaptioner/cli/commands/dub.py b/videocaptioner/cli/commands/dub.py index 853211fb..fd0dcc44 100644 --- a/videocaptioner/cli/commands/dub.py +++ b/videocaptioner/cli/commands/dub.py @@ -1,23 +1,23 @@ """dub command -- generate dubbed audio/video from subtitles.""" +import tempfile from argparse import Namespace from pathlib import Path from videocaptioner.cli import exit_codes as EXIT from videocaptioner.cli import output -from videocaptioner.cli.config import get from videocaptioner.cli.validators import ( validate_dubbing, validate_subtitle_input, validate_video_input, ) +from videocaptioner.core.application import output_paths +from videocaptioner.core.application.config_store import get from videocaptioner.core.dubbing import DubbingConfig, DubbingPipeline, SpeakerProfile -from videocaptioner.core.dubbing.models import DubbingProvider, FitMode -from videocaptioner.core.dubbing.presets import ( - get_dubbing_preset, - normalize_dubbing_voice, - validate_dubbing_voice, -) +from videocaptioner.core.dubbing.config_builder import build_dubbing_config + +# 命令行里逐条展示的超时分段警告上限,其余折叠成一行计数 +_MAX_INLINE_WARNINGS = 5 def run(args: Namespace, config: dict) -> int: @@ -48,10 +48,10 @@ def run(args: Namespace, config: dict) -> int: output.error(str(exc)) return EXIT.USAGE_ERROR - dub_config = _build_dubbing_config(config, speaker_profiles) - capability_error = _validate_provider_capabilities(dub_config) - if capability_error: - output.error(capability_error) + try: + dub_config = _build_dubbing_config(config, speaker_profiles) + except ValueError as exc: + output.error(str(exc)) return EXIT.USAGE_ERROR audio_output, video_output = _resolve_outputs(args, subtitle_path, video_path) @@ -71,16 +71,18 @@ def progress_callback(percent: int, message: str) -> None: last_logged_bucket = bucket output.info(f"Dubbing progress: {percent}% - {message}") + # 变速分段等中间产物进临时目录,跑完即清;原始 TTS 分段在全局缓存里复用。 try: - result = DubbingPipeline(dub_config).run( - str(subtitle_path), - str(audio_output), - video_path=str(video_path) if video_path else None, - output_video_path=str(video_output) if video_output else None, - text_track=getattr(args, "text_track", None) or "auto", - work_dir=str(audio_output.with_suffix("").with_name(audio_output.stem + "_parts")), - callback=progress_callback, - ) + with tempfile.TemporaryDirectory(prefix="videocaptioner-dub-") as work_dir: + result = DubbingPipeline(dub_config).run( + str(subtitle_path), + str(audio_output), + work_dir=work_dir, + video_path=str(video_path) if video_path else None, + output_video_path=str(video_output) if video_output else None, + text_track=getattr(args, "text_track", None) or "auto", + callback=progress_callback, + ) except Exception as exc: msg = output.clean_error(str(exc)) if progress: @@ -97,136 +99,49 @@ def progress_callback(percent: int, message: str) -> None: if progress: progress.finish(f"Done -> {final_path}") if result.warnings and not quiet: - output.warn(f"{len(result.warnings)} segment(s) exceeded their target duration; see {result.audio_path.with_suffix('.dubbing.json')}") + output.warn(f"{len(result.warnings)} segment(s) exceeded their target duration:") + for warning in result.warnings[:_MAX_INLINE_WARNINGS]: + output.warn(f" {warning}") + hidden = len(result.warnings) - _MAX_INLINE_WARNINGS + if hidden > 0: + output.warn(f" ... and {hidden} more") if quiet: print(final_path) return EXIT.SUCCESS def _build_dubbing_config(config: dict, speaker_profiles: dict[str, SpeakerProfile]) -> DubbingConfig: - resolved = _resolve_dubbing_settings(config) - provider = _resolve_provider(resolved["provider"]) - resolved["voice"] = normalize_dubbing_voice(provider, resolved["model"], resolved["voice"]) - for profile in speaker_profiles.values(): - if profile.voice: - profile.voice = normalize_dubbing_voice(provider, resolved["model"], profile.voice) - fit_mode, max_speed = _resolve_timing(config) - mix_original_audio, original_audio_volume = _resolve_audio_mix(config) - return DubbingConfig( - provider=provider, + return build_dubbing_config( + provider=get(config, "dubbing.provider", "edge"), + preset=get(config, "dubbing.preset", ""), api_key=get(config, "dubbing.api_key", ""), - base_url=resolved["api_base"], - model=resolved["model"], - voice=resolved["voice"], + api_base=get(config, "dubbing.api_base", ""), + model=get(config, "dubbing.model", ""), + voice=get(config, "dubbing.voice", ""), response_format=get(config, "dubbing.response_format", "mp3"), sample_rate=int(get(config, "dubbing.sample_rate", 32000)), speed=float(get(config, "dubbing.speed", 1.0)), gain=float(get(config, "dubbing.gain", 0)), use_cache=bool(get(config, "dubbing.use_cache", True)), tts_workers=int(get(config, "dubbing.tts_workers", 5)), - style_prompt=resolved["style_prompt"], - fit_mode=fit_mode, - max_speed=max_speed, + style_prompt=get(config, "dubbing.style_prompt", ""), + timing=get(config, "dubbing.timing", "balanced"), + audio_mode=get(config, "dubbing.audio_mode", "replace"), + fit_mode=get(config, "dubbing.fit_mode", None), + max_speed=float(get(config, "dubbing.max_speed", 2.0)), target_padding_ms=int(get(config, "dubbing.target_padding_ms", 80)), rewrite_too_long=bool(get(config, "dubbing.rewrite_too_long", False)), rewrite_threshold=float(get(config, "dubbing.rewrite_threshold", 1.15)), llm_api_key=get(config, "llm.api_key", ""), llm_api_base=get(config, "llm.api_base", ""), llm_model=get(config, "llm.model", ""), - mix_original_audio=mix_original_audio, - original_audio_volume=original_audio_volume, + mix_original_audio=bool(get(config, "dubbing.mix_original_audio", False)), + original_audio_volume=float(get(config, "dubbing.original_audio_volume", 0.25)), dubbed_audio_volume=float(get(config, "dubbing.dubbed_audio_volume", 1.0)), speaker_profiles=speaker_profiles, ) -def _resolve_dubbing_settings(config: dict) -> dict[str, str]: - preset_name = get(config, "dubbing.preset", "") - resolved = { - "provider": get(config, "dubbing.provider", "edge"), - "api_base": get(config, "dubbing.api_base", ""), - "model": get(config, "dubbing.model", ""), - "voice": get(config, "dubbing.voice", ""), - "style_prompt": get(config, "dubbing.style_prompt", ""), - } - if not preset_name: - return resolved - - preset = get_dubbing_preset(preset_name) - default_preset = get_dubbing_preset("edge-cn-female") - defaults = { - "provider": default_preset.provider, - "api_base": default_preset.api_base, - "model": default_preset.model, - "voice": default_preset.voice, - "style_prompt": "", - } - preset_values = { - "provider": preset.provider, - "api_base": preset.api_base, - "model": preset.model, - "voice": preset.voice, - "style_prompt": preset.style_prompt, - } - for key, value in preset_values.items(): - if not resolved[key] or resolved[key] == defaults[key]: - resolved[key] = value - return resolved - - -def _resolve_provider(value: str) -> DubbingProvider: - if value == "siliconflow": - return "siliconflow" - if value == "gemini": - return "gemini" - if value == "edge": - return "edge" - raise ValueError(f"Unsupported dubbing provider: {value}") - - -def _resolve_timing(config: dict) -> tuple[FitMode, float]: - timing = get(config, "dubbing.timing", "balanced") - explicit_fit = get(config, "dubbing.fit_mode", None) - explicit_max_speed = float(get(config, "dubbing.max_speed", 2.0)) - if timing == "none": - return "none", explicit_max_speed - fit_mode: FitMode = "tempo" if explicit_fit not in {"tempo", "none"} else explicit_fit - if timing == "natural": - return fit_mode, min(explicit_max_speed, 1.25) - if timing == "strict": - return fit_mode, max(explicit_max_speed, 2.0) - return fit_mode, explicit_max_speed - - -def _resolve_audio_mix(config: dict) -> tuple[bool, float]: - audio_mode = get(config, "dubbing.audio_mode", "replace") - explicit_mix = bool(get(config, "dubbing.mix_original_audio", False)) - explicit_volume = float(get(config, "dubbing.original_audio_volume", 0.25)) - if audio_mode == "replace": - return explicit_mix, explicit_volume - if audio_mode == "mix": - return True, explicit_volume - if audio_mode == "duck": - return True, min(explicit_volume, 0.12) - return explicit_mix, explicit_volume - - -def _validate_provider_capabilities(config: DubbingConfig) -> str | None: - if config.provider == "gemini" and any(p.clone_audio_path for p in config.speaker_profiles.values()): - return "Gemini TTS does not support voice cloning. Use a SiliconFlow preset/provider for --clone-audio or --speaker-clone." - if config.provider == "edge" and any(p.clone_audio_path for p in config.speaker_profiles.values()): - return "Edge TTS does not support voice cloning. Use a SiliconFlow preset/provider for --clone-audio or --speaker-clone." - voice_error = validate_dubbing_voice(config.provider, config.voice) - if voice_error: - return voice_error - for name, profile in config.speaker_profiles.items(): - if profile.voice: - voice_error = validate_dubbing_voice(config.provider, profile.voice) - if voice_error: - return f"Speaker {name}: {voice_error}" - return None - - def _apply_config_speaker_profiles(config: dict, profiles: dict[str, SpeakerProfile]) -> None: configured = get(config, "dubbing.speakers", {}) if not isinstance(configured, dict): @@ -290,14 +205,27 @@ def _resolve_outputs( subtitle_path: Path, video_path: Path | None, ) -> tuple[Path, Path | None]: + """默认输出:{stem}.dubbed.{ext},音频与视频共用 dubbed tag。""" audio_arg = getattr(args, "audio_output", None) output_arg = getattr(args, "output", None) if video_path: - video_output = Path(output_arg) if output_arg else video_path.with_stem(video_path.stem + "_dubbed") - audio_output = Path(audio_arg) if audio_arg else video_output.with_suffix(".dub.wav") + video_output = ( + Path(output_arg) + if output_arg + else output_paths.product_path(video_path, output_paths.TAG_DUBBED) + ) + audio_output = ( + Path(audio_arg) + if audio_arg + else output_paths.product_path(video_path, output_paths.TAG_DUBBED, ext=".wav") + ) return audio_output, video_output chosen_output = output_arg or audio_arg - audio_output = Path(chosen_output) if chosen_output else subtitle_path.with_suffix(".dub.wav") + audio_output = ( + Path(chosen_output) + if chosen_output + else output_paths.product_path(subtitle_path, output_paths.TAG_DUBBED, ext=".wav") + ) return audio_output, None diff --git a/videocaptioner/cli/commands/extract_hardsub.py b/videocaptioner/cli/commands/extract_hardsub.py new file mode 100644 index 00000000..7039b0be --- /dev/null +++ b/videocaptioner/cli/commands/extract_hardsub.py @@ -0,0 +1,132 @@ +"""extract-hardsub command — OCR 提取视频里的硬字幕(烧录字幕)为可编辑字幕。 + +抽帧 → 字幕区变化检测 → 变化点 OCR → 去重合并 → 导出 SRT/ASS。区域可手动框选(--roi)或 +自动检测(默认)。识别引擎 RapidOCR(纯 CPU,中英文质量好)。 +""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path +from typing import Optional + +from videocaptioner.cli import exit_codes as EXIT +from videocaptioner.cli import output +from videocaptioner.cli.validators import validate_video_input + + +def _parse_roi(text: str) -> Optional[tuple[int, int, int, int]]: + """解析 "x,y,w,h" 为整数元组;非法返回 None。""" + try: + parts = [int(v.strip()) for v in text.split(",")] + except ValueError: + return None + if len(parts) != 4 or parts[2] <= 0 or parts[3] <= 0: + return None + return parts[0], parts[1], parts[2], parts[3] + + +def run(args: Namespace, config: dict) -> int: + from videocaptioner.core.hardsub.config import HardsubConfig, RecognizeMode + from videocaptioner.core.hardsub.pipeline import extract_hardsub + from videocaptioner.core.ocr.rapid import create_rapidocr, ocr_dependency_ready + + video_path = Path(args.video) + if not video_path.exists(): + output.error(f"Video file not found: {video_path}") + return EXIT.FILE_NOT_FOUND + err = validate_video_input(video_path) + if err is not None: + return err + + missing = ocr_dependency_ready() + if missing is not None: + output.error(missing) + output.hint("安装 OCR 依赖: uv pip install rapidocr onnxruntime") + return EXIT.DEPENDENCY_MISSING + + quiet = getattr(args, "quiet", False) + verbose = getattr(args, "verbose", False) + lang = getattr(args, "lang", None) or "ch" + mode = RecognizeMode(getattr(args, "mode", None) or "standard") + + # ROI:显式 --roi 优先;否则自动检测;都没有则 pipeline 回退底部默认带。 + roi = None + roi_arg = getattr(args, "roi", None) + if roi_arg: + roi = _parse_roi(roi_arg) + if roi is None: + output.error("--roi 格式应为 x,y,w,h(像素,原始分辨率),例如 96,612,1728,120") + return EXIT.USAGE_ERROR + + # --roi 显式指定 = 手动区域:提取所见即所得,不套字号/居中过滤。 + cfg = HardsubConfig.from_mode( + str(video_path), mode=mode, lang=lang, roi=roi, roi_is_manual=bool(roi) + ) + engine = create_rapidocr( + lang=cfg.lang, ocr_version=cfg.ocr_version, model_type=cfg.model_type + ) + + if roi is None and not getattr(args, "no_auto_region", False): + probe = None if quiet else output.ProgressLine("检测字幕区域").start() + try: + from videocaptioner.core.hardsub.region import detect_subtitle_region + detected = detect_subtitle_region(str(video_path), engine, cfg.region_sample_count) + except Exception as exc: # noqa: BLE001 + detected = None + if verbose: + output.warn(f"自动检测字幕区域失败:{exc}") + if probe: + probe.finish(f"字幕区域 {detected.roi}" if detected else "未自动识别,用底部默认带") + if detected is not None: + cfg.roi = detected.roi + cfg.font_height = detected.font_height # 复用区域检测学到的字号,免再学一遍 + + if verbose: + output.info(f"语言: {lang} 模式: {mode.value} 引擎: {engine.name}") + output.info(f"字幕区域 ROI: {cfg.roi or '底部默认带'}") + + progress = None if quiet else output.ProgressLine("识别硬字幕").start() + + def on_progress(p) -> None: + if progress: + progress.update(p.percent, f"识别中 {p.cue_count} 条") + + # 提取用 768 引擎(比区域检测的 960 快、质量不降)。 + from videocaptioner.core.hardsub.pipeline import EXTRACT_DET_LIMIT + extract_engine = create_rapidocr( + lang=cfg.lang, ocr_version=cfg.ocr_version, model_type=cfg.model_type, + det_limit_side_len=EXTRACT_DET_LIMIT, + ) + try: + data = extract_hardsub(cfg, extract_engine, on_progress=on_progress) + except Exception as exc: # noqa: BLE001 + msg = output.clean_error(str(exc)) + if progress: + progress.fail(msg) + else: + output.error(msg) + if verbose: + import traceback + traceback.print_exc() + return EXIT.RUNTIME_ERROR + + if not data.has_data(): + if progress: + progress.fail("未识别到字幕") + output.hint("可能字幕区域不对,用 --roi x,y,w,h 手动指定,或换 --mode accurate") + return EXIT.RUNTIME_ERROR + + # 输出:默认源文件旁 {stem}.hardsub.srt + if getattr(args, "output", None): + out_path = Path(args.output) + else: + from videocaptioner.core.application import output_paths + out_path = output_paths.product_path(video_path, output_paths.TAG_HARDSUB, ext=".srt") + data.save(str(out_path)) + + if progress: + progress.finish(f"完成 -> {out_path}({len(data)} 条)") + if quiet: + print(out_path) + return EXIT.SUCCESS diff --git a/videocaptioner/cli/commands/models_cmd.py b/videocaptioner/cli/commands/models_cmd.py new file mode 100644 index 00000000..c955d7c3 --- /dev/null +++ b/videocaptioner/cli/commands/models_cmd.py @@ -0,0 +1,101 @@ +"""models command — manage local ASR models (whisper-cpp / faster-whisper).""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path + +from videocaptioner.cli import exit_codes as EXIT +from videocaptioner.cli import output +from videocaptioner.core.download import ( + DownloadCancelled, + DownloadError, + find_model, + iter_models, + model_install_state, +) + + +def _models_dir(args: Namespace) -> Path: + custom = getattr(args, "models_dir", None) + if custom: + path = Path(custom).expanduser() + path.mkdir(parents=True, exist_ok=True) + return path + from videocaptioner.config import MODEL_PATH + + return Path(MODEL_PATH) + + +def run(args: Namespace, config: dict) -> int: + action = getattr(args, "models_action", None) + if action == "download": + return _run_download(args) + return _run_list(args) + + +def _run_list(args: Namespace) -> int: + models_dir = _models_dir(args) + kind = getattr(args, "kind", None) + print(f"Models directory: {models_dir}") + current_kind = None + for spec in iter_models(kind): + if spec.kind != current_kind: + current_kind = spec.kind + print(f"\n{current_kind}") + installed = model_install_state(spec, models_dir) + mark = "✓ installed" if installed else " -" + print(f" {spec.name:<16} {spec.size_text:>8} {mark:<12} {spec.description}") + print() + print("Download with: videocaptioner models download ") + return EXIT.SUCCESS + + +def _run_download(args: Namespace) -> int: + spec = find_model(args.kind, args.name) + if spec is None: + output.error(f"Unknown model: {args.kind}/{args.name}") + output.hint("List available models with: videocaptioner models list") + return EXIT.USAGE_ERROR + + models_dir = _models_dir(args) + if model_install_state(spec, models_dir): + output.success(f"{spec.key} is already installed in {models_dir}") + return EXIT.SUCCESS + + quiet = getattr(args, "quiet", False) + progress = None if quiet else output.ProgressLine(f"Downloading {spec.key}").start() + + def report(event) -> None: + if progress is None: + return + if event.total_bytes: + percent = int(event.total_received * 100 / event.total_bytes) + elif event.file.total: + percent = int(event.file.received * 100 / event.file.total) + else: + percent = 0 + label = f"{event.file.file_name} ({event.file_index}/{event.file_count})" + progress.update(percent, f"Downloading {label}") + + from videocaptioner.core.download import download_model + + try: + target = download_model(spec, models_dir, on_progress=report) + except DownloadCancelled: + if progress: + progress.fail("Download cancelled") + return EXIT.GENERAL_ERROR + except DownloadError as exc: + if progress: + progress.fail(str(exc)) + else: + output.error(str(exc)) + output.hint("Check your network or retry later; partial files resume automatically.") + return EXIT.RUNTIME_ERROR + + if progress: + progress.finish(f"{spec.key} ready at {target}") + else: + output.success(f"{spec.key} ready at {target}") + return EXIT.SUCCESS diff --git a/videocaptioner/cli/commands/process.py b/videocaptioner/cli/commands/process.py index e164f7e5..ed429230 100644 --- a/videocaptioner/cli/commands/process.py +++ b/videocaptioner/cli/commands/process.py @@ -5,7 +5,19 @@ from videocaptioner.cli import exit_codes as EXIT from videocaptioner.cli import output -from videocaptioner.cli.config import get +from videocaptioner.core.application import output_paths +from videocaptioner.core.application.app_config import target_language_from_code +from videocaptioner.core.application.config_store import get + + +def _subtitle_product_tag(config: dict, args: Namespace, no_translate: bool) -> str: + """字幕产物 tag:翻译输出语言码,否则 optimized(与 GUI 同一套语法)。""" + if no_translate: + return output_paths.TAG_OPTIMIZED + code = getattr(args, "target_language", None) or get( + config, "translate.target_language", "zh-Hans" + ) + return output_paths.language_tag(target_language_from_code(code)) def run(args: Namespace, config: dict) -> int: @@ -68,8 +80,11 @@ def run(args: Namespace, config: dict) -> int: total_steps = 2 + (0 if no_synthesize else 1) + (1 if do_dub else 0) current_step = 1 - final_output_path = _resolve_final_output_path(out_arg, out_dir, path, do_dub, no_synthesize, is_audio_input) + final_output_path = _resolve_final_output_path( + out_arg, out_dir, path, do_dub, no_synthesize, is_audio_input + ) dubbed_video_path: str | None = None + subtitle_tag = _subtitle_product_tag(config, args, no_translate) # Step 1: Transcribe if not quiet: @@ -88,6 +103,9 @@ def run(args: Namespace, config: dict) -> int: whisper_api_key=getattr(args, "whisper_api_key", None), whisper_api_base=getattr(args, "whisper_api_base", None), whisper_model=None, whisper_prompt=None, + fun_asr_api_key=getattr(args, "fun_asr_api_key", None), + fun_asr_api_base=getattr(args, "fun_asr_api_base", None), + fun_asr_model=getattr(args, "fun_asr_model", None), ) from videocaptioner.cli.commands.transcribe import run as transcribe_run ret = transcribe_run(tr_args, config) @@ -100,7 +118,9 @@ def run(args: Namespace, config: dict) -> int: if not quiet: output.info(f"Step {current_step}/{total_steps}: Processing subtitles...") - processed_path = str(out_dir / f"{path.stem}_processed.srt") + processed_path = str( + output_paths.product_path(path, subtitle_tag, ext=".srt", directory=out_dir) + ) sub_args = Namespace( input=subtitle_path, output=processed_path, format=get(config, "output.format", "srt"), @@ -140,13 +160,17 @@ def run(args: Namespace, config: dict) -> int: text_track = "second" if layout_for_dub == "source-above" else "first" else: text_track = "first" - dub_audio_path = str(out_dir / f"{path.stem}_dubbed.wav") + dub_audio_path = str( + output_paths.product_path(path, output_paths.TAG_DUBBED, ext=".wav", directory=out_dir) + ) if is_audio: dub_video_path = None elif no_synthesize: dub_video_path = final_output_path else: - dub_video_path = str(out_dir / f"{path.stem}_dubbed{path.suffix}") + dub_video_path = str( + output_paths.product_path(path, output_paths.TAG_DUBBED, directory=out_dir) + ) dub_args = Namespace( subtitle=subtitle_path, video=None if is_audio else str(path), @@ -233,9 +257,11 @@ def _resolve_final_output_path( out_path = Path(output_arg) if out_path.suffix: return str(out_path) - suffix = ".wav" if is_audio and do_dub else input_path.suffix + ext = ".wav" if is_audio and do_dub else input_path.suffix if do_dub and no_synthesize: - return str(out_dir / f"{input_path.stem}_dubbed{suffix}") - if do_dub: - return str(out_dir / f"{input_path.stem}_dubbed_captioned{suffix}") - return str(out_dir / f"{input_path.stem}_captioned{suffix}") + tags = (output_paths.TAG_DUBBED,) + elif do_dub: + tags = (output_paths.TAG_DUBBED, output_paths.TAG_SUBTITLED) + else: + tags = (output_paths.TAG_SUBTITLED,) + return str(output_paths.product_path(input_path, *tags, ext=ext, directory=out_dir)) diff --git a/videocaptioner/cli/commands/style_cmd.py b/videocaptioner/cli/commands/style_cmd.py index b248099a..6c2f7e81 100644 --- a/videocaptioner/cli/commands/style_cmd.py +++ b/videocaptioner/cli/commands/style_cmd.py @@ -4,7 +4,12 @@ from videocaptioner.cli import exit_codes as EXIT from videocaptioner.cli import output -from videocaptioner.core.subtitle.style_manager import StyleMode, list_styles +from videocaptioner.core.subtitle.style_manager import ( + AssSubtitleStyle, + RoundedSubtitleStyle, + StyleMode, + list_styles, +) def _get_styles_dir(): @@ -42,16 +47,18 @@ def _list(args: Namespace) -> int: for style in styles: mode_str = style.mode.value + payload = style.style if style.description: desc = style.description - elif style.mode == StyleMode.ROUNDED: - desc = f"font={style.font_name}, size={style.font_size}, bg={style.bg_color}" + elif isinstance(payload, RoundedSubtitleStyle): + desc = f"font={payload.font_name}, size={payload.font_size}, bg={payload.bg_color}" else: - desc = f"font={style.font_name}, size={style.font_size}, color={style.primary_color}" - if style.bold: + assert isinstance(payload, AssSubtitleStyle) + desc = f"font={payload.font_name}, size={payload.font_size}, color={payload.primary_color}" + if payload.bold: desc += ", bold" - print(f" {style.name:<14} {mode_str:<10} {desc}") + print(f" {style.id:<14} {mode_str:<10} {desc}") # Show detailed parameters for all styles if style.mode == StyleMode.ROUNDED: @@ -63,8 +70,12 @@ def _list(args: Namespace) -> int: "outline_width", "bold", "spacing", "margin_bottom"]: if k in details: print(f" {k}: {details[k]}") - if style.secondary: - print(f" secondary: font={style.secondary.font_name}, size={style.secondary.font_size}, color={style.secondary.color}") + if isinstance(payload, AssSubtitleStyle) and payload.secondary: + secondary = payload.secondary + print( + f" secondary: font={secondary.font_name}, " + f"size={secondary.font_size}, color={secondary.color}" + ) print() print("\nUsage:") diff --git a/videocaptioner/cli/commands/subtitle.py b/videocaptioner/cli/commands/subtitle.py index 99aad149..d896fb87 100644 --- a/videocaptioner/cli/commands/subtitle.py +++ b/videocaptioner/cli/commands/subtitle.py @@ -6,7 +6,8 @@ from videocaptioner.cli import exit_codes as EXIT from videocaptioner.cli import output -from videocaptioner.cli.config import get +from videocaptioner.core.application.config_store import get +from videocaptioner.core.llm import free_model # BCP 47 → TargetLanguage.value (Chinese label) mapping for internal use _LANG_MAP = { @@ -77,6 +78,10 @@ def run(args: Namespace, config: dict) -> int: if not validate_llm(config): return EXIT.USAGE_ERROR target_lang_code = get(config, "translate.target_language", "zh-Hans") + # 语言码尽早校验一次:默认输出名和翻译阶段共用解析结果 + target_language = _resolve_target_language(target_lang_code) if need_translate else None + if need_translate and target_language is None: + return EXIT.USAGE_ERROR need_reflect = get(config, "translate.reflect", False) if need_reflect and translator_service in ("bing", "google"): output.warn("--reflect only works with LLM translator, ignored for " + translator_service) @@ -110,13 +115,24 @@ def run(args: Namespace, config: dict) -> int: verbose = getattr(args, "verbose", False) quiet = getattr(args, "quiet", False) - # Build output path + # Build output path(命名语法统一走 output_paths:{stem}.{lang|optimized}.{ext}) + from videocaptioner.core.application import output_paths + + def default_output(directory=None) -> str: + tag = ( + output_paths.language_tag(target_language) + if target_language is not None + else output_paths.TAG_OPTIMIZED + ) + return str( + output_paths.product_path(input_path, tag, ext=f".{out_fmt}", directory=directory) + ) + if args.output: out = Path(args.output) if out.is_dir() or str(args.output).endswith("/"): out.mkdir(parents=True, exist_ok=True) - suffix = f"_{target_lang_code}" if need_translate else "_optimized" - output_path = str(out / f"{input_path.stem}{suffix}.{out_fmt}") + output_path = default_output(directory=out) else: # If -o has no extension, auto-append from --format if not out.suffix: @@ -127,8 +143,7 @@ def run(args: Namespace, config: dict) -> int: if ext != out_fmt and out_fmt != "srt": output.warn(f"--format {out_fmt} ignored; output format determined by -o extension (.{ext})") else: - suffix = f"_{target_lang_code}" if need_translate else "_optimized" - output_path = str(input_path.with_stem(input_path.stem + suffix).with_suffix(f".{out_fmt}")) + output_path = default_output() # Validate output format from videocaptioner.cli.validators import validate_output_format @@ -137,13 +152,19 @@ def run(args: Namespace, config: dict) -> int: return err # Setup LLM environment - llm_api_key = get(config, "llm.api_key", "") - llm_api_base = get(config, "llm.api_base", "") - llm_model = get(config, "llm.model", "") - if llm_api_key: - os.environ["OPENAI_API_KEY"] = llm_api_key - if llm_api_base: - os.environ["OPENAI_BASE_URL"] = llm_api_base + if get(config, "llm.service", "") == "immersive": + # 公益大模型:base/model 固定,真实令牌在 LLM client 内实时取 + os.environ["OPENAI_BASE_URL"] = free_model.BASE_URL + os.environ["OPENAI_API_KEY"] = free_model.PLACEHOLDER_KEY + llm_model = free_model.MODEL + else: + llm_api_key = get(config, "llm.api_key", "") + llm_api_base = get(config, "llm.api_base", "") + llm_model = get(config, "llm.model", "") + if llm_api_key: + os.environ["OPENAI_API_KEY"] = llm_api_key + if llm_api_base: + os.environ["OPENAI_BASE_URL"] = llm_api_base # Load custom prompt (only if LLM features are needed) custom_prompt = getattr(args, "prompt", None) or "" @@ -215,16 +236,14 @@ def callback(result): if progress: progress.update(60, f"Translating to {target_lang_code}...") - target_language = _resolve_target_language(target_lang_code) - if not target_language: - if progress: - progress.finish() # Clean spinner without duplicate error - return EXIT.USAGE_ERROR - from videocaptioner.core.translate.factory import TranslatorFactory from videocaptioner.core.translate.types import TranslatorType - type_map = {"llm": TranslatorType.OPENAI, "bing": TranslatorType.BING, "google": TranslatorType.GOOGLE} + type_map = { + "llm": TranslatorType.OPENAI, + "bing": TranslatorType.BING, + "google": TranslatorType.GOOGLE, + } translator = TranslatorFactory.create_translator( translator_type=type_map.get(translator_service, TranslatorType.OPENAI), thread_num=thread_num, diff --git a/videocaptioner/cli/commands/synthesize.py b/videocaptioner/cli/commands/synthesize.py index 7e8f80dd..add26f54 100644 --- a/videocaptioner/cli/commands/synthesize.py +++ b/videocaptioner/cli/commands/synthesize.py @@ -7,13 +7,15 @@ from videocaptioner.cli import exit_codes as EXIT from videocaptioner.cli import output -from videocaptioner.cli.config import get from videocaptioner.cli.validators import validate_synthesize +from videocaptioner.core.application.config_store import get from videocaptioner.core.subtitle.style_manager import ( - StyleMode, - SubtitleStyle, + StyleSource, + SubtitleRenderer, available_style_names, load_style, + normalize_style_id, + preset_from_json, ) EncodePreset = Literal[ @@ -65,7 +67,9 @@ def _resolve_style(config: dict, verbose: bool) -> tuple: return None, None, None, None, None # Load base style from preset - style = load_style(style_name, mode=render_mode) + style_id = normalize_style_id(style_name, render_mode) + renderer = SubtitleRenderer.ROUNDED if render_mode == "rounded" else SubtitleRenderer.ASS + style = load_style(style_id, renderer=renderer) if style is None: names = available_style_names() output.error(f"Style preset not found: '{style_name}'") @@ -74,26 +78,18 @@ def _resolve_style(config: dict, verbose: bool) -> tuple: output.hint("Run 'videocaptioner style' to see all options") return None, None, None, None, None - # Mode mismatch - if render_mode == "rounded" and style.mode == StyleMode.ASS: - output.warn(f"'{style.name}' is an ASS preset. Switching to default rounded style.") - style = load_style("default", mode="rounded") or style - elif render_mode == "ass" and style.mode == StyleMode.ROUNDED: - output.warn(f"'{style.name}' is a rounded preset. Switching to default ASS style.") - style = load_style("default", mode="ass") or style - # Apply --style-override on top of base style if override_dict: # Auto-detect mode from override fields if any(k in override_dict for k in ("bg_color", "text_color", "corner_radius")): if render_mode == "ass": render_mode = "rounded" - # Reload with rounded base if currently ASS - if style.mode == StyleMode.ASS: - style = load_style("default", mode="rounded") or style + renderer = SubtitleRenderer.ROUNDED + style = load_style("rounded/default", renderer=renderer) or style base = style.to_json_dict() base.update(override_dict) - style = SubtitleStyle.from_json(base) + base["id"] = normalize_style_id(base.get("id") or style.id, render_mode) + style = preset_from_json(base, source=StyleSource.USER, renderer_hint=renderer) # Print final style config if verbose: @@ -186,13 +182,15 @@ def run(args: Namespace, config: dict) -> int: if soft and any([style_arg, override_arg, render_arg]): output.warn("Style options are ignored in soft subtitle mode (player controls rendering)") - # Output path + # Output path:软/硬是参数不是产物角色,统一 {stem}.subtitled.{ext} if args.output: output_path = args.output else: - stem = video_path.stem - suffix = "_captioned" if not soft else "_subtitled" - output_path = str(video_path.with_stem(stem + suffix)) + from videocaptioner.core.application import output_paths + + output_path = str( + output_paths.product_path(video_path, output_paths.TAG_SUBTITLED) + ) # Check input != output if Path(output_path).resolve() == video_path.resolve(): diff --git a/videocaptioner/cli/commands/transcribe.py b/videocaptioner/cli/commands/transcribe.py index e2df297c..9a828018 100644 --- a/videocaptioner/cli/commands/transcribe.py +++ b/videocaptioner/cli/commands/transcribe.py @@ -6,8 +6,8 @@ from videocaptioner.cli import exit_codes as EXIT from videocaptioner.cli import output -from videocaptioner.cli.config import get from videocaptioner.cli.validators import validate_transcribe +from videocaptioner.core.application.config_store import get def run(args: Namespace, config: dict) -> int: @@ -67,59 +67,12 @@ def run(args: Namespace, config: dict) -> int: if whisper_base: os.environ["OPENAI_BASE_URL"] = whisper_base - # Build TranscribeConfig - from videocaptioner.core.entities import ( - FasterWhisperModelEnum, - TranscribeConfig, - TranscribeModelEnum, - VadMethodEnum, - WhisperModelEnum, + from videocaptioner.cli.config_adapter import app_config_from_cli + from videocaptioner.core.application import TaskBuilder + transcribe_config = TaskBuilder(app_config_from_cli(config)).create_transcribe_config( + need_word_timestamp=getattr(args, "word_timestamps", False) ) - asr_map = { - "faster-whisper": TranscribeModelEnum.FASTER_WHISPER, - "whisper-api": TranscribeModelEnum.WHISPER_API, - "bijian": TranscribeModelEnum.BIJIAN, - "jianying": TranscribeModelEnum.JIANYING, - "whisper-cpp": TranscribeModelEnum.WHISPER_CPP, - } - - # Map CLI string values to enums - fw_model_str = get(config, "transcribe.faster_whisper.model", "large-v3") - fw_model_enum = next((m for m in FasterWhisperModelEnum if m.value == fw_model_str), None) - - vad_str = get(config, "transcribe.faster_whisper.vad_method", "silero-v4-fw") - vad_map = {v.value.replace("_", "-"): v for v in VadMethodEnum} - vad_enum = vad_map.get(vad_str.replace("_", "-")) - - # WhisperCpp model enum - wcpp_model_str = get(config, "transcribe.whisper_cpp.model", "large-v2") - wcpp_model_enum = next((m for m in WhisperModelEnum if m.value == wcpp_model_str), None) - - transcribe_config = TranscribeConfig( - transcribe_model=asr_map.get(asr_engine), - transcribe_language=language if language != "auto" else "", - need_word_time_stamp=getattr(args, "word_timestamps", False), - # FasterWhisper options - faster_whisper_model=fw_model_enum, - faster_whisper_model_dir=None, - faster_whisper_device=get(config, "transcribe.faster_whisper.device", "auto"), - faster_whisper_vad_filter=get(config, "transcribe.faster_whisper.vad_filter", True), - faster_whisper_vad_method=vad_enum, - faster_whisper_vad_threshold=get(config, "transcribe.faster_whisper.vad_threshold", 0.5), - faster_whisper_ff_mdx_kim2=get(config, "transcribe.faster_whisper.voice_extraction", False), - faster_whisper_one_word=True, - faster_whisper_prompt=get(config, "transcribe.faster_whisper.prompt", ""), - # WhisperCpp options - whisper_model=wcpp_model_enum, - # Whisper API options - whisper_api_key=get(config, "whisper_api.api_key", ""), - whisper_api_base=get(config, "whisper_api.api_base", ""), - whisper_api_model=get(config, "whisper_api.model", "whisper-1"), - whisper_api_prompt=get(config, "whisper_api.prompt", ""), - ) - - # Progress callback progress = None if quiet else output.ProgressLine(f"Transcribing [{asr_engine}]").start() diff --git a/videocaptioner/cli/config.py b/videocaptioner/cli/config.py deleted file mode 100644 index 965ba6df..00000000 --- a/videocaptioner/cli/config.py +++ /dev/null @@ -1,325 +0,0 @@ -"""CLI configuration management. - -Config priority (highest to lowest): - 1. Command-line arguments - 2. Environment variables (VIDEOCAPTIONER_*) - 3. User config file (~/.config/videocaptioner/config.toml) - 4. Built-in defaults -""" - -import os -import sys -from copy import deepcopy -from pathlib import Path -from typing import Any, Dict, Optional - -from platformdirs import user_config_dir - -if sys.version_info >= (3, 11): - import tomllib -else: - try: - import tomllib - except ModuleNotFoundError: - import tomli as tomllib # type: ignore[no-redef] - -APP_NAME = "videocaptioner" - -# Default config directory -CONFIG_DIR = Path(user_config_dir(APP_NAME)) -CONFIG_FILE = CONFIG_DIR / "config.toml" - -# Environment variable mappings: env var name → config dotted key -# Supports both OpenAI standard names and VIDEOCAPTIONER_ prefixed names -ENV_MAP: Dict[str, str] = { - # OpenAI standard (most tools recognize these) - "OPENAI_API_KEY": "llm.api_key", - "OPENAI_BASE_URL": "llm.api_base", - "OPENAI_MODEL": "llm.model", - # VIDEOCAPTIONER_ prefixed (take precedence over standard) - "VIDEOCAPTIONER_LLM_API_KEY": "llm.api_key", - "VIDEOCAPTIONER_LLM_API_BASE": "llm.api_base", - "VIDEOCAPTIONER_LLM_MODEL": "llm.model", - "VIDEOCAPTIONER_WHISPER_API_KEY": "whisper_api.api_key", - "VIDEOCAPTIONER_WHISPER_API_BASE": "whisper_api.api_base", - "VIDEOCAPTIONER_DEEPLX_ENDPOINT": "translate.deeplx_endpoint", - "VIDEOCAPTIONER_TARGET_LANG": "translate.target_language", - "VIDEOCAPTIONER_DUBBING_PROVIDER": "dubbing.provider", - "VIDEOCAPTIONER_DUB_PRESET": "dubbing.preset", - "VIDEOCAPTIONER_TTS_API_KEY": "dubbing.api_key", - "VIDEOCAPTIONER_TTS_API_BASE": "dubbing.api_base", - "VIDEOCAPTIONER_TTS_MODEL": "dubbing.model", - "VIDEOCAPTIONER_TTS_VOICE": "dubbing.voice", - "VIDEOCAPTIONER_TTS_STYLE_PROMPT": "dubbing.style_prompt", - "VIDEOCAPTIONER_TTS_WORKERS": "dubbing.tts_workers", - "VIDEOCAPTIONER_TTS_USE_CACHE": "dubbing.use_cache", - "VIDEOCAPTIONER_TTS_FIT_MODE": "dubbing.fit_mode", - "VIDEOCAPTIONER_DUB_TIMING": "dubbing.timing", - "VIDEOCAPTIONER_DUB_AUDIO_MODE": "dubbing.audio_mode", - "VIDEOCAPTIONER_TTS_MAX_SPEED": "dubbing.max_speed", - "VIDEOCAPTIONER_TTS_REWRITE_TOO_LONG": "dubbing.rewrite_too_long", - "VIDEOCAPTIONER_TTS_MIX_ORIGINAL_AUDIO": "dubbing.mix_original_audio", -} - -DEFAULTS: Dict[str, Any] = { - "llm": { - "api_key": "", - "api_base": "https://api.openai.com/v1", - "model": "gpt-4o-mini", - }, - "whisper_api": { - "api_key": "", - "api_base": "https://api.openai.com/v1", - "model": "whisper-1", - "prompt": "", - }, - "transcribe": { - "asr": "bijian", - "language": "auto", - "faster_whisper": { - "model": "large-v3", - "device": "auto", - "vad_filter": True, - "vad_method": "silero-v4-fw", - "vad_threshold": 0.5, - "voice_extraction": False, - "prompt": "", - }, - "whisper_cpp": { - "model": "large-v2", - }, - }, - "subtitle": { - "optimize": True, - "translate": False, - "split": True, - "max_word_count_cjk": 18, - "max_word_count_english": 12, - "thread_num": 4, - "batch_size": 20, - }, - "translate": { - "service": "bing", - "target_language": "zh-Hans", - "reflect": False, - "deeplx_endpoint": "", - }, - "synthesize": { - "subtitle_mode": "soft", - "quality": "medium", - "layout": "target-above", - "render_mode": "ass", - "style": "default", - }, - "dubbing": { - "provider": "edge", - "preset": "edge-cn-female", - "api_key": "", - "api_base": "", - "model": "edge-tts", - "voice": "zh-CN-XiaoxiaoNeural", - "response_format": "mp3", - "sample_rate": 32000, - "speed": 1.0, - "gain": 0, - "tts_workers": 5, - "use_cache": True, - "style_prompt": "", - "timing": "balanced", - "audio_mode": "replace", - "fit_mode": "tempo", - "max_speed": 2.0, - "target_padding_ms": 80, - "rewrite_too_long": False, - "rewrite_threshold": 1.15, - "mix_original_audio": False, - "original_audio_volume": 0.25, - "dubbed_audio_volume": 1.0, - }, - "output": { - "format": "srt", - }, -} - - -def _deep_merge(base: dict, override: dict) -> dict: - """Merge override into base recursively. Override values take precedence.""" - result = base.copy() - for key, value in override.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = _deep_merge(result[key], value) - else: - result[key] = value - return result - - -def _set_nested(d: dict, dotted_key: str, value: Any) -> None: - """Set a value in a nested dict using dotted key notation (e.g. 'llm.api_key').""" - keys = dotted_key.split(".") - for key in keys[:-1]: - d = d.setdefault(key, {}) - d[keys[-1]] = value - - -def _get_nested(d: dict, dotted_key: str, default: Any = None) -> Any: - """Get a value from a nested dict using dotted key notation.""" - keys = dotted_key.split(".") - for key in keys: - if not isinstance(d, dict): - return default - d = d.get(key, default) # type: ignore[assignment] - if d is default: - return default - return d - - -def load_config_file(path: Optional[Path] = None) -> dict: - """Load and parse a TOML config file. Returns empty dict if file doesn't exist.""" - path = path or CONFIG_FILE - if not path.exists(): - return {} - try: - with open(path, "rb") as f: - return tomllib.load(f) - except Exception as e: - import sys - print(f"! Warning: Failed to parse config file {path}: {e}", file=sys.stderr) - print(" Run 'videocaptioner config init' to recreate it.", file=sys.stderr) - return {} - - -def load_env_overrides() -> dict: - """Read environment variables and map them to config keys. - - Supports both OpenAI standard names (OPENAI_API_KEY) and - VIDEOCAPTIONER_ prefixed names. Prefixed names take precedence. - """ - overrides: Dict[str, Any] = {} - for env_var, dotted_key in ENV_MAP.items(): - value = os.environ.get(env_var) - if value is not None: - try: - parsed_value = _parse_value(value, dotted_key) - except ValueError as exc: - print(f"! Warning: Invalid environment value {env_var}: {exc}", file=sys.stderr) - continue - _set_nested(overrides, dotted_key, parsed_value) - return overrides - - -def build_config( - cli_overrides: Optional[dict] = None, - config_path: Optional[Path] = None, -) -> dict: - """Build final config by merging all sources (priority: cli > env > file > defaults).""" - config = deepcopy(DEFAULTS) - # Layer 1: config file - file_config = load_config_file(config_path) - config = _deep_merge(config, file_config) - # Layer 2: environment variables - env_config = load_env_overrides() - config = _deep_merge(config, env_config) - # Layer 3: CLI argument overrides - if cli_overrides: - config = _deep_merge(config, cli_overrides) - return config - - -def get(config: dict, key: str, default: Any = None) -> Any: - """Convenience accessor for dotted keys.""" - return _get_nested(config, key, default) - - -def ensure_config_dir() -> Path: - """Ensure the config directory exists and return its path.""" - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - return CONFIG_DIR - - -def _parse_value(raw: str, key: str) -> Any: - """Parse a string value into the correct Python type based on DEFAULTS.""" - # Infer type from defaults - default_val = _get_nested(DEFAULTS, key) - if isinstance(default_val, bool): - if raw.lower() in ("true", "1", "yes"): - return True - if raw.lower() in ("false", "0", "no"): - return False - raise ValueError(f"Expected boolean for '{key}', got '{raw}' (use true/false)") - if isinstance(default_val, int): - try: - return int(raw) - except ValueError: - raise ValueError(f"Expected integer for '{key}', got '{raw}'") - if isinstance(default_val, float): - try: - return float(raw) - except ValueError: - raise ValueError(f"Expected number for '{key}', got '{raw}'") - return raw - - -def save_config_value(key: str, value: str, config_path: Optional[Path] = None) -> None: - """Set a single value in the config file. Creates the file if it doesn't exist.""" - path = config_path or CONFIG_FILE - ensure_config_dir() - - existing = load_config_file(path) - _set_nested(existing, key, _parse_value(value, key)) - - with open(path, "w", encoding="utf-8") as f: - _write_toml(f, existing) - # Restrict permissions — config may contain API keys - try: - os.chmod(path, 0o600) - except OSError: - pass - - -def _write_toml(f, data: dict, parent_key: str = "") -> None: - """Write a dict as valid TOML, handling arbitrary nesting depth.""" - # Write scalar values at this level first - for key, value in data.items(): - if not isinstance(value, dict): - f.write(f"{key} = {_toml_value(value)}\n") - - # Write sub-tables recursively - for key, value in data.items(): - if isinstance(value, dict): - full_key = f"{parent_key}.{key}" if parent_key else key - f.write(f"\n[{full_key}]\n") - _write_toml(f, value, full_key) - - -def _toml_value(value: Any) -> str: - """Convert a Python value to TOML representation.""" - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return str(value) - if isinstance(value, str): - escaped = (value - .replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t")) - return f'"{escaped}"' - return f'"{value!s}"' - - -def format_config(config: dict, indent: int = 0) -> str: - """Format config dict for display.""" - lines = [] - prefix = " " * indent - for key, value in config.items(): - if isinstance(value, dict): - lines.append(f"{prefix}{key}:") - lines.append(format_config(value, indent + 1)) - elif isinstance(value, str) and ("key" in key or "token" in key) and value: - # Mask sensitive values - masked = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "****" - lines.append(f"{prefix}{key} = {masked}") - else: - lines.append(f"{prefix}{key} = {value}") - return "\n".join(lines) diff --git a/videocaptioner/cli/config_adapter.py b/videocaptioner/cli/config_adapter.py new file mode 100644 index 00000000..fe935a7e --- /dev/null +++ b/videocaptioner/cli/config_adapter.py @@ -0,0 +1,174 @@ +"""Convert merged CLI/shared TOML configuration dictionaries into AppConfig.""" + +from __future__ import annotations + +from videocaptioner.config import MODEL_PATH +from videocaptioner.core.application.app_config import ( + AppConfig, + DubbingSettings, + LLMSettings, + SubtitleSettings, + SynthesisSettings, + TranscribeSettings, + enum_by_value, + layout_from_cli, + quality_from_cli, + render_mode_from_cli, + target_language_from_code, + transcribe_model_from_cli, + transcribe_output_format_from_cli, + translator_from_cli, +) +from videocaptioner.core.application.config_store import get +from videocaptioner.core.entities import FasterWhisperModelEnum, VadMethodEnum, WhisperModelEnum +from videocaptioner.core.llm import free_model +from videocaptioner.core.subtitle.style_manager import normalize_style_id + + +def app_config_from_cli(config: dict) -> AppConfig: + asr = str(get(config, "transcribe.asr", "bijian")) + language = str(get(config, "transcribe.language", "auto")) + vad_method = str(get(config, "transcribe.faster_whisper.vad_method", "silero_v4")) + + subtitle_mode = str(get(config, "synthesize.subtitle_mode", "hard")) + llm_service = str(get(config, "llm.service", "openai")) + llm_provider = get(config, f"llm.providers.{llm_service}", {}) or {} + if llm_service == "immersive": + # 公益大模型:无需用户配置,base/model 固定,真实令牌在 LLM client 内实时取 + llm_key, llm_base, llm_model = ( + free_model.PLACEHOLDER_KEY, free_model.BASE_URL, free_model.MODEL, + ) + else: + llm_key = str(llm_provider.get("api_key") or get(config, "llm.api_key", "") or "") + llm_base = str( + llm_provider.get("api_base") + or get(config, "llm.api_base", "https://api.openai.com/v1") + or "" + ) + llm_model = str(llm_provider.get("model") or get(config, "llm.model", "gpt-4o-mini") or "") + return AppConfig( + work_dir=str(get(config, "app.work_dir", "") or ""), + cache_enabled=bool( + get(config, "app.cache_enabled", get(config, "dubbing.use_cache", True)) + ), + llm=LLMSettings( + service=_llm_service_from_key(llm_service), + api_key=llm_key, + api_base=llm_base, + model=llm_model, + ), + transcribe=TranscribeSettings( + model=transcribe_model_from_cli(asr), + language="" if language == "auto" else language, + language_label="Auto" if language == "auto" else language, + output_format=transcribe_output_format_from_cli( + get(config, "transcribe.output_format", get(config, "output.format", "srt")) + ), + whisper_model=enum_by_value( + WhisperModelEnum, + get(config, "transcribe.whisper_cpp.model", "tiny"), + WhisperModelEnum.TINY, + ), + whisper_api_key=str(get(config, "whisper_api.api_key", "") or ""), + whisper_api_base=str(get(config, "whisper_api.api_base", "") or ""), + whisper_api_model=str(get(config, "whisper_api.model", "whisper-1") or ""), + whisper_api_prompt=str(get(config, "whisper_api.prompt", "") or ""), + fun_asr_api_key=str(get(config, "fun_asr.api_key", "") or ""), + fun_asr_api_base=str( + get(config, "fun_asr.api_base", "https://dashscope.aliyuncs.com") or "" + ), + fun_asr_model=str(get(config, "fun_asr.model", "fun-asr") or ""), + faster_whisper_model=enum_by_value( + FasterWhisperModelEnum, + get(config, "transcribe.faster_whisper.model", "tiny"), + FasterWhisperModelEnum.TINY, + ), + faster_whisper_program=str( + get(config, "transcribe.faster_whisper.program", "faster-whisper-xxl.exe") + or "faster-whisper-xxl.exe" + ), + faster_whisper_model_dir=str( + get(config, "transcribe.faster_whisper.model_dir", "") or MODEL_PATH + ), + faster_whisper_device=str( + get(config, "transcribe.faster_whisper.device", "auto") or "auto" + ), + faster_whisper_vad_filter=bool( + get(config, "transcribe.faster_whisper.vad_filter", True) + ), + faster_whisper_vad_threshold=float( + get(config, "transcribe.faster_whisper.vad_threshold", 0.4) + ), + faster_whisper_vad_method=enum_by_value( + VadMethodEnum, vad_method, VadMethodEnum.SILERO_V4 + ), + faster_whisper_ff_mdx_kim2=bool( + get(config, "transcribe.faster_whisper.voice_extraction", False) + ), + faster_whisper_one_word=bool( + get(config, "transcribe.faster_whisper.one_word", True) + ), + faster_whisper_prompt=str(get(config, "transcribe.faster_whisper.prompt", "") or ""), + ), + subtitle=SubtitleSettings( + translator_service=translator_from_cli(get(config, "translate.service", "bing")), + need_reflect=bool(get(config, "translate.reflect", False)), + deeplx_endpoint=str(get(config, "translate.deeplx_endpoint", "") or ""), + thread_num=int(get(config, "subtitle.thread_num", 10)), + batch_size=int(get(config, "subtitle.batch_size", 10)), + need_optimize=bool(get(config, "subtitle.optimize", False)), + need_translate=bool(get(config, "subtitle.translate", False)), + need_split=bool(get(config, "subtitle.split", False)), + target_language=target_language_from_code( + get(config, "translate.target_language", "zh-Hans") + ), + max_word_count_cjk=int(get(config, "subtitle.max_word_count_cjk", 28)), + max_word_count_english=int(get(config, "subtitle.max_word_count_english", 20)), + custom_prompt_text=str( + get(config, "subtitle.custom_prompt", get(config, "subtitle.prompt", "")) or "" + ), + layout=layout_from_cli(get(config, "synthesize.layout", "target-above")), + style_name=str(get(config, "synthesize.style", "default") or "default"), + ), + synthesis=SynthesisSettings( + need_video=bool(get(config, "synthesize.need_video", True)), + soft_subtitle=bool(get(config, "synthesize.soft_subtitle", subtitle_mode != "hard")), + video_quality=quality_from_cli(get(config, "synthesize.quality", "medium")), + render_mode=render_mode_from_cli(get(config, "synthesize.render_mode", "rounded")), + style_id=normalize_style_id( + get(config, "synthesize.style", "default"), + get(config, "synthesize.render_mode", "rounded"), + ), + ), + dubbing=DubbingSettings( + enabled=bool(get(config, "dubbing.enabled", False)), + provider=str(get(config, "dubbing.provider", "edge") or "edge"), + preset=str(get(config, "dubbing.preset", "edge-cn-female") or ""), + voice=str(get(config, "dubbing.voice", "zh-CN-XiaoxiaoNeural") or ""), + text_track=str(get(config, "dubbing.text_track", "auto") or "auto"), + timing=str(get(config, "dubbing.timing", "balanced") or "balanced"), + audio_mode=str(get(config, "dubbing.audio_mode", "replace") or "replace"), + api_key=str(get(config, "dubbing.api_key", "") or ""), + api_base=str(get(config, "dubbing.api_base", "") or ""), + model=str(get(config, "dubbing.model", "") or ""), + tts_workers=int(get(config, "dubbing.tts_workers", 5)), + clone_audio_path=str(get(config, "dubbing.clone_audio", "") or ""), + clone_audio_text=str(get(config, "dubbing.clone_text", "") or ""), + ), + ) + + +def _llm_service_from_key(value: str): + from videocaptioner.core.entities import LLMServiceEnum + + return { + "openai": LLMServiceEnum.OPENAI, + "silicon_cloud": LLMServiceEnum.SILICON_CLOUD, + "siliconcloud": LLMServiceEnum.SILICON_CLOUD, + "deepseek": LLMServiceEnum.DEEPSEEK, + "ollama": LLMServiceEnum.OLLAMA, + "lm_studio": LLMServiceEnum.LM_STUDIO, + "lmstudio": LLMServiceEnum.LM_STUDIO, + "gemini": LLMServiceEnum.GEMINI, + "chatglm": LLMServiceEnum.CHATGLM, + }.get(str(value or "openai").lower(), LLMServiceEnum.OPENAI) diff --git a/videocaptioner/cli/main.py b/videocaptioner/cli/main.py index b495de9b..6b25de37 100644 --- a/videocaptioner/cli/main.py +++ b/videocaptioner/cli/main.py @@ -20,6 +20,7 @@ from typing import List, Optional from videocaptioner.cli import exit_codes as EXIT +from videocaptioner.core.application.app_config import CLI_ASR_CHOICES def _configure_stdio() -> None: @@ -112,10 +113,11 @@ def _build_transcribe_parser(subparsers) -> None: asr = p.add_argument_group("ASR options") asr.add_argument( "--asr", - choices=["bijian", "jianying", "whisper-api", "whisper-cpp"], + choices=CLI_ASR_CHOICES, help="ASR engine (default: bijian). " "bijian/jianying: free, no setup, Chinese & English only. " - "For other languages use whisper-api or whisper-cpp", + "fun-asr: Bailian recorded-file ASR. " + "For other languages use fun-asr, whisper-api, whisper-cpp or faster-whisper", ) asr.add_argument("--language", metavar="CODE", help="Source language as ISO 639-1 code, or 'auto' (default: auto)") @@ -129,6 +131,12 @@ def _build_transcribe_parser(subparsers) -> None: asr.add_argument("--whisper-model", metavar="NAME", help="Model name for whisper-api (default: whisper-1) " "or whisper-cpp (default: large-v2)") + asr.add_argument("--fun-asr-api-key", metavar="KEY", + help="Bailian/DashScope API key (for --asr fun-asr)") + asr.add_argument("--fun-asr-api-base", metavar="URL", + help="Bailian/DashScope API base URL") + asr.add_argument("--fun-asr-model", metavar="NAME", + help="Bailian Fun-ASR model name (default: fun-asr)") # Advanced options (configurable via 'config set', hidden from --help) for arg in ["--fw-model", "--fw-device", "--fw-vad-method", "--fw-prompt", "--whisper-prompt"]: @@ -255,6 +263,46 @@ def _build_synthesize_parser(subparsers) -> None: p.set_defaults(func=_run_synthesize) +def _build_extract_hardsub_parser(subparsers) -> None: + p = subparsers.add_parser( + "extract-hardsub", + help="Extract burned-in (hard) subtitles from video via OCR", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=( + "OCR the burned-in subtitles in a video into an editable subtitle file.\n" + "Auto-detects the subtitle region (override with --roi), only OCRs at\n" + "subtitle change points, and writes SRT/ASS. Engine: RapidOCR (CPU)." + ), + ) + p.add_argument("video", help="Input video file path") + _add_common_options(p) + + opt = p.add_argument_group("Extraction options") + opt.add_argument( + "--lang", + choices=["ch", "en", "japan", "korean", "chinese_cht"], + help="Subtitle language (default: ch = Chinese+English)", + ) + opt.add_argument( + "--mode", + choices=["fast", "standard", "accurate"], + help="Recognition mode — speed/accuracy tradeoff (default: standard)", + ) + opt.add_argument( + "--roi", + metavar="X,Y,W,H", + help="Subtitle region in original-resolution pixels (skip auto-detect)", + ) + opt.add_argument( + "--no-auto-region", + action="store_true", + help="Skip auto region detection; use --roi or the default bottom band", + ) + p.add_argument("-o", "--output", metavar="PATH", help="Output subtitle path (.srt/.ass/.txt)") + + p.set_defaults(func=_run_extract_hardsub) + + def _build_dub_parser(subparsers) -> None: from videocaptioner.core.dubbing.presets import available_dubbing_presets @@ -353,11 +401,14 @@ def _build_process_parser(subparsers) -> None: pipe.add_argument("--dub", action="store_true", help="Generate dubbed audio/video after subtitle processing") pipe.add_argument("--dub-only", action="store_true", help="Output only the dubbed result, skipping subtitle burn/embedding") - pipe.add_argument("--asr", choices=["bijian", "jianying", "whisper-api", "whisper-cpp"], + pipe.add_argument("--asr", choices=CLI_ASR_CHOICES, help="ASR engine (default: bijian)") pipe.add_argument("--language", metavar="CODE", help="Source language as ISO 639-1 code, or 'auto' (default: auto)") pipe.add_argument("--whisper-api-key", metavar="KEY", help="Whisper API key (for --asr whisper-api)") + pipe.add_argument("--fun-asr-api-key", metavar="KEY", help="Bailian/DashScope API key (for --asr fun-asr)") + pipe.add_argument("--fun-asr-api-base", metavar="URL", help="Bailian/DashScope API base URL") + pipe.add_argument("--fun-asr-model", metavar="NAME", help="Bailian Fun-ASR model name") pipe.add_argument("--translator", choices=["llm", "bing", "google"], help="Translation service (default: bing). bing and google are free") pipe.add_argument("--to", dest="target_language", metavar="CODE", help="Target language BCP 47 code") @@ -455,7 +506,7 @@ def _build_config_parser(subparsers) -> None: init_p.add_argument("--llm-api-key", metavar="KEY", help="LLM API key") init_p.add_argument("--llm-api-base", metavar="URL", help="LLM API base URL") init_p.add_argument("--llm-model", metavar="NAME", help="LLM model") - init_p.add_argument("--asr", choices=["bijian", "jianying", "whisper-api", "whisper-cpp"], help="Default ASR engine") + init_p.add_argument("--asr", choices=CLI_ASR_CHOICES, help="Default ASR engine") init_p.add_argument("--translator", choices=["llm", "bing", "google"], help="Default translation service") init_p.add_argument("--target-language", "--to", dest="target_language", metavar="CODE", help=argparse.SUPPRESS) init_p.add_argument("--no-optimize", action="store_true", help="Disable AI subtitle polish by default") @@ -477,6 +528,34 @@ def _build_config_parser(subparsers) -> None: p.set_defaults(func=_run_config) +def _build_models_parser(subparsers) -> None: + p = subparsers.add_parser( + "models", + help="Manage local ASR models (whisper-cpp / faster-whisper)", + description="List and download local speech-recognition models. " + "Downloads fall back across mirrors (HuggingFace → hf-mirror → ModelScope) " + "so they work both inside and outside mainland China.", + ) + models_sub = p.add_subparsers(dest="models_action", metavar="action") + + list_p = models_sub.add_parser("list", help="List models and install status") + list_p.add_argument( + "--kind", choices=["whisper-cpp", "faster-whisper"], help="Filter by engine" + ) + list_p.add_argument("--models-dir", metavar="DIR", help="Override models directory") + + dl_p = models_sub.add_parser( + "download", + help="Download a model with mirror fallback and resume", + ) + dl_p.add_argument("kind", choices=["whisper-cpp", "faster-whisper"], help="Engine") + dl_p.add_argument("name", help="Model name, e.g. tiny / base / large-v2") + dl_p.add_argument("--models-dir", metavar="DIR", help="Override models directory") + dl_p.add_argument("-q", "--quiet", action="store_true", help="No progress output") + + p.set_defaults(func=_run_models) + + def _build_doctor_parser(subparsers) -> None: p = subparsers.add_parser( "doctor", @@ -505,8 +584,10 @@ def build_parser() -> argparse.ArgumentParser: _build_subtitle_parser(subparsers) _build_dub_parser(subparsers) _build_synthesize_parser(subparsers) + _build_extract_hardsub_parser(subparsers) _build_process_parser(subparsers) _build_download_parser(subparsers) + _build_models_parser(subparsers) _build_config_parser(subparsers) _build_doctor_parser(subparsers) _build_style_parser(subparsers) @@ -532,8 +613,8 @@ def _build_cli_overrides(args: argparse.Namespace) -> dict: def _set(key: str, value) -> None: if value is not None: - from videocaptioner.cli.config import _set_nested - _set_nested(overrides, key, value) + from videocaptioner.core.application.config_store import set_nested + set_nested(overrides, key, value) # LLM _set("llm.api_key", getattr(args, "api_key", None)) @@ -545,6 +626,11 @@ def _set(key: str, value) -> None: _set("whisper_api.api_base", getattr(args, "whisper_api_base", None)) _set("whisper_api.model", getattr(args, "whisper_model", None)) + # Bailian Fun-ASR + _set("fun_asr.api_key", getattr(args, "fun_asr_api_key", None)) + _set("fun_asr.api_base", getattr(args, "fun_asr_api_base", None)) + _set("fun_asr.model", getattr(args, "fun_asr_model", None)) + # Transcribe _set("transcribe.asr", getattr(args, "asr", None)) _set("transcribe.language", getattr(args, "language", None)) @@ -630,7 +716,7 @@ def _set(key: str, value) -> None: def _load_config(args: argparse.Namespace) -> dict: """Load config with all layers merged.""" - from videocaptioner.cli.config import build_config + from videocaptioner.core.application.config_store import build_config config_path = None if getattr(args, "config", None): config_path = Path(args.config) @@ -677,6 +763,12 @@ def _run_dub(args: argparse.Namespace) -> int: return run(args, config) +def _run_extract_hardsub(args: argparse.Namespace) -> int: + from videocaptioner.cli.commands.extract_hardsub import run + config = _load_config(args) + return run(args, config) + + def _run_process(args: argparse.Namespace) -> int: from videocaptioner.cli.commands.process import run config = _load_config(args) @@ -695,6 +787,11 @@ def _run_config(args: argparse.Namespace) -> int: return run(args, config) +def _run_models(args: argparse.Namespace) -> int: + from videocaptioner.cli.commands.models_cmd import run + return run(args, {}) + + def _run_doctor(args: argparse.Namespace) -> int: from videocaptioner.cli.commands.doctor import run config = _load_config(args) diff --git a/videocaptioner/cli/validators.py b/videocaptioner/cli/validators.py index eb012106..61268bea 100644 --- a/videocaptioner/cli/validators.py +++ b/videocaptioner/cli/validators.py @@ -8,7 +8,7 @@ from pathlib import Path from videocaptioner.cli import output -from videocaptioner.cli.config import get +from videocaptioner.core.application.config_store import get # Shared file format constants AUDIO_EXTENSIONS = frozenset({"flac", "m4a", "mp3", "wav", "ogg", "opus", "aac", "wma"}) @@ -129,9 +129,11 @@ def validate_ffmpeg() -> bool: def validate_faster_whisper() -> bool: """Check that FasterWhisper executable is available.""" - if not shutil.which("faster-whisper-xxl") and not shutil.which("faster-whisper") and not shutil.which("faster_whisper"): - output.error("FasterWhisper not found on PATH") - output.hint("Download from the GUI (Settings > FasterWhisper), or install manually.") + from videocaptioner.core.download import detect_program, program_install_plan + + if not detect_program("faster-whisper").installed: + output.error("FasterWhisper not found") + output.hint(program_install_plan("faster-whisper").summary) output.hint("See: https://github.com/Purfview/whisper-standalone-win") return False return True @@ -139,21 +141,14 @@ def validate_faster_whisper() -> bool: def validate_whisper_cpp() -> bool: """Check that WhisperCpp executable is available.""" - # Check common names for whisper.cpp binary - names = ["whisper-cpp", "whisper", "whisper-cpp-main"] - if not any(shutil.which(n) for n in names): - # Also check project's bin directory - try: - from videocaptioner.config import BIN_PATH - if not any((BIN_PATH / n).exists() for n in names): - output.error("WhisperCpp not found") - output.hint("Download from the GUI (Settings > WhisperCpp), or install manually.") - output.hint("See: https://github.com/ggerganov/whisper.cpp") - return False - except ImportError: - output.error("WhisperCpp not found on PATH") - output.hint("See: https://github.com/ggerganov/whisper.cpp") - return False + from videocaptioner.core.download import detect_program, program_install_plan + + if not detect_program("whisper-cpp").installed: + plan = program_install_plan("whisper-cpp") + output.error("WhisperCpp not found") + output.hint(plan.command or plan.summary) + output.hint("Then download a model with 'videocaptioner models download whisper-cpp tiny'.") + return False return True @@ -163,6 +158,14 @@ def validate_transcribe(config: dict) -> bool: if asr == "whisper-api": return validate_whisper_api(config) + if asr == "fun-asr": + api_key = get(config, "fun_asr.api_key", "") + if not api_key: + output.error("Bailian Fun-ASR API key is required for --asr fun-asr") + output.hint("Use --fun-asr-api-key, set DASHSCOPE_API_KEY, or run:") + output.hint(" videocaptioner config set fun_asr.api_key ") + return False + return True if asr == "faster-whisper": return validate_faster_whisper() if asr == "whisper-cpp": diff --git a/videocaptioner/config.py b/videocaptioner/config.py index 5d8e47be..84ebf8fb 100644 --- a/videocaptioner/config.py +++ b/videocaptioner/config.py @@ -3,11 +3,19 @@ import shutil import sys from pathlib import Path +from typing import Optional + +from platformdirs import user_data_path try: + # 取干净的发布号("2.0.0.post1.dev0+g123" → "2.0.0")。 + # 版本唯一来源是 git tag(hatch-vcs 生成 _version.py),升版本打 tag 即可。 + import re as _re + from videocaptioner._version import __version__ as _raw_version - # Strip dev suffix (e.g. "1.5.0.dev103+g38544177c" → "1.5.0") - VERSION = _raw_version.split(".dev")[0] + + _match = _re.match(r"\d+\.\d+\.\d+", _raw_version) + VERSION = _match.group(0) if _match else _raw_version except Exception: VERSION = "0.0.0-dev" YEAR = 2026 @@ -18,56 +26,71 @@ GITHUB_REPO_URL = "https://github.com/WEIFENG2333/VideoCaptioner" RELEASE_URL = "https://github.com/WEIFENG2333/VideoCaptioner/releases/latest" FEEDBACK_URL = "https://github.com/WEIFENG2333/VideoCaptioner/issues" +# 更新检查后端:客户端启动调它,一次拿 block / update / announcement(飞书多维表格驱动)。 +# 二进制仍在 GitHub Release,响应给直链;core/update 下载时自动加 ghproxy 镜像兜底 + 校验 sha256。 +UPDATE_CHECK_URL = "https://backend.videocaptioner.cn/api/update/check" +# 用户反馈后端:客户端 multipart 提交到这里,后端写入飞书多维表格。端点写死,不走环境变量/配置。 +FEEDBACK_API_URL = "https://vc-feedback-backend.weifeng.workers.dev/api/feedback" -# Detect whether running from source tree or pip-installed +# Detect where read-only bundled/source resources live. _PACKAGE_DIR = Path(__file__).parent _PROJECT_ROOT = _PACKAGE_DIR.parent _IS_FROZEN = getattr(sys, "frozen", False) _PACKAGE_RESOURCE_PATH = _PACKAGE_DIR / "resources" - -# Development mode: resource/ exists next to the package -_IS_DEV = (_PROJECT_ROOT / "resource").is_dir() and not _IS_FROZEN +_SOURCE_RESOURCE_PATH = _PROJECT_ROOT / "resource" if _IS_FROZEN: - from platformdirs import user_data_path - ROOT_PATH = Path(sys.executable).resolve().parent RESOURCE_PATH = Path(getattr(sys, "_MEIPASS")) / "resource" - APPDATA_PATH = user_data_path(APP_NAME) - WORK_PATH = Path.home() / APP_NAME -elif _IS_DEV: +elif _SOURCE_RESOURCE_PATH.is_dir(): ROOT_PATH = _PROJECT_ROOT - RESOURCE_PATH = ROOT_PATH / "resource" - APPDATA_PATH = ROOT_PATH / "AppData" - WORK_PATH = ROOT_PATH / "work-dir" + RESOURCE_PATH = _SOURCE_RESOURCE_PATH else: - # Installed via pip — use platform-appropriate directories - from platformdirs import user_data_path - + # Installed via pip — package resources are copied into videocaptioner/resources. ROOT_PATH = user_data_path(APP_NAME) RESOURCE_PATH = _PACKAGE_RESOURCE_PATH if _PACKAGE_RESOURCE_PATH.exists() else ROOT_PATH / "resource" - APPDATA_PATH = ROOT_PATH - WORK_PATH = Path.home() / APP_NAME + +APPDATA_PATH = user_data_path(APP_NAME) +WORK_PATH = Path.home() / APP_NAME ASSETS_PATH = RESOURCE_PATH / "assets" -TRANSLATIONS_PATH = RESOURCE_PATH / "translations" - -# Writable user data. Keep generated/downloaded files out of frozen bundles and -# package directories so app upgrades are just replacing the program files. -if _IS_DEV: - BIN_PATH = RESOURCE_PATH / "bin" - SUBTITLE_STYLE_PATH = RESOURCE_PATH / "subtitle_style" - FONTS_PATH = RESOURCE_PATH / "fonts" -else: - BIN_PATH = APPDATA_PATH / "bin" - SUBTITLE_STYLE_PATH = APPDATA_PATH / "resource" / "subtitle_style" - FONTS_PATH = APPDATA_PATH / "resource" / "fonts" +# UI 翻译目录(key-based gettext):{lang}/LC_MESSAGES/videocaptioner.mo。 +I18N_PATH = RESOURCE_PATH / "i18n" +BUILTIN_SUBTITLE_STYLE_PATH = RESOURCE_PATH / "subtitle_styles" + +# Writable user data. Keep generated/downloaded files out of source trees, +# frozen bundles, and package directories so dev, pip, and desktop builds share +# the same runtime layout. +BIN_PATH = APPDATA_PATH / "bin" +USER_SUBTITLE_STYLE_PATH = APPDATA_PATH / "subtitle_styles" +SUBTITLE_STYLE_PATH = USER_SUBTITLE_STYLE_PATH +FONTS_PATH = RESOURCE_PATH / "fonts" BUNDLED_BIN_PATH = RESOURCE_PATH / "bin" + +def find_binary(name: str, configured: str = "") -> Optional[str]: + """发现一个随包/可下载的二进制:配置路径 → 自带 bin → 用户 bin → PATH,找不到返回 None。 + + ``name`` 不带扩展名(如 ``"ffmpeg"`` / ``"voxgate"``);Windows 自动补 ``.exe``(已带则不重复 + 补)。``configured`` 是设置里用户手动指定的绝对路径(可空,优先级最高)。voxgate / macsysaudio / + ffmpeg 等都走这里,别再各自重写一份发现逻辑。 + """ + exe = name + if os.name == "nt" and not name.lower().endswith(".exe"): + exe = f"{name}.exe" + candidates = [] + if configured: + candidates.append(Path(configured)) + candidates.append(BUNDLED_BIN_PATH / exe) + candidates.append(BIN_PATH / exe) + for cand in candidates: + if cand.is_file() and os.access(cand, os.X_OK): + return str(cand) + return shutil.which(exe) + LOG_PATH = APPDATA_PATH / "logs" LLM_LOG_FILE = LOG_PATH / "llm_requests.jsonl" -SETTINGS_PATH = APPDATA_PATH / "settings.json" CACHE_PATH = APPDATA_PATH / "cache" MODEL_PATH = APPDATA_PATH / "models" @@ -77,32 +100,12 @@ LOG_LEVEL = logging.INFO LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" -def _copy_missing_tree(src: Path, dst: Path) -> None: - """Copy bundled default files into the writable user directory.""" - if not src.exists(): - return - dst.mkdir(parents=True, exist_ok=True) - for item in src.iterdir(): - target = dst / item.name - if item.is_dir(): - _copy_missing_tree(item, target) - elif not target.exists(): - shutil.copy2(item, target) - - # Create data directories -for p in [APPDATA_PATH, CACHE_PATH, LOG_PATH, WORK_PATH, MODEL_PATH, BIN_PATH]: +for p in [APPDATA_PATH, CACHE_PATH, LOG_PATH, WORK_PATH, MODEL_PATH, BIN_PATH, USER_SUBTITLE_STYLE_PATH]: p.mkdir(parents=True, exist_ok=True) -if not _IS_DEV: - _copy_missing_tree(RESOURCE_PATH / "subtitle_style", SUBTITLE_STYLE_PATH) - _copy_missing_tree(RESOURCE_PATH / "fonts", FONTS_PATH) - # Add bin paths to PATH. User-downloaded binaries take precedence over bundled -# tools, while packaged ffmpeg/ffprobe still work out of the box. +# tools, while packaged ffmpeg still works out of the box. for _path in [FASTER_WHISPER_PATH, BIN_PATH, BUNDLED_BIN_PATH]: if _path.exists(): os.environ["PATH"] = str(_path) + os.pathsep + os.environ["PATH"] - -if (BIN_PATH / "vlc").exists(): - os.environ["PYTHON_VLC_MODULE_PATH"] = str(BIN_PATH / "vlc") diff --git a/videocaptioner/core/application/__init__.py b/videocaptioner/core/application/__init__.py new file mode 100644 index 00000000..06b2a674 --- /dev/null +++ b/videocaptioner/core/application/__init__.py @@ -0,0 +1,7 @@ +"""Application-layer configuration and task construction.""" + +from .app_config import AppConfig +from .config_store import CONFIG_FILE, build_config, save_many +from .task_builder import TaskBuilder + +__all__ = ["AppConfig", "CONFIG_FILE", "TaskBuilder", "build_config", "save_many"] diff --git a/videocaptioner/core/application/app_config.py b/videocaptioner/core/application/app_config.py new file mode 100644 index 00000000..93963f75 --- /dev/null +++ b/videocaptioner/core/application/app_config.py @@ -0,0 +1,253 @@ +"""Canonical application configuration models. + +The desktop UI and CLI can store settings differently, but task execution should +consume this module's plain data objects instead of UI widgets or CLI dicts. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, TypeVar + +from videocaptioner.core.entities import ( + FasterWhisperModelEnum, + LLMServiceEnum, + SubtitleLayoutEnum, + SubtitleRenderModeEnum, + TranscribeModelEnum, + TranscribeOutputFormatEnum, + TranslatorServiceEnum, + VadMethodEnum, + VideoQualityEnum, + WhisperModelEnum, +) +from videocaptioner.core.translate.types import TargetLanguage + +EnumT = TypeVar("EnumT") + + +@dataclass(frozen=True) +class LLMSettings: + service: LLMServiceEnum = LLMServiceEnum.OPENAI + api_key: str = "" + api_base: str = "https://api.openai.com/v1" + model: str = "gpt-4o-mini" + + +@dataclass(frozen=True) +class TranscribeSettings: + model: TranscribeModelEnum = TranscribeModelEnum.BIJIAN + language: str = "" + language_label: str = "自动检测" + output_format: TranscribeOutputFormatEnum = TranscribeOutputFormatEnum.SRT + whisper_model: WhisperModelEnum = WhisperModelEnum.TINY + whisper_api_key: str = "" + whisper_api_base: str = "" + whisper_api_model: str = "" + whisper_api_prompt: str = "" + fun_asr_api_key: str = "" + fun_asr_api_base: str = "https://dashscope.aliyuncs.com" + fun_asr_model: str = "fun-asr" + faster_whisper_program: str = "faster-whisper-xxl.exe" + faster_whisper_model: FasterWhisperModelEnum = FasterWhisperModelEnum.TINY + faster_whisper_model_dir: str = "" + faster_whisper_device: str = "auto" + faster_whisper_vad_filter: bool = True + faster_whisper_vad_threshold: float = 0.4 + faster_whisper_vad_method: VadMethodEnum = VadMethodEnum.SILERO_V4 + faster_whisper_ff_mdx_kim2: bool = False + faster_whisper_one_word: bool = True + faster_whisper_prompt: str = "" + + +@dataclass(frozen=True) +class SubtitleSettings: + translator_service: TranslatorServiceEnum = TranslatorServiceEnum.BING + need_reflect: bool = False + deeplx_endpoint: str = "" + thread_num: int = 10 + batch_size: int = 10 + need_optimize: bool = False + need_translate: bool = False + need_split: bool = False + target_language: TargetLanguage = TargetLanguage.SIMPLIFIED_CHINESE + max_word_count_cjk: int = 28 + max_word_count_english: int = 20 + custom_prompt_text: str = "" + layout: SubtitleLayoutEnum = SubtitleLayoutEnum.TRANSLATE_ON_TOP + style_name: str = "ass/default" + + +@dataclass(frozen=True) +class SynthesisSettings: + need_video: bool = True + soft_subtitle: bool = False + video_quality: VideoQualityEnum = VideoQualityEnum.MEDIUM + render_mode: SubtitleRenderModeEnum = SubtitleRenderModeEnum.ROUNDED_BG + style_id: str = "rounded/default" + + +@dataclass(frozen=True) +class DubbingSettings: + enabled: bool = False + provider: str = "edge" + preset: str = "edge-cn-female" + voice: str = "zh-CN-XiaoxiaoNeural" + text_track: str = "auto" + timing: str = "balanced" + audio_mode: str = "replace" + api_key: str = "" + api_base: str = "" + model: str = "" + tts_workers: int = 5 + clone_audio_path: str = "" + clone_audio_text: str = "" + + @property + def supports_clone(self) -> bool: + return self.provider == "siliconflow" + + +@dataclass(frozen=True) +class AppConfig: + work_dir: str = "" + cache_enabled: bool = True + llm: LLMSettings = field(default_factory=LLMSettings) + transcribe: TranscribeSettings = field(default_factory=TranscribeSettings) + subtitle: SubtitleSettings = field(default_factory=SubtitleSettings) + synthesis: SynthesisSettings = field(default_factory=SynthesisSettings) + dubbing: DubbingSettings = field(default_factory=DubbingSettings) + + +def enum_by_value(enum_class: type[EnumT], value: Any, default: EnumT) -> EnumT: + """Resolve enum values from config strings while preserving existing enum values.""" + if isinstance(value, enum_class): + return value + for item in enum_class: # type: ignore[attr-defined] + if getattr(item, "value", None) == value or getattr(item, "name", None) == value: + return item + return default + + +def target_language_from_code(value: Any) -> TargetLanguage: + if isinstance(value, TargetLanguage): + return value + + raw = str(value or "").strip() + if not raw: + return TargetLanguage.SIMPLIFIED_CHINESE + + code_map = { + "zh-hans": TargetLanguage.SIMPLIFIED_CHINESE, + "zh-cn": TargetLanguage.SIMPLIFIED_CHINESE, + "zh": TargetLanguage.SIMPLIFIED_CHINESE, + "zh-hant": TargetLanguage.TRADITIONAL_CHINESE, + "zh-tw": TargetLanguage.TRADITIONAL_CHINESE, + "en": TargetLanguage.ENGLISH, + "en-us": TargetLanguage.ENGLISH_US, + "en-gb": TargetLanguage.ENGLISH_UK, + "ja": TargetLanguage.JAPANESE, + "ko": TargetLanguage.KOREAN, + "yue": TargetLanguage.CANTONESE, + "th": TargetLanguage.THAI, + "vi": TargetLanguage.VIETNAMESE, + "id": TargetLanguage.INDONESIAN, + "ms": TargetLanguage.MALAY, + "tl": TargetLanguage.TAGALOG, + "fil": TargetLanguage.TAGALOG, + "fr": TargetLanguage.FRENCH, + "de": TargetLanguage.GERMAN, + "es": TargetLanguage.SPANISH, + "es-419": TargetLanguage.SPANISH_LATAM, + "ru": TargetLanguage.RUSSIAN, + "pt": TargetLanguage.PORTUGUESE, + "pt-br": TargetLanguage.PORTUGUESE_BR, + "pt-pt": TargetLanguage.PORTUGUESE_PT, + "it": TargetLanguage.ITALIAN, + "nl": TargetLanguage.DUTCH, + "pl": TargetLanguage.POLISH, + "tr": TargetLanguage.TURKISH, + "el": TargetLanguage.GREEK, + "cs": TargetLanguage.CZECH, + "sv": TargetLanguage.SWEDISH, + "da": TargetLanguage.DANISH, + "fi": TargetLanguage.FINNISH, + "nb": TargetLanguage.NORWEGIAN, + "no": TargetLanguage.NORWEGIAN, + "hu": TargetLanguage.HUNGARIAN, + "ro": TargetLanguage.ROMANIAN, + "bg": TargetLanguage.BULGARIAN, + "uk": TargetLanguage.UKRAINIAN, + "ar": TargetLanguage.ARABIC, + "he": TargetLanguage.HEBREW, + "fa": TargetLanguage.PERSIAN, + } + mapped = code_map.get(raw.lower()) + if mapped: + return mapped + + for lang in TargetLanguage: + if lang.value == raw or lang.name.lower() == raw.lower(): + return lang + return TargetLanguage.SIMPLIFIED_CHINESE + + +# CLI 的 --asr 取值与枚举的唯一映射:parser choices 必须从这里派生, +# 不要再手写清单(曾因三处手写各自漂移漏掉 faster-whisper / fun-asr)。 +CLI_ASR_MAPPING: dict[str, TranscribeModelEnum] = { + "bijian": TranscribeModelEnum.BIJIAN, + "jianying": TranscribeModelEnum.JIANYING, + "fun-asr": TranscribeModelEnum.BAILIAN_FUN_ASR, + "whisper-api": TranscribeModelEnum.WHISPER_API, + "whisper-cpp": TranscribeModelEnum.WHISPER_CPP, + "faster-whisper": TranscribeModelEnum.FASTER_WHISPER, +} +CLI_ASR_CHOICES: list[str] = list(CLI_ASR_MAPPING) + + +def transcribe_model_from_cli(value: str) -> TranscribeModelEnum: + return CLI_ASR_MAPPING.get(value, TranscribeModelEnum.BIJIAN) + + +def transcribe_output_format_from_cli(value: str) -> TranscribeOutputFormatEnum: + return { + "srt": TranscribeOutputFormatEnum.SRT, + "ass": TranscribeOutputFormatEnum.ASS, + "vtt": TranscribeOutputFormatEnum.VTT, + "txt": TranscribeOutputFormatEnum.TXT, + "all": TranscribeOutputFormatEnum.ALL, + }.get(str(value or "srt").lower(), TranscribeOutputFormatEnum.SRT) + + +def translator_from_cli(value: str) -> TranslatorServiceEnum: + return { + "llm": TranslatorServiceEnum.OPENAI, + "bing": TranslatorServiceEnum.BING, + "google": TranslatorServiceEnum.GOOGLE, + "deeplx": TranslatorServiceEnum.DEEPLX, + }.get(str(value or "bing").lower(), TranslatorServiceEnum.BING) + + +def layout_from_cli(value: str) -> SubtitleLayoutEnum: + return { + "target-above": SubtitleLayoutEnum.TRANSLATE_ON_TOP, + "source-above": SubtitleLayoutEnum.ORIGINAL_ON_TOP, + "target-only": SubtitleLayoutEnum.ONLY_TRANSLATE, + "source-only": SubtitleLayoutEnum.ONLY_ORIGINAL, + }.get(str(value or "target-above"), SubtitleLayoutEnum.TRANSLATE_ON_TOP) + + +def render_mode_from_cli(value: str) -> SubtitleRenderModeEnum: + return { + "ass": SubtitleRenderModeEnum.ASS_STYLE, + "rounded": SubtitleRenderModeEnum.ROUNDED_BG, + }.get(str(value or "ass"), SubtitleRenderModeEnum.ASS_STYLE) + + +def quality_from_cli(value: str) -> VideoQualityEnum: + return { + "ultra": VideoQualityEnum.ULTRA_HIGH, + "high": VideoQualityEnum.HIGH, + "medium": VideoQualityEnum.MEDIUM, + "low": VideoQualityEnum.LOW, + }.get(str(value or "medium"), VideoQualityEnum.MEDIUM) diff --git a/videocaptioner/core/application/config_store.py b/videocaptioner/core/application/config_store.py new file mode 100644 index 00000000..2595b0fe --- /dev/null +++ b/videocaptioner/core/application/config_store.py @@ -0,0 +1,513 @@ +"""Shared TOML configuration store used by CLI and desktop UI.""" + +from __future__ import annotations + +import os +import sys +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, Optional + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + +from videocaptioner.config import APPDATA_PATH + +_DEFAULT_CONFIG_FILE = APPDATA_PATH / "config.toml" +CONFIG_FILE = Path(os.environ.get("VIDEOCAPTIONER_CONFIG_FILE", str(_DEFAULT_CONFIG_FILE))) +CONFIG_DIR = CONFIG_FILE.parent + +ENV_MAP: Dict[str, str] = { + "OPENAI_API_KEY": "llm.providers.openai.api_key", + "OPENAI_BASE_URL": "llm.providers.openai.api_base", + "OPENAI_MODEL": "llm.providers.openai.model", + "VIDEOCAPTIONER_LLM_SERVICE": "llm.service", + "VIDEOCAPTIONER_LLM_API_KEY": "llm.api_key", + "VIDEOCAPTIONER_LLM_API_BASE": "llm.api_base", + "VIDEOCAPTIONER_LLM_MODEL": "llm.model", + "VIDEOCAPTIONER_WHISPER_API_KEY": "whisper_api.api_key", + "VIDEOCAPTIONER_WHISPER_API_BASE": "whisper_api.api_base", + "DASHSCOPE_API_KEY": "fun_asr.api_key", + "BAILIAN_ASR_API_KEY": "fun_asr.api_key", + "VIDEOCAPTIONER_FUN_ASR_API_KEY": "fun_asr.api_key", + "VIDEOCAPTIONER_FUN_ASR_API_BASE": "fun_asr.api_base", + "VIDEOCAPTIONER_FUN_ASR_MODEL": "fun_asr.model", + "VIDEOCAPTIONER_DEEPLX_ENDPOINT": "translate.deeplx_endpoint", + "VIDEOCAPTIONER_TARGET_LANG": "translate.target_language", + "VIDEOCAPTIONER_DUBBING_PROVIDER": "dubbing.provider", + "VIDEOCAPTIONER_DUB_PRESET": "dubbing.preset", + "VIDEOCAPTIONER_TTS_API_KEY": "dubbing.api_key", + "VIDEOCAPTIONER_TTS_API_BASE": "dubbing.api_base", + "VIDEOCAPTIONER_TTS_MODEL": "dubbing.model", + "VIDEOCAPTIONER_TTS_VOICE": "dubbing.voice", + "VIDEOCAPTIONER_TTS_STYLE_PROMPT": "dubbing.style_prompt", + "VIDEOCAPTIONER_TTS_WORKERS": "dubbing.tts_workers", + "VIDEOCAPTIONER_TTS_USE_CACHE": "dubbing.use_cache", + "VIDEOCAPTIONER_TTS_FIT_MODE": "dubbing.fit_mode", + "VIDEOCAPTIONER_DUB_TIMING": "dubbing.timing", + "VIDEOCAPTIONER_DUB_AUDIO_MODE": "dubbing.audio_mode", + "VIDEOCAPTIONER_TTS_MAX_SPEED": "dubbing.max_speed", + "VIDEOCAPTIONER_TTS_REWRITE_TOO_LONG": "dubbing.rewrite_too_long", + "VIDEOCAPTIONER_TTS_MIX_ORIGINAL_AUDIO": "dubbing.mix_original_audio", +} + +DEFAULTS: Dict[str, Any] = { + "app": { + "work_dir": "", + "cache_enabled": True, + # 流水线成功后保留任务目录(中间产物);默认跑完即清。 + "keep_intermediates": False, + }, + "ui": { + "theme_mode": "Dark", + "theme_color": "#ff00e889", + "dpi_scale": "Auto", + "language": "Auto", + "mica_enabled": False, + "check_update_at_startup": True, + "subtitle_preview_image": "", + # 字幕样式预览的自定义示例文字(原文/译文)与调色板最近用色:UI 持久化偏好, + # 必须进 DEFAULTS + 绑定,否则 cfg.set(save=True) 是空操作、重启即丢。 + "subtitle_preview_source": ( + "Mathematics is the language in which the laws of the universe are written." + ), + "subtitle_preview_target": "数学,是书写宇宙规律的语言。", + "recent_colors": [], + # 工作台右栏折叠态 + 批量页模式/并发:UI 持久化键,过去漏在 DEFAULTS 外, + # 导致 `config set` 因 parse_value 查不到键而按字符串存(bool 反向、范围抛错)。 + "transcribe_panel_collapsed": False, + "subtitle_panel_collapsed": False, + "synthesis_panel_collapsed": False, + "batch_mode": "full", + "batch_concurrency": 1, + }, + "llm": { + "service": "openai", + "api_key": "", + "api_base": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "providers": { + "openai": { + "api_key": "", + "api_base": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "model_options": [], + }, + "silicon_cloud": { + "api_key": "", + "api_base": "https://api.siliconflow.cn/v1", + "model": "gpt-4o-mini", + "model_options": [], + }, + "deepseek": { + "api_key": "", + "api_base": "https://api.deepseek.com/v1", + "model": "deepseek-chat", + "model_options": [], + }, + "ollama": { + "api_key": "ollama", + "api_base": "http://localhost:11434/v1", + "model": "llama2", + "model_options": [], + }, + "lm_studio": { + "api_key": "lmstudio", + "api_base": "http://localhost:1234/v1", + "model": "qwen2.5:7b", + "model_options": [], + }, + "gemini": { + "api_key": "", + "api_base": "https://generativelanguage.googleapis.com/v1beta/openai/", + "model": "gemini-pro", + "model_options": [], + }, + "chatglm": { + "api_key": "", + "api_base": "https://open.bigmodel.cn/api/paas/v4", + "model": "glm-4", + "model_options": [], + }, + }, + }, + "whisper_api": { + "api_key": "", + "api_base": "https://api.openai.com/v1", + "model": "whisper-1", + "prompt": "", + }, + "fun_asr": { + "api_key": "", + "api_base": "https://dashscope.aliyuncs.com", + "model": "fun-asr", + }, + "transcribe": { + "asr": "bijian", + "language": "auto", + "output_format": "srt", + "word_timestamp": False, + "faster_whisper": { + "program": "faster-whisper-xxl.exe", + "model": "tiny", + "model_dir": "", + "device": "auto", + "vad_filter": True, + "vad_method": "silero_v4", + "vad_threshold": 0.4, + "voice_extraction": False, + "one_word": True, + "prompt": "", + }, + "whisper_cpp": { + "model": "tiny", + }, + }, + "subtitle": { + "optimize": False, + "translate": False, + "split": False, + "max_word_count_cjk": 28, + "max_word_count_english": 20, + "thread_num": 10, + "batch_size": 10, + "custom_prompt": "", + }, + "translate": { + "service": "bing", + "target_language": "zh-Hans", + "reflect": False, + "deeplx_endpoint": "", + }, + "synthesize": { + "need_video": True, + # subtitle_mode 与 soft_subtitle 必须同义:硬字幕。过去 subtitle_mode="soft" + # 而 soft_subtitle=False,导致 CLI(读 subtitle_mode) 默认软、GUI(读 soft_subtitle) + # 默认硬,同一份全新配置出厂行为相反。 + "subtitle_mode": "hard", + "quality": "medium", + "layout": "target-above", + "render_mode": "rounded", + "style": "rounded/default", + "soft_subtitle": False, + }, + "dubbing": { + "enabled": False, + "provider": "edge", + "preset": "edge-cn-female", + "api_key": "", + "api_base": "", + "model": "edge-tts", + "voice": "zh-CN-XiaoxiaoNeural", + "text_track": "auto", + "clone_audio": "", + "clone_text": "", + "response_format": "mp3", + "sample_rate": 32000, + "speed": 1.0, + "gain": 0, + "tts_workers": 5, + "use_cache": True, + "style_prompt": "", + "timing": "balanced", + "audio_mode": "replace", + "fit_mode": "tempo", + "max_speed": 2.0, + "target_padding_ms": 80, + "rewrite_too_long": False, + "rewrite_threshold": 1.15, + "mix_original_audio": False, + "original_audio_volume": 0.25, + "dubbed_audio_volume": 1.0, + }, + "live_caption": { + "backend": "voxgate", + "fun_asr_model": "fun-asr-mtl-realtime", + "source_language": "auto", + "voxgate_binary": "", + "show_overlay": True, + "source": "microphone", + "device_index": -1, + "translate_enabled": True, + "translator_service": "bing", + "target_language": "简体中文", + "display_mode": "bilingual", + "bg_style": "translucent", + "font_scale": 60, + "auto_fade": True, + }, + "output": { + "format": "srt", + }, +} + + +def deep_merge(base: dict, override: dict) -> dict: + """Merge override into base recursively. Override values take precedence.""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +def set_nested(data: dict, dotted_key: str, value: Any) -> None: + keys = dotted_key.split(".") + for key in keys[:-1]: + data = data.setdefault(key, {}) + data[keys[-1]] = value + + +def get_nested(data: dict, dotted_key: str, default: Any = None) -> Any: + keys = dotted_key.split(".") + for key in keys: + if not isinstance(data, dict): + return default + data = data.get(key, default) # type: ignore[assignment] + if data is default: + return default + return data + + +def load_config_file(path: Optional[Path] = None) -> dict: + path = path or CONFIG_FILE + if not path.exists(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"! Warning: Failed to parse config file {path}: {exc}", file=sys.stderr) + print(" Run 'videocaptioner config init' to recreate it.", file=sys.stderr) + return {} + + +def load_env_overrides() -> dict: + overrides: Dict[str, Any] = {} + for env_var, dotted_key in ENV_MAP.items(): + value = os.environ.get(env_var) + if value is None: + continue + if is_secret_key(dotted_key): + value = value.strip() + try: + parsed_value = parse_value(value, dotted_key) + except ValueError as exc: + print(f"! Warning: Invalid environment value {env_var}: {exc}", file=sys.stderr) + continue + set_nested(overrides, dotted_key, parsed_value) + return overrides + + +def build_config( + cli_overrides: Optional[dict] = None, + config_path: Optional[Path] = None, +) -> dict: + config = deepcopy(DEFAULTS) + config = deep_merge(config, load_config_file(config_path)) + config = deep_merge(config, load_env_overrides()) + if cli_overrides: + config = deep_merge(config, cli_overrides) + normalize_active_aliases(config) + return config + + +def get(config: dict, key: str, default: Any = None) -> Any: + return get_nested(config, key, default) + + +def ensure_config_dir() -> Path: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + return CONFIG_DIR + + +def parse_value(raw: str, key: str) -> Any: + default_val = get_nested(DEFAULTS, key) + if isinstance(default_val, bool): + if raw.lower() in ("true", "1", "yes"): + return True + if raw.lower() in ("false", "0", "no"): + return False + raise ValueError(f"Expected boolean for '{key}', got '{raw}' (use true/false)") + if isinstance(default_val, int): + try: + return int(raw) + except ValueError as exc: + raise ValueError(f"Expected integer for '{key}', got '{raw}'") from exc + if isinstance(default_val, float): + try: + return float(raw) + except ValueError as exc: + raise ValueError(f"Expected number for '{key}', got '{raw}'") from exc + if isinstance(default_val, list): + raw = raw.strip() + if not raw: + return [] + if raw.startswith("["): + try: + parsed = tomllib.loads(f"value = {raw}")["value"] + except Exception as exc: + raise ValueError(f"Expected TOML array for '{key}', got '{raw}'") from exc + if not isinstance(parsed, list): + raise ValueError(f"Expected TOML array for '{key}', got '{raw}'") + return parsed + return [item.strip() for item in raw.split(",") if item.strip()] + return raw + + +def save_config_value(key: str, value: str, config_path: Optional[Path] = None) -> None: + path = config_path or CONFIG_FILE + ensure_config_dir() + existing = load_config_file(path) + if is_secret_key(key): + value = value.strip() + set_nested(existing, key, parse_value(value, key)) + sync_aliases_for_saved_key(existing, key) + write_config_file(existing, path) + + +def save_many(values: dict[str, Any], config_path: Optional[Path] = None) -> None: + path = config_path or CONFIG_FILE + ensure_config_dir() + existing = load_config_file(path) + for key, value in values.items(): + if is_secret_key(key) and isinstance(value, str): + value = value.strip() + set_nested(existing, key, value) + normalize_active_aliases(existing) + write_config_file(existing, path) + + +def normalize_active_aliases(config: dict) -> None: + """Keep generic LLM fields aligned with the selected provider. + + Provider sections are the canonical storage for per-provider credentials. + The generic `llm.api_key/api_base/model` fields remain as a runtime alias + for the currently selected provider because older execution paths and CLI + flags still use those keys. + """ + service = str(get_nested(config, "llm.service", "openai") or "openai") + providers = get_nested(config, "llm.providers", {}) + if not isinstance(providers, dict): + providers = {} + set_nested(config, "llm.providers", providers) + + provider = providers.setdefault(service, {}) + if not isinstance(provider, dict): + provider = {} + providers[service] = provider + + for field in ("api_key", "api_base", "model"): + provider_value = provider.get(field) + generic_value = get_nested(config, f"llm.{field}", "") + generic_default = DEFAULTS["llm"].get(field) + provider_default = DEFAULTS["llm"].get("providers", {}).get(service, {}).get(field) + generic_is_explicit = generic_value not in (None, "") and generic_value != generic_default + provider_is_explicit = ( + provider_value not in (None, "") and provider_value != provider_default + ) + + if provider_is_explicit: + set_nested(config, f"llm.{field}", provider_value) + elif generic_is_explicit: + provider[field] = generic_value + elif provider_value not in (None, ""): + set_nested(config, f"llm.{field}", provider_value) + elif generic_value not in (None, ""): + provider[field] = generic_value + + +def sync_aliases_for_saved_key(config: dict, changed_key: str) -> None: + """Synchronize LLM aliases after a single `config set` operation.""" + merged = deepcopy(DEFAULTS) + merged = deep_merge(merged, config) + active_provider = str(get_nested(merged, "llm.service", "openai") or "openai") + + parts = changed_key.split(".") + if changed_key == "llm.service": + normalize_active_aliases(merged) + for field in ("api_key", "api_base", "model"): + value = get_nested(merged, f"llm.{field}", "") + set_nested(config, f"llm.{field}", value) + return + + if len(parts) == 4 and parts[:2] == ["llm", "providers"]: + provider, field = parts[2], parts[3] + if provider == active_provider and field in {"api_key", "api_base", "model"}: + set_nested(config, f"llm.{field}", get_nested(config, changed_key, "")) + return + + if len(parts) == 2 and parts[0] == "llm" and parts[1] in {"api_key", "api_base", "model"}: + set_nested(config, f"llm.providers.{active_provider}.{parts[1]}", get_nested(config, changed_key, "")) + + +def write_config_file(data: dict, path: Optional[Path] = None) -> None: + path = path or CONFIG_FILE + ensure_config_dir() + with open(path, "w", encoding="utf-8") as f: + write_toml(f, data) + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def write_toml(f, data: dict, parent_key: str = "") -> None: + for key, value in data.items(): + if not isinstance(value, dict): + f.write(f"{key} = {toml_value(value)}\n") + for key, value in data.items(): + if isinstance(value, dict): + full_key = f"{parent_key}.{key}" if parent_key else key + f.write(f"\n[{full_key}]\n") + write_toml(f, value, full_key) + + +def toml_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + return _toml_string(value) + if isinstance(value, list): + return "[" + ", ".join(toml_value(item) for item in value) + "]" + return f'"{value!s}"' + + +def _toml_string(value: str) -> str: + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + return f'"{escaped}"' + + +def format_config(config: dict, indent: int = 0) -> str: + lines = [] + prefix = " " * indent + for key, value in config.items(): + if isinstance(value, dict): + lines.append(f"{prefix}{key}:") + lines.append(format_config(value, indent + 1)) + elif isinstance(value, str) and ("key" in key or "token" in key) and value: + masked = f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "****" + lines.append(f"{prefix}{key} = {masked}") + else: + lines.append(f"{prefix}{key} = {value}") + return "\n".join(lines) + + +def is_secret_key(key: str) -> bool: + leaf = key.rsplit(".", 1)[-1].lower() + return "key" in leaf or leaf.endswith("token") or leaf.endswith("secret") diff --git a/videocaptioner/core/application/output_paths.py b/videocaptioner/core/application/output_paths.py new file mode 100644 index 00000000..26ec151d --- /dev/null +++ b/videocaptioner/core/application/output_paths.py @@ -0,0 +1,170 @@ +"""输出文件命名与任务工作目录的唯一定义处。 + +命名语法(CLI 与 GUI 完全一致,禁止在调用方手写文件名模板): + + {stem}.{tag}.{ext} + +tag 只允许四类:目标语言码(zh-Hans / en / ja …,与 Bing 翻译码一致)、 +``optimized``(仅校正/断句)、``subtitled``(带字幕视频)、``dubbed`` +(配音音频或配音视频,由扩展名区分容器)。tag 按加工顺序可组合, +例如 ``video.dubbed.subtitled.mp4``。软/硬字幕、提供商、音色等参数 +细节一律不进文件名。 + +目录规则: + +- 成品永远落在源文件旁(或调用方显式指定的位置);GUI 路径用 + :func:`unique_path` 自增 `` (2)`` 防覆盖,CLI 保持确定性覆盖。 +- 一切中间产物进 ``{work_dir}/{task_type}/{YYYYMMDD-HHMMSS}-{stem}/`` 任务 + 目录,按功能归类(task_type = transcribe / synthesis / batch / dubbing), + 文件名固定(transcript.srt / subtitle.ass / dubbing/…),由目录而不是文件名 + 携带语义。成功后默认整目录删除(``app.keep_intermediates`` 打开时保留), + 失败保留供排查。 +- TTS 原始分段是跨任务的内容寻址缓存,不属于任务目录,见 + ``core/dubbing/pipeline.py``。 +""" + +from __future__ import annotations + +import datetime +import re +import shutil +from pathlib import Path +from typing import Optional, Union + +from videocaptioner.core.entities import SubtitleLayoutEnum +from videocaptioner.core.translate.types import BING_LANG_MAP, TargetLanguage + +PathLike = Union[str, Path] + +TAG_OPTIMIZED = "optimized" +TAG_SUBTITLED = "subtitled" +TAG_DUBBED = "dubbed" +TAG_HARDSUB = "hardsub" # 从视频画面 OCR 提取出的字幕 + +# 任务目录按功能归类:{work_dir}/{task_type}/{时间戳}-{stem}/,由目录携带语义。 +TASK_TRANSCRIBE = "transcribe" # 主页转录→字幕→合成 一条龙 +TASK_SYNTHESIS = "synthesis" # 视频合成页 +TASK_BATCH = "batch" # 批量处理页 +TASK_DUBBING = "dubbing" # 独立配音(CLI / 无上游任务目录时) +TASK_HARDSUB = "hardsub" # 硬字幕提取页 +_TASK_TYPES = frozenset( + {TASK_TRANSCRIBE, TASK_SYNTHESIS, TASK_BATCH, TASK_DUBBING, TASK_HARDSUB} +) + +# 任务目录内的固定文件名:路径即语义,文件名不再编码阶段信息。 +DOWNLOADS_DIR_NAME = "downloads" +TRANSCRIPT_FILE = "transcript.srt" +STYLED_SUBTITLE_FILE = "subtitle.ass" +DUBBING_DIR = "dubbing" +DUBBING_AUDIO_FILE = "audio.wav" +DUBBING_REPORT_FILE = "report.json" + +_LANGUAGE_TAGS = frozenset(BING_LANG_MAP.values()) +_KNOWN_TAGS = frozenset({TAG_OPTIMIZED, TAG_SUBTITLED, TAG_DUBBED, TAG_HARDSUB}) | _LANGUAGE_TAGS + +_LAYOUT_FILE_KEYS = { + SubtitleLayoutEnum.TRANSLATE_ON_TOP: "target-above", + SubtitleLayoutEnum.ORIGINAL_ON_TOP: "source-above", + SubtitleLayoutEnum.ONLY_TRANSLATE: "target-only", + SubtitleLayoutEnum.ONLY_ORIGINAL: "source-only", +} + +_TASK_DIR_STEM_MAX = 60 + + +def language_tag(language: TargetLanguage) -> str: + """目标语言的文件名 tag(BCP-47,播放器可识别为字幕语言)。""" + return BING_LANG_MAP[language] + + +def product_path( + source: PathLike, + *tags: str, + ext: Optional[str] = None, + directory: Optional[PathLike] = None, +) -> Path: + """成品路径:源文件旁的 ``{stem}.{tag}.{ext}``。 + + stem 先剥掉已有 tag(对 ``video.zh-Hans.srt`` 再翻译不会叠成 + ``video.zh-Hans.en.srt``)。``ext`` 缺省沿用源扩展名;``directory`` + 缺省为源文件所在目录。本函数不做防覆盖,GUI 调用方需配合 + :func:`unique_path`。 + """ + src = Path(source) + for tag in tags: + if tag not in _KNOWN_TAGS: + raise ValueError(f"unknown output tag: {tag!r}") + stem = strip_tags(src.stem) + suffix = ext if ext is not None else src.suffix + if suffix and not suffix.startswith("."): + suffix = f".{suffix}" + name = stem + "".join(f".{tag}" for tag in tags) + suffix + return Path(directory) / name if directory is not None else src.with_name(name) + + +def strip_tags(stem: str) -> str: + """从 stem 末尾剥掉本模块词汇表内的 tag(链式处理取干净名)。""" + while True: + base, dot, last = stem.rpartition(".") + if not dot or last not in _KNOWN_TAGS: + return stem + stem = base + + +def unique_path(path: PathLike) -> Path: + """已存在则按 OS 惯例自增 ``name (2).ext``,永不静默覆盖成品。""" + candidate = Path(path) + if not candidate.exists(): + return candidate + for index in range(2, 1000): + numbered = candidate.with_stem(f"{candidate.stem} ({index})") + if not numbered.exists(): + return numbered + raise FileExistsError(f"cannot find a free name for {candidate}") + + +def layout_copy_name(layout: SubtitleLayoutEnum) -> str: + """任务目录里其余布局副本的文件名(layout-target-above.srt …)。""" + return f"layout-{_LAYOUT_FILE_KEYS[layout]}.srt" + + +def downloads_dir(work_dir: PathLike) -> Path: + """在线下载的统一落盘目录 ``{work_dir}/downloads``(会创建)。""" + target = Path(work_dir) / DOWNLOADS_DIR_NAME + target.mkdir(parents=True, exist_ok=True) + return target + + +def new_task_dir(work_dir: PathLike, source: PathLike, task_type: str) -> Path: + """创建一次运行的任务目录:``{work_dir}/{task_type}/{时间戳}-{stem}``。 + + 按功能(transcribe/synthesis/batch/dubbing)分目录;时间戳保证排序且不同运行 + 互不覆盖,stem 让人能认出来源。 + """ + if task_type not in _TASK_TYPES: + raise ValueError(f"unknown task type: {task_type!r}") + stem = re.sub(r'[<>:"/\\|?*\0-\x1f]', "_", Path(source).stem).strip(" .") + stem = stem[:_TASK_DIR_STEM_MAX] or "task" + stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + root = Path(work_dir) / task_type + candidate = root / f"{stamp}-{stem}" + index = 2 + while candidate.exists(): + candidate = root / f"{stamp}-{stem}-{index}" + index += 1 + candidate.mkdir(parents=True) + return candidate + + +def cleanup_task_dir(task_dir: Optional[PathLike], *, keep: bool) -> None: + """成功收尾时删除任务目录(keep=True 保留)。 + + 只删功能任务目录(父目录名是已知 task_type):路径来自配置/信号链,误配时 + 宁可留下垃圾也不能 rmtree 到用户目录。 + """ + if keep or not task_dir: + return + target = Path(task_dir) + if target.parent.name not in _TASK_TYPES or not target.is_dir(): + return + shutil.rmtree(target, ignore_errors=True) diff --git a/videocaptioner/core/application/task_builder.py b/videocaptioner/core/application/task_builder.py new file mode 100644 index 00000000..4058b4d6 --- /dev/null +++ b/videocaptioner/core/application/task_builder.py @@ -0,0 +1,290 @@ +"""Build executable task dataclasses from canonical application config. + +输出命名与任务目录规则统一在 output_paths 模块,这里只负责把规则 +应用到各任务:成品落源文件旁(unique 防覆盖),中间产物进任务目录。 +""" + +from __future__ import annotations + +import datetime +from pathlib import Path +from typing import Optional + +from videocaptioner.config import WORK_PATH +from videocaptioner.core.application import output_paths +from videocaptioner.core.application.app_config import AppConfig +from videocaptioner.core.entities import ( + DubbingTask, + DubbingUIConfig, + SubtitleConfig, + SubtitleRenderModeEnum, + SubtitleTask, + SynthesisConfig, + SynthesisTask, + TranscribeConfig, + TranscribeTask, +) +from videocaptioner.core.subtitle.style_manager import ( + SubtitleRenderer, + load_style, + normalize_style_id, +) + + +class TaskBuilder: + """Create core tasks without depending on UI widgets or CLI argument shapes.""" + + def __init__(self, app_config: AppConfig): + self.config = app_config + + def new_task_dir(self, source: str, task_type: str) -> str: + """为一次流水线运行创建任务目录(流程所有者负责跨阶段传递与清理)。""" + return str(output_paths.new_task_dir(self.config.work_dir or WORK_PATH, source, task_type)) + + def get_ass_style(self, style_name: Optional[str] = None) -> str: + style = load_style(style_name or self.config.subtitle.style_name, renderer=SubtitleRenderer.ASS) + return style.to_ass_string() if style is not None else "" + + def get_rounded_style(self) -> dict: + style_id = normalize_style_id( + self.config.synthesis.style_id, + self.config.synthesis.render_mode.value, + ) + style = load_style(style_id, renderer=SubtitleRenderer.ROUNDED) + return style.to_rounded_dict() if style is not None else {} + + def create_transcribe_config(self, *, need_word_timestamp: bool) -> TranscribeConfig: + settings = self.config.transcribe + return TranscribeConfig( + transcribe_model=settings.model, + transcribe_language=settings.language, + need_word_time_stamp=need_word_timestamp, + output_format=settings.output_format, + whisper_model=settings.whisper_model, + whisper_api_key=settings.whisper_api_key, + whisper_api_base=settings.whisper_api_base, + whisper_api_model=settings.whisper_api_model, + whisper_api_prompt=settings.whisper_api_prompt, + fun_asr_api_key=settings.fun_asr_api_key, + fun_asr_api_base=settings.fun_asr_api_base, + fun_asr_model=settings.fun_asr_model, + faster_whisper_program=settings.faster_whisper_program, + faster_whisper_model=settings.faster_whisper_model, + faster_whisper_model_dir=settings.faster_whisper_model_dir, + faster_whisper_device=settings.faster_whisper_device, + faster_whisper_vad_filter=settings.faster_whisper_vad_filter, + faster_whisper_vad_threshold=settings.faster_whisper_vad_threshold, + faster_whisper_vad_method=settings.faster_whisper_vad_method, + faster_whisper_ff_mdx_kim2=settings.faster_whisper_ff_mdx_kim2, + faster_whisper_one_word=settings.faster_whisper_one_word, + faster_whisper_prompt=settings.faster_whisper_prompt, + ) + + def create_transcribe_task( + self, + file_path: str, + need_next_task: bool = False, + task_id: Optional[str] = None, + task_dir: Optional[str] = None, + ) -> TranscribeTask: + need_word_timestamp = self.config.subtitle.need_split if need_next_task else False + if need_next_task: + # 流水线中间产物:原始转录进任务目录,路径即语义。 + task_dir = task_dir or self.new_task_dir(file_path, output_paths.TASK_TRANSCRIBE) + output_path = str(Path(task_dir) / output_paths.TRANSCRIPT_FILE) + else: + output_path = str( + output_paths.unique_path(output_paths.product_path(file_path, ext=".srt")) + ) + + task = TranscribeTask( + queued_at=datetime.datetime.now(), + file_path=file_path, + output_path=output_path, + task_dir=task_dir, + transcribe_config=self.create_transcribe_config( + need_word_timestamp=need_word_timestamp + ), + need_next_task=need_next_task, + ) + if task_id: + task.task_id = task_id + return task + + def create_subtitle_config(self) -> SubtitleConfig: + llm = self.config.llm + settings = self.config.subtitle + return SubtitleConfig( + base_url=llm.api_base, + api_key=llm.api_key, + llm_model=llm.model, + deeplx_endpoint=settings.deeplx_endpoint, + translator_service=settings.translator_service, + need_reflect=settings.need_reflect, + need_translate=settings.need_translate, + need_optimize=settings.need_optimize, + thread_num=settings.thread_num, + batch_size=settings.batch_size, + subtitle_layout=settings.layout, + subtitle_style=self.get_ass_style(settings.style_name), + max_word_count_cjk=settings.max_word_count_cjk, + max_word_count_english=settings.max_word_count_english, + need_split=settings.need_split, + target_language=settings.target_language, + custom_prompt_text=settings.custom_prompt_text, + ) + + def subtitle_product_tag(self) -> str: + """字幕成品 tag:翻译输出目标语言码,否则 optimized。""" + if self.config.subtitle.need_translate: + return output_paths.language_tag(self.config.subtitle.target_language) + return output_paths.TAG_OPTIMIZED + + def create_subtitle_task( + self, + file_path: str, + video_path: Optional[str] = None, + need_next_task: bool = False, + task_id: Optional[str] = None, + task_dir: Optional[str] = None, + ) -> SubtitleTask: + if need_next_task: + # 流水线中间产物:样式字幕进任务目录,供后续合成消费。 + task_dir = task_dir or self.new_task_dir( + video_path or file_path, output_paths.TASK_TRANSCRIBE) + output_path = str(Path(task_dir) / output_paths.STYLED_SUBTITLE_FILE) + else: + # 成品锚定到媒体文件(有视频时),否则锚定到输入字幕。 + anchor = video_path or file_path + output_path = str( + output_paths.unique_path( + output_paths.product_path(anchor, self.subtitle_product_tag(), ext=".srt") + ) + ) + + task = SubtitleTask( + queued_at=datetime.datetime.now(), + subtitle_path=file_path, + video_path=video_path, + output_path=output_path, + task_dir=task_dir, + subtitle_config=self.create_subtitle_config(), + need_next_task=need_next_task, + ) + if task_id: + task.task_id = task_id + return task + + def create_synthesis_config(self) -> SynthesisConfig: + settings = self.config.synthesis + subtitle = self.config.subtitle + hard_subtitle = not settings.soft_subtitle + style_id = normalize_style_id(settings.style_id, settings.render_mode.value) + ass_style = "" + ass_line_gap = 0 + rounded_style = None + if hard_subtitle: + if settings.render_mode == SubtitleRenderModeEnum.ASS_STYLE: + style = load_style(style_id, renderer=SubtitleRenderer.ASS) + ass_style = style.to_ass_string() if style is not None else "" + ass_line_gap = getattr(style.style, "line_gap", 0) if style is not None else 0 + else: + style = load_style(style_id, renderer=SubtitleRenderer.ROUNDED) + rounded_style = style.to_rounded_dict() if style is not None else {} + return SynthesisConfig( + need_video=settings.need_video, + soft_subtitle=settings.soft_subtitle, + render_mode=settings.render_mode, + video_quality=settings.video_quality, + subtitle_layout=subtitle.layout, + ass_style=ass_style, + ass_line_gap=ass_line_gap, + rounded_style=rounded_style, + ) + + def create_synthesis_task( + self, + video_path: str, + subtitle_path: str, + need_next_task: bool = False, + task_id: Optional[str] = None, + task_dir: Optional[str] = None, + dubbed: bool = False, + ) -> SynthesisTask: + tags = ( + (output_paths.TAG_DUBBED, output_paths.TAG_SUBTITLED) + if dubbed + else (output_paths.TAG_SUBTITLED,) + ) + output_path = str( + output_paths.unique_path(output_paths.product_path(video_path, *tags, ext=".mp4")) + ) + task = SynthesisTask( + queued_at=datetime.datetime.now(), + video_path=video_path, + subtitle_path=subtitle_path, + output_path=output_path, + task_dir=task_dir, + synthesis_config=self.create_synthesis_config(), + need_next_task=need_next_task, + ) + if task_id: + task.task_id = task_id + return task + + def create_dubbing_ui_config(self) -> DubbingUIConfig: + settings = self.config.dubbing + return DubbingUIConfig( + enabled=settings.enabled, + preset=settings.preset, + provider=settings.provider, + api_key=settings.api_key, + api_base=settings.api_base, + model=settings.model, + voice=settings.voice, + text_track=settings.text_track, + timing=settings.timing, + audio_mode=settings.audio_mode, + tts_workers=settings.tts_workers, + use_cache=self.config.cache_enabled, + clone_audio_path=settings.clone_audio_path if settings.supports_clone else "", + clone_audio_text=settings.clone_audio_text if settings.supports_clone else "", + ) + + def create_dubbing_task( + self, + video_path: str, + subtitle_path: str, + output_video_path: Optional[str] = None, + output_audio_path: Optional[str] = None, + task_id: Optional[str] = None, + task_dir: Optional[str] = None, + ) -> DubbingTask: + anchor = video_path or subtitle_path + task_dir = task_dir or self.new_task_dir(anchor, output_paths.TASK_DUBBING) + if output_video_path is None and video_path: + output_video_path = str( + output_paths.unique_path( + output_paths.product_path(video_path, output_paths.TAG_DUBBED) + ) + ) + if output_audio_path is None: + # 配音音频与配音视频共用 dubbed tag,扩展名区分容器。 + output_audio_path = str( + output_paths.unique_path( + output_paths.product_path(anchor, output_paths.TAG_DUBBED, ext=".wav") + ) + ) + + task = DubbingTask( + queued_at=datetime.datetime.now(), + video_path=video_path or None, + subtitle_path=subtitle_path, + output_audio_path=output_audio_path, + output_video_path=output_video_path, + task_dir=task_dir, + dubbing_config=self.create_dubbing_ui_config(), + ) + if task_id: + task.task_id = task_id + return task diff --git a/videocaptioner/core/asr/__init__.py b/videocaptioner/core/asr/__init__.py index 1bc9703e..00546f8b 100644 --- a/videocaptioner/core/asr/__init__.py +++ b/videocaptioner/core/asr/__init__.py @@ -1,6 +1,7 @@ from .bcut import BcutASR from .chunked_asr import ChunkedASR from .faster_whisper import FasterWhisperASR +from .fun_asr import BailianFunASR from .jianying import JianYingASR from .status import ASRStatus from .transcribe import transcribe @@ -8,6 +9,7 @@ from .whisper_cpp import WhisperCppASR __all__ = [ + "BailianFunASR", "BcutASR", "ChunkedASR", "FasterWhisperASR", diff --git a/videocaptioner/core/asr/asr_data.py b/videocaptioner/core/asr/asr_data.py index 0040bb93..140d0e16 100644 --- a/videocaptioner/core/asr/asr_data.py +++ b/videocaptioner/core/asr/asr_data.py @@ -106,6 +106,11 @@ def __str__(self) -> str: class ASRData: def __init__(self, segments: List[ASRDataSeg]): filtered_segments = [seg for seg in segments if seg.text and seg.text.strip()] + for seg in filtered_segments: + # 防御外部坏字幕:倒置的时间区间(end < start)按笔误交换, + # 否则会原样写回非法 SRT,下游播放器/渲染器行为未定义 + if seg.end_time < seg.start_time: + seg.start_time, seg.end_time = seg.end_time, seg.start_time filtered_segments.sort(key=lambda x: x.start_time) self.segments = filtered_segments @@ -242,6 +247,8 @@ def save( json.dump(self.to_json(), f, ensure_ascii=False, indent=2) elif save_path.endswith(".ass"): self.to_ass(save_path=save_path, style_str=ass_style, layout=layout) + elif save_path.endswith(".vtt"): + self.to_vtt(save_path=save_path, layout=layout) else: raise ValueError(f"Unsupported file extension: {save_path}") @@ -324,6 +331,7 @@ def to_ass( save_path: Optional[str] = None, video_width: int = 1280, video_height: int = 720, + line_gap: int = 0, ) -> str: """Convert to ASS subtitle format @@ -361,7 +369,16 @@ def to_ass( "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" ) - dialogue_template = "Dialogue: 0,{},{},{},,0,0,0,,{}\n" + # 双语时给上行(Default)一个绝对 MarginV,制造可控的主副间距。 + # line_gap<=0 时 top_mv 为 None,沿用 libass 默认紧贴堆叠(存量零回归)。 + from videocaptioner.core.subtitle.ass_renderer import top_line_margin_v + + top_mv = top_line_margin_v(style_str, line_gap) + top_mv = top_mv if top_mv is not None else 0 + + def _dlg(start, end, style, text, marginv=0): + return f"Dialogue: 0,{start},{end},{style},,0,0,{marginv},,{text}\n" + for seg in self.segments: start_time, end_time = seg.to_ass_ts() # ASS uses \N for line breaks within dialogue @@ -372,38 +389,22 @@ def to_ass( if layout == SubtitleLayoutEnum.TRANSLATE_ON_TOP: if has_translation: # Secondary(原文)先写(渲染在下),Default(译文)后写(渲染在上) - ass_content += dialogue_template.format( - start_time, end_time, "Secondary", original - ) - ass_content += dialogue_template.format( - start_time, end_time, "Default", translated - ) + ass_content += _dlg(start_time, end_time, "Secondary", original) + ass_content += _dlg(start_time, end_time, "Default", translated, top_mv) else: - ass_content += dialogue_template.format( - start_time, end_time, "Default", original - ) + ass_content += _dlg(start_time, end_time, "Default", original) elif layout == SubtitleLayoutEnum.ORIGINAL_ON_TOP: if has_translation: # Secondary(译文)先写(渲染在下),Default(原文)后写(渲染在上) - ass_content += dialogue_template.format( - start_time, end_time, "Secondary", translated - ) - ass_content += dialogue_template.format( - start_time, end_time, "Default", original - ) + ass_content += _dlg(start_time, end_time, "Secondary", translated) + ass_content += _dlg(start_time, end_time, "Default", original, top_mv) else: - ass_content += dialogue_template.format( - start_time, end_time, "Default", original - ) + ass_content += _dlg(start_time, end_time, "Default", original) elif layout == SubtitleLayoutEnum.ONLY_ORIGINAL: - ass_content += dialogue_template.format( - start_time, end_time, "Default", original - ) + ass_content += _dlg(start_time, end_time, "Default", original) else: # ONLY_TRANSLATE text = translated if has_translation else original - ass_content += dialogue_template.format( - start_time, end_time, "Default", text - ) + ass_content += _dlg(start_time, end_time, "Default", text) if save_path: save_path = handle_long_path(save_path) @@ -411,34 +412,36 @@ def to_ass( f.write(ass_content) return ass_content - def to_vtt(self, save_path=None) -> str: - """Convert to WebVTT subtitle format - - Args: - save_path: Optional save path - - Returns: - WebVTT format subtitle content - """ - raise NotImplementedError("WebVTT format is not supported") - # # WebVTT头部 - # vtt_lines = ["WEBVTT\n"] - - # for n, seg in enumerate(self.segments, 1): - # # 转换时间戳格式从毫秒到 HH:MM:SS.mmm - # start_time = seg._ms_to_srt_time(seg.start_time).replace(",", ".") - # end_time = seg._ms_to_srt_time(seg.end_time).replace(",", ".") - - # # 添加序号(可选)和时间戳 - # vtt_lines.append(f"{n}\n{start_time} --> {end_time}\n{seg.transcript}\n") + def to_vtt( + self, + layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ORIGINAL_ON_TOP, + save_path=None, + ) -> str: + """Convert to WebVTT subtitle format""" + vtt_lines = ["WEBVTT\n"] + for n, seg in enumerate(self.segments, 1): + original = seg.text + translated = seg.translated_text - # vtt_text = "\n".join(vtt_lines) + if layout == SubtitleLayoutEnum.ORIGINAL_ON_TOP: + text = f"{original}\n{translated}" if translated else original + elif layout == SubtitleLayoutEnum.TRANSLATE_ON_TOP: + text = f"{translated}\n{original}" if translated else original + elif layout == SubtitleLayoutEnum.ONLY_ORIGINAL: + text = original + else: # ONLY_TRANSLATE + text = translated if translated else original - # if save_path: - # with open(save_path, "w", encoding="utf-8") as f: - # f.write(vtt_text) + # WebVTT 与 SRT 的时间戳仅毫秒分隔符不同(. 与 ,) + timestamp = seg.to_srt_ts().replace(",", ".") + vtt_lines.append(f"{n}\n{timestamp}\n{text}\n") - # return vtt_text + vtt_text = "\n".join(vtt_lines) + if save_path: + save_path = handle_long_path(save_path) + with open(save_path, "w", encoding="utf-8") as f: + f.write(vtt_text) + return vtt_text def merge_segments( self, start_index: int, end_index: int, merged_text: Optional[str] = None @@ -583,13 +586,34 @@ def from_srt(srt_str: str) -> "ASRData": ) blocks = re.split(r"\n\s*\n", srt_str.strip()) - # Detect bilingual mode: all 4-line + 70% different languages + # Detect bilingual mode: all 4-line + 70% different languages. + # + # 性能:langdetect 首次调用要加载磁盘语言库(~200ms),且每块原来调两次 detect, + # 上百次叠加会卡住 UI 线程(用户反馈"投入字幕后隔一会才加载")。绝大多数双语字幕是 + # 中↔英,用一个便宜的脚本类判定(含 CJK / 含拉丁)就能区分,零 langdetect 开销; + # 只有同为拉丁脚本(如 en↔ru)分不出时才退回 detect。 + def _script_class(text: str) -> str: + has_cjk = any( + "一" <= ch <= "鿿" # 汉字 + or "぀" <= ch <= "ヿ" # 假名 + or "가" <= ch <= "힣" # 谚文 + for ch in text + ) + if has_cjk: + return "cjk" + return "latin" if any(ch.isalpha() for ch in text) else "other" + def is_different_lang(block: str) -> bool: lines = block.splitlines() if len(lines) != 4: return False + top, bottom = lines[2], lines[3] + ct, cb = _script_class(top), _script_class(bottom) + if ct == "cjk" or cb == "cjk": + # 任一含 CJK:脚本类不同即判为不同语种(覆盖主力中↔英/日/韩双语),不碰 langdetect + return ct != cb try: - return detect(lines[2]) != detect(lines[3]) + return detect(top) != detect(bottom) except LangDetectException: return False diff --git a/videocaptioner/core/asr/check.py b/videocaptioner/core/asr/check.py new file mode 100644 index 00000000..dca337a6 --- /dev/null +++ b/videocaptioner/core/asr/check.py @@ -0,0 +1,56 @@ +"""转录服务连通性检查:所有提供商统一入口。 + +用内置短音频跑一次真实转录(B 接口 / J 接口 / Whisper API / 百炼 +Fun-ASR / whisper-cpp / faster-whisper 全部走生产路径),设置页的 +「测试转录」按钮与 ``videocaptioner doctor --check-api`` 共用。 + +强制 use_cache=False:缓存命中会让坏掉的 Key/服务误报成功。 +""" + +from __future__ import annotations + +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from videocaptioner.config import ASSETS_PATH +from videocaptioner.core.asr.transcribe import transcribe +from videocaptioner.core.entities import SubtitleLayoutEnum, TranscribeConfig +from videocaptioner.core.utils.video_utils import video2audio + +TEST_AUDIO_PATH = ASSETS_PATH / "en.mp3" + + +@dataclass(frozen=True) +class TranscribeCheckResult: + """转录检查结果:成功时 detail 是识别出的文本,失败时是错误信息。""" + + success: bool + detail: str + + +def check_transcribe( + config: TranscribeConfig, audio_path: str | Path | None = None +) -> TranscribeCheckResult: + """用短音频真实转录一次,验证当前转录服务可用。""" + path = Path(audio_path) if audio_path else TEST_AUDIO_PATH + if not path.exists(): + return TranscribeCheckResult(False, f"测试音频不存在: {path}") + work_dir = Path(tempfile.mkdtemp(prefix="videocaptioner-asr-check-")) + try: + # 与生产链路一致:先抽 16kHz 单声道 WAV 再转录(本地引擎只收 WAV) + wav_path = work_dir / "check.wav" + if not video2audio(str(path), output=str(wav_path)): + return TranscribeCheckResult(False, "ffmpeg 提取测试音频失败") + asr_data = transcribe(str(wav_path), config, use_cache=False) + except Exception as exc: # noqa: BLE001 —— 各提供商抛错类型不一,统一收敛为结果 + return TranscribeCheckResult(False, str(exc) or type(exc).__name__) + finally: + shutil.rmtree(work_dir, ignore_errors=True) + text = " ".join( + asr_data.to_txt(layout=SubtitleLayoutEnum.ONLY_ORIGINAL).split() + ).strip() + if not text: + return TranscribeCheckResult(False, "转录请求成功但结果为空") + return TranscribeCheckResult(True, text) diff --git a/videocaptioner/core/asr/fun_asr.py b/videocaptioner/core/asr/fun_asr.py new file mode 100644 index 00000000..49064934 --- /dev/null +++ b/videocaptioner/core/asr/fun_asr.py @@ -0,0 +1,396 @@ +"""Alibaba Cloud Bailian FunAudio-ASR HTTP client. + +This module intentionally uses plain HTTP requests instead of the DashScope SDK. +The recorded-file API accepts HTTP(S) URLs or temporary ``oss://`` URLs, so local +files are first uploaded through the official DashScope upload-policy endpoint. +""" + +from __future__ import annotations + +import os +import tempfile +import time +from pathlib import Path +from typing import Any, Literal, Optional, TypedDict, cast + +import requests + +from videocaptioner.core.utils.logger import setup_logger + +from .asr_data import ASRDataSeg +from .base import BaseASR + +logger = setup_logger("fun_asr") + +DEFAULT_FUN_ASR_API_BASE = "https://dashscope.aliyuncs.com" +DEFAULT_FUN_ASR_MODEL = "fun-asr" +SUPPORTED_LANGUAGE_HINTS = { + "zh", + "en", + "ja", + "ko", + "vi", + "th", + "id", + "ms", + "tl", + "hi", + "ar", + "fr", + "de", + "es", + "pt", + "ru", + "it", + "nl", + "sv", + "da", + "fi", + "no", + "el", + "pl", + "cs", + "hu", + "ro", + "bg", + "hr", + "sk", +} + + +class FunASRUploadPolicy(TypedDict): + policy: str + signature: str + upload_dir: str + upload_host: str + oss_access_key_id: str + x_oss_object_acl: str + x_oss_forbid_overwrite: str + expire_in_seconds: int + max_file_size_mb: int + + +class FunASRSubmitInput(TypedDict): + file_urls: list[str] + + +class FunASRSubmitParameters(TypedDict, total=False): + channel_id: list[int] + language_hints: list[str] + diarization_enabled: bool + speaker_count: int + vocabulary_id: str + special_word_filter: str + + +class FunASRSubmitRequest(TypedDict): + model: str + input: FunASRSubmitInput + parameters: FunASRSubmitParameters + + +class FunASRTaskOutput(TypedDict, total=False): + task_id: str + task_status: str + + +class FunASRSubmitResponse(TypedDict): + output: FunASRTaskOutput + request_id: str + + +class FunASRTaskResult(TypedDict, total=False): + subtask_status: str + file_url: str + transcription_url: str + code: str + message: str + + +class FunASRQueryOutput(FunASRTaskOutput, total=False): + results: list[FunASRTaskResult] + task_metrics: dict[str, int] + + +class FunASRQueryResponse(TypedDict): + output: FunASRQueryOutput + request_id: str + + +class FunASRWord(TypedDict, total=False): + begin_time: int + end_time: int + text: str + punctuation: str + + +class FunASRSentence(TypedDict, total=False): + begin_time: int + end_time: int + text: str + sentence_id: int + speaker_id: int + words: list[FunASRWord] + + +class FunASRTranscript(TypedDict, total=False): + channel_id: int + content_duration_in_milliseconds: int + text: str + sentences: list[FunASRSentence] + + +class FunASRTranscriptionResult(TypedDict, total=False): + file_url: str + properties: dict[str, Any] + transcripts: list[FunASRTranscript] + + +class BailianFunASR(BaseASR): + """ASR implementation for Alibaba Cloud Bailian FunAudio-ASR.""" + + def __init__( + self, + audio_input: Optional[str | bytes] = None, + use_cache: bool = False, + need_word_time_stamp: bool = False, + api_key: str = "", + api_base: str = DEFAULT_FUN_ASR_API_BASE, + model: str = DEFAULT_FUN_ASR_MODEL, + language: str = "", + poll_interval: float = 2.0, + timeout: int = 1800, + ): + self.api_key = (api_key or os.getenv("DASHSCOPE_API_KEY", "")).strip() + self.api_base = (api_base or DEFAULT_FUN_ASR_API_BASE).rstrip("/") + self.model = model or DEFAULT_FUN_ASR_MODEL + self.language = language or "" + self.need_word_time_stamp = need_word_time_stamp + self.poll_interval = poll_interval + self.timeout = timeout + self._source_path = audio_input if isinstance(audio_input, str) else None + super().__init__(audio_input, use_cache, need_word_time_stamp) + + def _get_key(self) -> str: + return f"{self.crc32_hex}:{self.model}:{self.language}:{self.need_word_time_stamp}" + + def _run(self, callback=None) -> FunASRTranscriptionResult: + if not self.api_key: + raise ValueError("百炼 ASR API Key 未配置,请先在设置页或环境变量中填写。") + + if callback: + callback(5, "上传音频") + audio_url = self._upload_input() + + if callback: + callback(20, "提交转录任务") + task_id = self._submit_task(audio_url) + + if callback: + callback(30, "等待转录结果") + transcription_url = self._wait_for_task(task_id, callback) + + if callback: + callback(95, "下载转录结果") + result = self._download_transcription(transcription_url) + + if callback: + callback(100, "转录完成") + return result + + def _headers(self, *, json_content: bool = True) -> dict[str, str]: + headers = {"Authorization": f"Bearer {self.api_key}"} + if json_content: + headers["Content-Type"] = "application/json" + return headers + + def _request_json( + self, + method: Literal["GET", "POST"], + url: str, + *, + expected_status: tuple[int, ...] = (200,), + **kwargs, + ) -> dict[str, Any]: + response = requests.request(method, url, timeout=60, **kwargs) + if response.status_code not in expected_status: + raise RuntimeError(self._format_http_error(response)) + try: + return cast(dict[str, Any], response.json()) + except ValueError as exc: + raise RuntimeError(f"百炼接口返回了非 JSON 响应: {response.text[:200]}") from exc + + def _format_http_error(self, response: requests.Response) -> str: + try: + payload = response.json() + message = payload.get("message") or payload.get("code") or response.text + except ValueError: + message = response.text + return f"百炼 ASR 请求失败 ({response.status_code}): {message}" + + def _get_upload_policy(self) -> FunASRUploadPolicy: + url = f"{self.api_base}/api/v1/uploads" + payload = self._request_json( + "GET", + url, + headers=self._headers(), + params={"action": "getPolicy", "model": self.model}, + ) + data = payload.get("data") + if not isinstance(data, dict): + raise RuntimeError("百炼上传凭证响应缺少 data 字段") + return cast(FunASRUploadPolicy, data) + + def _upload_input(self) -> str: + policy = self._get_upload_policy() + if self._source_path: + path = Path(self._source_path) + return self._upload_file(policy, path.name, path.read_bytes()) + + suffix = ".mp3" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(self.file_binary or b"") + tmp_path = Path(tmp.name) + try: + return self._upload_file(policy, f"{self.crc32_hex}{suffix}", tmp_path.read_bytes()) + finally: + tmp_path.unlink(missing_ok=True) + + def _upload_file(self, policy: FunASRUploadPolicy, file_name: str, data: bytes) -> str: + key = f"{policy['upload_dir']}/{file_name}" + files = { + "OSSAccessKeyId": (None, policy["oss_access_key_id"]), + "Signature": (None, policy["signature"]), + "policy": (None, policy["policy"]), + "x-oss-object-acl": (None, policy["x_oss_object_acl"]), + "x-oss-forbid-overwrite": (None, policy["x_oss_forbid_overwrite"]), + "key": (None, key), + "success_action_status": (None, "200"), + "file": (file_name, data), + } + response = requests.post(policy["upload_host"], files=files, timeout=120) + if response.status_code != 200: + raise RuntimeError(self._format_http_error(response)) + return f"oss://{key}" + + def _submit_task(self, audio_url: str) -> str: + url = f"{self.api_base}/api/v1/services/audio/asr/transcription" + parameters: FunASRSubmitParameters = {"channel_id": [0]} + language_hint = self._language_hint() + if language_hint: + parameters["language_hints"] = [language_hint] + body: FunASRSubmitRequest = { + "model": self.model, + "input": {"file_urls": [audio_url]}, + "parameters": parameters, + } + headers = self._headers() + headers["X-DashScope-Async"] = "enable" + headers["X-DashScope-OssResourceResolve"] = "enable" + payload = cast( + FunASRSubmitResponse, + self._request_json("POST", url, headers=headers, json=body), + ) + task_id = payload.get("output", {}).get("task_id") + if not task_id: + raise RuntimeError(f"百炼 ASR 提交任务失败: {payload}") + return task_id + + def _language_hint(self) -> str: + language = self.language.strip().lower() + if language in SUPPORTED_LANGUAGE_HINTS: + return language + if language: + logger.info("Fun-ASR does not support language hint '%s', using auto detection", language) + return "" + + def _wait_for_task(self, task_id: str, callback=None) -> str: + deadline = time.time() + self.timeout + url = f"{self.api_base}/api/v1/tasks/{task_id}" + last_status = "" + while time.time() < deadline: + payload = cast( + FunASRQueryResponse, + self._request_json("GET", url, headers=self._headers(json_content=False)), + ) + output = payload.get("output", {}) + status = output.get("task_status", "") + if status != last_status: + logger.info("Fun-ASR task %s status: %s", task_id, status) + last_status = status + if status == "SUCCEEDED": + return self._pick_transcription_url(output) + if status in {"FAILED", "CANCELED", "UNKNOWN"}: + raise RuntimeError(self._format_task_failure(output)) + if callback: + callback(50, f"任务状态:{status or '处理中'}") + time.sleep(self.poll_interval) + raise TimeoutError("百炼 ASR 等待结果超时") + + def _pick_transcription_url(self, output: FunASRQueryOutput) -> str: + for result in output.get("results", []) or []: + if result.get("subtask_status") == "SUCCEEDED" and result.get("transcription_url"): + return result["transcription_url"] + raise RuntimeError(self._format_task_failure(output)) + + def _format_task_failure(self, output: FunASRQueryOutput) -> str: + for result in output.get("results", []) or []: + code = result.get("code", "") + message = result.get("message", "") + if code or message: + return f"百炼 ASR 子任务失败: {code} {message}".strip() + return f"百炼 ASR 任务失败: {output.get('task_status', 'UNKNOWN')}" + + def _download_transcription(self, transcription_url: str) -> FunASRTranscriptionResult: + payload = self._request_json( + "GET", + transcription_url, + expected_status=(200,), + ) + return cast(FunASRTranscriptionResult, payload) + + def _make_segments(self, resp_data: FunASRTranscriptionResult) -> list[ASRDataSeg]: + segments: list[ASRDataSeg] = [] + for transcript in resp_data.get("transcripts", []) or []: + sentences = transcript.get("sentences", []) or [] + if self.need_word_time_stamp: + segments.extend(self._word_segments(sentences)) + else: + segments.extend(self._sentence_segments(sentences)) + return segments + + def _sentence_segments(self, sentences: list[FunASRSentence]) -> list[ASRDataSeg]: + segments: list[ASRDataSeg] = [] + for sentence in sentences: + text = (sentence.get("text") or "").strip() + if not text: + continue + start = int(sentence.get("begin_time") or 0) + end = int(sentence.get("end_time") or start) + segments.append(ASRDataSeg(text=text, start_time=start, end_time=end)) + return segments + + def _word_segments(self, sentences: list[FunASRSentence]) -> list[ASRDataSeg]: + segments: list[ASRDataSeg] = [] + for sentence in sentences: + words = sentence.get("words") or [] + if not words: + text = (sentence.get("text") or "").strip() + if text: + segments.append( + ASRDataSeg( + text=text, + start_time=int(sentence.get("begin_time") or 0), + end_time=int(sentence.get("end_time") or 0), + ) + ) + continue + for word in words: + text = f"{word.get('text', '')}{word.get('punctuation', '')}".strip() + if not text: + continue + start = int(word.get("begin_time") or 0) + end = int(word.get("end_time") or start) + segments.append(ASRDataSeg(text=text, start_time=start, end_time=end)) + return segments + diff --git a/videocaptioner/core/asr/transcribe.py b/videocaptioner/core/asr/transcribe.py index 4757097e..2b1cb51c 100644 --- a/videocaptioner/core/asr/transcribe.py +++ b/videocaptioner/core/asr/transcribe.py @@ -2,19 +2,28 @@ from videocaptioner.core.asr.bcut import BcutASR from videocaptioner.core.asr.chunked_asr import ChunkedASR from videocaptioner.core.asr.faster_whisper import FasterWhisperASR +from videocaptioner.core.asr.fun_asr import BailianFunASR from videocaptioner.core.asr.jianying import JianYingASR from videocaptioner.core.asr.whisper_api import WhisperAPI from videocaptioner.core.asr.whisper_cpp import WhisperCppASR from videocaptioner.core.entities import TranscribeConfig, TranscribeModelEnum -def transcribe(audio_path: str, config: TranscribeConfig, callback=None) -> ASRData: +def transcribe( + audio_path: str, + config: TranscribeConfig, + callback=None, + *, + use_cache: bool = True, +) -> ASRData: """Transcribe audio file using specified configuration. Args: audio_path: Path to audio file config: Transcription configuration callback: Progress callback function(progress: int, message: str) + use_cache: Reuse cached recognition results; connectivity checks + must pass False so a stale hit cannot mask a broken provider Returns: ASRData: Transcription result data @@ -30,7 +39,7 @@ def _default_callback(x, y): raise ValueError("Transcription model not set") # Create ASR instance based on model type - asr = _create_asr_instance(audio_path, config) + asr = _create_asr_instance(audio_path, config, use_cache=use_cache) # Run transcription asr_data = asr.run(callback=callback) @@ -42,12 +51,15 @@ def _default_callback(x, y): return asr_data -def _create_asr_instance(audio_path: str, config: TranscribeConfig) -> ChunkedASR: +def _create_asr_instance( + audio_path: str, config: TranscribeConfig, *, use_cache: bool = True +) -> ChunkedASR: """Create appropriate ASR instance based on configuration. Args: audio_path: Path to audio file config: Transcription configuration + use_cache: Reuse cached recognition results Returns: ChunkedASR: Chunked ASR instance ready to run @@ -55,28 +67,33 @@ def _create_asr_instance(audio_path: str, config: TranscribeConfig) -> ChunkedAS model_type = config.transcribe_model if model_type == TranscribeModelEnum.JIANYING: - return _create_jianying_asr(audio_path, config) + return _create_jianying_asr(audio_path, config, use_cache=use_cache) elif model_type == TranscribeModelEnum.BIJIAN: - return _create_bijian_asr(audio_path, config) + return _create_bijian_asr(audio_path, config, use_cache=use_cache) elif model_type == TranscribeModelEnum.WHISPER_CPP: - return _create_whisper_cpp_asr(audio_path, config) + return _create_whisper_cpp_asr(audio_path, config, use_cache=use_cache) elif model_type == TranscribeModelEnum.WHISPER_API: - return _create_whisper_api_asr(audio_path, config) + return _create_whisper_api_asr(audio_path, config, use_cache=use_cache) + + elif model_type == TranscribeModelEnum.BAILIAN_FUN_ASR: + return _create_fun_asr(audio_path, config, use_cache=use_cache) elif model_type == TranscribeModelEnum.FASTER_WHISPER: - return _create_faster_whisper_asr(audio_path, config) + return _create_faster_whisper_asr(audio_path, config, use_cache=use_cache) else: raise ValueError(f"Invalid transcription model: {model_type}") -def _create_jianying_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR: +def _create_jianying_asr( + audio_path: str, config: TranscribeConfig, *, use_cache: bool = True +) -> ChunkedASR: """Create JianYing ASR instance with chunking support.""" asr_kwargs = { - "use_cache": True, + "use_cache": use_cache, "need_word_time_stamp": config.need_word_time_stamp, } return ChunkedASR( @@ -84,19 +101,23 @@ def _create_jianying_asr(audio_path: str, config: TranscribeConfig) -> ChunkedAS ) -def _create_bijian_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR: +def _create_bijian_asr( + audio_path: str, config: TranscribeConfig, *, use_cache: bool = True +) -> ChunkedASR: """Create Bijian ASR instance with chunking support.""" asr_kwargs = { - "use_cache": True, + "use_cache": use_cache, "need_word_time_stamp": config.need_word_time_stamp, } return ChunkedASR(asr_class=BcutASR, audio_path=audio_path, asr_kwargs=asr_kwargs) -def _create_whisper_cpp_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR: +def _create_whisper_cpp_asr( + audio_path: str, config: TranscribeConfig, *, use_cache: bool = True +) -> ChunkedASR: """Create WhisperCpp ASR instance with chunking support.""" asr_kwargs = { - "use_cache": True, + "use_cache": use_cache, "need_word_time_stamp": config.need_word_time_stamp, "language": config.transcribe_language, "whisper_model": config.whisper_model.value if config.whisper_model else None, @@ -110,10 +131,12 @@ def _create_whisper_cpp_asr(audio_path: str, config: TranscribeConfig) -> Chunke ) -def _create_whisper_api_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR: +def _create_whisper_api_asr( + audio_path: str, config: TranscribeConfig, *, use_cache: bool = True +) -> ChunkedASR: """Create Whisper API ASR instance with chunking support.""" asr_kwargs = { - "use_cache": True, + "use_cache": use_cache, "need_word_time_stamp": config.need_word_time_stamp, "language": config.transcribe_language, "whisper_model": config.whisper_api_model or "whisper-1", @@ -126,10 +149,34 @@ def _create_whisper_api_asr(audio_path: str, config: TranscribeConfig) -> Chunke ) -def _create_faster_whisper_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR: +def _create_fun_asr( + audio_path: str, config: TranscribeConfig, *, use_cache: bool = True +) -> ChunkedASR: + """Create Bailian Fun-ASR instance with long-audio chunking support.""" + asr_kwargs = { + "use_cache": use_cache, + "need_word_time_stamp": config.need_word_time_stamp, + "language": config.transcribe_language, + "api_key": config.fun_asr_api_key or "", + "api_base": config.fun_asr_api_base or "", + "model": config.fun_asr_model or "fun-asr", + } + return ChunkedASR( + asr_class=BailianFunASR, + audio_path=audio_path, + asr_kwargs=asr_kwargs, + chunk_concurrency=1, + chunk_length=60 * 20, + chunk_overlap=0, + ) + + +def _create_faster_whisper_asr( + audio_path: str, config: TranscribeConfig, *, use_cache: bool = True +) -> ChunkedASR: """Create FasterWhisper ASR instance with chunking support.""" asr_kwargs = { - "use_cache": True, + "use_cache": use_cache, "need_word_time_stamp": config.need_word_time_stamp, "faster_whisper_program": config.faster_whisper_program or "", "language": config.transcribe_language, diff --git a/videocaptioner/core/asr/whisper_cpp.py b/videocaptioner/core/asr/whisper_cpp.py index edc26367..39db7c07 100644 --- a/videocaptioner/core/asr/whisper_cpp.py +++ b/videocaptioner/core/asr/whisper_cpp.py @@ -154,6 +154,7 @@ def _default_callback(_progress: int, _message: str) -> None: stderr=subprocess.PIPE, text=True, encoding="utf-8", + errors="replace", bufsize=1, ) diff --git a/videocaptioner/core/download/__init__.py b/videocaptioner/core/download/__init__.py new file mode 100644 index 00000000..38178d98 --- /dev/null +++ b/videocaptioner/core/download/__init__.py @@ -0,0 +1,107 @@ +"""模型与文件下载基建(纯 Python,无 PyQt 依赖)。 + +- downloader: 通用单文件下载(镜像兜底 / 断点续传 / 进度回调 / SHA1 校验) +- models: 本地 ASR 模型清单(whisper-cpp / faster-whisper)与安装管理 +- net: 在线视频下载的网络环境(代理 / cookies / B 站风控 / 错误翻译) +- source_check: 下载源连通性检查(YouTube / 哔哩哔哩 真实解析) +""" + +from videocaptioner.core.download.dependencies import ( + DEPENDENCIES, + DependencyAsset, + DependencySpec, + DependencyUnsupported, + asset_for, + current_platform, + dependency_for, + install_dependency, + installed_path, + is_installed, + iter_dependencies, +) +from videocaptioner.core.download.downloader import ( + DownloadCancelled, + DownloadError, + DownloadProgress, + download_file, +) +from videocaptioner.core.download.models import ( + FASTER_WHISPER_MODELS, + WHISPER_CPP_MODELS, + ModelFile, + ModelSpec, + download_model, + find_model, + has_partial_download, + iter_models, + model_install_state, + remove_model, +) +from videocaptioner.core.download.net import ( + cookies_file, + fetch_bilibili_buvid, + friendly_download_error, + inject_bilibili_buvid, + is_bilibili_url, + system_proxy, +) +from videocaptioner.core.download.programs import ( + ProgramInstallPlan, + ProgramStatus, + ProgramVariant, + detect_program, + program_install_plan, + program_variants, +) +from videocaptioner.core.download.source_check import ( + DOWNLOAD_SOURCES, + DownloadSource, + SourceCheckResult, + check_download_source, + check_download_sources, +) + +__all__ = [ + "DOWNLOAD_SOURCES", + "DownloadCancelled", + "DownloadError", + "DownloadProgress", + "DownloadSource", + "SourceCheckResult", + "check_download_source", + "check_download_sources", + "cookies_file", + "download_file", + "fetch_bilibili_buvid", + "friendly_download_error", + "inject_bilibili_buvid", + "is_bilibili_url", + "system_proxy", + "ModelFile", + "ModelSpec", + "WHISPER_CPP_MODELS", + "FASTER_WHISPER_MODELS", + "find_model", + "iter_models", + "model_install_state", + "has_partial_download", + "remove_model", + "download_model", + "ProgramStatus", + "ProgramInstallPlan", + "ProgramVariant", + "detect_program", + "program_install_plan", + "program_variants", + "DEPENDENCIES", + "DependencyAsset", + "DependencySpec", + "DependencyUnsupported", + "asset_for", + "current_platform", + "dependency_for", + "install_dependency", + "installed_path", + "is_installed", + "iter_dependencies", +] diff --git a/videocaptioner/core/download/dependencies.py b/videocaptioner/core/download/dependencies.py new file mode 100644 index 00000000..1e92a4e4 --- /dev/null +++ b/videocaptioner/core/download/dependencies.py @@ -0,0 +1,406 @@ +"""运行依赖(ffmpeg / voxgate)的下载清单与安装(纯 Python,无 PyQt)。 + +这是「以后 pip 安装等渠道缺二进制时,用一个弹窗补齐」的单一数据源:所有外部二进制的 +下载地址集中在 :data:`DEPENDENCIES` 注册表,按 依赖 × 系统 × 架构 给镜像兜底 +(ghproxy 镜像优先、GitHub Releases 直连兜底,与现有 HF→hf-mirror→ModelScope 思路一致)。 + +安装统一走「下载 → (压缩包则解压取出可执行)→ 补可执行位 → 落到 ``BIN_PATH``」。 +``BIN_PATH`` 在启动时已被前置到 PATH(见 config.py),所以装好即可被发现。 + +要加新依赖:只在 :data:`DEPENDENCIES` 里加一项即可,UI/线程/检测全部自动适配。 +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import tarfile +import zipfile +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from videocaptioner.config import BIN_PATH, CACHE_PATH, find_binary +from videocaptioner.core.download.downloader import ( + CancelCheck, + DownloadError, + DownloadProgress, + ProgressCallback, + download_file, +) +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("dependency_download") + +# ffmpeg 发布在主项目 Release(用户自行上传,见 design/MANIFEST); +# voxgate 有自己的发布仓库(CI 出全平台包),直接从那里取,固定版本以保证与客户端协议匹配。 +_MAIN_REPO = "WEIFENG2333/VideoCaptioner" +# ffmpeg 放主仓库一个「专用、不随 app 版本移动」的固定 tag 的 release 里, +# 这样发 app 新版(latest 移动)不会影响 ffmpeg 下载。 +_FFMPEG_TAG = "ffmpeg-bin" +_VOXGATE_REPO = "WEIFENG2333/voxgate" +_VOXGATE_TAG = "v0.3.0" # 升级 voxgate:改这里 + 同步下方 sha256(需回归实时字幕协议) +# release 附带 checksums.txt 的 sha256:加速镜像是第三方代理,靠校验和防篡改/防错误页 +_VOXGATE_SHA256 = { + "voxgate_darwin_amd64.tar.gz": "7848a0cad309c38cdd1ddd07b471621aec4a43b24deec6f3febaee3180fba30f", + "voxgate_darwin_arm64.tar.gz": "4054dc98aa11994fde3e649f0ba15178b14d58bc29016e3b2a7d589d7cd68af1", + "voxgate_linux_amd64.tar.gz": "66ac755d9b8e9d4bedfb20c0afedb491c41dc67404aa2d1e856422fc5d438b33", + "voxgate_linux_arm64.tar.gz": "a9b285d5622d8b5f043ea97d90ce469c0b7bc3c97f6b188fa46c213d986ceb99", + "voxgate_windows_amd64.zip": "c7f953490daf53ba9fd2fdcc6987c24bf9da0bc86a828c33427f63700329e138", +} +# 国内加速镜像优先,最后回落 GitHub 直连(download_file 会按顺序兜底)。 +# 加速镜像生态更迭快、死镜像常以 200 返回 HTML 页——下载后有压缩包验真兜底。 +_GH_MIRRORS = ("https://gh-proxy.com/", "https://ghfast.top/") + +PhaseCallback = Callable[[str], None] + + +def _gh_urls(repo: str, tag: str, asset: str) -> tuple[str, ...]: + """某 release 资产的镜像兜底地址:ghproxy 镜像优先,GitHub 直连兜底。 + + tag="latest" 用 releases/latest/download(永远指向最新 release 的同名资产); + 具体 tag 用 releases/download/(固定版本,可复现)。 + """ + if tag == "latest": + direct = f"https://github.com/{repo}/releases/latest/download/{asset}" + else: + direct = f"https://github.com/{repo}/releases/download/{tag}/{asset}" + return tuple(mirror + direct for mirror in _GH_MIRRORS) + (direct,) + + +def current_platform() -> tuple[str, str]: + """归一当前系统/架构为 (os_key, arch):os ∈ {macos, windows, linux},arch ∈ {arm64, x64}。""" + os_key = {"Darwin": "macos", "Windows": "windows"}.get(platform.system(), "linux") + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "x64" + return os_key, arch + + +@dataclass(frozen=True) +class DependencyAsset: + """某依赖在某平台的下载件。""" + + asset: str # release 资产名(也是 _gh_urls 的输入) + executables: tuple[str, ...] # 安装后应落到 BIN_PATH 的可执行文件名(用于检测/解压匹配) + archive: bool = False # True=压缩包(解压取出可执行+随附动态库);False=裸二进制(重命名落地) + sha1: Optional[str] = None + sha256: Optional[str] = None + size_bytes: Optional[int] = None + repo: str = _MAIN_REPO # 资产所在仓库 + tag: str = "latest" # release tag;latest=取最新 + + @property + def urls(self) -> tuple[str, ...]: + return _gh_urls(self.repo, self.tag, self.asset) + + +@dataclass(frozen=True) +class DependencySpec: + """一个运行依赖(含各平台下载件)。""" + + key: str # "ffmpeg" | "voxgate" + display_name: str + description: str + optional: bool # 可选功能依赖(缺失只警告,不算硬错误) + assets: dict[str, DependencyAsset] # "{os}-{arch}" -> 下载件 + + def asset_for(self, os_key: str, arch: str) -> Optional[DependencyAsset]: + return self.assets.get(f"{os_key}-{arch}") + + +# ---- 注册表 ----------------------------------------------------------------- + +_PLATFORMS = ( + ("macos", "arm64"), + ("macos", "x64"), + ("windows", "x64"), + ("linux", "x64"), + ("linux", "arm64"), +) + + +def _voxgate_asset(os_key: str, arch: str) -> DependencyAsset: + # voxgate 自己的 release 用 Go 命名(darwin/amd64)+ 压缩包;Windows 包内还随附 + # libogg/libopus.dll,靠解压时一并取出动态库来保证可运行。 + go_os = {"macos": "darwin", "windows": "windows", "linux": "linux"}[os_key] + go_arch = {"arm64": "arm64", "x64": "amd64"}[arch] + if os_key == "windows": + asset = f"voxgate_{go_os}_{go_arch}.zip" + exes = ("voxgate.exe",) + else: + asset = f"voxgate_{go_os}_{go_arch}.tar.gz" + exes = ("voxgate",) + return DependencyAsset( + asset=asset, + executables=exes, + archive=True, + sha256=_VOXGATE_SHA256.get(asset), + repo=_VOXGATE_REPO, + tag=_VOXGATE_TAG, + ) + + +def _ffmpeg_asset(os_key: str, arch: str) -> DependencyAsset: + ext = ".exe" if os_key == "windows" else "" + return DependencyAsset( + asset=f"ffmpeg-{os_key}-{arch}.zip", + # ffprobe 一并装:应用探测只用 ffmpeg,但 pydub(配音)读 mp3 仍需 ffprobe + executables=(f"ffmpeg{ext}", f"ffprobe{ext}"), + archive=True, + tag=_FFMPEG_TAG, + ) + + +DEPENDENCIES: tuple[DependencySpec, ...] = ( + DependencySpec( + key="ffmpeg", + display_name="FFmpeg", + description="音视频转码与字幕压制", + optional=False, + assets={f"{o}-{a}": _ffmpeg_asset(o, a) for o, a in _PLATFORMS}, + ), + DependencySpec( + key="voxgate", + display_name="voxgate", + description="实时字幕本地转录引擎", + optional=True, + assets={f"{o}-{a}": _voxgate_asset(o, a) for o, a in _PLATFORMS}, + ), +) + + +def iter_dependencies() -> tuple[DependencySpec, ...]: + return DEPENDENCIES + + +def dependency_for(key: str) -> DependencySpec: + for spec in DEPENDENCIES: + if spec.key == key: + return spec + raise KeyError(f"未知依赖:{key}") + + +def asset_for(spec: DependencySpec) -> Optional[DependencyAsset]: + """当前平台的下载件;None 表示该平台暂无预编译件。""" + os_key, arch = current_platform() + return spec.asset_for(os_key, arch) + + +# ---- 检测 ------------------------------------------------------------------- + + +def _find_executable(name: str) -> Optional[str]: + """在 自带 bin / 用户 bin / PATH 里找可执行文件(统一走 config.find_binary)。""" + return find_binary(name) + + +# (path, mtime, size) -> version。is_installed 在 GUI 线程被反复调用(诊断页/下载 +# 对话框刷新),--version 子进程不能每次都跑(Windows 首启还会被杀软扫描卡几秒)。 +_voxgate_version_cache: dict[tuple[str, float, int], str] = {} + + +def _voxgate_version(binary: str) -> str: + """`voxgate --version` → "0.3.0";探测失败返回空串(按版本未知处理,不拦使用)。""" + try: + stat = os.stat(binary) + cache_key = (binary, stat.st_mtime, stat.st_size) + except OSError: + return "" + if cache_key in _voxgate_version_cache: + return _voxgate_version_cache[cache_key] + try: + result = subprocess.run( + [binary, "--version"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0, + ) + parts = (result.stdout or result.stderr).strip().split() + version = parts[-1] if parts else "" + except (OSError, subprocess.TimeoutExpired): + version = "" + _voxgate_version_cache[cache_key] = version + return version + + +def is_installed(spec: DependencySpec) -> bool: + """该依赖是否已就绪(所有可执行文件都能找到)。""" + if spec.key == "voxgate": + # 复用 voxgate 的发现顺序(含用户在设置里指定的路径) + from videocaptioner.core.realtime.backends.voxgate import find_voxgate_binary + + found = find_voxgate_binary() + if found is None: + return False + # 我们装进 BIN_PATH 的实例跟着钉住的版本走:旧版本视为未就绪,诊断页 + # 的「下载 voxgate」即升级入口。用户自己指定/系统 PATH 的不做版本检查。 + if Path(found).parent == Path(BIN_PATH): + version = _voxgate_version(found) + if version and version != _VOXGATE_TAG.lstrip("v"): + return False + return True + asset = asset_for(spec) + if asset is None: + return False + return all(_find_executable(name) is not None for name in asset.executables) + + +def installed_path(spec: DependencySpec) -> Optional[str]: + """已安装时返回主可执行文件路径,否则 None。""" + if spec.key == "voxgate": + from videocaptioner.core.realtime.backends.voxgate import find_voxgate_binary + + return find_voxgate_binary() + asset = asset_for(spec) + if asset is None or not asset.executables: + return None + return _find_executable(asset.executables[0]) + + +# ---- 安装 ------------------------------------------------------------------- + + +class DependencyUnsupported(RuntimeError): + """当前平台没有该依赖的预编译件。""" + + +def install_dependency( + spec: DependencySpec, + *, + on_progress: Optional[ProgressCallback] = None, + on_phase: Optional[PhaseCallback] = None, + should_cancel: Optional[CancelCheck] = None, + bin_dir: Optional[Path] = None, +) -> Path: + """下载并安装一个依赖到 ``bin_dir``(默认 BIN_PATH),返回主可执行文件路径。 + + 流程:下载到缓存 → 压缩包则解压取出可执行(否则重命名)→ 补可执行位。下载阶段经 + ``on_progress`` 上报字节进度;解压阶段经 ``on_phase`` 上报一句状态文案。 + """ + asset = asset_for(spec) + if asset is None: + raise DependencyUnsupported(f"{spec.display_name} 暂无适用于当前系统的预编译件") + + bin_path = Path(bin_dir) if bin_dir is not None else Path(BIN_PATH) + bin_path.mkdir(parents=True, exist_ok=True) + cache_dir = Path(CACHE_PATH) / "deps" + download_dest = cache_dir / asset.asset + + download_file( + asset.urls, + download_dest, + sha1=asset.sha1, + sha256=asset.sha256, + validate=_validate_archive if asset.archive else None, + on_progress=on_progress, + should_cancel=should_cancel, + ) + + if asset.archive: + if on_phase is not None: + on_phase("正在解压…") + placed = _extract_runtime_files(download_dest, asset.executables, bin_path) + download_dest.unlink(missing_ok=True) # 解压完删压缩包,省空间 + main = bin_path / asset.executables[0] + if main not in placed: + raise RuntimeError(f"{spec.display_name} 安装失败:压缩包里没有 {asset.executables[0]}") + else: + main = bin_path / asset.executables[0] + os.replace(download_dest, main) + placed = [main] + + # 给可执行文件补 +x(动态库不必,但 chmod 也无害) + for path in placed: + _make_executable(path) + logger.info("%s 已安装:%s", spec.display_name, ", ".join(p.name for p in placed)) + return main + + +def _is_shared_lib(name: str) -> bool: + """是否是运行所需的动态库(随可执行一起取出,否则 Windows 上缺 dll 跑不起来)。""" + low = name.lower() + return low.endswith((".dll", ".dylib", ".so")) or ".so." in low + + +def _validate_archive(path: Path) -> None: + """压缩包验真:失效镜像会以 HTTP 200 返回 HTML 错误页,必须当场识破换下一个镜像。""" + if not (zipfile.is_zipfile(path) or tarfile.is_tarfile(path)): + raise DownloadError(f"{path.name}: 下载内容不是有效压缩包(镜像可能已失效)") + + +def _extract_runtime_files(archive: Path, executables: tuple[str, ...], dest_dir: Path) -> list[Path]: + """从压缩包里取出可执行文件 + 随附动态库,按 basename 扁平落到 dest_dir。 + + 支持 zip 与 tar(.gz/.xz);按 basename 匹配以兼容压缩包内的嵌套目录。除目标可执行外, + 一并取出 .dll/.dylib/.so(如 voxgate Windows 包随附的 libopus/libogg.dll),跳过 + LICENSE/README 等文档。 + """ + dest_dir.mkdir(parents=True, exist_ok=True) + wanted = set(executables) + + def _want(base: str) -> bool: + return base in wanted or _is_shared_lib(base) + + placed: list[Path] = [] + if zipfile.is_zipfile(archive): + with zipfile.ZipFile(archive) as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + base = Path(info.filename).name + if _want(base): + target = dest_dir / base + with zf.open(info) as src, open(target, "wb") as out: + shutil.copyfileobj(src, out) + placed.append(target) + elif tarfile.is_tarfile(archive): + with tarfile.open(archive) as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + base = Path(member.name).name + if _want(base): + extracted = tf.extractfile(member) + if extracted is None: + continue + target = dest_dir / base + with extracted as src, open(target, "wb") as out: + shutil.copyfileobj(src, out) + placed.append(target) + else: + raise RuntimeError(f"无法识别的压缩格式:{archive.name}") + return placed + + +def _make_executable(path: Path) -> None: + """补上可执行位(Windows 无需)。""" + if os.name == "nt": + return + try: + mode = os.stat(path).st_mode + os.chmod(path, mode | 0o111) # u+x g+x o+x + except OSError: + logger.debug("chmod +x 失败:%s", path, exc_info=True) + + +# 进度类型对外(UI 线程引用) +__all__ = [ + "DependencyAsset", + "DependencySpec", + "DependencyUnsupported", + "DEPENDENCIES", + "DownloadProgress", + "asset_for", + "current_platform", + "dependency_for", + "install_dependency", + "installed_path", + "is_installed", + "iter_dependencies", +] diff --git a/videocaptioner/core/download/downloader.py b/videocaptioner/core/download/downloader.py new file mode 100644 index 00000000..6af9415d --- /dev/null +++ b/videocaptioner/core/download/downloader.py @@ -0,0 +1,158 @@ +"""通用文件下载器。 + +设计要点: +- 一个文件可配多个镜像 URL,按顺序兜底(连接失败 / 超时 / 4xx/5xx 都切下一个); +- 下载写入 ``.part``,完成并通过校验后原子替换到目标路径; +- 同一 URL 重试时用 HTTP Range 续传 ``.part``,服务器不支持就从头来; +- 进度通过回调上报,取消通过回调轮询,便于 CLI / Qt 线程各自接入。 +""" + +from __future__ import annotations + +import hashlib +import logging +import os +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +import requests + +logger = logging.getLogger(__name__) + +CONNECT_TIMEOUT = 10 +READ_TIMEOUT = 60 +CHUNK_SIZE = 256 * 1024 + + +class DownloadError(Exception): + """所有镜像都下载失败。""" + + +class DownloadCancelled(Exception): + """调用方主动取消下载。""" + + +@dataclass(frozen=True) +class DownloadProgress: + """单文件下载进度(total 未知时为 None)。""" + + file_name: str + received: int + total: int | None + + +ProgressCallback = Callable[[DownloadProgress], None] +CancelCheck = Callable[[], bool] + + +def download_file( + urls: list[str] | tuple[str, ...], + dest: Path, + *, + sha1: str | None = None, + sha256: str | None = None, + validate: Callable[[Path], None] | None = None, + on_progress: ProgressCallback | None = None, + should_cancel: CancelCheck | None = None, + session: requests.Session | None = None, +) -> Path: + """从镜像列表下载一个文件到 dest,返回 dest。 + + 任意镜像成功即返回;全部失败抛 DownloadError; + 取消抛 DownloadCancelled(保留 .part 供下次续传)。 + ``validate(part)`` 在校验和之后执行,抛 DownloadError 视为该镜像失败换下一个 + ——失效的加速镜像常以 HTTP 200 返回 HTML 错误页,校验和缺失时靠它兜底。 + """ + if not urls: + raise DownloadError(f"{dest.name}: 没有可用的下载地址") + dest.parent.mkdir(parents=True, exist_ok=True) + part = dest.with_suffix(dest.suffix + ".part") + http = session or requests.Session() + + errors: list[str] = [] + for url in urls: + try: + _fetch_to_part(http, url, part, dest.name, on_progress, should_cancel) + _verify_digest(part, "sha256", sha256) + _verify_digest(part, "sha1", sha1) + if validate is not None: + validate(part) + os.replace(part, dest) + return dest + except DownloadCancelled: + raise + except (requests.RequestException, DownloadError, OSError) as exc: + errors.append(f"{_host(url)}: {exc}") + logger.warning("download failed from %s: %s", url, exc) + if isinstance(exc, DownloadError): + # 校验失败说明 .part 内容坏了,不能带到下一个镜像续传 + part.unlink(missing_ok=True) + raise DownloadError(f"{dest.name} 下载失败({len(urls)} 个镜像都不可用):" + ";".join(errors)) + + +def _fetch_to_part( + http: requests.Session, + url: str, + part: Path, + display_name: str, + on_progress: ProgressCallback | None, + should_cancel: CancelCheck | None, +) -> None: + resume_from = part.stat().st_size if part.exists() else 0 + headers = {"Range": f"bytes={resume_from}-"} if resume_from else {} + response = http.get( + url, + stream=True, + headers=headers, + timeout=(CONNECT_TIMEOUT, READ_TIMEOUT), + allow_redirects=True, + ) + if resume_from and response.status_code == 200: + # 服务器不支持 Range,从头下 + resume_from = 0 + elif response.status_code not in (200, 206): + response.close() + raise DownloadError(f"HTTP {response.status_code}") + + total = _total_size(response, resume_from) + mode = "ab" if resume_from else "wb" + received = resume_from + with open(part, mode) as fh: + for chunk in response.iter_content(chunk_size=CHUNK_SIZE): + if should_cancel is not None and should_cancel(): + response.close() + raise DownloadCancelled(display_name) + if not chunk: + continue + fh.write(chunk) + received += len(chunk) + if on_progress is not None: + on_progress(DownloadProgress(display_name, received, total)) + if total is not None and received < total: + raise DownloadError(f"连接中断({received}/{total} 字节)") + + +def _total_size(response: requests.Response, resume_from: int) -> int | None: + length = response.headers.get("Content-Length") + if length is None or not length.isdigit(): + return None + if response.status_code == 206: + return resume_from + int(length) + return int(length) + + +def _verify_digest(path: Path, algo: str, expected: str | None) -> None: + if not expected: + return + digest = hashlib.new(algo) + with open(path, "rb") as fh: + for block in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(block) + actual = digest.hexdigest() + if actual.lower() != expected.lower(): + raise DownloadError(f"{algo.upper()} 校验失败({actual[:12]}… != {expected[:12]}…)") + + +def _host(url: str) -> str: + return url.split("/")[2] if "://" in url else url diff --git a/videocaptioner/core/download/media.py b/videocaptioner/core/download/media.py new file mode 100644 index 00000000..447a8fdc --- /dev/null +++ b/videocaptioner/core/download/media.py @@ -0,0 +1,341 @@ +"""yt-dlp 在线视频下载引擎(无 Qt 依赖)。 + +GUI 下载线程(ui/thread/media_download_thread.py)与 CLI download 命令 +共用本引擎:站点专属回退(TED HLS、YouTube android 播放端)、浏览器 +登录态兜底、代理分流、B 站 buvid、同语言字幕 sidecar 一处实现。 + +进度/元数据通过回调上报;取消通过 ``cancel_check``(在数据块边界 +调用,由调用方抛异常中断,引擎不吞)。 +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Callable, Optional + +import requests +import yt_dlp + +from videocaptioner.core.download import net +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("media_download") + +_WINDOWS_RESERVED = { + "CON", "PRN", "AUX", "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), +} + +# 默认优先 mp4 直链;个别站点(TED)403 时退回 HLS 流 +_DEFAULT_FORMAT = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" +_HLS_FORMAT = "bestvideo[protocol^=m3u8]+bestaudio[protocol^=m3u8]/best[protocol^=m3u8]/best" + +ProgressCallback = Callable[[int, str], None] +StatsCallback = Callable[[str, str], None] +InfoCallback = Callable[[dict], None] +CancelCheck = Callable[[], None] + + +def sanitize_filename(name: str, replacement: str = "_") -> str: + """清理文件名:非法字符、控制符、结尾空格点、超长与 Windows 保留名。""" + sanitized = re.sub(r'[<>:"/\\|?*]', replacement, name) + sanitized = re.sub(r"[\x00-\x1f]", "", sanitized).rstrip(" .") + if len(sanitized) > 255: + base, ext = os.path.splitext(sanitized) + sanitized = base[: 255 - len(ext)] + ext + if os.path.splitext(sanitized)[0].upper() in _WINDOWS_RESERVED: + sanitized = f"{sanitized}_" + return sanitized or "media" + + +class MediaDownloader: + """把一个 http(s) 链接下载到 target_dir,连同可用的同语言字幕。 + + 字幕落盘为 ``{标题}.{语言}.{ext}`` sidecar(播放器可自动加载), + 路径随返回值显式传给调用方,下游不做按名扫描。 + """ + + def __init__( + self, + url: str, + target_dir: Optional[str] = None, + *, + probe_only: bool = False, + max_height: Optional[int] = None, + on_progress: Optional[ProgressCallback] = None, + on_stats: Optional[StatsCallback] = None, + on_media: Optional[InfoCallback] = None, + on_probed: Optional[InfoCallback] = None, + cancel_check: Optional[CancelCheck] = None, + ): + if not probe_only and not target_dir: + raise ValueError("target_dir is required unless probe_only") + self.url = url + self.target_dir = target_dir + self.probe_only = probe_only # 只解析信息,不下载 + self.max_height = max_height # 用户选择的清晰度上限 + self._on_progress = on_progress or (lambda _v, _m: None) + self._on_stats = on_stats or (lambda _s, _e: None) + self._on_media = on_media or (lambda _info: None) + self._on_probed = on_probed or (lambda _info: None) + self._cancel_check = cancel_check or (lambda: None) + + def run(self) -> tuple[Optional[str], Optional[str]]: + """执行下载(或 probe),返回 (视频路径, 字幕路径或 None)。 + + 匿名/cookies.txt 失败且像登录态问题时,自动按浏览器登录态阶梯 + 重试;全部失败抛带可行动提示的 RuntimeError。 + """ + result, _used_browser = net.run_with_browser_cookie_fallback( + self.url, + self._download_any, + on_attempt=lambda title: self._on_progress(0, f"读取 {title} 登录态重试"), + cancel_check=self._cancel_check, + ) + return result + + # ----- 站点专属回退 ----- + + def _download_any( + self, cookies_browser: Optional[str] = None + ) -> tuple[Optional[str], Optional[str]]: + """一次完整下载尝试,内含站点专属回退: + + - TED 403:改用 HLS 流 + - YouTube 403:DASH 流被节流(PO token 限制),改用 android + 播放端重试(清晰度可能降低,但能稳定下完) + """ + try: + return self._download(self._preferred_format(), cookies_browser) + except Exception as exc: + self._cancel_check() + message = str(exc) + if "ted.com" in self.url and "HTTP Error 403" in message: + logger.warning("TED mp4 直链失败,改用 HLS 流重试: %s", exc) + return self._download(_HLS_FORMAT, cookies_browser) + if "youtu" in self.url and "HTTP Error 403" in message: + logger.warning("YouTube 403 节流,改用 android 播放端重试") + self._on_progress(0, "切换播放端重试") + return self._download( + self._preferred_format(), + cookies_browser, + player_client=("android", "web_safari"), + ) + raise + + def _preferred_format(self) -> str: + """按用户选择的清晰度上限构造格式串;未选择时取最佳。""" + if not self.max_height: + return _DEFAULT_FORMAT + h = self.max_height + return ( + f"bestvideo[height<={h}][ext=mp4]+bestaudio[ext=m4a]/" + f"best[height<={h}][ext=mp4]/best[height<={h}]/best" + ) + + # ----- yt-dlp ----- + + def _progress_hook(self, data: dict): + self._cancel_check() # 协作取消:在数据块边界退出 + if data.get("status") != "downloading": + return + percent = net.strip_ansi(data.get("_percent_str", "0")).replace("%", "") + speed = net.strip_ansi(data.get("_speed_str", "")) or "--" + eta = net.strip_ansi(data.get("_eta_str", "")) or "--" + try: + value = int(float(percent)) + except ValueError: + value = 0 + self._on_progress(value, "正在下载媒体") + self._on_stats(speed, f"剩余 {eta}") + + def _postprocessor_hook(self, data: dict): + """下载字节完成后的后处理(B 站 DASH 分离流的 ffmpeg 合并等)。这段没有百分比,但耗时, + 必须给反馈——否则进度停在 100% 看起来像卡死。""" + if data.get("status") == "started": + self._on_progress(100, "正在合并音视频…") + self._on_stats("", "处理中") + + def _download( + self, + video_format: str, + cookies_browser: Optional[str] = None, + player_client: Optional[tuple[str, ...]] = None, + ) -> tuple[Optional[str], Optional[str]]: + logger.info("开始下载: %s", self.url) + options = { + # 字幕沿用默认模板:yt-dlp 自动产出 {标题}.{语言}.{ext} sidecar + "outtmpl": { + "default": "%(title).200s.%(ext)s", + }, + "format": video_format, + "progress_hooks": [self._progress_hook], + "postprocessor_hooks": [self._postprocessor_hook], + "quiet": True, + "no_warnings": True, + "noprogress": True, + "writesubtitles": True, + "writeautomaticsub": True, + # 多分 P / 合集链接只取当前一集,避免一次拖下整个列表 + "noplaylist": True, + # 解析阶段不至于无限挂起(站点不可达时尽快报错) + "socket_timeout": 30, + # 瞬时网络错误自动重试 + "retries": 5, + "fragment_retries": 5, + } + if player_client is not None: + options["extractor_args"] = {"youtube": {"player_client": list(player_client)}} + proxy = net.proxy_for_url(self.url) + if proxy: + logger.info("使用代理: %s", proxy) + options["proxy"] = proxy + elif proxy == "": + # 国内站点强制直连:全局代理(海外出口)反而更易触发风控 + options["proxy"] = "" + if cookies_browser is not None: + # 直接读取浏览器登录态(macOS 上 Chrome 系首次会弹钥匙串授权) + options["cookiesfrombrowser"] = (cookies_browser,) + elif net.cookies_file().exists(): + logger.info("使用 cookies: %s", net.cookies_file()) + options["cookiefile"] = str(net.cookies_file()) + + with yt_dlp.YoutubeDL(options) as ydl: + if net.is_bilibili_url(self.url): + # B 站对没有 buvid 设备指纹的匿名请求返回 412 风控 + net.inject_bilibili_buvid(ydl) + self._on_progress(0, "解析视频信息") + info = ydl.extract_info(self.url, download=False) + self._cancel_check() + if info.get("_type") == "playlist": + entries = [entry for entry in info.get("entries") or [] if entry] + if not entries: + raise RuntimeError("链接里没有可下载的视频。") + info = entries[0] + if info.get("is_live"): + raise RuntimeError("暂不支持下载直播,请等视频生成回放后再试。") + self._on_media(media_summary(info)) + if self.probe_only: + self._on_probed(probe_summary(info)) + return None, None + + target_dir = Path(self.target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + language = (info.get("language") or "").lower().split("-")[0] or None + if language: + ydl.params["subtitleslangs"] = [language] + ydl.params.update({"paths": {"home": str(target_dir)}}) + ydl.process_info(info) + self._cancel_check() + + video_path = Path(ydl.prepare_filename(info)) + subtitle_path = resolve_downloaded_subtitle(info, video_path, language, proxy) + logger.info("下载完成: %s (字幕: %s)", video_path, subtitle_path) + return ( + str(video_path) if video_path.exists() else None, + subtitle_path, + ) + + +def resolve_downloaded_subtitle( + info: dict, video_path: Path, language: Optional[str], proxy: Optional[str] = None +) -> Optional[str]: + """取与视频同语言的字幕 sidecar;语言不匹配时用字幕直链重下一次。 + + sidecar 是 yt-dlp 刚写出的 {视频名}.{语言}.{ext},按视频名前缀 + 匹配(iterdir + startswith,不用 glob:标题可能含通配元字符)。 + 只认可解析的字幕格式:B 站等站点会把弹幕(.danmaku.xml)当 + 字幕轨返回,混进流水线会解析失败。 + """ + from videocaptioner.core.entities import SupportedSubtitleFormats + + usable = {f".{fmt.value}" for fmt in SupportedSubtitleFormats} + downloaded = next( + ( + file + for file in sorted(video_path.parent.iterdir()) + if file != video_path + and file.name.startswith(video_path.stem) + and file.suffix.lower() in usable + ), + None, + ) + if downloaded is None: + return None + if not language or f".{language}" in downloaded.name.lower(): + return str(downloaded) + + link = None + for captions in (info.get("subtitles"), info.get("automatic_captions")): + if not captions: + continue + for code, tracks in captions.items(): + if code.startswith(language) and tracks: + link = tracks[-1].get("url") + break + if link: + break + downloaded.unlink(missing_ok=True) + if not link: + return None + # 与引擎同源的按站代理:YouTube 等需走代理,B 站强制直连(proxy=="")。 + try: + with requests.Session() as session: + if proxy: + session.proxies.update({"http": proxy, "https": proxy}) + elif proxy == "": + session.trust_env = False # 忽略环境代理,强制直连 + text = session.get(link, timeout=30).text + except requests.RequestException: + logger.warning("按语言重下字幕失败: %s", link) + return None + if not text: + return None + target = video_path.parent / f"{video_path.stem}.{language}.vtt" + target.write_text(text, encoding="utf-8") + return str(target) + + +def media_summary(info: dict) -> dict: + """从 yt-dlp info 提取站点通用的展示字段(缺失的不展示)。""" + summary = {"title": info.get("title") or ""} + if info.get("uploader"): + summary["uploader"] = info["uploader"] + duration = info.get("duration") + if duration: + minutes, seconds = divmod(int(duration), 60) + if minutes >= 60: + hours, minutes = divmod(minutes, 60) + summary["duration"] = f"{hours}:{minutes:02d}:{seconds:02d}" + else: + summary["duration"] = f"{minutes:02d}:{seconds:02d}" + site = info.get("extractor_key") or "" + if site and site.lower() != "generic": + summary["site"] = site + return summary + + +def probe_summary(info: dict) -> dict: + """probe 模式的解析结果:元数据 + 清晰度档位 + 字幕可用性。""" + summary = media_summary(info) + heights = sorted( + { + f["height"] + # 只排除明确的纯音频流(vcodec == "none");缺省 vcodec 的合流也保留—— + # 某些 extractor 在 progressive/muxed 格式上不写 vcodec,旧逻辑会误删导致清晰度为空 + for f in info.get("formats") or [] + if f.get("height") and f.get("vcodec") != "none" + }, + reverse=True, + ) + qualities = [h for h in heights if h >= 144] + # 无 formats 数组 / 无 height 的源(直链、部分 HLS):兜底用顶层分辨率给出单档,避免清晰度全空 + if not qualities and info.get("height"): + qualities = [int(info["height"])] + summary["qualities"] = qualities + summary["has_subtitle"] = bool(info.get("subtitles")) + return summary + diff --git a/videocaptioner/core/download/models.py b/videocaptioner/core/download/models.py new file mode 100644 index 00000000..b336fec5 --- /dev/null +++ b/videocaptioner/core/download/models.py @@ -0,0 +1,313 @@ +"""本地 ASR 模型清单与安装管理。 + +清单覆盖 whisper-cpp(单个 ggml .bin)与 faster-whisper(目录式多文件)。 +每个文件都带按序兜底的镜像链:HuggingFace 官方 → hf-mirror(国内可达的 +HF 反代)→ ModelScope,保证国内外网络都能下载成功。 + +模型保存约定与 core/asr 的查找逻辑保持一致: +- whisper-cpp: ``/ggml-.bin`` +- faster-whisper: ``/faster-whisper-/`` 目录 +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from pathlib import Path + +from videocaptioner.core.download.downloader import ( + CancelCheck, + DownloadProgress, + download_file, +) + +KIND_WHISPER_CPP = "whisper-cpp" +KIND_FASTER_WHISPER = "faster-whisper" + +_HF = "https://huggingface.co" +_HF_MIRROR = "https://hf-mirror.com" +_MODELSCOPE = "https://www.modelscope.cn/models" + + +@dataclass(frozen=True) +class ModelFile: + name: str + urls: tuple[str, ...] + sha1: str | None = None + size_bytes: int | None = None + + +@dataclass(frozen=True) +class ModelSpec: + kind: str + name: str + label: str + files: tuple[ModelFile, ...] + description: str = "" + + @property + def key(self) -> str: + return f"{self.kind}/{self.name}" + + @property + def display_name(self) -> str: + """用户视角的落盘名:单文件模型是文件名,目录模型是目录名。""" + if self.kind == KIND_FASTER_WHISPER: + return f"faster-whisper-{self.name}" + return self.files[0].name + + @property + def total_bytes(self) -> int: + return sum(file.size_bytes or 0 for file in self.files) + + @property + def size_text(self) -> str: + return _format_size(self.total_bytes) + + def target_dir(self, models_dir: Path) -> Path: + if self.kind == KIND_FASTER_WHISPER: + return Path(models_dir) / f"faster-whisper-{self.name}" + return Path(models_dir) + + +def _format_size(num_bytes: int) -> str: + if num_bytes >= 1_000_000_000: + return f"{num_bytes / 1_000_000_000:.1f} GB" + return f"{num_bytes / 1_000_000:.0f} MB" + + +@dataclass(frozen=True) +class ModelDownloadProgress: + """模型级下载进度。 + + file 是当前文件的单文件进度;total_received / total_bytes 是按清单 + 精确字节数聚合出的模型级总进度(含已完成文件)。 + """ + + file_index: int + file_count: int + file: DownloadProgress + total_received: int + total_bytes: int + + +def _ggml_urls(filename: str) -> tuple[str, ...]: + return ( + f"{_HF}/ggerganov/whisper.cpp/resolve/main/{filename}", + f"{_HF_MIRROR}/ggerganov/whisper.cpp/resolve/main/{filename}", + f"{_MODELSCOPE}/cjc1887415157/whisper.cpp/resolve/master/{filename}", + ) + + +# 模型用途一句话(弹窗/CLI 共用) +MODEL_DESCRIPTIONS = { + "tiny": "轻量测试和快速预览", + "base": "速度和准确率更均衡", + "small": "课程视频常用模型", + "medium": "更高准确率", + "large-v1": "上一代高准确率模型", + "large-v2": "高准确率离线转录", + "large-v3": "高准确率离线转录", + "large-v3-turbo": "提速版大模型,接近 large-v3", +} + + +def _whisper_cpp_spec(name: str, label: str, size_bytes: int, sha1: str) -> ModelSpec: + filename = f"ggml-{name}.bin" + return ModelSpec( + kind=KIND_WHISPER_CPP, + name=name, + label=label, + files=(ModelFile(filename, _ggml_urls(filename), sha1, size_bytes),), + description=MODEL_DESCRIPTIONS.get(name, ""), + ) + + +# 字节数与 SHA1 均来自官方仓库实测(HEAD Content-Length / GET,2026-06-11 抓取)。 +WHISPER_CPP_MODELS: tuple[ModelSpec, ...] = ( + _whisper_cpp_spec("tiny", "Tiny", 77_691_713, "bd577a113a864445d4c299885e0cb97d4ba92b5f"), + _whisper_cpp_spec("base", "Base", 147_951_465, "465707469ff3a37a2b9b8d8f89f2f99de7299dac"), + _whisper_cpp_spec("small", "Small", 487_601_967, "55356645c2b361a969dfd0ef2c5a50d530afd8d5"), + _whisper_cpp_spec("medium", "Medium", 1_533_763_059, "fd9727b6e1217c2f614f9b698455c4ffd82463b4"), + _whisper_cpp_spec("large-v1", "Large v1", 3_094_623_691, "b1caaf735c4cc1429223d5a74f0f4d0b9b59a299"), + _whisper_cpp_spec("large-v2", "Large v2", 3_094_623_691, "0f4c8e34f21cf1a914c59d8b3ce882345ad349d6"), +) + + +def _faster_whisper_urls(name: str, filename: str) -> tuple[str, ...]: + # Systran 没有发布 large-v3-turbo,社区 ct2 转换仓是事实标准 + hf_repo = ( + "deepdml/faster-whisper-large-v3-turbo-ct2" + if name == "large-v3-turbo" + else f"Systran/faster-whisper-{name}" + ) + return ( + f"{_HF}/{hf_repo}/resolve/main/{filename}", + f"{_HF_MIRROR}/{hf_repo}/resolve/main/{filename}", + f"{_MODELSCOPE}/pengzhendong/faster-whisper-{name}/resolve/master/{filename}", + ) + + +def _faster_whisper_spec( + name: str, label: str, file_sizes: dict[str, int] +) -> ModelSpec: + return ModelSpec( + kind=KIND_FASTER_WHISPER, + name=name, + label=label, + files=tuple( + ModelFile( + filename, + _faster_whisper_urls(name, filename), + size_bytes=size_bytes, + ) + for filename, size_bytes in file_sizes.items() + ), + description=MODEL_DESCRIPTIONS.get(name, ""), + ) + + +# 每个文件的字节数均来自官方仓库实测(HEAD Content-Length / GET,2026-06-11 抓取)。 +FASTER_WHISPER_MODELS: tuple[ModelSpec, ...] = ( + _faster_whisper_spec("tiny", "Tiny", { + "config.json": 2_249, + "model.bin": 75_538_270, + "tokenizer.json": 2_203_239, + "vocabulary.txt": 459_861, + }), + _faster_whisper_spec("base", "Base", { + "config.json": 2_309, + "model.bin": 145_217_532, + "tokenizer.json": 2_203_239, + "vocabulary.txt": 459_861, + }), + _faster_whisper_spec("small", "Small", { + "config.json": 2_370, + "model.bin": 483_546_902, + "tokenizer.json": 2_203_239, + "vocabulary.txt": 459_861, + }), + _faster_whisper_spec("medium", "Medium", { + "config.json": 2_257, + "model.bin": 1_527_906_378, + "tokenizer.json": 2_203_239, + "vocabulary.txt": 459_861, + }), + _faster_whisper_spec("large-v1", "Large v1", { + "config.json": 2_352, + "model.bin": 3_086_912_962, + "tokenizer.json": 2_203_239, + "vocabulary.txt": 459_861, + }), + _faster_whisper_spec("large-v2", "Large v2", { + "config.json": 2_796, + "model.bin": 3_086_912_962, + "tokenizer.json": 2_203_239, + "vocabulary.txt": 459_861, + }), + _faster_whisper_spec("large-v3", "Large v3", { + "config.json": 2_394, + "model.bin": 3_087_284_237, + "tokenizer.json": 2_480_617, + "vocabulary.json": 1_068_114, + "preprocessor_config.json": 340, + }), + _faster_whisper_spec("large-v3-turbo", "Large v3 Turbo", { + "config.json": 2_263, + "model.bin": 1_617_884_929, + "tokenizer.json": 2_710_337, + "vocabulary.json": 1_068_114, + "preprocessor_config.json": 340, + }), +) + + +def iter_models(kind: str | None = None) -> Iterator[ModelSpec]: + for spec in (*WHISPER_CPP_MODELS, *FASTER_WHISPER_MODELS): + if kind is None or spec.kind == kind: + yield spec + + +def find_model(kind: str, name: str) -> ModelSpec | None: + for spec in iter_models(kind): + if spec.name == name: + return spec + return None + + +def model_install_state(spec: ModelSpec, models_dir: Path) -> bool: + """模型文件是否已就位(与 core/asr 的查找口径一致)。""" + target = spec.target_dir(models_dir) + if spec.kind == KIND_WHISPER_CPP: + # 与 whisper_cpp.py 的 glob 口径一致(兼容手动放入的同名变体) + return any(Path(models_dir).glob(f"*ggml*{spec.name}*.bin")) + return all((target / file.name).exists() for file in spec.files) + + +def has_partial_download(spec: ModelSpec, models_dir: Path) -> bool: + """是否存在中断的下载(.part 文件,下次下载会续传)。""" + target = spec.target_dir(models_dir) + return any( + (target / f"{file.name}.part").exists() + for file in spec.files + if not (target / file.name).exists() + ) + + +def remove_model(spec: ModelSpec, models_dir: Path) -> None: + """删除模型的全部本地文件(含 .part 残留;faster-whisper 删整个目录)。""" + target = spec.target_dir(models_dir) + if spec.kind == KIND_FASTER_WHISPER: + import shutil + + if target.exists(): + shutil.rmtree(target) + return + for file in spec.files: + (target / file.name).unlink(missing_ok=True) + (target / f"{file.name}.part").unlink(missing_ok=True) + # 同名变体(手动放入的 ggml-*tiny*.bin)也一并清掉,保证状态判定翻转 + for matched in Path(models_dir).glob(f"*ggml*{spec.name}*.bin"): + matched.unlink(missing_ok=True) + + +def download_model( + spec: ModelSpec, + models_dir: Path, + *, + on_progress: Callable[[ModelDownloadProgress], None] | None = None, + should_cancel: CancelCheck | None = None, +) -> Path: + """下载模型的全部文件(已存在的跳过),返回模型所在目录。""" + target = spec.target_dir(models_dir) + count = len(spec.files) + done_bytes = 0 + for index, file in enumerate(spec.files, start=1): + dest = target / file.name + if dest.exists(): + done_bytes += file.size_bytes or 0 + continue + + def report( + progress: DownloadProgress, _index: int = index, _base: int = done_bytes + ) -> None: + if on_progress is not None: + on_progress( + ModelDownloadProgress( + _index, + count, + progress, + _base + progress.received, + spec.total_bytes, + ) + ) + + download_file( + file.urls, + dest, + sha1=file.sha1, + on_progress=report if on_progress is not None else None, + should_cancel=should_cancel, + ) + done_bytes += file.size_bytes or 0 + return target diff --git a/videocaptioner/core/download/net.py b/videocaptioner/core/download/net.py new file mode 100644 index 00000000..433d63e3 --- /dev/null +++ b/videocaptioner/core/download/net.py @@ -0,0 +1,348 @@ +"""在线视频下载的网络环境:代理、cookies、浏览器登录态、B 站风控、错误翻译。 + +下载引擎(core/download/media.py)与诊断的下载源检查(source_check.py) +共用这里的逻辑,保证"诊断说通 = 真的能下、诊断说不通 = 兜底也试过了"。 +""" + +from __future__ import annotations + +import json +import os +import platform +import re +import urllib.request +from http.cookiejar import Cookie +from pathlib import Path +from typing import Callable, Optional, TypeVar + +from videocaptioner.config import APPDATA_PATH +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("download_net") + +T = TypeVar("T") + +_BROWSER_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) + + +def cookies_file() -> Path: + return APPDATA_PATH / "cookies.txt" + + +def system_proxy() -> Optional[str]: + """探测可用代理:环境变量优先,其次操作系统代理设置。 + + GUI 双击启动的进程继承不到 shell 的 HTTP_PROXY,而 yt-dlp 默认 + 只读环境变量——结果浏览器能开 YouTube、应用内却直连超时。 + """ + for key in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"): + value = os.environ.get(key) + if value: + return value + try: + if platform.system() == "Darwin": + import _scproxy + + proxies = _scproxy._get_proxies() + else: + proxies = urllib.request.getproxies() + except Exception: + return None + return proxies.get("https") or proxies.get("http") + + +def fetch_bilibili_buvid(timeout: int = 8) -> dict[str, str]: + """从 B 站 spi 接口领一份匿名设备指纹 cookie(buvid3/buvid4)。 + + B 站对没有 buvid 的请求按风控返回 412 Precondition Failed + (yt-dlp#9119),匿名解析/下载前注入即可通过。失败返回空 dict, + 调用方按"没有缓解手段"继续。 + """ + request = urllib.request.Request( + "https://api.bilibili.com/x/frontend/finger/spi", + headers={"User-Agent": _BROWSER_UA, "Referer": "https://www.bilibili.com/"}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.load(response) + data = payload.get("data") or {} + cookies = {} + if data.get("b_3"): + cookies["buvid3"] = data["b_3"] + if data.get("b_4"): + cookies["buvid4"] = data["b_4"] + return cookies + except Exception as exc: + logger.warning("获取 B 站匿名 buvid 失败: %s", exc) + return {} + + +def inject_bilibili_buvid(ydl) -> None: + """给 YoutubeDL 实例补 B 站匿名 buvid cookie(已有则不动)。""" + jar = getattr(ydl, "cookiejar", None) + if jar is None: + return + existing = {cookie.name for cookie in jar if "bilibili.com" in (cookie.domain or "")} + if "buvid3" in existing: + return + for name, value in fetch_bilibili_buvid().items(): + if name in existing: + continue + jar.set_cookie( + Cookie( + 0, name, value, None, False, + ".bilibili.com", True, True, "/", True, + False, None, False, None, None, {}, + ) + ) + + +def is_bilibili_url(url: str) -> bool: + return "bilibili.com" in url or "b23.tv" in url + + +def proxy_for_url(url: str) -> Optional[str]: + """按站点分流代理:B 站等国内站点直连(与浏览器分流一致)。 + + 全局代理通常是海外出口,B 站对海外 IP 风控更严;返回空串让 + yt-dlp 强制直连,返回 None 表示不设置代理键。 + """ + if is_bilibili_url(url): + return "" + return system_proxy() + + +# 错误翻译表:按序匹配(特征关键词集, 简短提示)。提示统一为 +# "一句原因 + 一句建议",技术细节走日志,不在界面堆砌路径。 +_ERROR_RULES = [ + (("sign in to confirm", "not a bot"), "YouTube 判定当前网络异常,要求登录验证。"), + (("is only available for registered users",), "该视频需要登录后才能观看。"), + (("http error 412",), "站点拒绝了请求(需要有效登录态)。"), + (("premium", "membership", "大会员", "充电专属"), "该视频为会员或付费内容,当前账号没有观看权限。"), + (("not available in your", "geo restriction", "geo-restricted"), "该视频在当前地区不可用。"), + (("unsupported url",), "暂不支持该网站的链接。"), + (("http error 404", "does not exist", "video unavailable"), "链接指向的内容不存在,请检查链接。"), + (("http error 403",), "站点拒绝了下载请求,请稍后重试。"), + (("unable to download webpage", "getaddrinfo", "timed out", "connection"), "网络连接失败,请检查网络后重试。"), + (("ffmpeg is not installed", "ffmpeg not found", "postprocessing"), "合并音视频需要 FFmpeg,请先安装。"), + (("no space left",), "磁盘空间不足,请清理后重试。"), +] + + +def strip_ansi(text: str) -> str: + """去掉 yt-dlp 报错里的终端颜色码(漏进 UI 会显示成 [0;31m 乱码)。""" + return re.sub(r"\x1b\[[0-9;]*m", "", str(text)).strip() + + +def friendly_download_error(url: str, message: str) -> str: + """把 yt-dlp 的报错翻译成简短、可行动的提示。""" + message = strip_ansi(message) + lowered = message.lower() + if "ted.com" in url and "http error 403" in lowered: + return "TED 拒绝了请求,请稍后重试或换一个公开链接。" + if is_bilibili_url(url) and "http error 412" in lowered: + return "哔哩哔哩风控拦截了请求(412),请稍等几分钟再试。" + for hints, friendly in _ERROR_RULES: + if any(hint in lowered for hint in hints): + return friendly + first_line = message.splitlines()[0] if message else "下载失败" + first_line = re.sub(r"^ERROR:\s*", "", first_line) + return first_line[:120] or "下载失败" + + +# --------------------------------------------------------------------------- +# 浏览器登录态兜底阶梯:下载与诊断共用同一条失败回退链路 +# --------------------------------------------------------------------------- + +# 需要登录态的失败特征:命中后自动改用浏览器 cookies 重试 +_COOKIE_ERROR_HINTS = ( + "http error 412", + "sign in", + "log in", + "login", + "cookie", + "private video", + "members only", + "需要登录", +) + +# 浏览器 cookies 回退顺序与本地安装探测路径(macOS / Windows / Linux) +_BROWSER_PROFILES = { + "chrome": ( + "~/Library/Application Support/Google/Chrome", + "~/AppData/Local/Google/Chrome", + "~/.config/google-chrome", + ), + "edge": ( + "~/Library/Application Support/Microsoft Edge", + "~/AppData/Local/Microsoft/Edge", + "~/.config/microsoft-edge", + ), + "brave": ( + "~/Library/Application Support/BraveSoftware/Brave-Browser", + "~/AppData/Local/BraveSoftware/Brave-Browser", + "~/.config/BraveSoftware/Brave-Browser", + ), + "firefox": ( + "~/Library/Application Support/Firefox/Profiles", + "~/AppData/Roaming/Mozilla/Firefox/Profiles", + "~/.mozilla/firefox", + ), + "safari": ( + "~/Library/Cookies/Cookies.binarycookies", + "~/Library/Containers/com.apple.Safari", + ), +} +_BROWSER_TITLES = { + "chrome": "Chrome", + "edge": "Edge", + "brave": "Brave", + "firefox": "Firefox", + "safari": "Safari", +} + +# 会话内记忆:上次成功提供登录态的浏览器,回退时排到第一位, +# 避免重复下载时每次都把全部浏览器试一遍。 +_last_good_browser: Optional[str] = None + + +def detect_cookie_browsers() -> list[str]: + """探测本机已安装、可读取登录态的浏览器(按回退顺序)。""" + found = [] + for browser, candidates in _BROWSER_PROFILES.items(): + if any(Path(path).expanduser().exists() for path in candidates): + found.append(browser) + if _last_good_browser in found: + found.remove(_last_good_browser) + found.insert(0, _last_good_browser) + return found + + +def looks_like_cookie_error(message: str) -> bool: + lowered = message.lower() + return any(hint in lowered for hint in _COOKIE_ERROR_HINTS) + + +# 浏览器 cookie 文件本身读不出来的特征(macOS TCC 隐私保护、数据库被锁等)。 +# 这类失败是本机环境问题,不是"登录态被网站拒绝",提示要分开。 +_COOKIE_READ_FAILURE_HINTS = ( + "operation not permitted", + "permission denied", + "could not copy", + "could not find", + "failed to decrypt", + "database is locked", +) + + +def _is_cookie_read_failure(message: str) -> bool: + lowered = message.lower() + return any(hint in lowered for hint in _COOKIE_READ_FAILURE_HINTS) + + +def _describe_unreadable(unreadable: list[tuple[str, str]]) -> str: + """按真实原因解释「浏览器登录态读不出来」,给平台对症的说法。 + + Windows 上 Chrome/Edge 是两类硬伤:浏览器开着 → cookie 库被锁; + Chromium 127+ 的 App-Bound Encryption → yt-dlp 无法解密(上游限制)。 + macOS 则是 TCC 隐私保护,放行「完全磁盘访问权限」即可。 + """ + locked: list[str] = [] + encrypted: list[str] = [] + other: list[str] = [] + for title, message in unreadable: + lowered = message.lower() + if "could not copy" in lowered or "database is locked" in lowered: + locked.append(title) + elif "failed to decrypt" in lowered or "dpapi" in lowered: + encrypted.append(title) + else: + other.append(title) + parts = [] + if locked: + parts.append(f"{'、'.join(locked)} 正在运行、登录态被锁定") + if encrypted: + parts.append(f"{'、'.join(encrypted)} 新版加密不支持读取") + if other: + if platform.system() == "Darwin": + parts.append( + f"{'、'.join(other)} 被系统隐私保护拦截" + "(系统设置 → 隐私与安全性 → 完全磁盘访问权限 中放行)" + ) + else: + parts.append(f"{'、'.join(other)} 登录态无法读取") + return ";".join(parts) + + +def run_with_browser_cookie_fallback( + url: str, + attempt: Callable[[Optional[str]], T], + *, + on_attempt: Optional[Callable[[str], None]] = None, + cancel_check: Optional[Callable[[], None]] = None, +) -> tuple[T, Optional[str]]: + """先匿名(或 cookies.txt)执行 attempt,登录态错误时依次换浏览器登录态。 + + 成功返回 ``(结果, 使用的浏览器名或 None)``;全部失败抛带可行动提示的 + RuntimeError。``attempt(cookies_browser)`` 收到 None 表示匿名/cookies.txt, + 收到浏览器 key 时应改用 ``cookiesfrombrowser``。 + + cookies.txt 仍然是首选;但它失效(过期/导出不全被站点拒绝)时不能 + 就此卡死,浏览器里的活登录态是更可靠的兜底。 + """ + try: + return attempt(None), None + except Exception as original: + if cancel_check: + cancel_check() # 取消引发的中断不当错误处理 + # 提示主体始终用最初的站点报错:兜底尝试自身的失败(比如读不了 + # Safari cookie 的本机权限错误)不能反客为主变成标题。 + original_message = strip_ansi(str(original)) + browsers = detect_cookie_browsers() + if not looks_like_cookie_error(original_message) or not browsers: + friendly = friendly_download_error(url, original_message) + if looks_like_cookie_error(original_message): + friendly += " 请在浏览器登录该网站后导出 cookies.txt 到应用数据目录再试。" + raise RuntimeError(friendly) from original + + global _last_good_browser + had_cookies_file = cookies_file().exists() + rejected: list[str] = [] + unreadable: list[tuple[str, str]] = [] + for browser in browsers: + title = _BROWSER_TITLES[browser] + logger.info("需登录态,尝试 %s 浏览器 cookies: %s", title, url) + if on_attempt: + on_attempt(title) + try: + result = attempt(browser) + _last_good_browser = browser + return result, title + except Exception as exc: + if cancel_check: + cancel_check() + message = strip_ansi(str(exc)) + logger.warning("%s 登录态重试失败: %s", title, message) + if _is_cookie_read_failure(message): + unreadable.append((title, message)) + else: + rejected.append(title) + + # 短句结论 + 一条最可靠的行动;细节在日志里 + parts = [friendly_download_error(url, original_message)] + if rejected: + parts.append(f"已用 {'、'.join(rejected)} 登录态重试仍被拒绝。") + if unreadable: + parts.append(_describe_unreadable(unreadable) + "。") + if had_cookies_file: + parts.append("cookies.txt 已失效,请删除后重新导出。") + actions = [] + if "firefox" not in browsers: + actions.append("安装 Firefox 并登录后重试") + actions.append("导出 cookies.txt 到应用数据目录") + parts.append("解决:" + ",或".join(actions) + "。") + logger.warning("浏览器登录态全部失败,cookies.txt=%s", cookies_file()) + raise RuntimeError(" ".join(parts)) from original diff --git a/videocaptioner/core/download/programs.py b/videocaptioner/core/download/programs.py new file mode 100644 index 00000000..6e867400 --- /dev/null +++ b/videocaptioner/core/download/programs.py @@ -0,0 +1,193 @@ +"""本地 ASR 运行程序的检测与安装方案。 + +模型文件之外,whisper-cpp / faster-whisper 还各需要一个可执行程序: + +- whisper-cpp:macOS 走 Homebrew;Windows 用官方预编译包。 +- faster-whisper:独立程序(Purfview standalone)只有 Windows 版, + CPU 版单文件可直接下载到 ``BIN_PATH``(启动时已注入 PATH)。 + +检测口径与 ``core/asr`` 保持一致:先查 PATH,再查应用 bin 目录。 +""" + +from __future__ import annotations + +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path + +from videocaptioner.core.download.models import KIND_FASTER_WHISPER, KIND_WHISPER_CPP, ModelFile + +WHISPER_CPP_EXECUTABLES = ("whisper-cli", "whisper-cpp", "whisper", "whisper-cpp-main") +FASTER_WHISPER_EXECUTABLES = ( + "faster-whisper-xxl", + "faster-whisper", + "whisper-faster", + "faster_whisper", +) + +_EXECUTABLES = { + KIND_WHISPER_CPP: WHISPER_CPP_EXECUTABLES, + KIND_FASTER_WHISPER: FASTER_WHISPER_EXECUTABLES, +} + +WHISPER_CPP_RELEASES_URL = "https://github.com/ggerganov/whisper.cpp/releases" +FASTER_WHISPER_XXL_7Z_URL = ( + "https://modelscope.cn/models/bkfengg/whisper-cpp/resolve/master/" + "Faster-Whisper-XXL_r245.2_windows.7z" +) + +# Windows CPU 版单文件程序(88.4 MB,2026-06-11 实测 Content-Range) +_FASTER_WHISPER_CPU_EXE = ModelFile( + name="whisper-faster.exe", + urls=( + "https://modelscope.cn/models/bkfengg/whisper-cpp/resolve/master/whisper-faster.exe", + ), + size_bytes=88_436_526, +) + + +@dataclass(frozen=True) +class ProgramStatus: + installed: bool + name: str | None = None + path: str | None = None + + +@dataclass(frozen=True) +class ProgramVariant: + """运行程序的一个可安装形态(弹窗"运行程序"区一行)。 + + 操作优先级:download(可直接下载)> command(可复制命令)> link(打开页面)。 + """ + + key: str + title: str + description_missing: str + description_ready: str + executables: tuple[str, ...] + command: str | None = None + download: ModelFile | None = None + link: str | None = None + + def detect(self, extra_dirs: tuple[Path, ...] | None = None) -> ProgramStatus: + return _detect_executables(self.executables, extra_dirs) + + +@dataclass(frozen=True) +class ProgramInstallPlan: + """未安装时的引导方案:summary 必有,其余按平台可选。""" + + summary: str + command: str | None = None # 可复制到终端执行的安装命令 + download: ModelFile | None = None # 可直接下载的单文件程序(落到 bin 目录) + link: str | None = None # 手动下载页面 + supported: bool = True # 当前平台是否支持该引擎 + + +def detect_program(kind: str, extra_dirs: tuple[Path, ...] | None = None) -> ProgramStatus: + """检测运行程序:PATH + 应用 bin 目录(含 Faster-Whisper-XXL 子目录)。""" + return _detect_executables(_EXECUTABLES.get(kind, ()), extra_dirs) + + +def _detect_executables( + names: tuple[str, ...], extra_dirs: tuple[Path, ...] | None = None +) -> ProgramStatus: + dirs = extra_dirs if extra_dirs is not None else _default_bin_dirs() + for name in names: + path = shutil.which(name) + if path: + return ProgramStatus(True, name, path) + for directory in dirs: + for candidate in (directory / name, directory / f"{name}.exe"): + if candidate.exists(): + return ProgramStatus(True, name, str(candidate)) + return ProgramStatus(False) + + +def _default_bin_dirs() -> tuple[Path, ...]: + from videocaptioner.config import BIN_PATH, FASTER_WHISPER_PATH + + return (Path(BIN_PATH), Path(FASTER_WHISPER_PATH)) + + +def program_variants(kind: str, platform: str | None = None) -> tuple[ProgramVariant, ...]: + """弹窗"运行程序"区展示的安装形态(按推荐顺序)。""" + plat = platform or sys.platform + if kind == KIND_WHISPER_CPP: + if plat == "darwin": + return ( + ProgramVariant( + key="default", + title="Whisper CPP 程序", + description_missing="未找到本地运行程序", + description_ready="可执行文件已找到", + executables=WHISPER_CPP_EXECUTABLES, + command="brew install whisper-cpp", + link=WHISPER_CPP_RELEASES_URL, + ), + ) + return ( + ProgramVariant( + key="default", + title="Whisper CPP 程序", + description_missing="未找到本地运行程序,可从官方页面下载", + description_ready="可执行文件已找到", + executables=WHISPER_CPP_EXECUTABLES, + link=WHISPER_CPP_RELEASES_URL, + ), + ) + if kind == KIND_FASTER_WHISPER: + if not plat.startswith("win"): + return () + return ( + ProgramVariant( + key="cpu", + title="CPU 版", + description_missing="没有独立显卡也能用,安装最简单", + description_ready="可执行文件已找到", + executables=("whisper-faster",), + download=_FASTER_WHISPER_CPU_EXE, + ), + ProgramVariant( + key="gpu", + title="GPU 版", + description_missing="有 NVIDIA 显卡时选择,长视频更快", + description_ready="已检测到,可用于长视频转录", + executables=("faster-whisper-xxl", "faster-whisper", "faster_whisper"), + link=FASTER_WHISPER_XXL_7Z_URL, + ), + ) + raise ValueError(f"unknown program kind: {kind}") + + +def program_install_plan(kind: str, platform: str | None = None) -> ProgramInstallPlan: + plat = platform or sys.platform + if kind == KIND_WHISPER_CPP: + if plat == "darwin": + return ProgramInstallPlan( + summary="用 Homebrew 安装 whisper.cpp,完成后点重新检测。", + command="brew install whisper-cpp", + link=WHISPER_CPP_RELEASES_URL, + ) + if plat.startswith("win"): + return ProgramInstallPlan( + summary="下载官方预编译包,解压到任意 PATH 目录或应用 bin 目录。", + link=WHISPER_CPP_RELEASES_URL, + ) + return ProgramInstallPlan( + summary="用系统包管理器安装 whisper.cpp(或自行编译),完成后点重新检测。", + link=WHISPER_CPP_RELEASES_URL, + ) + if kind == KIND_FASTER_WHISPER: + if plat.startswith("win"): + return ProgramInstallPlan( + summary="可直接下载 CPU 版程序;需要 GPU 加速请手动下载 XXL 完整包。", + download=_FASTER_WHISPER_CPU_EXE, + link=FASTER_WHISPER_XXL_7Z_URL, + ) + return ProgramInstallPlan( + summary="Faster Whisper 独立程序仅支持 Windows,当前系统请改用 WhisperCpp。", + supported=False, + ) + raise ValueError(f"unknown program kind: {kind}") diff --git a/videocaptioner/core/download/source_check.py b/videocaptioner/core/download/source_check.py new file mode 100644 index 00000000..af47a32e --- /dev/null +++ b/videocaptioner/core/download/source_check.py @@ -0,0 +1,108 @@ +"""视频下载源连通性检查:用 yt-dlp 真实解析稳定公开视频。 + +诊断页"视频下载"项与 ``videocaptioner doctor --check-api`` 共用。 +失败回退链路(cookies.txt → 浏览器登录态)与真实下载完全一致: +只有连浏览器登录态都被拒绝,才报"不可用"。 +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Optional + +from videocaptioner.core.download import net + + +@dataclass(frozen=True) +class DownloadSource: + key: str + title: str + # 长期稳定的公开视频:YouTube 用站史第一条视频,B 站用官方 MV + probe_url: str + fix_hint: str + + +DOWNLOAD_SOURCES: tuple[DownloadSource, ...] = ( + DownloadSource( + key="youtube", + title="YouTube", + probe_url="https://www.youtube.com/watch?v=jNQXAC9IVRw", + fix_hint="访问 YouTube 通常需要代理:开启系统代理或设置 HTTPS_PROXY 后重试。", + ), + DownloadSource( + key="bilibili", + title="哔哩哔哩", + probe_url="https://www.bilibili.com/video/BV1GJ411x7h7", + fix_hint="检查网络后重试;若提示风控(412),先在浏览器中登录哔哩哔哩,或导出 cookies.txt 到应用数据目录。", + ), +) + + +@dataclass(frozen=True) +class SourceCheckResult: + key: str + title: str + success: bool + detail: str # 成功=解析出的视频标题(可能附登录态说明);失败=简短可行动的原因 + + +def _probe_title(url: str, timeout: int, cookies_browser: Optional[str]) -> str: + """一次轻量解析尝试:retries=0 快速给结论,cookie 选项与真实下载同源。""" + import yt_dlp + + options = { + "quiet": True, + "no_warnings": True, + "skip_download": True, + "noplaylist": True, + "socket_timeout": timeout, + # 连通性检查每次尝试只试一回,失败交给回退阶梯 + "retries": 0, + "extractor_retries": 0, + } + proxy = net.proxy_for_url(url) + if proxy is not None: + options["proxy"] = proxy + if cookies_browser is not None: + options["cookiesfrombrowser"] = (cookies_browser,) + elif net.cookies_file().exists(): + options["cookiefile"] = str(net.cookies_file()) + + with yt_dlp.YoutubeDL(options) as ydl: + if net.is_bilibili_url(url): + net.inject_bilibili_buvid(ydl) + info = ydl.extract_info(url, download=False, process=False) + title = str(info.get("title") or "").strip() + if not title: + raise RuntimeError("解析成功但没有取到视频信息") + return title + + +def check_download_source( + source: DownloadSource, timeout: int = 15 +) -> SourceCheckResult: + """真实解析一条稳定公开链接,验证该站点当前可用。 + + 匿名被风控时按真实下载的回退阶梯试浏览器登录态;兜底能解析就算 + 可用(只是提示走了登录态),全部失败才是真不可用。 + """ + try: + title, used_browser = net.run_with_browser_cookie_fallback( + source.probe_url, + lambda browser: _probe_title(source.probe_url, timeout, browser), + ) + except Exception as exc: # noqa: BLE001 —— 阶梯已收敛为可行动提示 + return SourceCheckResult(source.key, source.title, False, str(exc)) + detail = title if not used_browser else f"{title}(已通过 {used_browser} 登录态验证)" + return SourceCheckResult(source.key, source.title, True, detail) + + +def check_download_sources(timeout: int = 15) -> list[SourceCheckResult]: + """并行检查全部下载源(总耗时约等于最慢的一个)。""" + with ThreadPoolExecutor(max_workers=len(DOWNLOAD_SOURCES)) as pool: + futures = [ + pool.submit(check_download_source, source, timeout) + for source in DOWNLOAD_SOURCES + ] + return [future.result() for future in futures] diff --git a/videocaptioner/core/dubbing/__init__.py b/videocaptioner/core/dubbing/__init__.py index 70065d55..53a30b26 100644 --- a/videocaptioner/core/dubbing/__init__.py +++ b/videocaptioner/core/dubbing/__init__.py @@ -1,10 +1,12 @@ """Subtitle dubbing pipeline.""" +from .config_builder import build_dubbing_config, validate_provider_capabilities from .models import DubbingConfig, DubbingResult, DubbingSegment, SpeakerProfile from .pipeline import DubbingPipeline from .presets import available_dubbing_presets, get_dubbing_preset __all__ = [ + "build_dubbing_config", "DubbingConfig", "DubbingPipeline", "DubbingResult", @@ -12,4 +14,5 @@ "SpeakerProfile", "available_dubbing_presets", "get_dubbing_preset", + "validate_provider_capabilities", ] diff --git a/videocaptioner/core/dubbing/audio.py b/videocaptioner/core/dubbing/audio.py index 536c84de..fb3ca89b 100644 --- a/videocaptioner/core/dubbing/audio.py +++ b/videocaptioner/core/dubbing/audio.py @@ -1,11 +1,12 @@ """Audio helpers for dubbing timeline assembly.""" -import json import subprocess from pathlib import Path from pydub import AudioSegment +from videocaptioner.core.utils.media_info import probe_media + def get_audio_duration_ms(path: str) -> int: audio = AudioSegment.from_file(path) @@ -60,63 +61,43 @@ def mux_dubbed_audio( original_audio_volume: float = 0.25, dubbed_audio_volume: float = 1.0, ) -> None: - """Replace or mix a video's audio track with dubbed audio.""" + """Replace or mix a media file's audio track with dubbed audio. + + 源带视频流(mp4/mkv…)→ 复制视频 + 替换/混音音频,输出视频容器(aac)。 + 源为纯音频(mp3/m4a…)→ 没有视频流可 map/copy,强行 ``-map 0:v:0`` 会让 ffmpeg + 以 exit 234 报 "Stream map '0:v:0' matches no streams";此时只输出配音后的音频, + 编码器交给 ffmpeg 按输出扩展名自动选择。 + """ Path(output_path).parent.mkdir(parents=True, exist_ok=True) - if mix_original_audio and _video_has_audio(video_path): - filter_complex = ( + info = probe_media(video_path) # 一次探测同时拿视频/音频流有无 + has_video = info is not None and info.has_video + mix = mix_original_audio and info is not None and info.has_audio + + cmd = ["ffmpeg", "-y", "-v", "error", "-i", video_path, "-i", audio_path] + if mix: + cmd += [ + "-filter_complex", f"[0:a]volume={original_audio_volume}[a0];" f"[1:a]volume={dubbed_audio_volume}[a1];" - "[a0][a1]amix=inputs=2:duration=longest:dropout_transition=0[a]" - ) - cmd = [ - "ffmpeg", - "-y", - "-v", - "error", - "-i", - video_path, - "-i", - audio_path, - "-filter_complex", - filter_complex, - "-map", - "0:v:0", - "-map", - "[a]", - "-c:v", - "copy", - "-c:a", - "aac", - "-strict", - "-2", - "-movflags", - "+faststart", - output_path, + "[a0][a1]amix=inputs=2:duration=longest:dropout_transition=0[a]", ] + audio_map = "[a]" else: - cmd = [ - "ffmpeg", - "-y", - "-v", - "error", - "-i", - video_path, - "-i", - audio_path, - "-map", - "0:v:0", - "-map", - "1:a:0", - "-c:v", - "copy", - "-c:a", - "aac", - "-strict", - "-2", - "-movflags", - "+faststart", - output_path, + audio_map = "1:a:0" + + if has_video: + cmd += [ + "-map", "0:v:0", + "-map", audio_map, + "-c:v", "copy", + "-c:a", "aac", + "-strict", "-2", + "-movflags", "+faststart", ] + else: + # 纯音频源:无视频流,只输出配音音频;不指定 -c:a,让 ffmpeg 按扩展名挑编码器。 + cmd += ["-map", audio_map] + cmd.append(output_path) subprocess.run(cmd, check=True) @@ -141,19 +122,13 @@ def _linear_to_db(volume: float) -> float: return 20 * math.log10(volume) +def _video_has_video_stream(video_path: str) -> bool: + """探测失败时按无处理。""" + info = probe_media(video_path) + return info is not None and info.has_video + + def _video_has_audio(video_path: str) -> bool: - cmd = [ - "ffprobe", - "-v", - "error", - "-select_streams", - "a", - "-show_entries", - "stream=index", - "-of", - "json", - video_path, - ] - result = subprocess.run(cmd, check=True, capture_output=True, text=True) - data = json.loads(result.stdout or "{}") - return bool(data.get("streams")) + """探测失败时按无处理。""" + info = probe_media(video_path) + return info is not None and info.has_audio diff --git a/videocaptioner/core/dubbing/config_builder.py b/videocaptioner/core/dubbing/config_builder.py new file mode 100644 index 00000000..670f1e74 --- /dev/null +++ b/videocaptioner/core/dubbing/config_builder.py @@ -0,0 +1,215 @@ +"""Shared configuration helpers for subtitle dubbing.""" + +from __future__ import annotations + +from typing import Optional + +from .models import DubbingConfig, DubbingProvider, FitMode, SpeakerProfile +from .presets import get_dubbing_preset, normalize_dubbing_voice, validate_dubbing_voice + + +def build_dubbing_config( + *, + provider: str = "edge", + preset: str = "", + api_key: str = "", + api_base: str = "", + model: str = "", + voice: str = "", + response_format: str = "mp3", + sample_rate: int = 32000, + speed: float = 1.0, + gain: float = 0, + use_cache: bool = True, + tts_workers: int = 5, + style_prompt: str = "", + timing: str = "balanced", + audio_mode: str = "replace", + fit_mode: Optional[str] = None, + max_speed: float = 2.0, + stretch_to_fit: bool = True, + min_speed: float = 0.5, + target_padding_ms: int = 80, + rewrite_too_long: bool = False, + rewrite_threshold: float = 1.15, + llm_api_key: str = "", + llm_api_base: str = "", + llm_model: str = "", + mix_original_audio: bool = False, + original_audio_volume: float = 0.25, + dubbed_audio_volume: float = 1.0, + speaker_profiles: Optional[dict[str, SpeakerProfile]] = None, +) -> DubbingConfig: + """Build a validated provider-neutral dubbing config. + + This is intentionally shared by CLI and desktop UI so provider presets, + aliases, timing policies, and capability errors behave the same way. + """ + resolved = resolve_dubbing_settings( + provider=provider, + preset=preset, + api_base=api_base, + model=model, + voice=voice, + style_prompt=style_prompt, + ) + resolved_provider = resolve_provider(resolved["provider"]) + resolved["voice"] = normalize_dubbing_voice( + resolved_provider, + resolved["model"], + resolved["voice"], + ) + + profiles = speaker_profiles or {} + for profile in profiles.values(): + if profile.voice: + profile.voice = normalize_dubbing_voice( + resolved_provider, + resolved["model"], + profile.voice, + ) + + resolved_fit_mode, resolved_max_speed = resolve_timing( + timing=timing, + explicit_fit=fit_mode, + explicit_max_speed=max_speed, + ) + resolved_mix, resolved_original_volume = resolve_audio_mix( + audio_mode=audio_mode, + explicit_mix=mix_original_audio, + explicit_volume=original_audio_volume, + ) + + config = DubbingConfig( + provider=resolved_provider, + api_key=api_key.strip(), + base_url=resolved["api_base"], + model=resolved["model"], + voice=resolved["voice"], + response_format=response_format, # type: ignore[arg-type] + sample_rate=sample_rate, + speed=speed, + gain=gain, + use_cache=use_cache, + tts_workers=tts_workers, + style_prompt=resolved["style_prompt"], + fit_mode=resolved_fit_mode, + max_speed=resolved_max_speed, + stretch_to_fit=stretch_to_fit, + min_speed=min_speed, + target_padding_ms=target_padding_ms, + rewrite_too_long=rewrite_too_long, + rewrite_threshold=rewrite_threshold, + llm_api_key=llm_api_key, + llm_api_base=llm_api_base, + llm_model=llm_model, + mix_original_audio=resolved_mix, + original_audio_volume=resolved_original_volume, + dubbed_audio_volume=dubbed_audio_volume, + speaker_profiles=profiles, + ) + error = validate_provider_capabilities(config) + if error: + raise ValueError(error) + return config + + +def resolve_dubbing_settings( + *, + provider: str, + preset: str, + api_base: str, + model: str, + voice: str, + style_prompt: str, +) -> dict[str, str]: + resolved = { + "provider": provider or "edge", + "api_base": api_base or "", + "model": model or "", + "voice": voice or "", + "style_prompt": style_prompt or "", + } + if not preset: + return resolved + + selected = get_dubbing_preset(preset) + default = get_dubbing_preset("edge-cn-female") + defaults = { + "provider": default.provider, + "api_base": default.api_base, + "model": default.model, + "voice": default.voice, + "style_prompt": "", + } + preset_values = { + "provider": selected.provider, + "api_base": selected.api_base, + "model": selected.model, + "voice": selected.voice, + "style_prompt": selected.style_prompt, + } + for key, value in preset_values.items(): + if not resolved[key] or resolved[key] == defaults[key]: + resolved[key] = value + return resolved + + +def resolve_provider(value: str) -> DubbingProvider: + if value == "siliconflow": + return "siliconflow" + if value == "gemini": + return "gemini" + if value == "edge": + return "edge" + raise ValueError(f"Unsupported dubbing provider: {value}") + + +def resolve_timing( + *, + timing: str, + explicit_fit: Optional[str], + explicit_max_speed: float, +) -> tuple[FitMode, float]: + if timing == "none": + return "none", explicit_max_speed + fit: FitMode = "tempo" if explicit_fit not in {"tempo", "none"} else explicit_fit # type: ignore[assignment] + if timing == "natural": + return fit, min(explicit_max_speed, 1.25) + if timing == "strict": + return fit, max(explicit_max_speed, 2.0) + if timing != "balanced": + raise ValueError(f"Invalid dubbing timing: {timing}") + return fit, explicit_max_speed + + +def resolve_audio_mix( + *, + audio_mode: str, + explicit_mix: bool, + explicit_volume: float, +) -> tuple[bool, float]: + if audio_mode == "replace": + return explicit_mix, explicit_volume + if audio_mode == "mix": + return True, explicit_volume + if audio_mode == "duck": + return True, min(explicit_volume, 0.12) + raise ValueError(f"Invalid dubbing audio mode: {audio_mode}") + + +def validate_provider_capabilities(config: DubbingConfig) -> str | None: + has_clone = any(p.clone_audio_path for p in config.speaker_profiles.values()) + if config.provider == "gemini" and has_clone: + return "Gemini TTS does not support voice cloning. Use SiliconFlow for voice cloning." + if config.provider == "edge" and has_clone: + return "Edge TTS does not support voice cloning. Use SiliconFlow for voice cloning." + voice_error = validate_dubbing_voice(config.provider, config.voice) + if voice_error: + return voice_error + for name, profile in config.speaker_profiles.items(): + if profile.voice: + voice_error = validate_dubbing_voice(config.provider, profile.voice) + if voice_error: + return f"Speaker {name}: {voice_error}" + return None diff --git a/videocaptioner/core/dubbing/models.py b/videocaptioner/core/dubbing/models.py index 07342eea..ae65dd43 100644 --- a/videocaptioner/core/dubbing/models.py +++ b/videocaptioner/core/dubbing/models.py @@ -18,6 +18,13 @@ class SpeakerProfile: clone_audio_text: Optional[str] = None style_prompt: Optional[str] = None + def __post_init__(self): + self.name = self.name.strip() + self.voice = self.voice.strip() if self.voice else self.voice + self.clone_audio_path = self.clone_audio_path.strip() if self.clone_audio_path else self.clone_audio_path + self.clone_audio_text = self.clone_audio_text.strip() if self.clone_audio_text else self.clone_audio_text + self.style_prompt = self.style_prompt.strip() if self.style_prompt else self.style_prompt + @dataclass class DubbingSegment: @@ -69,6 +76,9 @@ class DubbingConfig: style_prompt: str = "" fit_mode: FitMode = "tempo" max_speed: float = 1.35 + # 默认把偏短的配音放慢拉伸到原时长,避免比原声短造成时间轴漂移/突兀静音。 + stretch_to_fit: bool = True + min_speed: float = 0.5 # 放慢下限:过慢会发闷,低于此值就不再继续拉伸 target_padding_ms: int = 80 rewrite_too_long: bool = False rewrite_threshold: float = 1.15 @@ -79,6 +89,17 @@ class DubbingConfig: original_audio_volume: float = 0.25 dubbed_audio_volume: float = 1.0 + def __post_init__(self): + self.api_key = self.api_key.strip() + self.base_url = self.base_url.strip() + self.model = self.model.strip() + self.voice = self.voice.strip() + self.response_format = self.response_format.strip() # type: ignore[attr-defined] + self.style_prompt = self.style_prompt.strip() + self.llm_api_key = self.llm_api_key.strip() + self.llm_api_base = self.llm_api_base.strip() + self.llm_model = self.llm_model.strip() + @dataclass class DubbingResult: @@ -89,3 +110,5 @@ class DubbingResult: segments: list[DubbingSegment] duration_ms: int warnings: list[str] = field(default_factory=list) + # 分段报告(report.json),随工作目录生灭 + report_path: Optional[Path] = None diff --git a/videocaptioner/core/dubbing/pipeline.py b/videocaptioner/core/dubbing/pipeline.py index 5dd342a6..4f7d06b9 100644 --- a/videocaptioner/core/dubbing/pipeline.py +++ b/videocaptioner/core/dubbing/pipeline.py @@ -1,4 +1,12 @@ -"""End-to-end subtitle dubbing pipeline.""" +"""End-to-end subtitle dubbing pipeline. + +文件落盘分两类: + +- 原始 TTS 分段是跨任务缓存:按(文本 + 全部影响发音的配置)内容寻址, + 存全局 CACHE_PATH/tts_segments,配置一变 hash 即变,不会复用到旧音频。 +- 变速拟合分段与 report.json 是本次任务的中间产物:进调用方传入的 + work_dir(任务目录),随任务目录一起清理。 +""" import hashlib import json @@ -6,6 +14,8 @@ from pathlib import Path from typing import Callable, Literal, Optional +from videocaptioner.config import CACHE_PATH +from videocaptioner.core.application import output_paths from videocaptioner.core.speech import ( SpeechProviderConfig, SynthesisRequest, @@ -19,6 +29,11 @@ ProgressCallback = Callable[[int, str], None] +# Edge 是免费 TTS,并发不暴露给用户、程序内部固定,避免免费接口被高并发限流。 +EDGE_TTS_WORKERS = 5 + +TTS_SEGMENT_CACHE_DIR = CACHE_PATH / "tts_segments" + class DubbingPipeline: """Create a dubbed audio track, optionally muxed into a video.""" @@ -45,15 +60,16 @@ def run( subtitle_path: str, output_audio_path: str, *, + work_dir: str, video_path: Optional[str] = None, output_video_path: Optional[str] = None, text_track: str = "auto", - work_dir: Optional[str] = None, callback: Optional[ProgressCallback] = None, ) -> DubbingResult: cb = callback or (lambda _progress, _message: None) out_audio = Path(output_audio_path) - work = Path(work_dir) if work_dir else out_audio.parent / f"{out_audio.stem}_parts" + out_audio.parent.mkdir(parents=True, exist_ok=True) + work = Path(work_dir) work.mkdir(parents=True, exist_ok=True) cb(2, "loading subtitles") @@ -68,7 +84,10 @@ def run( warnings: list[str] = [] timeline_items: list[tuple[str, int]] = [] total = len(segments) - workers = max(1, min(self.config.tts_workers, total)) + configured_workers = ( + EDGE_TTS_WORKERS if self.config.provider == "edge" else self.config.tts_workers + ) + workers = max(1, min(configured_workers, total)) completed = 0 with ThreadPoolExecutor(max_workers=workers) as executor: future_to_pos = { @@ -107,8 +126,9 @@ def run( out_video: Optional[Path] = None if video_path: if not output_video_path: - base = Path(video_path) - output_video_path = str(base.with_stem(base.stem + "_dubbed")) + output_video_path = str( + output_paths.product_path(video_path, output_paths.TAG_DUBBED) + ) cb(94, "muxing video") mux_dubbed_audio( video_path, @@ -120,7 +140,8 @@ def run( ) out_video = Path(output_video_path) - self._write_report(out_audio.with_suffix(".dubbing.json"), segments, warnings) + report_path = work / output_paths.DUBBING_REPORT_FILE + self._write_report(report_path, segments, warnings) cb(100, "completed") return DubbingResult( audio_path=out_audio, @@ -128,6 +149,7 @@ def run( segments=segments, duration_ms=duration_ms, warnings=warnings, + report_path=report_path, ) def _apply_speakers(self, segments: list[DubbingSegment]) -> None: @@ -157,18 +179,37 @@ def _fit_segment(self, segment: DubbingSegment, work_dir: Path) -> str: if self.config.fit_mode == "none" or not segment.target_duration_ms: return source target_ms = max(100, segment.target_duration_ms - self.config.target_padding_ms) - if segment.synthesized_duration_ms <= target_ms: - segment.speed_factor = 1.0 - return source - required = segment.synthesized_duration_ms / target_ms - factor = min(required, self.config.max_speed) + synth_ms = segment.synthesized_duration_ms + if synth_ms <= target_ms: + # 短于目标:默认放慢拉伸到原时长(factor<1 → atempo 拉长),下限 min_speed。 + if not self.config.stretch_to_fit: + segment.speed_factor = 1.0 + return source + factor = max(synth_ms / target_ms, self.config.min_speed) + if factor >= 0.999: # 已基本贴合,无需处理 + segment.speed_factor = 1.0 + return source + segment.speed_factor = factor + out_path = work_dir / f"{segment.index:04d}_fit.wav" + change_tempo(source, str(out_path), factor) + return str(out_path) + # 长于目标:加速压缩,上限 max_speed。 + factor = min(synth_ms / target_ms, self.config.max_speed) segment.speed_factor = factor - out_path = work_dir / f"{segment.index:04d}_{self._segment_hash(segment)}_fit.wav" + out_path = work_dir / f"{segment.index:04d}_fit.wav" change_tempo(source, str(out_path), factor) return str(out_path) + def _raw_segment_path(self, segment: DubbingSegment, work: Path) -> Path: + """原始 TTS 分段路径:开缓存走全局内容寻址,关缓存进任务目录。""" + ext = self._provider_extension() + if self.config.use_cache: + TTS_SEGMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True) + return TTS_SEGMENT_CACHE_DIR / f"{self._segment_hash(segment)}.{ext}" + return work / f"{segment.index:04d}_raw.{ext}" + def _process_segment(self, segment: DubbingSegment, work: Path) -> DubbingSegment: - raw_path = work / f"{segment.index:04d}_{self._segment_hash(segment)}_raw.{self._provider_extension()}" + raw_path = self._raw_segment_path(segment, work) reusable_raw = self.config.use_cache and self._valid_audio_path(raw_path) if reusable_raw: segment.synthesized_path = str(raw_path) @@ -235,8 +276,12 @@ def _provider_response_format( return "mp3" return config.response_format - @staticmethod - def _segment_hash(segment: DubbingSegment) -> str: + def _segment_hash(self, segment: DubbingSegment) -> str: + """缓存键:必须覆盖所有影响发音的输入。 + + 漏掉任何一项(曾漏过 speed/gain)都会让用户改完配置仍命中 + 旧音频;新增会影响合成结果的配置字段时这里必须同步。 + """ raw = "|".join( [ segment.text_for_tts, @@ -244,9 +289,15 @@ def _segment_hash(segment: DubbingSegment) -> str: segment.style_prompt or "", segment.clone_audio_path or "", segment.clone_audio_text or "", + str(self.config.provider), + self.config.model, + str(self.config.speed), + str(self.config.gain), + str(self.config.sample_rate), + self.config.response_format, ] ) - return hashlib.md5(raw.encode()).hexdigest()[:10] + return hashlib.md5(raw.encode()).hexdigest()[:16] @staticmethod def _valid_audio_path(path: Path) -> bool: diff --git a/videocaptioner/core/dubbing/presets.py b/videocaptioner/core/dubbing/presets.py index f25dc718..897c8994 100644 --- a/videocaptioner/core/dubbing/presets.py +++ b/videocaptioner/core/dubbing/presets.py @@ -7,11 +7,20 @@ SILICONFLOW_VOICE_ALIASES = { "anna": f"{SILICONFLOW_COSYVOICE2_MODEL}:anna", "alex": f"{SILICONFLOW_COSYVOICE2_MODEL}:alex", + "bella": f"{SILICONFLOW_COSYVOICE2_MODEL}:bella", "benjamin": f"{SILICONFLOW_COSYVOICE2_MODEL}:benjamin", + "charles": f"{SILICONFLOW_COSYVOICE2_MODEL}:charles", + "claire": f"{SILICONFLOW_COSYVOICE2_MODEL}:claire", + "david": f"{SILICONFLOW_COSYVOICE2_MODEL}:david", + "diana": f"{SILICONFLOW_COSYVOICE2_MODEL}:diana", } GEMINI_VOICES = { + "Achernar", "Achird", + "Algenib", + "Algieba", + "Alnilam", "Aoede", "Autonoe", "Callirrhoe", @@ -44,10 +53,25 @@ "xiaoyi": "zh-CN-XiaoyiNeural", "yunjian": "zh-CN-YunjianNeural", "yunxi": "zh-CN-YunxiNeural", + "yunxia": "zh-CN-YunxiaNeural", "yunyang": "zh-CN-YunyangNeural", + "hiugaai": "zh-HK-HiuGaaiNeural", + "hiumaan": "zh-HK-HiuMaanNeural", + "wanlung": "zh-HK-WanLungNeural", + "hsiaochen": "zh-TW-HsiaoChenNeural", + "hsiaoyu": "zh-TW-HsiaoYuNeural", + "yunjhe": "zh-TW-YunJheNeural", + "ava": "en-US-AvaNeural", + "andrew": "en-US-AndrewNeural", + "emma": "en-US-EmmaNeural", + "brian": "en-US-BrianNeural", "jenny": "en-US-JennyNeural", "guy": "en-US-GuyNeural", "aria": "en-US-AriaNeural", + "libby": "en-GB-LibbyNeural", + "ryan": "en-GB-RyanNeural", + "sonia": "en-GB-SoniaNeural", + "thomas": "en-GB-ThomasNeural", } @@ -140,6 +164,97 @@ class DubbingPreset: ), } +for _preset_name, _alias, _prompt in [ + ("siliconflow-cn-bella", "bella", "请用热情、清晰、适合视频配音的中文语气朗读。"), + ("siliconflow-cn-charles", "charles", "请用磁性、清晰、适合视频配音的中文语气朗读。"), + ("siliconflow-cn-claire", "claire", "请用温柔、清晰、适合视频配音的中文语气朗读。"), + ("siliconflow-cn-david", "david", "请用欢快、清晰、适合视频配音的中文语气朗读。"), + ("siliconflow-cn-diana", "diana", "请用欢快、清晰、适合视频配音的中文语气朗读。"), +]: + PRESETS[_preset_name] = DubbingPreset( + name=_preset_name, + provider="siliconflow", + api_base="https://api.siliconflow.cn/v1", + model=SILICONFLOW_COSYVOICE2_MODEL, + voice=SILICONFLOW_VOICE_ALIASES[_alias], + style_prompt=_prompt, + ) + +for _preset_name, _alias, _prompt in [ + ("edge-cn-xiaoyi", "xiaoyi", ""), + ("edge-cn-yunjian", "yunjian", ""), + ("edge-cn-yunyang", "yunyang", ""), + ("edge-cn-yunxia", "yunxia", ""), + ("edge-hk-hiugaai", "hiugaai", ""), + ("edge-hk-hiumaan", "hiumaan", ""), + ("edge-hk-wanlung", "wanlung", ""), + ("edge-tw-hsiaochen", "hsiaochen", ""), + ("edge-tw-hsiaoyu", "hsiaoyu", ""), + ("edge-tw-yunjhe", "yunjhe", ""), + ("edge-en-ava", "ava", ""), + ("edge-en-andrew", "andrew", ""), + ("edge-en-emma", "emma", ""), + ("edge-en-brian", "brian", ""), + ("edge-en-aria", "aria", ""), + ("edge-en-libby", "libby", ""), + ("edge-en-ryan", "ryan", ""), + ("edge-en-sonia", "sonia", ""), + ("edge-en-thomas", "thomas", ""), +]: + PRESETS[_preset_name] = DubbingPreset( + name=_preset_name, + provider="edge", + api_base="", + model="edge-tts", + voice=EDGE_VOICE_ALIASES[_alias], + style_prompt=_prompt, + ) + +for _voice, _style in [ + ("Zephyr", "bright"), + ("Puck", "upbeat"), + ("Charon", "informative"), + ("Kore", "firm"), + ("Fenrir", "excitable"), + ("Leda", "youthful"), + ("Orus", "firm"), + ("Aoede", "breezy"), + ("Callirrhoe", "easy-going"), + ("Autonoe", "bright"), + ("Enceladus", "breathy"), + ("Iapetus", "clear"), + ("Umbriel", "easy-going"), + ("Algieba", "smooth"), + ("Despina", "smooth"), + ("Erinome", "clear"), + ("Algenib", "gravelly"), + ("Rasalgethi", "informative"), + ("Laomedeia", "upbeat"), + ("Achernar", "soft"), + ("Alnilam", "firm"), + ("Schedar", "even"), + ("Gacrux", "mature"), + ("Pulcherrima", "forward"), + ("Achird", "friendly"), + ("Zubenelgenubi", "casual"), + ("Vindemiatrix", "gentle"), + ("Sadachbia", "lively"), + ("Sadaltager", "knowledgeable"), + ("Sulafat", "warm"), +]: + _preset_name = f"gemini-{_voice.lower()}" + PRESETS.setdefault( + _preset_name, + DubbingPreset( + name=_preset_name, + provider="gemini", + api_base="https://generativelanguage.googleapis.com/v1beta", + model="gemini-3.1-flash-tts-preview", + voice=_voice, + style_prompt=f"Read in a {_style}, natural voice for a video dubbing track.", + ), + ) + def get_dubbing_preset(name: str) -> DubbingPreset: try: diff --git a/videocaptioner/core/dubbing/rewriter.py b/videocaptioner/core/dubbing/rewriter.py index 32b2891e..f257a50d 100644 --- a/videocaptioner/core/dubbing/rewriter.py +++ b/videocaptioner/core/dubbing/rewriter.py @@ -65,7 +65,6 @@ def rewrite_segments_if_needed(segments: Iterable[DubbingSegment], config: Dubbi response = client.chat.completions.create( model=config.llm_model, messages=messages, # type: ignore[arg-type] - temperature=0.2, response_format={"type": "json_object"}, ) content = response.choices[0].message.content or "{}" diff --git a/videocaptioner/core/entities.py b/videocaptioner/core/entities.py index 7d2ea52a..cba87fa8 100644 --- a/videocaptioner/core/entities.py +++ b/videocaptioner/core/entities.py @@ -111,6 +111,7 @@ class LLMServiceEnum(Enum): LM_STUDIO = "LM Studio" GEMINI = "Gemini" CHATGLM = "ChatGLM" + IMMERSIVE = "公益大模型" class TranscribeModelEnum(Enum): @@ -118,6 +119,7 @@ class TranscribeModelEnum(Enum): BIJIAN = "B 接口" JIANYING = "J 接口" + BAILIAN_FUN_ASR = "百炼 Fun-ASR" WHISPER_API = "Whisper [API] ✨" FASTER_WHISPER = "FasterWhisper ✨" WHISPER_CPP = "WhisperCpp" @@ -465,58 +467,32 @@ class FasterWhisperModelEnum(Enum): } -@dataclass -class ASRLanguageCapability: - """ASR语言支持能力""" - - supported_languages: list[TranscribeLanguageEnum] - supports_auto: bool - - -def _get_all_languages_except_auto() -> list[TranscribeLanguageEnum]: - """获取除 AUTO 外的All语言""" - return [lang for lang in TranscribeLanguageEnum if lang != TranscribeLanguageEnum.AUTO] - - -ASR_LANGUAGE_CAPABILITIES: dict[TranscribeModelEnum, ASRLanguageCapability] = { - TranscribeModelEnum.BIJIAN: ASRLanguageCapability( - supported_languages=[ - TranscribeLanguageEnum.CHINESE, - TranscribeLanguageEnum.ENGLISH, - ], - supports_auto=True, - ), - TranscribeModelEnum.JIANYING: ASRLanguageCapability( - supported_languages=[ - TranscribeLanguageEnum.CHINESE, - TranscribeLanguageEnum.ENGLISH, - ], - supports_auto=True, +# 各转录接口支持的源语言(唯一真源,CLI 与 GUI 共用)。 +# B 接口(必剪)/ J 接口(剪映)是国内剪辑工具的云端 ASR,只识别中文与英文, +# 且实际由服务端自动判别、忽略显式语言;其余接口(Whisper / Fun-ASR)支持多语种。 +# 不在表内的接口视为「不限制」,可选全部 TranscribeLanguageEnum。 +TRANSCRIBE_MODEL_LANGUAGES: dict[ + TranscribeModelEnum, tuple[TranscribeLanguageEnum, ...] +] = { + TranscribeModelEnum.BIJIAN: ( + TranscribeLanguageEnum.AUTO, + TranscribeLanguageEnum.CHINESE, + TranscribeLanguageEnum.ENGLISH, ), - TranscribeModelEnum.FASTER_WHISPER: ASRLanguageCapability( - supported_languages=_get_all_languages_except_auto(), - supports_auto=False, - ), - TranscribeModelEnum.WHISPER_CPP: ASRLanguageCapability( - supported_languages=_get_all_languages_except_auto(), - supports_auto=True, - ), - TranscribeModelEnum.WHISPER_API: ASRLanguageCapability( - supported_languages=_get_all_languages_except_auto(), - supports_auto=True, + TranscribeModelEnum.JIANYING: ( + TranscribeLanguageEnum.AUTO, + TranscribeLanguageEnum.CHINESE, + TranscribeLanguageEnum.ENGLISH, ), } -def get_asr_language_capability(model: TranscribeModelEnum) -> ASRLanguageCapability: - """获取指定模型的语言能力""" - return ASR_LANGUAGE_CAPABILITIES.get( - model, - ASRLanguageCapability( - supported_languages=_get_all_languages_except_auto(), - supports_auto=True, - ), - ) +def transcribe_languages_for( + model: TranscribeModelEnum, +) -> list[TranscribeLanguageEnum]: + """该转录接口可选的源语言;未在限制表中的接口返回全部语言。""" + restricted = TRANSCRIBE_MODEL_LANGUAGES.get(model) + return list(restricted) if restricted is not None else list(TranscribeLanguageEnum) @dataclass @@ -562,6 +538,10 @@ class TranscribeConfig: whisper_api_base: Optional[str] = None whisper_api_model: Optional[str] = None whisper_api_prompt: Optional[str] = None + # 百炼 Fun-ASR 配置 + fun_asr_api_key: Optional[str] = None + fun_asr_api_base: Optional[str] = None + fun_asr_model: Optional[str] = None # Faster Whisper 配置 faster_whisper_program: Optional[str] = None faster_whisper_model: Optional[FasterWhisperModelEnum] = None @@ -599,6 +579,11 @@ def print_config(self) -> str: if self.whisper_api_prompt: lines.append(f"Prompt: {self.whisper_api_prompt[:30]}...") + elif self.transcribe_model == TranscribeModelEnum.BAILIAN_FUN_ASR: + lines.append(f"API Base: {self.fun_asr_api_base}") + lines.append(f"API Key: {self._mask_key(self.fun_asr_api_key)}") + lines.append(f"API Model: {self.fun_asr_model}") + elif self.transcribe_model == TranscribeModelEnum.FASTER_WHISPER: lines.append( f"Model: {self.faster_whisper_model.value if self.faster_whisper_model else 'None'}" @@ -701,6 +686,7 @@ class SynthesisConfig: subtitle_layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ORIGINAL_ON_TOP # 字幕样式配置 ass_style: str = "" # ASS 样式字符串 + ass_line_gap: int = 0 # ASS 主副字幕间距(双语时作用于上行的对话 MarginV) rounded_style: Optional[dict] = None # 圆角背景样式配置 def print_config(self) -> str: @@ -718,6 +704,58 @@ def print_config(self) -> str: return "\n".join(lines) +@dataclass +class DubbingUIConfig: + """桌面端配音配置。 + + 这里保留用户意图,不暴露 provider 的低层参数;线程层再转换为 + core.dubbing.DubbingConfig。 + """ + + enabled: bool = False + preset: str = "edge-cn-female" + provider: str = "edge" + api_key: str = "" + api_base: str = "" + model: str = "edge-tts" + voice: str = "zh-CN-XiaoxiaoNeural" + text_track: str = "auto" + timing: str = "balanced" + audio_mode: str = "replace" + tts_workers: int = 5 + use_cache: bool = True + speaker_voices: dict[str, str] = field(default_factory=dict) + clone_audio_path: str = "" + clone_audio_text: str = "" + + def __post_init__(self): + self.preset = self.preset.strip() + self.provider = self.provider.strip() + self.api_key = self.api_key.strip() + self.api_base = self.api_base.strip() + self.model = self.model.strip() + self.voice = self.voice.strip() + self.text_track = self.text_track.strip() + self.timing = self.timing.strip() + self.audio_mode = self.audio_mode.strip() + self.clone_audio_path = self.clone_audio_path.strip() + self.clone_audio_text = self.clone_audio_text.strip() + + def print_config(self) -> str: + lines = ["=========== Dubbing Task ==========="] + lines.append(f"Enabled: {self.enabled}") + if self.enabled: + lines.append(f"Preset: {self.preset}") + lines.append(f"Provider: {self.provider}") + lines.append(f"Voice: {self.voice}") + lines.append(f"Text Track: {self.text_track}") + lines.append(f"Timing: {self.timing}") + lines.append(f"Audio Mode: {self.audio_mode}") + lines.append(f"Speakers: {len(self.speaker_voices)}") + lines.append("=" * 38) + return "\n".join(lines) + + @dataclass class TranscribeTask: """转录任务类""" @@ -741,6 +779,9 @@ class TranscribeTask: # 选中的音轨索引 selected_audio_track_index: int = 0 + # 流水线共享的任务工作目录(中间产物落盘处,成功后由流程所有者清理) + task_dir: Optional[str] = None + transcribe_config: Optional[TranscribeConfig] = None @@ -766,6 +807,9 @@ class SubtitleTask: # 是否需要执行下一个任务(视频合成) need_next_task: bool = True + # 流水线共享的任务工作目录(布局副本等中间产物落盘处) + task_dir: Optional[str] = None + subtitle_config: Optional[SubtitleConfig] = None @@ -790,70 +834,28 @@ class SynthesisTask: # 是否需要执行下一个任务(预留) need_next_task: bool = False - synthesis_config: Optional[SynthesisConfig] = None - - -@dataclass -class TranscriptAndSubtitleTask: - """转录和字幕任务类""" - - # 任务标识 - task_id: str = field(default_factory=_generate_task_id) - - queued_at: Optional[datetime.datetime] = None - started_at: Optional[datetime.datetime] = None - completed_at: Optional[datetime.datetime] = None - - # 输入 - file_path: Optional[str] = None - - # 输出 - output_path: Optional[str] = None + # 流水线共享的任务工作目录(链尾阶段负责按 keep_intermediates 清理) + task_dir: Optional[str] = None - transcribe_config: Optional[TranscribeConfig] = None - subtitle_config: Optional[SubtitleConfig] = None + synthesis_config: Optional[SynthesisConfig] = None @dataclass -class FullProcessTask: - """完整处理任务类(转录+字幕+合成)""" +class DubbingTask: + """视频/音频配音任务类""" - # 任务标识 task_id: str = field(default_factory=_generate_task_id) queued_at: Optional[datetime.datetime] = None started_at: Optional[datetime.datetime] = None completed_at: Optional[datetime.datetime] = None - # 输入 - file_path: Optional[str] = None - # 输出 - output_path: Optional[str] = None - - transcribe_config: Optional[TranscribeConfig] = None - subtitle_config: Optional[SubtitleConfig] = None - synthesis_config: Optional[SynthesisConfig] = None - - -class BatchTaskType(Enum): - """批量处理任务类型""" - - TRANSCRIBE = "批量转录" - SUBTITLE = "批量字幕" - TRANS_SUB = "转录+字幕" - FULL_PROCESS = "全流程处理" - - def __str__(self): - return self.value - - -class BatchTaskStatus(Enum): - """批量处理任务状态""" + video_path: Optional[str] = None + subtitle_path: Optional[str] = None + output_audio_path: Optional[str] = None + output_video_path: Optional[str] = None - WAITING = "等待中" - RUNNING = "处理中" - COMPLETED = "已完成" - FAILED = "失败" + # 配音中间产物(分段/报告)所在的任务工作目录 + task_dir: Optional[str] = None - def __str__(self): - return self.value + dubbing_config: Optional[DubbingUIConfig] = None diff --git a/videocaptioner/core/feedback/__init__.py b/videocaptioner/core/feedback/__init__.py new file mode 100644 index 00000000..dee59085 --- /dev/null +++ b/videocaptioner/core/feedback/__init__.py @@ -0,0 +1,42 @@ +"""用户反馈:组装报告 → multipart 提交后端(写入飞书多维表格)。无 PyQt 依赖。""" + +from videocaptioner.core.feedback.client import ( + FeedbackClient, + FeedbackResult, +) +from videocaptioner.core.feedback.diagnostics import ( + gather_diagnostics, + get_or_create_client_id, + platform_tag, +) +from videocaptioner.core.feedback.logs import collect_recent_logs, scrub_log_text +from videocaptioner.core.feedback.models import ( + ALLOWED_MIME, + CATEGORIES, + MAX_FILE_BYTES, + MAX_FILES, + MAX_TOTAL_BYTES, + MESSAGE_MAX, + FeedbackAttachment, + FeedbackReport, + FeedbackValidationError, +) + +__all__ = [ + "FeedbackClient", + "FeedbackResult", + "FeedbackReport", + "FeedbackAttachment", + "FeedbackValidationError", + "gather_diagnostics", + "get_or_create_client_id", + "platform_tag", + "collect_recent_logs", + "scrub_log_text", + "CATEGORIES", + "ALLOWED_MIME", + "MAX_FILES", + "MAX_FILE_BYTES", + "MAX_TOTAL_BYTES", + "MESSAGE_MAX", +] diff --git a/videocaptioner/core/feedback/client.py b/videocaptioner/core/feedback/client.py new file mode 100644 index 00000000..e7fb987b --- /dev/null +++ b/videocaptioner/core/feedback/client.py @@ -0,0 +1,94 @@ +"""提交反馈到后端(multipart/form-data)。无 PyQt。 + +代理走 net.system_proxy(GUI 进程拿不到 shell 的 HTTP_PROXY);端点写死在 +config.FEEDBACK_API_URL。本期后端无幂等,request_id 仅供排查。 +""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from typing import Optional + +import requests + +from videocaptioner.config import APP_NAME, FEEDBACK_API_URL, VERSION +from videocaptioner.core.download.net import system_proxy +from videocaptioner.core.feedback.diagnostics import get_or_create_client_id, platform_tag +from videocaptioner.core.feedback.models import FeedbackReport +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("feedback_client") + + +@dataclass(frozen=True) +class FeedbackResult: + ok: bool + id: str = "" + code: str = "" + error: str = "" + + +class FeedbackClient: + def __init__(self, endpoint: str = "", *, session: Optional[requests.Session] = None, timeout: int = 30): + self._endpoint = endpoint or FEEDBACK_API_URL + self._session = session + self._timeout = timeout + + def submit(self, report: FeedbackReport) -> FeedbackResult: + """校验 → multipart POST → 解析 {ok,id} / {ok,code,error}。网络/服务端失败返回 ok=False。""" + report.validate() # 本地预校验,失败抛 FeedbackValidationError(调用方先处理) + http = self._session or requests.Session() + plat = platform_tag() + headers = { + "X-App-Version": VERSION, + "X-App-Platform": plat, + "User-Agent": f"{APP_NAME}/{VERSION} ({plat})", + } + fields = { + "category": report.category, + "message": report.message.strip(), + "client_id": get_or_create_client_id(), + "request_id": str(uuid.uuid4()), + } + if report.contact.strip(): + fields["contact"] = report.contact.strip() + if report.diagnostics: + fields["diagnostics"] = json.dumps(report.diagnostics, ensure_ascii=False) + # 后端强制 multipart/form-data。文本字段也作为 multipart part(filename=None)发送, + # 否则无截图时 requests 会退化成 application/x-www-form-urlencoded 被后端拒绝。 + parts = [(key, (None, value)) for key, value in fields.items()] + parts += [("files", (att.filename, att.data, att.mime)) for att in report.attachments] + parts += [("logs", (att.filename, att.data, att.mime)) for att in report.logs] + + proxy = system_proxy() + proxies = {"http": proxy, "https": proxy} if proxy else None + try: + resp = http.post( + self._endpoint, + headers=headers, + files=parts, + proxies=proxies, + timeout=self._timeout, + ) + except requests.RequestException as exc: + logger.warning("feedback submit failed: %s", exc) + return FeedbackResult(ok=False, code="network", error=str(exc)) + return _parse_response(resp) + + +def _parse_response(resp: requests.Response) -> FeedbackResult: + try: + body = resp.json() + except ValueError: + body = {} + if resp.status_code == 200 and isinstance(body, dict) and body.get("ok"): + return FeedbackResult(ok=True, id=str(body.get("id", ""))) + code = str(body.get("code", "")) if isinstance(body, dict) else "" + error = str(body.get("error", "")) if isinstance(body, dict) else "" + if not code: + code = {400: "invalid_request", 401: "unauthorized", 413: "too_large"}.get( + resp.status_code, "server_error" + ) + return FeedbackResult(ok=False, code=code, error=error or f"HTTP {resp.status_code}") diff --git a/videocaptioner/core/feedback/diagnostics.py b/videocaptioner/core/feedback/diagnostics.py new file mode 100644 index 00000000..c295e752 --- /dev/null +++ b/videocaptioner/core/feedback/diagnostics.py @@ -0,0 +1,83 @@ +"""采集环境诊断(绝无密钥)+ 匿名 client_id。无 PyQt。 + +诊断只读一个**非敏感 key 白名单**(provider/语言名称等),绝不读 `*.api_key` / `*.api_base`; +再过一道 `_is_safe` 兜底,凡像密钥/URL/token 的值一律丢弃。对齐项目「发送前剥离密钥」硬规则。 +""" + +from __future__ import annotations + +import platform as _platform +import sys +import uuid + +from videocaptioner import config +from videocaptioner.config import APPDATA_PATH +from videocaptioner.core.application import config_store +from videocaptioner.core.download.dependencies import current_platform + +# 匿名设备 ID 存独立文件(不进配置文件):一行 UUID,丢了重生成即可。 +_CLIENT_ID_FILE = APPDATA_PATH / "feedback_client_id" + +# 只采集这些非敏感字段(值是 provider/语言名称,不是凭据)。 +_DIAG_KEYS = { + "llm_service": "llm.service", + "llm_model": "llm.model", + "translate_service": "translate.service", + "translate_target": "translate.target_language", + "dubbing_provider": "dubbing.provider", +} + +# 兜底:值里出现这些片段视为疑似敏感,丢弃不发(白名单已保证安全,这是双保险)。 +_SECRET_MARKERS = ("key", "token", "secret", "sk-", "bearer", "://", "password") + + +def _is_safe(value: str) -> bool: + low = value.lower() + return not any(marker in low for marker in _SECRET_MARKERS) and len(value) <= 64 + + +def platform_tag() -> str: + """X-App-Platform:收敛到契约枚举 windows-x64 / macos-x64 / macos-arm64 + (current_platform() 的 dev linux-x64 / windows-arm64 不在枚举内,归入最近发行值)。""" + os_key, arch = current_platform() + tag = f"{os_key}-{arch}" + if tag in ("windows-x64", "macos-x64", "macos-arm64"): + return tag + return "macos-x64" if os_key == "macos" else "windows-x64" + + +def get_or_create_client_id() -> str: + """本机匿名设备 ID(首次生成并持久化到独立文件,不入配置文件)。""" + try: + cid = _CLIENT_ID_FILE.read_text(encoding="utf-8").strip() + if cid: + return cid + except OSError: + pass + cid = str(uuid.uuid4()) + try: + _CLIENT_ID_FILE.write_text(cid, encoding="utf-8") + except OSError: # 落盘失败不该挡住反馈,用临时 ID 继续 + pass + return cid + + +def gather_diagnostics() -> dict: + """组装随反馈一起发送的环境信息(默认附带,无 UI 开关)。绝不含密钥。""" + diag: dict[str, object] = { + "app_version": config.VERSION, + "platform": platform_tag(), + "os": _platform.platform(), + "python": _platform.python_version(), + "frozen": bool(getattr(sys, "frozen", False)), + "ffmpeg": "ok" if config.find_binary("ffmpeg") else "missing", + } + cfg = config_store.load_config_file() + language = str(config_store.get_nested(cfg, "ui.language", "") or "") + if language: + diag["language"] = language + for label, key in _DIAG_KEYS.items(): + value = config_store.get_nested(cfg, key, "") + if isinstance(value, str) and value and _is_safe(value): + diag[label] = value + return diag diff --git a/videocaptioner/core/feedback/logs.py b/videocaptioner/core/feedback/logs.py new file mode 100644 index 00000000..50059e04 --- /dev/null +++ b/videocaptioner/core/feedback/logs.py @@ -0,0 +1,65 @@ +"""采集随反馈上传的「最近日志」附件(默认开启)。无 PyQt。 + +取 app.log 尾部(~256KB),发送前脱敏。硬规则:logs 不得含 API key / base URL / token。 +""" + +from __future__ import annotations + +import re + +from videocaptioner.config import LOG_PATH +from videocaptioner.core.feedback.models import FeedbackAttachment + +_LOG_FILE = LOG_PATH / "app.log" +_MAX_TAIL_BYTES = 256 * 1024 # 最近日志尾部上限 + +# 打码顺序固定。Bearer/Basic 单独成条保留 scheme 便于可读,故键值对列表不含 +# authorization(否则会把 scheme 词二次吃成 ***)。base URL/endpoint 也算敏感,一并打码。 +_SCRUB_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._\-+/=]+"), r"\1 ***"), + (re.compile(r"\bsk-[A-Za-z0-9._\-]{6,}"), "sk-***"), + (re.compile(r"\bAIza[0-9A-Za-z_\-]{20,}"), "AIza***"), + ( + re.compile( + r"(?i)(\"?(?:api[_-]?key|apikey|api[_-]?secret|access[_-]?token|secret|password|token)\"?\s*[:=]\s*\"?)" + r"([^\s\"',}]+)" + ), + r"\1***", + ), + # query string 凭据:以 ?/& 紧邻参数名锚定,避免误伤 keyboard/monkey 等普通词。 + (re.compile(r"(?i)([?&](?:api[_-]?key|key|token|access[_-]?token)=)([^&\s\"']+)"), r"\1***"), + # base URL / endpoint 标记后的地址(含 deeplx endpoint);值停在引号/逗号/花括号/空白。 + ( + re.compile( + r"(?i)(\"?(?:api[ _]?base|base[ _]?url|endpoint|deeplx[ _]?endpoint)\"?\s*[:=]\s*\"?)" + r"([^\s\"',}]+)" + ), + r"\1***", + ), + (re.compile(r"([a-zA-Z][a-zA-Z0-9+.\-]*://[^/\s:@]+):([^/\s@]+)@"), r"\1:***@"), +) + + +def scrub_log_text(text: str) -> str: + """把日志文本里疑似密钥/凭据打码(发送前必经)。""" + for pattern, repl in _SCRUB_PATTERNS: + text = pattern.sub(repl, text) + return text + + +def collect_recent_logs() -> list[FeedbackAttachment]: + """读取 app.log 尾部并脱敏,返回一个 recent.log 附件;无日志或读失败返回 []。""" + try: + size = _LOG_FILE.stat().st_size + with _LOG_FILE.open("rb") as fh: + if size > _MAX_TAIL_BYTES: + fh.seek(size - _MAX_TAIL_BYTES) + fh.readline() # 丢弃截断处的半行,从完整行开始 + raw = fh.read() + except OSError: + return [] + text = scrub_log_text(raw.decode("utf-8", errors="replace")) + data = text.encode("utf-8") + if not data.strip(): + return [] + return [FeedbackAttachment(filename="recent.log", data=data, mime="text/plain")] diff --git a/videocaptioner/core/feedback/models.py b/videocaptioner/core/feedback/models.py new file mode 100644 index 00000000..ba4e80a4 --- /dev/null +++ b/videocaptioner/core/feedback/models.py @@ -0,0 +1,80 @@ +"""反馈数据模型 + 本地预校验(无 PyQt)。限制对齐后端契约 docs/dev/feedback-api 与 vc-backend。""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +CATEGORIES = ("bug", "feature", "question", "other") +MESSAGE_MIN = 1 +MESSAGE_MAX = 5000 +CONTACT_MAX_BYTES = 200 +MAX_FILES = 3 +MAX_LOG_FILES = 3 +MAX_FILE_BYTES = 5 * 1024 * 1024 +MAX_TOTAL_BYTES = 12 * 1024 * 1024 # 后端按整条请求体(截图 + 日志 + 文本字段)卡 12 MB +# 本地 total 也计入文本字段与框架开销,成为后端上限的真超集,避免本地放行后被后端 413。 +_FRAMING_RESERVE_BYTES = 2048 +ALLOWED_MIME = ("image/png", "image/jpeg") + + +class FeedbackValidationError(ValueError): + """提交前本地校验失败。``code`` 供 UI 映射到本地化提示(core 不做 i18n)。""" + + def __init__(self, code: str, message: str = ""): + super().__init__(message or code) + self.code = code + + +@dataclass +class FeedbackAttachment: + """一张用户截图:内存字节,不落临时文件。""" + + filename: str + data: bytes + mime: str # image/png | image/jpeg + + @property + def size(self) -> int: + return len(self.data) + + +@dataclass +class FeedbackReport: + category: str + message: str + contact: str = "" + attachments: list[FeedbackAttachment] = field(default_factory=list) + logs: list[FeedbackAttachment] = field(default_factory=list) + diagnostics: dict = field(default_factory=dict) + + def validate(self) -> None: + if self.category not in CATEGORIES: + raise FeedbackValidationError("category_invalid", f"未知反馈类型:{self.category}") + msg = self.message.strip() + if len(msg) < MESSAGE_MIN: + raise FeedbackValidationError("message_required", "请填写问题描述") + if len(msg) > MESSAGE_MAX: + raise FeedbackValidationError("message_too_long", f"问题描述需 ≤ {MESSAGE_MAX} 字") + if len(self.contact.encode("utf-8")) > CONTACT_MAX_BYTES: + raise FeedbackValidationError("contact_too_long", "联系方式过长") + if len(self.attachments) > MAX_FILES: + raise FeedbackValidationError("too_many_files", f"最多 {MAX_FILES} 张截图") + if len(self.logs) > MAX_LOG_FILES: + raise FeedbackValidationError("too_many_logs", f"最多 {MAX_LOG_FILES} 个日志文件") + total = len(msg.encode("utf-8")) + len(self.contact.strip().encode("utf-8")) + if self.diagnostics: + total += len(json.dumps(self.diagnostics, ensure_ascii=False).encode("utf-8")) + total += _FRAMING_RESERVE_BYTES + for att in self.attachments: + if att.mime not in ALLOWED_MIME: + raise FeedbackValidationError("file_type", "仅支持 PNG / JPEG 截图") + if att.size > MAX_FILE_BYTES: + raise FeedbackValidationError("file_too_large", "单张截图需 ≤ 5 MB") + total += att.size + for log in self.logs: + if log.size > MAX_FILE_BYTES: + raise FeedbackValidationError("log_too_large", "单个日志需 ≤ 5 MB") + total += log.size + if total > MAX_TOTAL_BYTES: + raise FeedbackValidationError("total_too_large", "截图与日志总大小需 ≤ 12 MB") diff --git a/videocaptioner/core/hardsub/__init__.py b/videocaptioner/core/hardsub/__init__.py new file mode 100644 index 00000000..8a66bae8 --- /dev/null +++ b/videocaptioner/core/hardsub/__init__.py @@ -0,0 +1,14 @@ +"""硬字幕提取业务逻辑(无 PyQt)。 + +从带烧录字幕的视频里识别字幕并生成可编辑字幕:抽帧 → 字幕区变化检测(只在变化点 OCR) +→ 识别 → 文本相似度去重合并 → 时间轴。识别引擎走 :mod:`videocaptioner.core.ocr` 抽象。 +""" + +from videocaptioner.core.hardsub.config import ( + LANGUAGES, + HardsubConfig, + RecognizeMode, + mode_preset, +) + +__all__ = ["HardsubConfig", "RecognizeMode", "LANGUAGES", "mode_preset"] diff --git a/videocaptioner/core/hardsub/config.py b/videocaptioner/core/hardsub/config.py new file mode 100644 index 00000000..7a78aab1 --- /dev/null +++ b/videocaptioner/core/hardsub/config.py @@ -0,0 +1,115 @@ +"""硬字幕提取的配置数据类与预设(无 PyQt,CLI/GUI 共用)。""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from enum import Enum +from typing import Optional + + +class RecognizeMode(str, Enum): + """识别模式:速度/精度档位。""" + + FAST = "fast" + STANDARD = "standard" + ACCURATE = "accurate" + + +# 主面板语言下拉:(rec 语言码, 中文显示名)。"ch" 模型中英通吃,是默认。 +LANGUAGES: list[tuple[str, str]] = [ + ("ch", "中文 + 英文"), + ("en", "英文"), + ("japan", "日语"), + ("korean", "韩语"), + ("chinese_cht", "繁体中文"), +] + +# 模式 → (OCR 版本, 模型档, 抽帧间隔秒)。间隔越小时间轴越准但越慢;server 模型更准更慢。 +_PRESETS: dict[RecognizeMode, tuple[str, str, float]] = { + RecognizeMode.FAST: ("PP-OCRv4", "mobile", 0.5), + RecognizeMode.STANDARD: ("PP-OCRv4", "mobile", 0.3), + RecognizeMode.ACCURATE: ("PP-OCRv5", "mobile", 0.2), +} + + +def mode_preset(mode: RecognizeMode) -> tuple[str, str, float]: + """返回该模式的 (ocr_version, model_type, sample_interval)。""" + return _PRESETS.get(mode, _PRESETS[RecognizeMode.STANDARD]) + + +# ROI:原始分辨率像素 (x, y, w, h) +Roi = tuple[int, int, int, int] + + +@dataclass +class HardsubConfig: + """一次硬字幕提取的完整参数。""" + + video_path: str + # 字幕区域(原始分辨率像素)。None = 未指定,pipeline 会回退到底部默认带。 + roi: Optional[Roi] = None + # ROI 是否由用户显式指定(GUI 手动框选/调整、CLI --roi)而非自动检测。手动指定时提取所见即所得, + # 不套字号门/居中过滤(否则会挡掉用户特意框进来的小字)。 + roi_is_manual: bool = False + lang: str = "ch" + + # 引擎 / 模型(由模式预设填充,也可单独覆盖) + ocr_version: str = "PP-OCRv4" + model_type: str = "mobile" + threads: int = 0 # onnxruntime intra-op 线程数;0=自动 + + # 抽帧 + sample_interval: float = 0.3 # 每隔多少秒抽一帧(时间分辨率) + + # 字幕区变化检测(只在变化点 OCR,是性能核心) + similar_pixel_threshold: int = 25 # 像素亮度差 > 此值算「不同」 + change_area_ratio: float = 0.006 # ROI 中「不同像素」占比 ≥ 此值算「内容变了」 + blank_area_ratio: float = 0.0018 # 前景像素占比 < 此值算「无字幕」 + + # 去重合并:同一显示块内多次识别合并成一条;紧邻出现高置信且相似度低于此值的文本才判硬切新句。 + text_sim_threshold: int = 70 # 低于此相似度 + 高置信 = 硬切新句 + conf_threshold: float = 0.6 # 低于此置信度的识别结果丢弃 + min_duration: float = 0.3 # 短于此时长的字幕视为噪声丢弃 + + # 段水平中心偏移 ≤ 此比例才保留(0=正中,≥1=不过滤)。烧录字幕几乎总居中,两侧卡片/计数被滤。 + center_tolerance: float = 0.34 + + # 自动区域检测采样 + region_sample_count: int = 20 # 检测字幕区时均匀抽多少帧(每帧一次 OCR,预处理耗时主体) + # 主导字幕字号(原始分辨率像素),由 detect_subtitle_region 顺带学到回填供提取复用。 + # None = 未知(手动框选/默认带),提取时现学或不过滤。 + font_height: Optional[float] = None + + @property + def merge_gap(self) -> float: + """跨小间隙合并的时间窗:两个采样间隔。""" + return self.sample_interval * 2.0 + + @classmethod + def from_mode( + cls, video_path: str, mode: RecognizeMode = RecognizeMode.STANDARD, **kwargs + ) -> "HardsubConfig": + version, model_type, interval = mode_preset(mode) + cfg = cls( + video_path=video_path, + ocr_version=version, + model_type=model_type, + sample_interval=interval, + ) + return replace(cfg, **kwargs) if kwargs else cfg + + +@dataclass +class HardsubProgress: + """提取进度快照(pipeline → UI)。""" + + current_time: float # 已处理到的视频时刻(秒) + duration: float # 视频总时长(秒) + cue_count: int # 已产出字幕条数 + ocr_calls: int = field(default=0) # 实际 OCR 次数(调试/优化用) + + @property + def percent(self) -> int: + if self.duration <= 0: + return 0 + return max(0, min(100, int(self.current_time / self.duration * 100))) diff --git a/videocaptioner/core/hardsub/frames.py b/videocaptioner/core/hardsub/frames.py new file mode 100644 index 00000000..129b95c7 --- /dev/null +++ b/videocaptioner/core/hardsub/frames.py @@ -0,0 +1,195 @@ +"""基于项目自带 ffmpeg 的抽帧(无 PyQt/OpenCV):rawvideo 管道直出 numpy,多平台一致。 + +- ``grab_frame`` 单帧 / ``sample_frames`` 均匀 N 帧(区域检测)/ ``iter_roi_rgb`` 按间隔流式裁 ROI(提取)。 +- 所有解码带 ``-noautorotate``:probe 读编码宽高,自动转向会让竖屏视频宽高互换、reshape 错切(静默损坏)。 +""" + +from __future__ import annotations + +import os +import subprocess +from concurrent.futures import ThreadPoolExecutor +from typing import Iterator, Optional + +import numpy as np + +from videocaptioner import config as app_config +from videocaptioner.core.utils.logger import setup_logger +from videocaptioner.core.utils.media_info import probe_media + +logger = setup_logger("hardsub_frames") + +_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + + +def ffmpeg_executable() -> str: + """解析 ffmpeg 可执行路径(自带 bin → 用户 bin → PATH);找不到回退裸名让系统再碰运气。""" + return app_config.find_binary("ffmpeg") or "ffmpeg" + + +def probe_dimensions(video_path: str) -> Optional[tuple[int, int, float, float]]: + """返回 (width, height, fps, duration_seconds);失败返回 None。""" + info = probe_media(video_path) + if info is None or info.width <= 0 or info.height <= 0: + return None + fps = info.fps if info.fps > 0 else 25.0 + return info.width, info.height, fps, info.duration_seconds + + +def _read_exact(stream, size: int) -> Optional[bytes]: + """从管道精确读满 size 字节(管道会分片返回,必须循环读满才算一帧)。""" + chunks: list[bytes] = [] + remaining = size + while remaining > 0: + chunk = stream.read(remaining) + if not chunk: + return None + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def grab_frame(video_path: str, t_sec: float, max_width: int = 0) -> Optional[np.ndarray]: + """取 ``t_sec`` 处单帧为 RGB ndarray (H,W,3)。max_width>0 时等比缩到该宽度。 + + ``-ss`` 放 ``-i`` 前 = 快速关键帧 seek,对框选预览足够(不需要精确到帧)。 + """ + dims = probe_dimensions(video_path) + if dims is None: + return None + width, height, _, _ = dims + vf = [] + if max_width and width > max_width: + new_w = max_width + new_h = int(round(height * max_width / width)) + new_h -= new_h % 2 + vf.append(f"scale={new_w}:{new_h}") + out_w, out_h = new_w, new_h + else: + out_w, out_h = width, height + cmd = [ + ffmpeg_executable(), "-nostdin", "-hide_banner", "-loglevel", "error", "-noautorotate", + "-ss", f"{max(0.0, t_sec):.3f}", "-i", video_path, "-frames:v", "1", + ] + if vf: + cmd += ["-vf", ",".join(vf)] + cmd += ["-f", "rawvideo", "-pix_fmt", "rgb24", "-"] + try: + out = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + creationflags=_NO_WINDOW, + ).stdout + except Exception as exc: # noqa: BLE001 + logger.warning("抽帧失败 t=%.2f: %s", t_sec, exc) + return None + expected = out_w * out_h * 3 + if len(out) < expected: + return None + return np.frombuffer(out[:expected], np.uint8).reshape(out_h, out_w, 3) + + +def _grab_scaled(video_path: str, t_sec: float, out_w: int, out_h: int) -> Optional[np.ndarray]: + """``-ss`` 快速 seek 抽 t 处单帧、缩到 out_w×out_h 的 RGB(区域采样用,定位到最近关键帧即可)。""" + cmd = [ + ffmpeg_executable(), "-nostdin", "-hide_banner", "-loglevel", "error", "-noautorotate", + "-ss", f"{max(0.0, t_sec):.3f}", "-i", video_path, "-frames:v", "1", + "-vf", f"scale={out_w}:{out_h}", "-f", "rawvideo", "-pix_fmt", "rgb24", "-", + ] + try: + out = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=_NO_WINDOW + ).stdout + except Exception: # noqa: BLE001 + return None + expected = out_w * out_h * 3 + if len(out) < expected: + return None + return np.frombuffer(out[:expected], np.uint8).reshape(out_h, out_w, 3) + + +def sample_frames( + video_path: str, count: int, max_width: int = 960 +) -> list[tuple[float, np.ndarray]]: + """均匀抽 ``count`` 帧(缩到 max_width 宽)为 RGB,给字幕区域自动检测。返回 [(时间秒, frame)]。 + + 用 ``count`` 个 ``-ss`` 快速 seek 并行抽帧,而非 ``fps`` 滤镜(后者全解码整段、长视频极慢)。 + """ + dims = probe_dimensions(video_path) + if dims is None or count <= 0: + return [] + width, height, _, duration = dims + out_w = min(width, max_width) + out_h = int(round(height * out_w / width)) + out_h -= out_h % 2 + if duration <= 0: + single = _grab_scaled(video_path, 0.0, out_w, out_h) + return [(0.0, single)] if single is not None else [] + + interval = duration / count + # 偏移半个间隔取每段中点,避开纯片头/片尾黑场 + timestamps = [min(duration, (k + 0.5) * interval) for k in range(count)] + workers = min(8, max(2, (os.cpu_count() or 4))) + frames: list[tuple[float, Optional[np.ndarray]]] = [(t, None) for t in timestamps] + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = { + pool.submit(_grab_scaled, video_path, t, out_w, out_h): i + for i, t in enumerate(timestamps) + } + for fut in futures: + i = futures[fut] + try: + frames[i] = (timestamps[i], fut.result()) + except Exception: # noqa: BLE001 + pass + return [(t, f) for t, f in frames if f is not None] + + +def iter_roi_rgb( + video_path: str, roi: tuple[int, int, int, int], interval: float +) -> Iterator[tuple[float, np.ndarray]]: + """按 ``interval`` 秒抽样,yield (时间秒, 裁过 ROI 的 RGB 帧 (h,w,3))。 + + 抽全帧后 numpy 裁 ROI(ffmpeg crop 滤镜会让文字错位/重影);按帧号选帧(fps 滤镜会漂移到错误源帧); + 用 RGB(gray 的亮度加权丢白边字对比度)。 + """ + dims = probe_dimensions(video_path) + if dims is None: + return + src_w, src_h, fps, _ = dims + x, y, w, h = roi + if w <= 0 or h <= 0 or interval <= 0 or fps <= 0: + return + step = max(1, round(fps * interval)) # 每隔 step 个源帧取一帧 + out_interval = step / fps # 实际采样间隔(≈ 请求的 interval) + vf = f"select=not(mod(n\\,{step}))" + cmd = [ + ffmpeg_executable(), "-nostdin", "-hide_banner", "-loglevel", "error", "-noautorotate", + "-i", video_path, "-vf", vf, "-vsync", "0", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-", + ] + frame_bytes = src_w * src_h * 3 + try: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + bufsize=frame_bytes, creationflags=_NO_WINDOW, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("ROI 抽帧启动失败: %s", exc) + return + assert proc.stdout is not None + k = 0 + try: + while True: + raw = _read_exact(proc.stdout, frame_bytes) + if raw is None: + break + full = np.frombuffer(raw, np.uint8).reshape(src_h, src_w, 3) + yield k * out_interval, full[y : y + h, x : x + w] + k += 1 + finally: + try: + proc.stdout.close() + proc.terminate() + proc.wait(timeout=2) + except Exception: # noqa: BLE001 + pass diff --git a/videocaptioner/core/hardsub/pipeline.py b/videocaptioner/core/hardsub/pipeline.py new file mode 100644 index 00000000..925e796e --- /dev/null +++ b/videocaptioner/core/hardsub/pipeline.py @@ -0,0 +1,391 @@ +"""硬字幕提取主流程:抽帧 → 变化检测 → 变化点 OCR → 去重合并 → 时间轴 → ASRData。 + +只在字幕变化点 OCR(靠 ROI 灰度差分跳过保持帧),把 OCR 次数压到字幕条数级。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional + +import numpy as np + +from videocaptioner.core.asr.asr_data import ASRData, ASRDataSeg +from videocaptioner.core.hardsub.config import HardsubConfig, HardsubProgress +from videocaptioner.core.hardsub.frames import grab_frame, iter_roi_rgb, probe_dimensions +from videocaptioner.core.hardsub.region import centered_segments +from videocaptioner.core.ocr.base import OcrEngine, OcrLine +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("hardsub_pipeline") + +OnCue = Callable[["HardsubCue"], None] +OnProgress = Callable[[HardsubProgress], None] +ShouldCancel = Callable[[], bool] + +# 判硬切(无空白间隔的连切新句)时,新读与当前读都得「足够自信」才算真换句, +# 否则淡入淡出的低置信残字会被误判成新句。 +_STABLE_CONF = 0.85 + +# 段内按「单框自身中心偏移 > 此值」从两端剔偏心框(两侧卡片标题/角标)。比段级 center_tolerance 宽: +# 字幕被 OCR 拆成几块时每块自身也略偏,要留住这些半块。 +_BOX_CENTER_TOL = 0.45 + +# 单框行(完整一行、无拆块)的居中门,比段级 center_tolerance 严:整行字幕几乎总居中,偏置的署名/角标 +# 虽未达拆块容差仍明显偏心,在此剔除。 +_LINE_CENTER_TOL = 0.25 + +# 帧内相对字号门:同一帧里字高 < 最大行 × 此比例的次要行被剔(双语英文译文≈0.7×主行)。 +# 比绝对字号门稳:同帧主/次比例恒定,不受 fh 估计或逐帧测高抖动影响。 +_SECONDARY_LINE_RATIO = 0.8 + +# 提取阶段 OCR 检测输入最长边上限,小于区域检测的 960:提取 ROI 已贴字幕、字号大,768 更快且质量不降 +# (区域检测对分辨率敏感不可用,见 rapid.py)。 +EXTRACT_DET_LIMIT = 768 + + +@dataclass +class HardsubCue: + """一条提取出的字幕:起止时间(秒)+ 多行文本(按从上到下排序,中英分行天然保留)。""" + + start: float + end: float + lines: list[str] = field(default_factory=list) + score: float = 0.0 + stable_ocr: bool = False # 是否已在「稳定帧」补过一次 OCR(那帧最干净) + + @property + def text(self) -> str: + return "\n".join(self.lines) + + def consider(self, cand_lines: list[str], score: float) -> None: + """用更干净的一次识别替换:优先置信度(稳定帧 ≈1.0、淡帧偏低),接近时取更完整(更长)。""" + cand_len = sum(len(x) for x in cand_lines) + cur_len = sum(len(x) for x in self.lines) + if (round(score, 2), cand_len) > (round(self.score, 2), cur_len): + self.lines = cand_lines + self.score = score + + +def default_bottom_roi(width: int, height: int) -> tuple[int, int, int, int]: + """硬字幕先验默认带:底部 ~18% 高、左右各留 5%。自动检测/手动框选未给时的兜底。""" + x = int(width * 0.05) + w = int(width * 0.90) + y = int(height * 0.78) + h = int(height * 0.18) + return x, y, w, h + + +def extract_hardsub( + config: HardsubConfig, + engine: OcrEngine, + on_cue: Optional[OnCue] = None, + on_progress: Optional[OnProgress] = None, + should_cancel: Optional[ShouldCancel] = None, +) -> ASRData: + """跑完整提取,流式回调每条字幕,返回 :class:`ASRData`(已按时间排序、过滤空段)。""" + dims = probe_dimensions(config.video_path) + if dims is None: + raise ValueError(f"无法读取视频信息:{config.video_path}") + width, height, _, duration = dims + roi = config.roi or default_bottom_roi(width, height) + _, _, roi_w, roi_h = roi + area = max(1, roi_w * roi_h) + change_pixels = max(80, int(area * config.change_area_ratio)) + blank_pixels = max(40, int(area * config.blank_area_ratio)) + # 字号门 + 居中门只在自动检测区域时启用(自动 ROI 近全宽,需挑主导字号、挡两侧异号杂质); + # 手动框选/--roi 指定区域时所见即所得,不二次过滤。 + if config.roi_is_manual: + font_height: Optional[float] = None + center_tol = float("inf") # 不按居中过滤 + else: + # 优先复用区域检测顺带学到的主导字号;缺失时(默认带/未给)才独立采样现学。 + font_height = ( + config.font_height if config.font_height is not None + else _dominant_font_height(engine, config.video_path, roi, config.center_tolerance) + ) + center_tol = config.center_tolerance + + cues: list[HardsubCue] = [] + active: Optional[HardsubCue] = None + prev: Optional[np.ndarray] = None + ocr_calls = 0 + last_emit_pct = -1 + + def finalize() -> None: + """定稿在途字幕:end 取「最后一次出现的帧」+ 一个采样间隔(否则系统性偏早半个间隔)。""" + nonlocal active + if active is None: + return + active.end += config.sample_interval + if active.lines and (active.end - active.start) >= config.min_duration: + cues.append(active) + if on_cue is not None: + on_cue(active) + active = None + + for t, frame in iter_roi_rgb(config.video_path, roi, config.sample_interval): + if should_cancel is not None and should_cancel(): + break + # 变化/空白检测用绿通道近似亮度(廉价);OCR 喂原 RGB(灰度会丢白色描边字的对比度)。 + gray = frame[:, :, 1] + + # 1) 近似均匀帧 = 字幕空档:收尾在途字幕,跳过 OCR(保守,只命中真·空白带)。 + if _is_blank(gray, blank_pixels): + if active is not None: + finalize() + prev = gray + _emit(on_progress, t, duration, len(cues), ocr_calls) + continue + + # 2) 与上一帧几乎相同 = 字幕保持:延长当前条不 OCR(性能核心);稳定后补一次 OCR(无淡入残影最干净)。 + if prev is not None and active is not None and not _changed(prev, gray, change_pixels): + active.end = t + if not active.stable_ocr: + active.stable_ocr = True + ocr_calls += 1 + slines = _read_lines( + engine, frame, config.conf_threshold, center_tol, font_height + ) + if slines: + active.consider([s for s, _ in slines], _mean_score(slines)) + prev = gray + continue + + # 3) 变化点:真正 OCR(用 RGB 原帧)。 + ocr_calls += 1 + lines = _read_lines( + engine, frame, config.conf_threshold, center_tol, font_height + ) + prev = gray + if not lines: + # 字幕消失 → 收尾在途条。 + if active is not None: + finalize() + _emit(on_progress, t, duration, len(cues), ocr_calls) + continue + + cand_lines = [line for line, _ in lines] + text_norm = _norm("".join(cand_lines)) + score = sum(s for _, s in lines) / len(lines) + + # 同一显示块(时间紧邻、中间无空白)默认并入,不靠相似度(淡入残字相似度低会误分裂); + # 仅紧邻出现「高置信且与当前相似度很低」的文本才判硬切(无空白的连切新句)。 + same_block = active is not None and t - active.end <= config.merge_gap + hard_cut = False + if same_block: + sim = _similar(text_norm, _norm("".join(active.lines))) + hard_cut = ( + sim < config.text_sim_threshold + and score >= _STABLE_CONF + and active.score >= _STABLE_CONF + ) + + if same_block and not hard_cut: + active.end = t + active.consider(cand_lines, score) + active.stable_ocr = False # 内容仍在变(淡入),稳定后再补一次干净 OCR + else: + finalize() + active = HardsubCue(start=t, end=t, lines=cand_lines, score=score) + + pct = int(t / duration * 100) if duration > 0 else 0 + if pct != last_emit_pct or on_progress is None: + last_emit_pct = pct + _emit(on_progress, t, duration, len(cues), ocr_calls) + + finalize() + logger.info("硬字幕提取完成:%d 条字幕,OCR %d 次", len(cues), ocr_calls) + return cues_to_asrdata(cues) + + +def cues_to_asrdata(cues: list[HardsubCue]) -> ASRData: + """HardsubCue(秒)→ ASRData(毫秒整数段)。""" + segs = [ + ASRDataSeg(cue.text, int(round(cue.start * 1000)), int(round(cue.end * 1000))) + for cue in cues + if cue.text.strip() + ] + return ASRData(segs) + + +def _mean_score(lines: list[tuple[str, float]]) -> float: + return sum(s for _, s in lines) / len(lines) if lines else 0.0 + + +def _dominant_font_height( + engine: OcrEngine, + video_path: str, + roi: tuple[int, int, int, int], + center_tolerance: float, + n: int = 12, +) -> Optional[float]: + """全局学「主导字幕字号」:采样 ROI 带内若干帧,取居中文字段高度里加权最大的那类(中位)。 + + 字幕字号整段稳定为一类,计数/时长/角标/译文等明显更小——据此挡掉小字杂质。学不到返回 None。 + """ + dims = probe_dimensions(video_path) + if dims is None: + return None + duration = dims[3] + x, y, w, h = roi + half_w = max(1.0, w / 2.0) + times = [duration * (i + 0.5) / n for i in range(n)] if duration > 0 else [0.0] + heights: list[float] = [] + for t in times: + frame = grab_frame(video_path, t) + if frame is None: + continue + crop = frame[y : y + h, x : x + w] + try: + boxes = engine.detect(crop) + except Exception: # noqa: BLE001 + continue + if not boxes: + continue + line_h = float(np.median([b[3] - b[1] for b in boxes])) or 1.0 + for seg in centered_segments(boxes, half_w, line_h, center_tolerance): + heights.append(max(b[3] for b in seg) - min(b[1] for b in seg)) + if not heights: + return None + hs = sorted(heights) + clusters: list[list[float]] = [[hs[0]]] + for v in hs[1:]: + if v > clusters[-1][-1] * 1.4: # 字号跳变 >40% → 另一类字号 + clusters.append([v]) + else: + clusters[-1].append(v) + # 主导字号类:按 出现次数 × 字号 加权,偏向次数多且字号大的主字幕行(双语取原文行、混剪取大歌词)。 + best = max(clusters, key=lambda c: len(c) * float(np.median(c))) + return float(np.median(best)) + + +def _center_offset(seg: list[OcrLine], half_w: float) -> float: + """一段框整体的水平中心偏移(0=正中,1=贴边):(左缘+右缘)/2 距画面中心的归一化距离。""" + cx = (seg[0].bbox[0] + seg[-1].bbox[2]) / 2.0 + return abs(cx - half_w) / half_w + + +def _trim_offcenter_ends(seg: list[OcrLine], half_w: float, box_tol: float) -> list[OcrLine]: + """按 x 排序的一段框,反复剔除「自身中心」偏离画面中心 > ``box_tol`` 的端框,只剔两端、中间不动。 + + 两侧卡片标题/角标常与字幕同字号、同行紧挨,段级中心拦不住(混入后整段仍近居中);但它们自身偏在 + 两侧,按单框偏移从两端剔即可,又保住被 OCR 拆成几块、每块都靠中心的真字幕。 + """ + bs = list(seg) + while len(bs) > 1: + loff = abs((bs[0].bbox[0] + bs[0].bbox[2]) / 2.0 - half_w) / half_w + roff = abs((bs[-1].bbox[0] + bs[-1].bbox[2]) / 2.0 - half_w) / half_w + if loff >= roff and loff > box_tol: + bs = bs[1:] + elif roff > box_tol: + bs = bs[:-1] + else: + break + return bs + + +def _read_lines( + engine: OcrEngine, + image: np.ndarray, + conf_threshold: float, + center_tolerance: float = 1.0, + font_height: Optional[float] = None, +) -> list[tuple[str, float]]: + """OCR 一帧 → 居中的文字行 [(文本, 置信度)],从上到下。 + + 按 cy 聚行 → 行内按「> ~2 行高的横向间隙」切段 → 段内修剪两端偏心框 → 留下「居中 + 属于全局 + 主导字号」的段。``center_tolerance >= 1`` 关闭居中过滤(手动框选所见即所得 / 测试), + ``font_height=None`` 关闭字号过滤。 + """ + result = engine.recognize(image) + kept: list[OcrLine] = [ln for ln in result if ln.score >= conf_threshold and ln.text.strip()] + if not kept: + return [] + line_h = float(np.median([ln.height for ln in kept])) or 20.0 + half_w = max(1.0, image.shape[1] / 2.0) + kept.sort(key=lambda ln: ln.cy) + rows: list[list[OcrLine]] = [] + for ln in kept: + if rows and abs(ln.cy - rows[-1][-1].cy) <= line_h * 0.6: + rows[-1].append(ln) + else: + rows.append([ln]) + + out: list[tuple[float, str, float, float]] = [] # (cy, text, score, seg_h) + for row in rows: + row.sort(key=lambda ln: ln.bbox[0]) + seg: list[OcrLine] = [row[0]] + segments: list[list[OcrLine]] = [] + for ln in row[1:]: + if ln.bbox[0] - seg[-1].bbox[2] > line_h * 2.0: + segments.append(seg) + seg = [ln] + else: + seg.append(ln) + segments.append(seg) + centering_on = center_tolerance < 1.0 + for s in segments: + if centering_on: + # 修剪两端偏心框;若修剪后反不居中(被 OCR 拆成两半的居中长句会被误剪),回退原段兜底。 + trimmed = _trim_offcenter_ends(s, half_w, _BOX_CENTER_TOL) + cand = trimmed if (trimmed and _center_offset(trimmed, half_w) <= center_tolerance) else s + # 单框行用更严的居中门(剔偏置署名/角标);多框行可能是拆开的居中长句,用 center_tolerance。 + seg_tol = min(_LINE_CENTER_TOL, center_tolerance) if len(cand) == 1 else center_tolerance + if not cand or _center_offset(cand, half_w) > seg_tol: + continue + s = cand + seg_h = max(ln.bbox[3] for ln in s) - min(ln.bbox[1] for ln in s) + # 全局字号门:段高在主导字号 0.75–1.7× 内才算同字幕字号;更小的译文/计数/角标被挡。 + if font_height and not (font_height * 0.75 <= seg_h <= font_height * 1.7): + continue + # 段内只取主导字号的框(字幕一行字号一致;异常大小的覆盖框不算) + med_h = float(np.median([ln.height for ln in s])) or 1.0 + core = [ln for ln in s if 0.5 * med_h <= ln.height <= 1.8 * med_h] or s + text = " ".join(ln.text.strip() for ln in core) + score = sum(ln.score for ln in core) / len(core) + out.append((s[0].cy, text, score, seg_h)) + # 帧内相对字号门:只留本帧最大字号那一档,比它小一档的次要行(中英双语的英文译文≈0.7×主行)剔除。 + # 比绝对字号门稳——同帧主/次行比例恒定,不受 fh 估计误差或英文逐帧测高抖动影响(治忽中忽英)。 + if font_height and out: + max_h = max(h for *_, h in out) + out = [c for c in out if c[3] >= max_h * _SECONDARY_LINE_RATIO] + out.sort(key=lambda x: x[0]) + return [(text, score) for _, text, score, _h in out] + + +def _changed(prev: np.ndarray, cur: np.ndarray, area_thr: int) -> bool: + """两帧 ROI 灰度的「不同像素」数是否达到阈值(纯 numpy,单帧 < 1ms)。""" + diff = np.abs(cur.astype(np.int16) - prev.astype(np.int16)) + return int(np.count_nonzero(diff > 25)) >= area_thr + + +def _is_blank(gray: np.ndarray, blank_thr: int) -> bool: + """ROI 是否近似无文字:偏离中值较多的「前景像素」极少。保守判定,避免误杀有字帧。""" + median = float(np.median(gray)) + fg = int(np.count_nonzero(np.abs(gray.astype(np.int16) - median) > 35)) + return fg < blank_thr + + +def _norm(text: str) -> str: + """归一化用于相似度比较:去所有空白(OCR 空格不稳定)。""" + return "".join(text.split()) + + +def _similar(a: str, b: str) -> int: + """文本相似度 0-100。优先 rapidfuzz,退化到标准库 difflib。""" + if not a and not b: + return 100 + try: + from rapidfuzz import fuzz + return int(fuzz.ratio(a, b)) + except Exception: # noqa: BLE001 + from difflib import SequenceMatcher + return int(SequenceMatcher(None, a, b).ratio() * 100) + + +def _emit( + on_progress: Optional[OnProgress], t: float, duration: float, cue_count: int, ocr_calls: int +) -> None: + if on_progress is not None: + on_progress(HardsubProgress(t, duration, cue_count, ocr_calls)) diff --git a/videocaptioner/core/hardsub/region.py b/videocaptioner/core/hardsub/region.py new file mode 100644 index 00000000..1c57d8fd --- /dev/null +++ b/videocaptioner/core/hardsub/region.py @@ -0,0 +1,241 @@ +"""自动检测字幕区域(字幕本质:1-3 行水平居中、位置稳定、内容随时间变的文字)。 + +采样 N 帧 → 每帧文本检测 → 聚成居中文字段 → 跨帧聚成行候选 → 按「时间稳定 + 内容在变 + +偏底部」打分选主行 → 并入相邻同字号居中行 → 输出原始分辨率 ROI(横向近全宽)。检测不到返回 None。 +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Callable, Optional + +import numpy as np + +from videocaptioner.core.hardsub.frames import probe_dimensions, sample_frames +from videocaptioner.core.ocr.base import BBox, OcrEngine +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("hardsub_region") + +Roi = tuple[int, int, int, int] + + +@dataclass +class RegionResult: + """区域检测结果:ROI + 顺带学到的主导字幕字号(原始分辨率像素)。 + + 字号来自主行段高换算回原始分辨率,供提取复用、省掉再独立采样学一遍。 + """ + + roi: Roi + font_height: float + +# 段中心偏移 ≤ 此比例(0=正中)才算居中。字幕几乎总居中,两侧台标/卡片偏心更大被滤。 +CENTER_TOL = 0.34 + + +@dataclass +class _Line: + """一条候选字幕行(缩放坐标系):跨帧的同垂直位置居中文字段聚合。""" + + cy: float + top: float + bottom: float + frames_hit: int + widths: list[float] + centers_x: list[float] + font: float = 0.0 # 该行文字段的中位高度(字号);并入相邻行时要求字号相近 + + +def centered_segments( + boxes: list[BBox], half_w: float, line_h: float, center_tol: float = CENTER_TOL +) -> list[list[BBox]]: + """一组文本框 → 「居中文字段」列表:按 cy 聚行 → 行内按 >2 行高的间隙切段 → 只留居中段。 + + 检测与提取共用此原语。段从上到下、段内从左到右。 + """ + if not boxes: + return [] + items = sorted(boxes, key=lambda b: (b[1] + b[3]) / 2.0) + rows: list[list[BBox]] = [] + for b in items: + cy = (b[1] + b[3]) / 2.0 + if rows and cy - (rows[-1][-1][1] + rows[-1][-1][3]) / 2.0 <= line_h * 0.6: + rows[-1].append(b) + else: + rows.append([b]) + out: list[list[BBox]] = [] + for row in rows: + row.sort(key=lambda b: b[0]) + seg = [row[0]] + for b in row[1:]: + if b[0] - seg[-1][2] > line_h * 2.0: + _keep_if_centered(seg, half_w, center_tol, out) + seg = [b] + else: + seg.append(b) + _keep_if_centered(seg, half_w, center_tol, out) + return out + + +def _keep_if_centered( + seg: list[BBox], half_w: float, center_tol: float, out: list[list[BBox]] +) -> None: + cx = (seg[0][0] + seg[-1][2]) / 2.0 + if abs(cx - half_w) / max(1.0, half_w) <= center_tol: + out.append(seg) + + +def detect_subtitle_region( + video_path: str, + engine: OcrEngine, + sample_count: int = 20, + max_width: int = 960, + on_progress: Optional[Callable[[int], None]] = None, +) -> Optional[RegionResult]: + """返回字幕区域 :class:`RegionResult`(原始分辨率 ROI + 主导字号),检测不到返回 None。 + + ``on_progress(percent)``:抽帧 0-25%,逐帧检测 25-95%(耗时主体)。 + """ + def _report(pct: int) -> None: + if on_progress is not None: + on_progress(max(0, min(100, pct))) + + _report(2) + dims = probe_dimensions(video_path) + if dims is None: + return None + orig_w, orig_h, _, _ = dims + + frames = sample_frames(video_path, sample_count, max_width) + if len(frames) < 3: + return None + fh, fw = frames[0][1].shape[:2] + num_frames = len(frames) + half_w = fw / 2.0 + _report(25) + + # 1) 每帧检测,收集原始框(单遍 OCR);顺带累积框高估行高(聚行/切段的尺度)。 + raw: list[tuple[int, list[BBox]]] = [] + heights: list[float] = [] + for idx, (_, frame) in enumerate(frames): + try: + boxes = engine.detect(frame) + except Exception as exc: # noqa: BLE001 — 个别帧失败不致命 + logger.debug("第 %d 帧检测失败:%s", idx, exc) + boxes = [] + raw.append((idx, boxes)) + heights.extend(b[3] - b[1] for b in boxes) + _report(25 + int((idx + 1) / num_frames * 70)) + line_h = float(np.median(heights)) if heights else fh * 0.06 + if line_h <= 1: + line_h = fh * 0.06 + + # 2) 每帧 → 居中文字段,记 (帧序, cy, top, bottom, width, cx) + segs: list[tuple[int, float, float, float, float, float]] = [] + for idx, boxes in raw: + for seg in centered_segments(boxes, half_w, line_h): + top = min(b[1] for b in seg) + bottom = max(b[3] for b in seg) + left, right = seg[0][0], seg[-1][2] + segs.append((idx, (top + bottom) / 2.0, top, bottom, right - left, (left + right) / 2.0)) + if not segs: + return None + + # 3) 居中段按 cy 聚成「行候选」 + lines = _cluster_lines(segs, eps=line_h * 0.6) + scored = sorted(((_score_line(ln, num_frames, fh), ln) for ln in lines), key=lambda x: -x[0]) + scored = [(s, ln) for s, ln in scored if s > 0] + if not scored or scored[0][0] < 0.35: + return None + + # 4) 选主行 + 并入相邻「同字号」居中行(同号多行字幕一起取,异号译文/角标不并入)。最多 3 行、总高 ≤22%。 + main = scored[0][1] + band = [main] + max_band = fh * 0.22 + while len(band) < 3: + cur_top = min(ln.top for ln in band) + cur_bot = max(ln.bottom for ln in band) + chosen = None + for _, ln in scored: + if ln in band: + continue + gap = ln.top - cur_bot if ln.top >= cur_bot else (cur_top - ln.bottom if ln.bottom <= cur_top else 0.0) + merged_h = max(cur_bot, ln.bottom) - min(cur_top, ln.top) + same_font = abs(ln.font - main.font) <= main.font * 0.35 + if 0 <= gap <= line_h * 0.9 and merged_h <= max_band and same_font: + chosen = ln + break + if chosen is None: + break + band.append(chosen) + + top = min(ln.top for ln in band) + bottom = max(ln.bottom for ln in band) + + # 5) 横向近全宽(只定位垂直带,长短句都容得下);上下留白;缩放 → 原始分辨率。 + pad = line_h * 0.3 + sx, sy = orig_w / fw, orig_h / fh + x0 = max(0, int(fw * 0.02 * sx)) + x1 = min(orig_w, int(fw * 0.98 * sx)) + y0 = max(0, int((top - pad) * sy)) + y1 = min(orig_h, int((bottom + pad) * sy)) + if x1 - x0 < 8 or y1 - y0 < 8: + return None + # 主行段高(缩放系)换算回原始分辨率 = 主导字号,供提取复用。 + font_height = max(1.0, main.font * sy) + return RegionResult((x0, y0, x1 - x0, y1 - y0), font_height) + + +def _cluster_lines( + segs: list[tuple[int, float, float, float, float, float]], eps: float +) -> list[_Line]: + """居中段按 cy 一维聚类成行候选。""" + items = sorted(segs, key=lambda s: s[1]) + lines: list[_Line] = [] + cur: list[tuple[int, float, float, float, float, float]] = [] + last_cy: Optional[float] = None + for s in items: + if last_cy is not None and s[1] - last_cy > eps: + lines.append(_make_line(cur)) + cur = [] + cur.append(s) + last_cy = s[1] + if cur: + lines.append(_make_line(cur)) + return lines + + +def _make_line(items: list[tuple[int, float, float, float, float, float]]) -> _Line: + return _Line( + cy=float(np.median([s[1] for s in items])), + top=float(np.percentile([s[2] for s in items], 10)), + bottom=float(np.percentile([s[3] for s in items], 90)), + frames_hit=len({s[0] for s in items}), + widths=[s[4] for s in items], + centers_x=[s[5] for s in items], + font=float(np.median([s[3] - s[2] for s in items])), # 段高 ≈ 字号 + ) + + +def _score_line(line: _Line, num_frames: int, fh: int) -> float: + """给行候选打「像字幕」分:时间稳定(出现率) + 内容在变 + 偏底部;剔除恒定台标与上半部分。""" + presence = line.frames_hit / max(1, num_frames) + # 内容变化:字幕逐句换 → 段宽/水平中心在变;台标几乎恒定 → 方差≈0。 + change = _variation(line.widths) * 0.5 + _variation(line.centers_x) * 0.5 + cy_norm = line.cy / max(1, fh) + if cy_norm < 0.55: + return 0.0 # 上半部分基本不是字幕(标题/正文/台标) + if presence > 0.95 and change < 0.06: + return 0.0 # 一直在且几乎不变 = 台标/水印 + bottom_prior = math.exp(-((cy_norm - 0.9) ** 2) / (2 * 0.13 ** 2)) + return presence * 0.32 + change * 0.28 + bottom_prior * 0.6 + + +def _variation(values: list[float]) -> float: + """一组量的归一化离散度(0-1):变异系数(std/mean),尺度无关,近似「内容变化率」。""" + if len(values) < 2: + return 0.0 + mean = float(np.mean(values)) or 1.0 + return max(0.0, min(1.0, float(np.std(values)) / (mean * 0.6))) diff --git a/videocaptioner/core/llm/__init__.py b/videocaptioner/core/llm/__init__.py index 8b662752..2646250c 100644 --- a/videocaptioner/core/llm/__init__.py +++ b/videocaptioner/core/llm/__init__.py @@ -1,7 +1,6 @@ """LLM unified client module.""" from .check_llm import check_llm_connection, get_available_models -from .check_whisper import check_whisper_connection from .client import call_llm, get_llm_client __all__ = [ @@ -9,5 +8,4 @@ "get_llm_client", "check_llm_connection", "get_available_models", - "check_whisper_connection", ] diff --git a/videocaptioner/core/llm/check_llm.py b/videocaptioner/core/llm/check_llm.py index f3eaa2e4..b369575f 100644 --- a/videocaptioner/core/llm/check_llm.py +++ b/videocaptioner/core/llm/check_llm.py @@ -23,11 +23,18 @@ def check_llm_connection( (是否成功, Error output或AI助手的回复) """ try: + from videocaptioner.core.llm import free_model + # 创建OpenAI客户端并发送请求到API base_url = normalize_base_url(base_url) api_key = api_key.strip() + client_kwargs = {} + if free_model.is_free_base(base_url): + # 公益网关:占位 key 换实时令牌;默认 httpx 指纹会被 Cloudflare 403 + api_key = free_model.token() + client_kwargs["http_client"] = free_model.make_http_client() response = openai.OpenAI( - base_url=base_url, api_key=api_key, timeout=60 + base_url=base_url, api_key=api_key, timeout=60, **client_kwargs ).chat.completions.create( model=model, messages=[ diff --git a/videocaptioner/core/llm/check_whisper.py b/videocaptioner/core/llm/check_whisper.py deleted file mode 100644 index b52fd2a7..00000000 --- a/videocaptioner/core/llm/check_whisper.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Whisper API 连接测试工具""" - -from typing import Literal, Optional - -import openai - -from videocaptioner.config import ASSETS_PATH -from videocaptioner.core.llm.client import normalize_base_url - -# 测试音频文件路径 -TEST_AUDIO_PATH = ASSETS_PATH / "en.mp3" - - -def check_whisper_connection( - base_url: str, api_key: str, model: str -) -> tuple[Literal[True], Optional[str]] | tuple[Literal[False], Optional[str]]: - """ - 测试 Whisper API 连接 - - 使用测试音频文件进行转录测试,并返回转录结果文本。 - - 参数: - base_url: API 基础 URL - api_key: API 密钥 - model: 模型名称 - - 返回: - (是否成功, 转录结果文本或Error output) - """ - try: - # 检查测试音频文件是否存在 - if not TEST_AUDIO_PATH.exists(): - return False, f"Test audio file not found: {TEST_AUDIO_PATH}" - - # 创建 OpenAI 客户端 - base_url = normalize_base_url(base_url) - api_key = api_key.strip() - client = openai.OpenAI(base_url=base_url, api_key=api_key, timeout=60) - - # Reading音频文件 - with open(TEST_AUDIO_PATH, "rb") as audio_file: - # 调用 Whisper API 进行转录 - response = client.audio.transcriptions.create( - model=model, - file=audio_file, - response_format="verbose_json", - timestamp_granularities=["word", "segment"], - timeout=30, - ) - - # 返回成功结果和转录文本 - if isinstance(response, str): - raise ValueError( - "WhisperAPI returned type error, please check your base URL." - ) - else: - resp = f"{response.text}" - return True, resp - - except openai.APIConnectionError: - return False, "API Connection Error. Please check your network or VPN." - except openai.RateLimitError as e: - return False, "Rate Limit Error: " + str(e) - except openai.AuthenticationError: - return False, "Authentication Error. Please check your API key." - except openai.NotFoundError: - return False, "URL Not Found Error. Please check your Base URL." - except openai.BadRequestError as e: - return False, "Bad Request Error: " + str(e) - except openai.OpenAIError as e: - return False, "OpenAI Error: " + str(e) - except FileNotFoundError: - return False, f"Test audio file not found: {TEST_AUDIO_PATH}" - except Exception as e: - return False, str(e) diff --git a/videocaptioner/core/llm/client.py b/videocaptioner/core/llm/client.py index 7099bfc1..70240fb0 100644 --- a/videocaptioner/core/llm/client.py +++ b/videocaptioner/core/llm/client.py @@ -18,9 +18,11 @@ from videocaptioner.core.utils.cache import get_llm_cache, memoize from videocaptioner.core.utils.logger import setup_logger +from . import free_model from .request_logger import create_logging_http_client, log_llm_response _global_client: Optional[OpenAI] = None +_client_fingerprint: Optional[tuple] = None # 构建当前 client 所用的 (base_url, api_key) _client_lock = threading.Lock() logger = setup_logger("llm_client") @@ -50,26 +52,40 @@ def normalize_base_url(base_url: str) -> str: def get_llm_client() -> OpenAI: - """Get global LLM client instance (thread-safe singleton).""" - global _global_client + """Get the LLM client, rebuilt when the active provider (base/key) changes. + + base/key 来自 OPENAI_BASE_URL/OPENAI_API_KEY 环境变量(各功能在调用前写入自己选的 provider + 凭证)。早期是「首次构建后永久缓存」的纯单例,会导致同进程内功能间串台:例如先跑字幕 LLM 翻译 + 建好 client 后,再开实时字幕选另一个 provider,env 虽改了却仍复用旧 base/key,译文静默走错端点。 + 这里按 (base, key) 指纹缓存:指纹不变直接复用,变了就重建——既保留单例复用,又能正确切换。""" + global _global_client, _client_fingerprint + + base_url = normalize_base_url(os.getenv("OPENAI_BASE_URL", "").strip()) + api_key = os.getenv("OPENAI_API_KEY", "").strip() + # 公益大模型网关无需用户 key:忽略占位 key,实时取令牌(指纹含令牌,过期换发后自动重建) + if free_model.is_free_base(base_url): + api_key = free_model.token() + if not base_url or not api_key: + raise ValueError( + "OPENAI_BASE_URL and OPENAI_API_KEY environment variables must be set" + ) + fingerprint = (base_url, api_key) - if _global_client is None: + if _global_client is None or _client_fingerprint != fingerprint: with _client_lock: - if _global_client is None: - base_url = os.getenv("OPENAI_BASE_URL", "").strip() - base_url = normalize_base_url(base_url) - api_key = os.getenv("OPENAI_API_KEY", "").strip() - - if not base_url or not api_key: - raise ValueError( - "OPENAI_BASE_URL and OPENAI_API_KEY environment variables must be set" - ) - + if _global_client is None or _client_fingerprint != fingerprint: + # 公益网关在 Cloudflare 后,httpx 默认指纹会被 403:改走 curl_cffi transport + http_client = ( + free_model.make_http_client() + if free_model.is_free_base(base_url) + else create_logging_http_client() + ) _global_client = OpenAI( base_url=base_url, api_key=api_key, - http_client=create_logging_http_client(), + http_client=http_client, ) + _client_fingerprint = fingerprint return _global_client @@ -80,6 +96,29 @@ def before_sleep_log(retry_state: RetryCallState) -> None: ) +# 记住「不支持 enable_thinking 的 (端点, 模型)」(如 OpenAI 官方、或腾讯 Hunyuan-MT 这类非推理模型会 +# 400):之后直接不带该参数,免得每次先失败一次。按 (base, model) 记——同一端点下有的模型支持、有的 +# 不支持(如 SiliconFlow 同时有 DeepSeek 思考模型和 Hunyuan-MT),不能按端点一刀切。 +_thinking_unsupported: set = set() + + +def _is_bad_request(exc: Exception) -> bool: + return (getattr(exc, "status_code", None) == 400 + or type(exc).__name__ == "BadRequestError" + or "rror code: 400" in str(exc)) + + +def _strip_thinking(kwargs: dict) -> dict: + """去掉 extra_body 里的 enable_thinking(其余原样保留)。""" + extra = {k: v for k, v in (kwargs.get("extra_body") or {}).items() if k != "enable_thinking"} + out = dict(kwargs) + if extra: + out["extra_body"] = extra + else: + out.pop("extra_body", None) + return out + + @retry( stop=stop_after_attempt(10), wait=wait_random_exponential(multiplier=1, min=5, max=60), @@ -89,18 +128,46 @@ def before_sleep_log(retry_state: RetryCallState) -> None: def _call_llm_api( messages: List[dict], model: str, - temperature: float = 1, **kwargs: Any, ) -> Any: - """实际调用 LLM API(带重试)""" - client = get_llm_client() + """实际调用 LLM API(带重试)。不设 temperature:用各模型自身默认值。 - response = client.chat.completions.create( - model=model, - messages=messages, # pyright: ignore[reportArgumentType] - temperature=temperature, - **kwargs, - ) + 若带了 enable_thinking(关思考求快)而端点不支持(如 OpenAI 官方报 400 unknown parameter), + 自动去掉该参数重试一次、并记住该端点之后不再带——这样无论 provider 认不认都不会因此报错。""" + client = get_llm_client() + key = (os.getenv("OPENAI_BASE_URL", ""), model) + if "enable_thinking" in (kwargs.get("extra_body") or {}) and key in _thinking_unsupported: + kwargs = _strip_thinking(kwargs) # 已知该模型不支持 → 提前去掉 + + try: + response = client.chat.completions.create( + model=model, + messages=messages, # pyright: ignore[reportArgumentType] + **kwargs, + ) + except openai.AuthenticationError: + # 公益大模型 JWT 过期:换发后重建 client 重试一次(其它端点的 401 仍照常抛出) + if not free_model.is_free_base(os.getenv("OPENAI_BASE_URL", "")): + raise + logger.info("公益大模型令牌过期,刷新后重试") + free_model.token(force=True) + client = get_llm_client() + response = client.chat.completions.create( + model=model, + messages=messages, # pyright: ignore[reportArgumentType] + **kwargs, + ) + except Exception as exc: + if "enable_thinking" in (kwargs.get("extra_body") or {}) and _is_bad_request(exc): + logger.info("LLM 模型不支持 enable_thinking,去掉该参数重试:%s", model) + _thinking_unsupported.add(key) + response = client.chat.completions.create( + model=model, + messages=messages, # pyright: ignore[reportArgumentType] + **_strip_thinking(kwargs), + ) + else: + raise # 记录响应内容 log_llm_response(response) @@ -112,11 +179,10 @@ def _call_llm_api( def call_llm( messages: List[dict], model: str, - temperature: float = 1, **kwargs: Any, ) -> Any: """Call LLM API with automatic caching.""" - response = _call_llm_api(messages, model, temperature, **kwargs) + response = _call_llm_api(messages, model, **kwargs) if not ( response diff --git a/videocaptioner/core/llm/free_model.py b/videocaptioner/core/llm/free_model.py new file mode 100644 index 00000000..0bc75537 --- /dev/null +++ b/videocaptioner/core/llm/free_model.py @@ -0,0 +1,151 @@ +"""公益大模型免费网关:OpenAI 兼容,无需 API key。无 PyQt。 + +网关在 Cloudflare managed challenge 后面,普通 headless 客户端(requests / httpx / OpenAI SDK) +高频请求会被 403「Just a moment」。统一用 curl_cffi(模拟浏览器 TLS 指纹)过 CF:换 token 用它, +LLM client 也借它的 httpx transport。每安装随机持久化一个 deviceId(按设备分摊免费额度), +用它换 30min JWT 当 api_key(过期 / 401 自动续)。 +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import threading +import time + +import httpx +from curl_cffi import requests as cffi_requests + +from videocaptioner.config import APPDATA_PATH +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("free_model") + +# OpenAI client 以此为 base,自动拼 /chat/completions +BASE_URL = "https://aigw1.immersivetranslate.com/v1/free" +MODEL = "THUDM/GLM-4-9B-0414" +# 凭据解析阶段先填的占位 key(真实令牌在 get_llm_client 内实时取),非空以通过「已配置」校验 +PLACEHOLDER_KEY = "free" +_TOKEN_URL = "https://api2.immersivetranslate.com/free-model/get-token" +_DEVICE_ID_FILE = APPDATA_PATH / "free_model_device_id" +_REFRESH_MARGIN = 120 # JWT 到期前 2 分钟主动续,避开临界并发 +_IMPERSONATE = "chrome" # curl_cffi 模拟的浏览器 TLS 指纹 + + +def is_free_base(base_url: str) -> bool: + return "aigw1.immersivetranslate.com" in (base_url or "") + + +def _new_device_id() -> str: + """64 位 base62,形态对齐浏览器扩展生成的 deviceId。""" + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + return "".join(secrets.choice(alphabet) for _ in range(64)) + + +def _jwt_exp(token: str) -> float: + """解析 JWT 的 exp(unix 秒);解析失败返回 0。""" + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return float(json.loads(base64.urlsafe_b64decode(payload)).get("exp", 0)) + except Exception: + return 0.0 + + +class _TokenProvider: + """进程级 deviceId + JWT 管理:多线程共享,加锁续期。""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._device_id = "" + self._token = "" + self._expire_at = 0.0 + + def device_id(self) -> str: + if self._device_id: + return self._device_id + try: + did = _DEVICE_ID_FILE.read_text(encoding="utf-8").strip() + except OSError: + did = "" + if len(did) < 32: + did = _new_device_id() + try: + _DEVICE_ID_FILE.write_text(did, encoding="utf-8") + except OSError: # 落盘失败用临时 id 继续,不挡使用 + pass + self._device_id = did + return did + + def token(self, *, force: bool = False) -> str: + with self._lock: + now = time.time() + if not force and self._token and now < self._expire_at: + return self._token + resp = cffi_requests.get( + _TOKEN_URL, + params={"deviceId": self.device_id()}, + impersonate=_IMPERSONATE, + timeout=15, + ) + resp.raise_for_status() + token = str((resp.json() or {}).get("data") or "") + if not token: + raise RuntimeError("获取公益大模型令牌失败") + self._token = token + exp = _jwt_exp(token) + self._expire_at = (exp - _REFRESH_MARGIN) if exp else (now + 1500) + return token + + +_provider = _TokenProvider() + + +def token(*, force: bool = False) -> str: + """当前有效令牌(缓存,过期或 force 时重取)。""" + return _provider.token(force=force) + + +class _CurlCffiTransport(httpx.BaseTransport): + """让 OpenAI SDK(httpx)借 curl_cffi 的浏览器 TLS 过 Cloudflare。 + + call_llm 不走流式,只需整体请求/响应。curl_cffi 已解压响应体,故丢掉 content-encoding / + content-length,避免 httpx 二次解压或长度不符。 + """ + + def __init__(self) -> None: + self._session = cffi_requests.Session() + + def handle_request(self, request: httpx.Request) -> httpx.Response: + # 只透传业务头:UA / accept-encoding / sec-ch-* 等指纹头交给 impersonate, + # 否则 OpenAI SDK 的 user-agent 会覆盖浏览器画像、被 Cloudflare 识破 → 403 + headers = { + k: v + for k, v in request.headers.items() + if k.lower() in ("authorization", "content-type") + } + resp = self._session.request( + method=request.method, # type: ignore[arg-type] + url=str(request.url), + headers=headers, + data=request.content or None, + impersonate=_IMPERSONATE, + timeout=300, + ) + headers = [ + (k, v) + for k, v in resp.headers.items() + if k.lower() not in ("content-encoding", "content-length", "transfer-encoding") + ] + return httpx.Response( + status_code=resp.status_code, + headers=headers, + content=resp.content, + request=request, + ) + + +def make_http_client() -> httpx.Client: + """给 OpenAI SDK 的 http_client:经 curl_cffi transport 过 CF。""" + return httpx.Client(transport=_CurlCffiTransport(), timeout=300) diff --git a/videocaptioner/core/ocr/__init__.py b/videocaptioner/core/ocr/__init__.py new file mode 100644 index 00000000..cd117e2e --- /dev/null +++ b/videocaptioner/core/ocr/__init__.py @@ -0,0 +1,9 @@ +"""OCR 引擎抽象与实现(无 PyQt)。 + +后端无关:硬字幕提取只依赖 :class:`OcrEngine` 协议,换引擎只需实现它。包 ``__init__`` 不 +eager import 任何引擎实现,避免把 onnxruntime/rapidocr 拉进无关启动路径——按需 import 具体子模块。 +""" + +from videocaptioner.core.ocr.base import OcrEngine, OcrError, OcrLine + +__all__ = ["OcrEngine", "OcrError", "OcrLine"] diff --git a/videocaptioner/core/ocr/base.py b/videocaptioner/core/ocr/base.py new file mode 100644 index 00000000..85170af2 --- /dev/null +++ b/videocaptioner/core/ocr/base.py @@ -0,0 +1,76 @@ +"""OCR 引擎协议:硬字幕提取依赖的最小识别接口。 + +一条识别结果是 :class:`OcrLine`(文本 + 置信度 + 四点框)。引擎提供两档能力:``recognize`` +(检测 + 识别,给字幕条出文本)与 ``detect``(仅检测,给区域自动检测用,省掉识别开销)。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import numpy as np + +# 四点多边形:((x,y), (x,y), (x,y), (x,y)),顺序为左上→右上→右下→左下 +Polygon = tuple[tuple[float, float], ...] +# 轴对齐包围盒:(x0, y0, x1, y1) +BBox = tuple[float, float, float, float] + + +class OcrError(Exception): + """OCR 引擎不可用(依赖缺失 / 模型下载失败 / 推理异常)。""" + + +@dataclass(frozen=True) +class OcrLine: + """一行识别结果。box 为四点多边形(图像像素坐标),按需取轴对齐包围盒/中心。""" + + text: str + score: float + box: Polygon + + @property + def bbox(self) -> BBox: + xs = [p[0] for p in self.box] + ys = [p[1] for p in self.box] + return (min(xs), min(ys), max(xs), max(ys)) + + @property + def cy(self) -> float: + ys = [p[1] for p in self.box] + return (min(ys) + max(ys)) / 2.0 + + @property + def height(self) -> float: + ys = [p[1] for p in self.box] + return max(ys) - min(ys) + + +def bbox_of(box: Polygon) -> BBox: + xs = [p[0] for p in box] + ys = [p[1] for p in box] + return (min(xs), min(ys), max(xs), max(ys)) + + +class OcrEngine(ABC): + """图像 OCR 引擎。实现需线程安全到「同一实例可被一个工作线程串行复用」即可。 + + 图像约定为 HxWx3 的 numpy 数组(RGB 或 BGR 都接受——RapidOCR/OpenCV 对字幕这种 + 高对比文本不敏感;调用方保持一致即可)。 + """ + + @property + @abstractmethod + def name(self) -> str: + """引擎短名(用于诊断/日志/配置回显)。""" + + @abstractmethod + def recognize(self, image: np.ndarray) -> list[OcrLine]: + """检测 + 识别整张图,返回多行结果(无文本返回空列表)。失败抛 :class:`OcrError`。""" + + @abstractmethod + def detect(self, image: np.ndarray) -> list[BBox]: + """仅做文本检测(不识别),返回文本框包围盒列表。区域自动检测用,比 recognize 快。""" + + def warmup(self) -> None: + """可选:提前加载模型,避免首帧卡顿。默认 no-op。""" diff --git a/videocaptioner/core/ocr/rapid.py b/videocaptioner/core/ocr/rapid.py new file mode 100644 index 00000000..e64cb7fa --- /dev/null +++ b/videocaptioner/core/ocr/rapid.py @@ -0,0 +1,165 @@ +"""RapidOCR(onnxruntime) 引擎:PP-OCR 模型转 ONNX,torch-free、纯 CPU、跨平台一致。 + +重库 ``rapidocr``/``onnxruntime`` 懒加载(首次推理才 import),不拖慢启动。 +""" + +from __future__ import annotations + +import logging +from typing import Optional + +import numpy as np + +from videocaptioner.core.ocr.base import BBox, OcrEngine, OcrError, OcrLine, bbox_of +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("ocr_rapid") + + +def _silence_rapidocr_logs() -> None: + """把 RapidOCR 各子模块 logger 压到 ERROR:它每帧刷 INFO/WARNING(File exists / detection result is empty)淹没日志。""" + for name in list(logging.root.manager.loggerDict): # type: ignore[attr-defined] + if "rapidocr" in name.lower(): + logging.getLogger(name).setLevel(logging.ERROR) + logging.getLogger("RapidOCR").setLevel(logging.ERROR) + +# rec 语言 → det 语言。det 只分中/英/多语三套模型;CJK 系(中/繁/日/韩)统一走 multi det +# 召回最稳,纯英文用 en det 更快更准。 +_DET_LANG = {"ch": "ch", "en": "en", "chinese_cht": "ch"} + + +class RapidOcrEngine(OcrEngine): + """RapidOCR 封装。``lang`` 为识别语言("ch" 即中英通吃),其余为质量/速度旋钮。""" + + def __init__( + self, + lang: str = "ch", + ocr_version: str = "PP-OCRv4", + model_type: str = "mobile", + text_score: float = 0.5, + det_limit_side_len: int = 960, + intra_op_num_threads: int = 0, + ) -> None: + self._lang = lang or "ch" + self._ocr_version = ocr_version + self._model_type = model_type + self._text_score = text_score + # 检测输入最长边上限。区域检测需 960(缩放帧上找居中段对分辨率敏感,过小会误并卡片标题进字幕带); + # 提取阶段 ROI 已贴字幕、字号大,用 768 更快且质量不降。 + self._det_limit_side_len = det_limit_side_len + # 0 表示不指定(RapidOCR 默认 -1 = 自动用全部核);>0 时限制线程数。 + self._threads = intra_op_num_threads + self._engine = None # 懒加载 + + @property + def name(self) -> str: + return f"RapidOCR/{self._ocr_version}/{self._model_type}/{self._lang}" + + def _ensure(self) -> None: + if self._engine is not None: + return + try: + from rapidocr import LangDet, LangRec, ModelType, OCRVersion, RapidOCR + except Exception as exc: # noqa: BLE001 — 依赖缺失对用户是「功能未就绪」,统一成 OcrError + raise OcrError( + "未安装 OCR 引擎依赖(rapidocr / onnxruntime)。硬字幕提取需要它," + "请先安装或在诊断页下载。" + ) from exc + # RapidOCR 的 params 值必须是其枚举类型,不能是字符串。 + rec_lang = getattr(LangRec, self._lang.upper(), LangRec.CH) + det_lang = getattr(LangDet, _DET_LANG.get(self._lang, "multi").upper(), LangDet.MULTI) + version = OCRVersion.PPOCRV5 if "v5" in self._ocr_version.lower() else OCRVersion.PPOCRV4 + model = ModelType.SERVER if self._model_type == "server" else ModelType.MOBILE + params: dict = { + "Global.text_score": self._text_score, + "Det.lang_type": det_lang, + "Det.ocr_version": version, + "Det.model_type": model, + "Det.limit_side_len": self._det_limit_side_len, + "Rec.lang_type": rec_lang, + "Rec.ocr_version": version, + "Rec.model_type": model, + } + if self._threads > 0: + params["EngineConfig.onnxruntime.intra_op_num_threads"] = self._threads + # RapidOCR 在构造里会把自己的 logger 设回 INFO,预先 setLevel 压不住——构造期间全局禁掉 + # INFO 及以下,构造后再把它的 logger 钉到 ERROR(管住后续每帧识别的噪音)。 + logging.disable(logging.INFO) + try: + self._engine = RapidOCR(params=params) + except Exception as exc: # noqa: BLE001 — 模型下载/初始化失败统一成 OcrError + raise OcrError(f"OCR 引擎初始化失败(模型下载或加载出错):{exc}") from exc + finally: + logging.disable(logging.NOTSET) + _silence_rapidocr_logs() + + def warmup(self) -> None: + self._ensure() + + def recognize(self, image: np.ndarray) -> list[OcrLine]: + self._ensure() + assert self._engine is not None + try: + # 必须显式传全部 flag:RapidOCR 实例有状态,调过一次 use_rec=False(detect)后, + # 不显式传就继承上次的 False,导致后续识别返回空。字幕不旋转,跳过 cls 提速。 + result = self._engine(image, use_det=True, use_cls=False, use_rec=True) + except Exception as exc: # noqa: BLE001 + raise OcrError(f"OCR 识别失败:{exc}") from exc + lines: list[OcrLine] = [] + boxes = getattr(result, "boxes", None) + txts = getattr(result, "txts", None) + scores = getattr(result, "scores", None) + if boxes is None or txts is None: + return lines + if scores is None: + scores = [1.0] * len(txts) + for box, txt, score in zip(boxes, txts, scores): + text = (txt or "").strip() + if not text: + continue + lines.append(OcrLine(text=text, score=float(score), box=_to_poly(box))) + return lines + + def detect(self, image: np.ndarray) -> list[BBox]: + self._ensure() + assert self._engine is not None + try: + result = self._engine(image, use_det=True, use_cls=False, use_rec=False) + except Exception as exc: # noqa: BLE001 + raise OcrError(f"OCR 文本检测失败:{exc}") from exc + boxes = getattr(result, "boxes", None) + if boxes is None: + return [] + return [bbox_of(_to_poly(box)) for box in boxes] + + +def _to_poly(box) -> tuple[tuple[float, float], ...]: + """RapidOCR 的 box(np.ndarray (4,2) 或四点列表)→ 纯 Python 四点元组。""" + pts: list[tuple[float, float]] = [] + for point in box: + pts.append((float(point[0]), float(point[1]))) + return tuple(pts) + + +def create_rapidocr( + lang: str = "ch", + ocr_version: str = "PP-OCRv4", + model_type: str = "mobile", + threads: int = 0, + det_limit_side_len: int = 960, +) -> RapidOcrEngine: + """构造 RapidOCR 引擎。``det_limit_side_len``:检测输入最长边上限,区域检测用 960、提取用 768。""" + return RapidOcrEngine( + lang=lang, ocr_version=ocr_version, model_type=model_type, + intra_op_num_threads=threads, det_limit_side_len=det_limit_side_len, + ) + + +def ocr_dependency_ready() -> Optional[str]: + """OCR 依赖是否就绪。就绪返回 None""" + import importlib.util + + missing = [m for m in ("onnxruntime", "rapidocr") if importlib.util.find_spec(m) is None] + if missing: + return f"缺少 OCR 引擎依赖:{', '.join(missing)}" + return None diff --git a/videocaptioner/core/optimize/optimize.py b/videocaptioner/core/optimize/optimize.py index 76193bf4..d76897d8 100644 --- a/videocaptioner/core/optimize/optimize.py +++ b/videocaptioner/core/optimize/optimize.py @@ -48,7 +48,6 @@ def __init__( batch_num: 每批处理的字幕数量 model: LLM模型名称 custom_prompt: 自定义优化提示词 - temperature: LLM温度参数 update_callback: 进度更新回调函数 """ self.thread_num = thread_num @@ -222,7 +221,6 @@ def agent_loop(self, subtitle_chunk: Dict[str, str]) -> Dict[str, str]: response = call_llm( messages=messages, model=self.model, - temperature=0.2, ) result_text = response.choices[0].message.content diff --git a/videocaptioner/core/realtime/__init__.py b/videocaptioner/core/realtime/__init__.py new file mode 100644 index 00000000..bedc8df6 --- /dev/null +++ b/videocaptioner/core/realtime/__init__.py @@ -0,0 +1,27 @@ +"""实时语音转录 / 翻译(实时字幕)核心层。 + +后端无关:每个 ASR 后端把自家 wire 协议翻成统一的实时转录事件 +(:class:`TranscriptSegment`,自带稳定 per-sentence ``seg_id``),再由 +:class:`CaptionAssembler` 按 ``seg_id`` upsert + 双色 + 翻译装配成可直接上屏的 +:class:`CaptionEntry`(**分句是后端的职责**)。新增后端只需实现 :class:`LiveTranscriber`。 +""" + +from videocaptioner.core.realtime.backends.base import ( + LiveCaptionError, + LiveTranscriber, + TranscriberState, +) +from videocaptioner.core.realtime.caption import CaptionAssembler +from videocaptioner.core.realtime.config import LiveCaptionConfig, LiveCaptionSource +from videocaptioner.core.realtime.events import CaptionEntry, TranscriptSegment + +__all__ = [ + "CaptionAssembler", + "CaptionEntry", + "LiveCaptionConfig", + "LiveCaptionError", + "LiveCaptionSource", + "LiveTranscriber", + "TranscriberState", + "TranscriptSegment", +] diff --git a/videocaptioner/core/realtime/audio/__init__.py b/videocaptioner/core/realtime/audio/__init__.py new file mode 100644 index 00000000..7eb518db --- /dev/null +++ b/videocaptioner/core/realtime/audio/__init__.py @@ -0,0 +1,3 @@ +""" +音频采集源(无 Qt),统一输出 16k/mono/s16le PCM,接口对齐 ``start()/read()/stop()``。 +""" diff --git a/videocaptioner/core/realtime/audio/capture.py b/videocaptioner/core/realtime/audio/capture.py new file mode 100644 index 00000000..58d5dfa5 --- /dev/null +++ b/videocaptioner/core/realtime/audio/capture.py @@ -0,0 +1,160 @@ +"""本地音频采集(sounddevice / PortAudio)→ 16k/mono/s16le PCM。 + +首选 16k 单声道打开,系统按需做抗混叠重采样;设备不收单声道则回退原生声道数、回调里按声道 +均值下混(非取声道 0,防非对称设备丢音)。回调只入队(满丢最新块)、不阻塞不抛异常;上层用 +read() 拉取。 + +macOS 系统声音走 audio/system_mac.py,不经本模块。 +""" + +from __future__ import annotations + +import queue +from dataclasses import dataclass +from typing import List, Optional + +from videocaptioner.core.realtime.backends.base import SAMPLE_RATE +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_audio") + + +class AudioSourceError(RuntimeError): + """音频采集错误(无设备 / 打开失败)。""" + + +@dataclass(frozen=True) +class AudioDevice: + """一个可选的输入设备。""" + + index: int + name: str + is_default: bool + + +def _sd(): + """延迟导入 sounddevice:导入即加载 PortAudio,放到真正用时再触发。""" + try: + import sounddevice # noqa: PLC0415 + except Exception as exc: # PortAudio 缺失等 + raise AudioSourceError(f"无法加载音频库 sounddevice:{exc}") from exc + return sounddevice + + +def list_input_devices() -> List[AudioDevice]: + """枚举所有可用输入设备(max_input_channels > 0)。""" + sd = _sd() + try: + devices = sd.query_devices() + default_in = sd.default.device[0] + except Exception as exc: + raise AudioSourceError(f"枚举音频设备失败:{exc}") from exc + + result: List[AudioDevice] = [] + for idx, dev in enumerate(devices): + if dev.get("max_input_channels", 0) <= 0: + continue + result.append( + AudioDevice( + index=idx, + name=dev.get("name", f"设备 {idx}"), + is_default=(idx == default_in), + ) + ) + return result + + +class AudioCapture: + """从一个输入设备以 16k/mono/s16le 采集,按块入队供拉取。""" + + def __init__(self, device_index: Optional[int] = None, queue_max: int = 256) -> None: + self._device_index = device_index + self._queue: "queue.Queue[bytes]" = queue.Queue(maxsize=queue_max) + self._stream = None + self._channels = 1 # 实际打开的声道数(>1 时回调里均值下混成 mono) + self._dropped = 0 + self._overruns = 0 + + def _device_max_channels(self, sd) -> int: # noqa: ANN001 + """目标设备的最大输入声道数(默认设备 None 也解析到真实设备);查不到返回 0。""" + try: + index = self._device_index + if index is None: + index = sd.default.device[0] + info = sd.query_devices(index) + return int(info.get("max_input_channels", 0)) + except Exception: + return 0 + + def start(self) -> None: + sd = _sd() + # 部分设备拒绝单声道打开:先试 1 声道,不收则回退原生声道数,多声道由回调下混成 mono。 + candidates = [] + for ch in (1, self._device_max_channels(sd), 2): + if ch >= 1 and ch not in candidates: + candidates.append(ch) + last_exc: Optional[Exception] = None + for ch in candidates: + stream = None + try: + stream = sd.InputStream( + samplerate=SAMPLE_RATE, + channels=ch, + dtype="int16", + device=self._device_index, + blocksize=SAMPLE_RATE // 20, # ~50ms 一块 + callback=self._callback, + ) + stream.start() + except Exception as exc: + last_exc = exc + if stream is not None: # start() 失败先关掉,避免泄漏 PortAudio 流 + try: + stream.close() + except Exception: + pass + continue + self._stream = stream + self._channels = ch + logger.info("音频采集已启动:设备=%s 声道=%d @16k", self._device_index, ch) + return + raise AudioSourceError( + f"打开音频输入失败(无法以 16kHz 打开该设备,请在设置里换一个输入设备):{last_exc}" + ) from last_exc + + def _callback(self, indata, frames, time_info, status) -> None: # noqa: ANN001 + if status and getattr(status, "input_overflow", False): + # 输入溢出:音频在入队前已被设备/回调丢弃,计数告警 + self._overruns += 1 + if self._overruns % 20 == 1: + logger.warning("音频采集 overflow ×%d:回调跟不上,可能丢音频", self._overruns) + # 多声道按声道均值下混成 mono(非取声道 0,防非对称设备丢音);int32 累加防 int16 溢出。 + mono = (indata if self._channels == 1 + else indata.astype("int32").mean(axis=1).round().astype("int16").reshape(-1, 1)) + pcm = mono.tobytes() + if not pcm: + return + try: + self._queue.put_nowait(pcm) + except queue.Full: + # 队列满丢「最新」块(接缝落在实时边缘、危害最小);绝不丢最旧,否则从语音中段挖洞。 + self._dropped += 1 + if self._dropped % 20 == 1: + logger.warning("音频队列溢出丢帧 ×%d:消费端跟不上", self._dropped) + + def read(self, timeout: float = 0.1) -> Optional[bytes]: + """拉取一块 PCM;超时返回 None(让上层有机会检查取消)。""" + try: + return self._queue.get(timeout=timeout) + except queue.Empty: + return None + + def stop(self) -> None: + stream = self._stream + self._stream = None + if stream is not None: + try: + stream.stop() + stream.close() + except Exception: + logger.debug("关闭音频流异常", exc_info=True) diff --git a/videocaptioner/core/realtime/audio/system_mac.py b/videocaptioner/core/realtime/audio/system_mac.py new file mode 100644 index 00000000..b87f5a34 --- /dev/null +++ b/videocaptioner/core/realtime/audio/system_mac.py @@ -0,0 +1,161 @@ +"""macOS 原生系统声音采集。 + +起 ``macsysaudio`` 子进程(源见 ``native/macsysaudio/``)捕获本机播放声→16k/mono/s16le +PCM 写 stdout,本类读出入队,对外接口与 :class:`AudioCapture` 完全一致(drop-in),关 +stdin(EOF)即停。SCK 音频归「屏幕录制」权限;未授权时 helper 退出码 2 + stderr +"PERMISSION",这里抛 :class:`MacSystemAudioPermissionError` 供上层提示用户授权后重启。 +""" + +from __future__ import annotations + +import collections +import queue +import subprocess +import sys +import threading +import time +from typing import Optional + +from videocaptioner import config +from videocaptioner.core.realtime.audio.capture import AudioSourceError +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_sysaudio") + +_BINARY_NAME = "macsysaudio" +_READY_TOKEN = "STARTED" +_PERMISSION_EXIT = 2 + + +class MacSystemAudioPermissionError(AudioSourceError): + """未授予「屏幕录制」权限——系统声音捕获不可用。""" + + +def find_macsysaudio_binary(configured: str = "") -> Optional[str]: + """发现 macsysaudio 可执行文件(配置路径 → 自带 bin → 用户 bin → PATH)。""" + return config.find_binary(_BINARY_NAME, configured) + + +def system_audio_supported() -> bool: + """当前平台是否支持原生系统声音捕获(仅 macOS 且找得到 helper)。""" + return sys.platform == "darwin" and find_macsysaudio_binary() is not None + + +class MacSystemAudioCapture: + """ScreenCaptureKit 系统声音采集,接口与 :class:`AudioCapture` 对齐(drop-in)。""" + + def __init__(self, binary: str = "", queue_max: int = 256) -> None: + self._binary = binary + self._queue: "queue.Queue[bytes]" = queue.Queue(maxsize=queue_max) + self._proc: Optional[subprocess.Popen] = None + self._read_thread: Optional[threading.Thread] = None + # 只保留尾部供报错(与 voxgate 一致):长会话里 helper 的 stderr 行不无限累积。 + self._stderr_lines: "collections.deque[str]" = collections.deque(maxlen=40) + self._started = threading.Event() + self._dropped = 0 + + def start(self) -> None: + binary = find_macsysaudio_binary(self._binary) + if not binary: + raise AudioSourceError( + "未找到系统声音捕获程序 macsysaudio。请先构建 native/macsysaudio(build.sh)。" + ) + try: + self._proc = subprocess.Popen( + [binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + except Exception as exc: + raise AudioSourceError(f"启动系统声音捕获失败:{exc}") from exc + + threading.Thread(target=self._drain_stderr, daemon=True).start() + # 等 helper 就绪("STARTED",SCK 异步启动给足 8s);轮询进程状态,崩了立即报错而非干等。 + deadline = time.monotonic() + 8.0 + while not self._started.wait(timeout=0.1): + code = self._proc.poll() + if code is not None: + self._raise_for_exit(code) + if time.monotonic() >= deadline: + self.stop() + raise AudioSourceError("系统声音捕获启动超时(ScreenCaptureKit 未就绪)。") + + # 保留句柄:stop() 需 join 读线程到 EOF,确保尾音入队后再回收进程。 + self._read_thread = threading.Thread(target=self._read_loop, daemon=True) + self._read_thread.start() + logger.info("系统声音采集已启动:%s", binary) + + def _raise_for_exit(self, code: int) -> None: + detail = " ".join(list(self._stderr_lines)[-3:]).strip() + if code == _PERMISSION_EXIT or "PERMISSION" in detail: + raise MacSystemAudioPermissionError( + "未获得「屏幕录制」权限,无法捕获系统声音。请在 系统设置 → 隐私与安全性 → " + "屏幕录制 中勾选本应用(或运行它的终端),然后重启应用重试。" + ) + raise AudioSourceError(f"系统声音捕获启动失败(退出码 {code}):{detail or '未知错误'}") + + def _drain_stderr(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + for raw in iter(proc.stderr.readline, b""): + line = raw.decode("utf-8", "replace").strip() + if not line: + continue + self._stderr_lines.append(line) + if _READY_TOKEN in line: + self._started.set() + else: + logger.debug("macsysaudio: %s", line) + + def _read_loop(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + stdout = proc.stdout + while True: + chunk = stdout.read(3200) # ~100ms @16k/mono/s16le + if not chunk: + break # helper 退出 / 管道关闭 + try: + self._queue.put_nowait(chunk) + except queue.Full: + # 与 AudioCapture 一致:满则丢最新块,绝不丢最旧。 + self._dropped += 1 + if self._dropped % 20 == 1: + logger.warning("系统声音队列溢出丢帧 ×%d:消费端跟不上", self._dropped) + + def read(self, timeout: float = 0.1) -> Optional[bytes]: + try: + return self._queue.get(timeout=timeout) + except queue.Empty: + return None + + def stop(self) -> None: + proc = self._proc + self._proc = None + if proc is None: + return + try: + if proc.stdin: + proc.stdin.close() # EOF → helper 自行停止退出 + except Exception: + pass + # 先 join 读线程到 EOF,确保 helper 冲刷的尾音入队供上层排空,再回收进程。 + rt = self._read_thread + self._read_thread = None + if rt is not None: + rt.join(timeout=2.0) + try: + proc.wait(timeout=3.0) + except Exception: + try: + proc.terminate() + proc.wait(timeout=2.0) + except Exception: + try: + proc.kill() + except Exception: + pass diff --git a/videocaptioner/core/realtime/backends/__init__.py b/videocaptioner/core/realtime/backends/__init__.py new file mode 100644 index 00000000..81d940a3 --- /dev/null +++ b/videocaptioner/core/realtime/backends/__init__.py @@ -0,0 +1,10 @@ +"""实时转录后端(无 Qt)。 + +- ``base``::class:`LiveTranscriber` 抽象 + 音频契约常量(SAMPLE_RATE 等)+ 回调类型。 +- ``voxgate`` / ``fun_asr`` / ``qwen_asr``:三个具体后端,各自连接、按句切分、回调统一协议。 +- ``languages``:每个 provider 支持的识别语言(单一事实来源)。 + +刻意不在此处 re-export 各后端类:工厂只在 ``build_backend`` 命中对应 ``cfg.backend`` 分支时才 +import 走 WS 的 fun-asr / qwen-asr,避免未用到的后端把 ``websocket`` 拉进启动路径(voxgate 走子进程、 +不依赖 websocket,故由工厂顶部直接 import)。需要哪个后端就从对应子模块直接 import。 +""" diff --git a/videocaptioner/core/realtime/backends/base.py b/videocaptioner/core/realtime/backends/base.py new file mode 100644 index 00000000..91666b80 --- /dev/null +++ b/videocaptioner/core/realtime/backends/base.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import threading +import time +from abc import ABC, abstractmethod +from enum import Enum +from typing import Callable, Optional + +from videocaptioner.core.realtime.events import TranscriptSegment + +# 统一的音频契约:所有后端都按这个格式喂音频。 +SAMPLE_RATE = 16000 +CHANNELS = 1 +SAMPLE_WIDTH = 2 # int16 + + +class LiveCaptionError(RuntimeError): + """实时字幕链路错误(连接 / 鉴权 / 后端失败)。""" + + +class TranscriberState(str, Enum): + """后端连接状态,用于 UI 状态指示。 + + 只保留各后端真正会发的三态;错误走 ``on_error`` 回调而非状态位。 + """ + + CONNECTING = "connecting" + LISTENING = "listening" + STOPPED = "stopped" + + +# 后端 → 上层的回调签名 +OnSegment = Callable[[TranscriptSegment], None] +OnState = Callable[[TranscriberState], None] +OnError = Callable[[str], None] + + +class LiveTranscriber(ABC): + """流式转录后端基类。 + + 线程模型:``feed()`` 在调用方线程(音频泵)被频繁调用;后端内部通常另起一个 + 接收线程解析事件并触发 ``on_segment``。回调可能在后端的接收线程被调用,上层 + (Qt 信号)需自行处理跨线程。 + """ + + # 掉线续录治理参数(WS 后端共用) + _RECONNECT_MAX_DELAY_S: float = 5.0 # 连不上时指数退避上限 + _RECONNECT_HEALTHY_S: float = 5.0 # 连接存活超此时长再断算偶发,否则计入抖动期 + _RECONNECT_MAX_FAILS: int = 12 # 连续建连失败上限(约 1 分钟)→ 上报致命并放弃 + + def __init__( + self, + on_segment: Optional[OnSegment] = None, + on_state: Optional[OnState] = None, + on_error: Optional[OnError] = None, + ) -> None: + self.on_segment = on_segment + self.on_state = on_state + self.on_error = on_error + # 调试钩子(默认 None):设置后,后端把每条原生事件原文回调出去供落盘排查;生产路径不设。 + self.on_raw: Optional[Callable[[str], None]] = None + self._reconnect_wake = threading.Event() # stop() set 它以立即结束退避等待 + self._reconnect_streak = 0 # 连续重连次数,用于日志限频 + self._last_alive_at = 0.0 # 上次确认连接存活的单调时刻 + + # ----- 子类实现 ----- + + @abstractmethod + def start(self) -> None: + """建立连接 / 会话;就绪后才可 ``feed``。失败抛 :class:`LiveCaptionError`。""" + + @abstractmethod + def feed(self, pcm16: bytes) -> None: + """送入一段 16k/mono/s16le PCM(chunk 大小任意)。""" + + @abstractmethod + def stop(self) -> None: + """收尾并断开;幂等。""" + + # ----- 给子类的便捷分发 ----- + + def _emit_segment(self, segment: TranscriptSegment) -> None: + if self.on_segment is not None: + self.on_segment(segment) + + def _emit_state(self, state: TranscriberState) -> None: + if self.on_state is not None: + self.on_state(state) + + def _emit_error(self, message: str) -> None: + if self.on_error is not None: + self.on_error(message) + + # ----- 给 WS 后端共用的掉线续录治理 ----- + + def _note_alive(self) -> None: + """收到任意服务端数据时调用:刷新连接存活时刻,供重连治理区分偶发断与抖动期。""" + self._last_alive_at = time.monotonic() + + def _reconnect_with_backoff(self, reason, *, attempt, is_stopping, logger, name) -> bool: + """掉线 / 服务端中途结束时的统一续录治理(多个 WS 后端共用,消除各自重复实现)。 + + ``attempt`` 由子类提供:建连 + 换 ws + 重置会话状态,成功正常返回、失败抛异常。偶发单断 + 立即重连(首轮无等待);连不上则指数退避至 ``_RECONNECT_MAX_DELAY_S``,连续失败超 + ``_RECONNECT_MAX_FAILS`` 才上报致命并放弃(不再一次失败就退线程)。连上又秒断的抖动期 + (配额满 / 限流等 "accept-then-drop")里 attempt 每次都建连成功、``fails`` 永不累加,必须靠 + 跨调用累计的 ``_reconnect_streak`` 兜底终止,否则会每 0.5s 无限重连、永不出字也永不报错。 + ``is_stopping`` 为真或 ``_reconnect_wake`` 被 set(stop())时立即中断。 + """ + now = time.monotonic() + flaky = self._last_alive_at and (now - self._last_alive_at) < self._RECONNECT_HEALTHY_S + self._reconnect_streak = self._reconnect_streak + 1 if flaky else 1 + if self._reconnect_streak > self._RECONNECT_MAX_FAILS: + self._emit_error( + f"{name} 反复连上即断(已 {self._reconnect_streak} 次,疑似配额 / 限流),停止续录:{reason}") + return False + if self._reconnect_streak <= 2 or self._reconnect_streak % 10 == 0: + logger.info("%s 连接中断,重连续录(第 %d 次,%s)", name, self._reconnect_streak, reason) + # 偶发单断立即重连;抖动期(连上又秒断)先等一个最小间隔,否则会全速重连空转打满 CPU。 + delay = 0.5 if flaky else 0.0 + fails = 0 + while not is_stopping(): + if delay and self._reconnect_wake.wait(delay): + return False # 被 stop() 唤醒 + try: + attempt() + except Exception as exc: + fails += 1 + if fails >= self._RECONNECT_MAX_FAILS: + self._emit_error(f"{name} 网络持续不可用(连续 {fails} 次重连失败):{exc}") + return False + delay = min(max(delay, 0.5) * 2, self._RECONNECT_MAX_DELAY_S) + continue + self._last_alive_at = time.monotonic() + return True + return False diff --git a/videocaptioner/core/realtime/backends/fun_asr.py b/videocaptioner/core/realtime/backends/fun_asr.py new file mode 100644 index 00000000..11e087a6 --- /dev/null +++ b/videocaptioner/core/realtime/backends/fun_asr.py @@ -0,0 +1,245 @@ +"""阿里云百炼 Fun-ASR 实时转录后端(DashScope WS)。 + +后端负责切句:一个 sentence_id 一句,映射成独立 seg_id(``funasr##``),句文本是生长式全文,同句改写 upsert 同一 seg_id。 +关键坑:``sentence_end`` 不可靠,故定稿靠「下一句出现」触发、末句靠 ``task-finished``。 +""" + +from __future__ import annotations + +import threading +import uuid +from typing import Optional + +import websocket + +from videocaptioner.core.realtime.backends.base import ( + LiveCaptionError, + OnError, + OnSegment, + OnState, + TranscriberState, +) +from videocaptioner.core.realtime.backends.languages import ( + FUN_ASR_MTL_LANGS, + FUN_ASR_REALTIME_LANGS, +) +from videocaptioner.core.realtime.backends.ws_base import WebSocketTranscriber +from videocaptioner.core.realtime.events import TranscriptSegment +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_fun_asr_backend") + +_WS_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/inference" +DEFAULT_MODEL = "fun-asr-mtl-realtime" # 多语种实时:中/粤/英/日/泰/越/印尼 + + +class FunAsrBackend(WebSocketTranscriber): + def __init__( + self, + api_key: str, + model: str = DEFAULT_MODEL, + language: str = "zh", + *, + on_segment: Optional[OnSegment] = None, + on_state: Optional[OnState] = None, + on_error: Optional[OnError] = None, + ) -> None: + # 基类设 WS 共性状态:_ws / _send_lock / _recv_thread / _closed / _stopping / _fed_any + super().__init__(on_segment, on_state, on_error) + if not api_key: + raise LiveCaptionError("Fun-ASR 实时转录需要阿里云百炼 API Key(在设置里填写)。") + self._api_key = api_key + self._model = model or DEFAULT_MODEL + self._language = language or "zh" + self._task_id = uuid.uuid4().hex + self._started_event = threading.Event() # 收到 task-started + self._finished_event = threading.Event() # 收到 task-finished/failed + self._gen = 0 # 重连代数:seg_id 带 gen 避免新旧 task 的 sentence_id 撞号 + self._open_sid: object = None # 正在生长、尚未定稿的句 id + self._open_seg_id: Optional[str] = None # 该句完整 seg_id(带 gen),定稿用 + self._open_text = "" # 该句最新文本 + self._open_start: Optional[float] = None + self._open_end: Optional[float] = None + + # ----- LiveTranscriber ----- + + def start(self) -> None: + self._emit_state(TranscriberState.CONNECTING) + header = [f"Authorization: Bearer {self._api_key}"] + try: + self._ws = websocket.create_connection( + _WS_URL, timeout=10, enable_multithread=True, header=header) + self._ws.settimeout(0.5) + except Exception as exc: + raise LiveCaptionError(f"连接 Fun-ASR 实时服务失败:{exc}") from exc + + self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True) + self._recv_thread.start() + self._send_run_task() + # 等 task-started 再宣布就绪:之前喂的音频会被服务端丢弃 + if not self._started_event.wait(timeout=10): + self.stop() + raise LiveCaptionError("Fun-ASR 实时服务未在 10s 内就绪(检查 API Key / 网络)。") + self._emit_state(TranscriberState.LISTENING) + + def feed(self, pcm16: bytes) -> None: + if self._closed or self._stopping or not pcm16: + return + # 未收到 task-started(含重连缝隙)前不喂:服务端会丢弃该时段的二进制帧。 + if not self._started_event.is_set(): + return + try: + with self._send_lock: # 锁内取并用 ws,避免与 stop() 关 ws 的竞态 + ws = self._ws + if ws is None: + return + ws.send(pcm16, opcode=websocket.ABNF.OPCODE_BINARY) + self._fed_any = True + except Exception as exc: + if not self._closed: + logger.debug("发送音频失败:%s", exc) + + def stop(self) -> None: + if self._closed or self._stopping: + return + self._stopping = True # 停止喂音频,但接收线程仍存活以接住末句 + task-finished + self._reconnect_wake.set() # 唤醒可能正在退避等待的重连,立即收尾 + ws = self._ws + if ws is not None: + self._finished_event.clear() + if self._fed_any: # 没喂过音频就别 finish-task(空任务直接关更稳) + try: + self._send_json( + { + "header": { + "action": "finish-task", + "task_id": self._task_id, + "streaming": "duplex", + }, + "payload": {"input": {}}, + } + ) + except Exception: + pass + self._finished_event.wait(timeout=3.0) + self._closed = True + try: + ws.close() + except Exception: + pass + else: + self._closed = True + if self._recv_thread is not None: + self._recv_thread.join(timeout=2) + self._emit_state(TranscriberState.STOPPED) + + # ----- 内部 ----- + + def _language_hints(self) -> list: + """language_hints:显式选了语言就用它;auto 则给该模型支持的整套语言由模型自动识别。""" + if self._language and self._language != "auto": + return [self._language] + return list(FUN_ASR_MTL_LANGS if "mtl" in self._model else FUN_ASR_REALTIME_LANGS) + + def _send_run_task(self) -> None: + """开一个 ASR 任务(start 首连 + 重连续录共用)。""" + self._send_json({ + "header": {"action": "run-task", "task_id": self._task_id, "streaming": "duplex"}, + "payload": { + "task_group": "audio", "task": "asr", "function": "recognition", + "model": self._model, + "parameters": { + "format": "pcm", "sample_rate": 16000, + "language_hints": self._language_hints(), + # 用 VAD 断句而非语义分句:语义分句会关闭 VAD,连续语音下单句过长、段落过粗。 + "semantic_punctuation_enabled": False, + "max_sentence_silence": 800, # 停顿 ~0.8s 即断句(默认 1300 偏大) + "multi_threshold_mode_enabled": True, # 长句也能切,单句不拖太长 + "heartbeat": True, # 连续静音也保活,减少空闲断连/重连 + }, + "input": {}, + }, + }) + + def _try_reconnect(self, reason) -> bool: + """掉线或服务端中途 task-finished(实时单 task 有时长上限)时透明续录:定稿在途句,退避重连 + 新连接并重发 run-task + bump gen。退避 / 限频 / 失败上限见 base._reconnect_with_backoff。""" + self._finalize_open() # 续录前最后一句先定稿,不丢 + + def attempt() -> None: + new = websocket.create_connection( + _WS_URL, timeout=10, enable_multithread=True, + header=[f"Authorization: Bearer {self._api_key}"]) + new.settimeout(0.5) + with self._send_lock: + old, self._ws = self._ws, new + if old is not None: + try: + old.close() + except Exception: + pass + self._gen += 1 # 新 task 的 sentence_id 又从 1 起,bump gen 避免 seg_id 撞号 + self._task_id = uuid.uuid4().hex + self._started_event.clear() + self._send_run_task() # task-started 由本循环后续自然收到 + + return self._reconnect_with_backoff( + reason, attempt=attempt, + is_stopping=lambda: self._closed or self._stopping, + logger=logger, name="Fun-ASR") + + @staticmethod + def _ms_to_s(v) -> Optional[float]: + return v / 1000.0 if isinstance(v, (int, float)) else None + + def _finalize_open(self) -> None: + """把当前句作为定稿段发出并清空在途状态(下一句出现 / 收尾时调用);清空避免被二次定稿。""" + if self._open_seg_id and self._open_text: + self._emit_segment(TranscriptSegment( + self._open_seg_id, self._open_text, is_final=True, + start_time=self._open_start, end_time=self._open_end)) + self._open_sid = None + self._open_seg_id = None + self._open_text = "" + self._open_start = self._open_end = None + + def _dispatch(self, ev: dict) -> None: + event = ev.get("header", {}).get("event", "") + if event == "task-started": + self._started_event.set() + elif event == "result-generated": + sentence = ev.get("payload", {}).get("output", {}).get("sentence") or {} + if sentence.get("heartbeat"): + return # 心跳结果可跳过 + text = (sentence.get("text") or "").strip() + if not text: + return + sid = sentence.get("sentence_id") + if sid is None: + sid = sentence.get("begin_time") # 兜底身份(极少缺 sentence_id) + st = self._ms_to_s(sentence.get("begin_time")) + et = self._ms_to_s(sentence.get("end_time")) + # 新句出现 → 上一句可靠定稿(不信 sentence_end)。同句改写只 upsert 自己的 seg_id。 + if self._open_sid is not None and sid != self._open_sid: + self._finalize_open() + seg_id = f"funasr#{self._gen}#{sid}" + self._open_sid, self._open_seg_id, self._open_text = sid, seg_id, text + self._open_start, self._open_end = st, et + self._emit_segment(TranscriptSegment( + seg_id, text, is_final=False, start_time=st, end_time=et)) + elif event == "task-failed": + msg = ev.get("header", {}).get("error_message", "Fun-ASR 实时任务失败") + self._emit_error(str(msg)) + self._started_event.set() # 解开可能在等就绪的 start() + self._finished_event.set() + elif event == "task-finished": + if self._stopping or self._closed: + # 我们主动 finish-task 后的正常收尾:末句没有「下一句」触发边界,这里定稿。 + self._finalize_open() + self._started_event.set() # 解开可能在等就绪的 start() + self._finished_event.set() + else: + # 服务端中途结束 task(实时单 task 有时长上限)但音频还在喂 → 重起续录,不当结束。 + logger.info("Fun-ASR 服务端中途结束 task,重起续录") + if not self._try_reconnect("服务端结束 task"): + self._fail_close() # 重起失败:关 ws + 发 STOPPED,终态与 stop() 一致 diff --git a/videocaptioner/core/realtime/backends/languages.py b/videocaptioner/core/realtime/backends/languages.py new file mode 100644 index 00000000..17e5e8d5 --- /dev/null +++ b/videocaptioner/core/realtime/backends/languages.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +AUTO = "auto" + +# Fun-ASR 两个实时模型各自的支持集(不含 auto);后端 _language_hints 按模型取用。 +FUN_ASR_MTL_LANGS: tuple[str, ...] = ("zh", "yue", "en", "ja", "th", "vi", "id") +FUN_ASR_REALTIME_LANGS: tuple[str, ...] = ("zh", "en", "ja") + +# 每个 provider 的识别语言下拉集(含 auto,且 auto 在首位)。 +VOXGATE_LANGS: tuple[str, ...] = (AUTO, "zh", "en") +FUN_ASR_LANGS: tuple[str, ...] = (AUTO, *FUN_ASR_MTL_LANGS) +QWEN_ASR_LANGS: tuple[str, ...] = ( + AUTO, + "zh", "yue", "en", "es", "ja", "ko", "fr", "de", "pt", "ru", + "it", "ar", "hi", "id", "th", "vi", "tr", "uk", "ms", "fil", + "pl", "cs", "sv", "da", "no", "fi", "is", +) + +PROVIDER_SOURCE_LANGS: dict[str, tuple[str, ...]] = { + "voxgate": VOXGATE_LANGS, + "fun-asr": FUN_ASR_LANGS, + "qwen-asr": QWEN_ASR_LANGS, +} + + +def source_lang_codes(provider: str) -> tuple[str, ...]: + """该 provider 支持的识别语言 code(含 auto);未知 provider 退回 voxgate 的中英集。""" + return PROVIDER_SOURCE_LANGS.get(provider, VOXGATE_LANGS) + + +def all_source_lang_codes() -> list[str]: + """所有 provider 支持语言的并集(保序去重)。校验器用:任一 provider 的选择都能持久化。""" + seen: dict[str, None] = {} + for codes in PROVIDER_SOURCE_LANGS.values(): + for code in codes: + seen.setdefault(code, None) + return list(seen) diff --git a/videocaptioner/core/realtime/backends/qwen_asr.py b/videocaptioner/core/realtime/backends/qwen_asr.py new file mode 100644 index 00000000..1e0254d1 --- /dev/null +++ b/videocaptioner/core/realtime/backends/qwen_asr.py @@ -0,0 +1,226 @@ +""" +阿里云百炼 Qwen-ASR 实时转录后端(DashScope WS,OpenAI-realtime 风格,27 语种含自动识别)。 +""" + +from __future__ import annotations + +import base64 +import json +import threading +import uuid +from typing import Optional + +import websocket + +from videocaptioner.core.realtime.backends.base import ( + SAMPLE_RATE, + LiveCaptionError, + OnError, + OnSegment, + OnState, + TranscriberState, +) +from videocaptioner.core.realtime.backends.ws_base import WebSocketTranscriber +from videocaptioner.core.realtime.events import TranscriptSegment +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_qwen_asr_backend") + +_WS_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" +DEFAULT_MODEL = "qwen3-asr-flash-realtime" # 多语种实时:27 种语言 + 自动识别 +_SILENCE_MS = 800 # server_vad 停顿断句阈值 + +_TEXT_EVENT = "conversation.item.input_audio_transcription.text" +_DONE_EVENT = "conversation.item.input_audio_transcription.completed" +_FAIL_EVENT = "conversation.item.input_audio_transcription.failed" + + +class QwenAsrBackend(WebSocketTranscriber): + def __init__( + self, + api_key: str, + model: str = DEFAULT_MODEL, + language: str = "auto", + *, + on_segment: Optional[OnSegment] = None, + on_state: Optional[OnState] = None, + on_error: Optional[OnError] = None, + ) -> None: + super().__init__(on_segment, on_state, on_error) + if not api_key: + raise LiveCaptionError("Qwen-ASR 实时转录需要阿里云百炼 API Key(在设置里填写)。") + # 基类设 WS 共性状态:_ws / _send_lock / _recv_thread / _closed / _stopping / _fed_any + self._api_key = api_key + self._model = model or DEFAULT_MODEL + self._language = language or "auto" + self._session_ready = threading.Event() # 已收 session.created 并发出 session.update + self._finished_event = threading.Event() # 收尾用:已收 session.finished/error + # 在途未定稿句,重连/收尾时补定稿以免丢末句。 + self._open_item: Optional[str] = None + self._open_text = "" + + # ----- LiveTranscriber ----- + + def start(self) -> None: + self._emit_state(TranscriberState.CONNECTING) + try: + self._ws = self._connect() + except Exception as exc: + raise LiveCaptionError(f"连接 Qwen-ASR 实时服务失败:{exc}") from exc + self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True) + self._recv_thread.start() + # 收到 session.created 并回发 session.update(见 _dispatch)后才算就绪 + if not self._session_ready.wait(timeout=10): + self.stop() + raise LiveCaptionError("Qwen-ASR 实时服务未在 10s 内就绪(检查 API Key / 网络)。") + self._emit_state(TranscriberState.LISTENING) + + def feed(self, pcm16: bytes) -> None: + if self._closed or self._stopping or not pcm16: + return + if not self._session_ready.is_set(): + # 必须先 update 后喂音频:音频先到会被服务端拒为「session already started」。 + return + msg = json.dumps({ + "event_id": uuid.uuid4().hex, + "type": "input_audio_buffer.append", + "audio": base64.b64encode(pcm16).decode("ascii"), + }) + try: + with self._send_lock: # 锁内取并用 ws:stop()/重连会换 ws,锁外取有竞态 + ws = self._ws + if ws is None: + return + ws.send(msg) + self._fed_any = True + except Exception as exc: + if not self._closed: + logger.debug("发送音频失败:%s", exc) + + def stop(self) -> None: + if self._closed or self._stopping: + return + self._stopping = True # 停喂音频,接收线程仍存活以接住末句 + session.finished + self._reconnect_wake.set() # 唤醒可能正在退避等待的重连,立即收尾 + ws = self._ws + if ws is not None: + self._finished_event.clear() + if self._fed_any: # 没喂过音频则跳过 session.finish(空任务直接关更稳) + try: + self._send_json({"event_id": uuid.uuid4().hex, "type": "session.finish"}) + except Exception: + pass + self._finished_event.wait(timeout=3.0) + self._closed = True + try: + ws.close() + except Exception: + pass + else: + self._closed = True + if self._recv_thread is not None: + self._recv_thread.join(timeout=2) + self._finalize_open() # 兜底:末句没等到 .completed 时用在途全文定稿 + self._emit_state(TranscriberState.STOPPED) + + # ----- 内部 ----- + + def _connect(self) -> websocket.WebSocket: + ws = websocket.create_connection( + f"{_WS_URL}?model={self._model}", timeout=10, enable_multithread=True, + header=[f"Authorization: Bearer {self._api_key}"], + ) + ws.settimeout(0.5) + return ws + + def _send_session_update(self) -> None: + """配置会话(连上/重连收到 session.created 后回发);auto 省略 language 走自动识别。""" + transcription: dict = {} if self._language == "auto" else {"language": self._language} + self._send_json({ + "event_id": uuid.uuid4().hex, + "type": "session.update", + "session": { + "input_audio_format": "pcm", + "sample_rate": SAMPLE_RATE, + "input_audio_transcription": transcription, + "turn_detection": {"type": "server_vad", "silence_duration_ms": _SILENCE_MS}, + }, + }) + + def _try_reconnect(self, reason) -> bool: + """掉线或服务端中途结束 session 时透明续录:定稿在途句,退避重连新连接(session.update + 由新连接的 session.created 自动回发)。退避 / 限频 / 失败上限见 base._reconnect_with_backoff。""" + self._finalize_open() # 续录前先定稿在途句,不丢 + + def attempt() -> None: + new = self._connect() + with self._send_lock: + old, self._ws = self._ws, new + if old is not None: + try: + old.close() + except Exception: + pass + self._session_ready.clear() # 新连接的 session.created 会再次触发 session.update + + return self._reconnect_with_backoff( + reason, attempt=attempt, + is_stopping=lambda: self._closed or self._stopping, + logger=logger, name="Qwen-ASR") + + def _finalize_open(self) -> None: + """把在途句作为定稿段发出并清空(.completed / 重连 / 收尾时调用);清空避免被二次定稿。""" + if self._open_item and self._open_text: + self._emit_segment(TranscriptSegment( + f"qwen#{self._open_item}", self._open_text, is_final=True)) + self._open_item = None + self._open_text = "" + + def _dispatch(self, ev: dict) -> None: + etype = ev.get("type", "") + if etype == "session.created": + self._send_session_update() # 连上/重连:收到 created 即配置会话 + self._session_ready.set() + elif etype == _TEXT_EVENT: + item = ev.get("item_id") + text = ((ev.get("text") or "") + (ev.get("stash") or "")).strip() + if not item or not text: + return + if self._open_item and item != self._open_item: + # 新句到来兜底定稿上一句(不全信每句必有 .completed;upsert 幂等,迟到无害)。 + self._emit_segment( + TranscriptSegment(f"qwen#{self._open_item}", self._open_text, is_final=True)) + self._open_item, self._open_text = item, text + self._emit_segment(TranscriptSegment(f"qwen#{item}", text, is_final=False)) + elif etype == _DONE_EVENT: + item = ev.get("item_id") + transcript = (ev.get("transcript") or "").strip() + if item and transcript: + self._emit_segment(TranscriptSegment(f"qwen#{item}", transcript, is_final=True)) + if item == self._open_item: # 已定稿,清在途避免收尾/重连二次发 + self._open_item, self._open_text = None, "" + elif etype == _FAIL_EVENT: + logger.debug("Qwen-ASR 单句转写失败(跳过):%s", ev.get("error")) + if ev.get("item_id") == self._open_item: + self._open_item, self._open_text = None, "" + elif etype == "error": + # error 字段不保证是 dict(可能 str/None);非 dict 直接 .get 会 AttributeError 杀线程,故防御取值。 + err = ev.get("error") + msg = err.get("message", "Qwen-ASR 实时错误") if isinstance(err, dict) else str(err or "Qwen-ASR 实时错误") + low = msg.lower() + # 「already…」「session update error」多为重连后迟到的 session.update 被拒,会话仍在跑:不致命。 + if "already" in low or "session update error" in low: + logger.info("Qwen-ASR 忽略非致命错误:%s", msg) + return + self._emit_error(msg) + elif etype == "session.finished": + if self._stopping or self._closed: + self._finalize_open() # 主动 finish 后的正常收尾 + self._session_ready.set() + self._finished_event.set() + else: + # 服务端中途结束 session(单 session 有时长上限)→ 重起续录,不当结束。 + logger.info("Qwen-ASR 服务端中途结束 session,重起续录") + if not self._try_reconnect("服务端结束 session"): + self._fail_close() # 重起失败:关 ws + 发 STOPPED,终态与 stop() 一致 + # speech_started/stopped、committed、conversation.item.created 等信息性事件:忽略 diff --git a/videocaptioner/core/realtime/backends/voxgate.py b/videocaptioner/core/realtime/backends/voxgate.py new file mode 100644 index 00000000..7b47e605 --- /dev/null +++ b/videocaptioner/core/realtime/backends/voxgate.py @@ -0,0 +1,287 @@ +""" +voxgate 子进程 stdio 后端:PCM 写入 ``voxgate transcribe`` 的 stdin,从 stdout 读原生 +protocol 报文(``-f protocol``,每行一条 ASR send/recv JSON,详见 docs/dev/voxgate-protocol.md)。 +无服务、无端口、无 WebSocket。 +""" + +from __future__ import annotations + +import collections +import json +import queue +import subprocess +import sys +import threading +import time +from typing import Deque, Optional + +from videocaptioner import config +from videocaptioner.core.realtime.backends.base import ( + LiveCaptionError, + LiveTranscriber, + OnError, + OnSegment, + OnState, + TranscriberState, +) +from videocaptioner.core.realtime.events import TranscriptSegment +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_voxgate_backend") + +_EXE = ".exe" if sys.platform.startswith("win") else "" +_BINARY_NAME = f"voxgate{_EXE}" + +_OK_CODE = 20000000 # status_code 成功值 + + +def find_voxgate_binary(configured: str = "") -> Optional[str]: + """发现 voxgate 可执行文件(配置路径 → 自带 bin → 用户 bin → PATH)。""" + return config.find_binary(_BINARY_NAME, configured) + + +def _no_window_kwargs() -> dict: + """Windows 下不要弹出控制台窗口。""" + if sys.platform.startswith("win"): + return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)} + return {} + + +class VoxgateBackend(LiveTranscriber): + """``voxgate transcribe`` 子进程 stdio 后端。""" + + def __init__( + self, + binary: str, + language: str = "zh", + on_segment: Optional[OnSegment] = None, + on_state: Optional[OnState] = None, + on_error: Optional[OnError] = None, + ) -> None: + super().__init__(on_segment, on_state, on_error) + if not binary: + raise LiveCaptionError("未找到 voxgate 转录程序:可到「诊断」页一键下载,或在设置中指定其路径。") + self._binary = binary + self._language = language or "zh" + self._proc: Optional[subprocess.Popen] = None + # 写 stdin 解耦到独立 sender 线程:feed() 只入队,voxgate 背压只卡 sender, + # 音频泵继续排空采集队列,不在语音中段丢句。 + self._send_q: "queue.Queue[Optional[bytes]]" = queue.Queue() + self._send_thread: Optional[threading.Thread] = None + self._recv_thread: Optional[threading.Thread] = None + self._err_thread: Optional[threading.Thread] = None + self._stderr_tail: Deque[str] = collections.deque(maxlen=40) + self._closed = False # 进程已关、接收线程应退出 + self._stopping = False # 收尾中:停止喂音频,但接收线程仍要读到末段 + self._ready = threading.Event() # 收到首个事件(确认进程活着) + # 全局句号:见模块 docstring / _consume_results 的句边界判定。 + self._sentence_no = 0 + self._cur_text = "" # 当前句在途文本(用于重置检测 + 边界补定稿) + self._cur_start: Optional[float] = None + self._cur_end: Optional[float] = None + self._last_index: Optional[int] = None + + # ----- LiveTranscriber ----- + + def start(self) -> None: + self._emit_state(TranscriberState.CONNECTING) + cmd = [ + self._binary, "transcribe", "-", + "--input-format", "pcm16", "--stream", "-f", "protocol", + "-l", self._language, + ] + logger.info("启动 voxgate transcribe(stdio):%s", " ".join(cmd)) + try: + self._proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **_no_window_kwargs(), + ) + except Exception as exc: + raise LiveCaptionError(f"无法启动 voxgate 子进程:{exc}") from exc + + self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True) + self._recv_thread.start() + self._err_thread = threading.Thread(target=self._drain_stderr, daemon=True) + self._err_thread.start() + self._send_thread = threading.Thread(target=self._send_loop, daemon=True) + self._send_thread.start() + + # 短暂等待:进程立刻退出(坏二进制/配置/鉴权)则抛错,否则就绪。不死等首个事件—— + # task.started 可能要等首帧音频,而音频在 start 之后才喂。 + deadline = time.monotonic() + 1.5 + while time.monotonic() < deadline: + if self._proc.poll() is not None: + raise LiveCaptionError( + f"voxgate 启动即退出(code={self._proc.returncode}):{self._stderr_text()}" + ) + if self._ready.is_set(): + break + time.sleep(0.05) + self._emit_state(TranscriberState.LISTENING) + + def feed(self, pcm16: bytes) -> None: + # 只入队,写 stdin 由 _send_loop 完成。 + if self._closed or self._stopping or not pcm16: + return + self._send_q.put_nowait(pcm16) + + def _send_loop(self) -> None: + """唯一的 stdin 写入者:从队列取 PCM 写给 voxgate;None 哨兵=排空完毕。""" + while True: + pcm = self._send_q.get() + if pcm is None: # 收尾哨兵:已入队音频写完 + return + proc = self._proc + stdin = proc.stdin if proc is not None else None + if stdin is None: + continue + try: + stdin.write(pcm) + stdin.flush() + except Exception as exc: + if not self._closed: + logger.debug("写入 voxgate stdin 失败:%s", exc) + return + + def stop(self) -> None: + if self._closed or self._stopping: + return + # 收尾顺序不可乱:停喂 → 排空已入队音频 → 关 stdin(EOF) → 等接收线程读完末段。 + # 提前 terminate 或漏写末段音频都会截断末句。 + self._stopping = True # feed() 起不再入队 + proc = self._proc + # 哨兵让 sender 写尽队列后再关 stdin + self._send_q.put_nowait(None) + if self._send_thread is not None: + self._send_thread.join(timeout=3.0) + if proc is not None: + try: + if proc.stdin is not None: + proc.stdin.close() # EOF:voxgate 冲刷末段后退出 + except Exception: + pass + # 等接收线程读到 EOF(含末段 ASR 往返),给足宽限,勿提前 terminate + if self._recv_thread is not None: + self._recv_thread.join(timeout=5.0) + self._closed = True + if proc.poll() is None: # 还没退就强制收 + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + else: + self._closed = True + self._emit_state(TranscriberState.STOPPED) + + # ----- 接收解析 ----- + + def _recv_loop(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + out = proc.stdout + while True: + try: + raw = out.readline() # 逐行 protocol JSON;readline 无迭代器的预读批延迟 + except Exception: + break + if not raw: # EOF:进程结束 + break + self._ready.set() + line = raw.decode("utf-8", "replace").strip() + if not line: + continue + if self.on_raw is not None: # 调试落盘:把原生 protocol JSON 行原文回调出去(LiveDebugTap) + try: + self.on_raw(line) + except Exception: + pass + try: + ev = json.loads(line) + except Exception: + continue + self._dispatch(ev) + # 非收尾态下进程意外退出(崩溃/被杀/网络断):上报错误,免得 UI 一直挂在「监听中」 + if not self._stopping and not self._closed: + code = proc.poll() + self._emit_error(f"voxgate 进程意外退出(code={code}):{self._stderr_text()}") + + def _drain_stderr(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + for line in proc.stderr: + text = line.decode("utf-8", "replace").rstrip() + if text: + self._stderr_tail.append(text) + + def _stderr_text(self) -> str: + return " | ".join(list(self._stderr_tail)[-6:]) or "(无错误输出)" + + def _dispatch(self, ev: dict) -> None: + # 原生 protocol:每行一条 send/recv 报文。只关心 recv 的 result_json(转录结果) + # 与 SessionFinished(收尾)。分句由 _consume_results 用 _sentence_no + index变化/滚动重置 + # 判定,绝不直接拿 index 当 seg_id(连续语音会一直复用同一 index,按 index 上屏会全被覆盖)。 + if ev.get("direction") != "recv": + return + code = ev.get("status_code") + if code is not None and code != _OK_CODE: + self._emit_error(f"voxgate 转录错误({code}):{ev.get('status_message') or ''}".strip()) + return + if ev.get("message_type") == "SessionFinished": + return # 末句若没等到自己的 FIN,交装配器 close() 兜底定稿 + rj = ev.get("result_json") + if isinstance(rj, dict) and rj.get("results"): + self._consume_results(rj["results"]) + + @staticmethod + def _is_reset(old: str, new: str) -> bool: + """豆包「滚动重置」检测:连续语音到单句上限会把 text 截短重来(同 index 复用)。 + **只看长度腰斩**(new 不到 old 的一半,如 211→15)。绝不能看「公共前缀变小」:twopass + 改写(whose→who's、补标点)只降一两字却变前缀,据此切会把同句的生长前缀重复定稿成多句。""" + return bool(old) and len(new) * 2 < len(old) + + def _consume_results(self, results: list) -> None: + # 句边界 = 豆包 index 变化 OR 文本滚动重置;命中则把在途的上一段定稿、开新句。 + # 否则连续语音(无 VAD、index 不变)会被覆盖成最后残段。 + for r in results: + if not isinstance(r, dict): + continue + text = r.get("text") or "" + if not text.strip(): + continue # VAD 收尾帧带的下一句空占位(text:"") + idx = r.get("index", 0) + st, et = r.get("start_time"), r.get("end_time") + is_final = bool(r.get("is_vad_finished")) # 真·停顿才是天然句边界 + boundary = ( + (self._last_index is not None and idx != self._last_index) + or self._is_reset(self._cur_text, text) + ) + if boundary and self._cur_text.strip(): + # 在途上一段还没等到自己的定稿信号 → 在边界处补定稿,再开新句(不丢内容) + self._emit_segment(TranscriptSegment( + f"voxgate#{self._sentence_no}", self._cur_text, is_final=True, + start_time=self._cur_start, end_time=self._cur_end)) + self._sentence_no += 1 + self._cur_text = "" + self._cur_start = self._cur_end = None + self._last_index = idx + self._emit_segment(TranscriptSegment( + f"voxgate#{self._sentence_no}", text, is_final=is_final, + start_time=st, end_time=et)) + if is_final: # 自带 VAD 定稿:本句收尾,开新句 + self._sentence_no += 1 + self._cur_text = "" + self._cur_start = self._cur_end = None + else: + self._cur_text = text + if self._cur_start is None: + self._cur_start = st + self._cur_end = et diff --git a/videocaptioner/core/realtime/backends/ws_base.py b/videocaptioner/core/realtime/backends/ws_base.py new file mode 100644 index 00000000..be91d21e --- /dev/null +++ b/videocaptioner/core/realtime/backends/ws_base.py @@ -0,0 +1,109 @@ +"""WS 类实时后端(fun-asr / qwen-asr)的共性骨架。 + +抽出两后端逐字重复的部分:WS 连接状态、接收线程循环、送帧加锁、失败收尾;子类只实现协议差异 +(start/stop/feed 的握手与编码、_dispatch 解析、_try_reconnect 续录)。掉线退避治理在更上层的 +``LiveTranscriber._reconnect_with_backoff``。 + +voxgate 是子进程 stdio、不走这里——它直接继承 LiveTranscriber,避免把 websocket 拉进其启动路径 +(backends 包刻意不 re-export,保持后端懒加载)。 +""" + +from __future__ import annotations + +import json +import threading +from abc import abstractmethod +from typing import Optional + +import websocket + +from videocaptioner.core.realtime.backends.base import ( + LiveTranscriber, + OnError, + OnSegment, + OnState, + TranscriberState, +) + + +class WebSocketTranscriber(LiveTranscriber): + """fun-asr / qwen-asr 共用:WS 连接 + 接收线程 + 送帧加锁 + 终态收尾。""" + + def __init__( + self, + on_segment: Optional[OnSegment] = None, + on_state: Optional[OnState] = None, + on_error: Optional[OnError] = None, + ) -> None: + super().__init__(on_segment, on_state, on_error) + self._ws: Optional[websocket.WebSocket] = None + self._send_lock = threading.Lock() + self._recv_thread: Optional[threading.Thread] = None + self._closed = False # ws 已关、接收线程应退出 + self._stopping = False # 收尾中:停喂音频,接收线程仍接末句 + 结束帧 + self._fed_any = False # 没喂过音频则跳过结束帧(空任务直接关更稳) + + def _send_json(self, obj: dict) -> None: + with self._send_lock: # 锁内取并用 ws,避免与 stop() / 重连换 ws 竞态 + ws = self._ws + if ws is None: + return + ws.send(json.dumps(obj)) + + def _recv_loop(self) -> None: + while not self._closed: + ws = self._ws + if ws is None: + break + try: + raw = ws.recv() + except websocket.WebSocketTimeoutException: + continue + except Exception as exc: + if self._closed or self._stopping: + break # 主动收尾:正常退出 + if not self._try_reconnect(exc): + break + continue + if not raw: # 服务端关闭连接 + if self._closed or self._stopping or not self._try_reconnect(None): + break + continue + self._note_alive() # 收到数据帧 → 连接存活(供重连治理判定抖动期) + if isinstance(raw, bytes): + continue # 两后端都不回二进制 + if self.on_raw is not None: # 调试落盘钩子(生产路径不设) + try: + self.on_raw(raw) + except Exception: + pass + try: + ev = json.loads(raw) + except Exception: + continue + self._dispatch(ev) + + def _fail_close(self) -> None: + """重连彻底失败 / 服务端结束 session 的终态收尾:关 ws + 置 _closed + 发 STOPPED。 + + 与 stop() 的终态一致——否则只置 ``_closed`` 会泄漏 ws fd(stop() 因 _closed 已早退、不再 + close),且 UI 收不到 STOPPED 状态。 + """ + self._closed = True + ws = self._ws + if ws is not None: + try: + ws.close() + except Exception: + pass + self._emit_state(TranscriberState.STOPPED) + + # ----- 子类实现协议差异 ----- + + @abstractmethod + def _dispatch(self, ev: dict) -> None: + """解析一条服务端 JSON 事件:emit 段落 / 处理就绪 / 结束 / 错误。""" + + @abstractmethod + def _try_reconnect(self, reason) -> bool: + """掉线 / 服务端中途结束时续录(通常委托 _reconnect_with_backoff)。""" diff --git a/videocaptioner/core/realtime/caption.py b/videocaptioner/core/realtime/caption.py new file mode 100644 index 00000000..321b17b6 --- /dev/null +++ b/videocaptioner/core/realtime/caption.py @@ -0,0 +1,233 @@ +"""把后端**已分段**的转录装配成双语字幕(:class:`CaptionEntry`)并按 ``seg_id`` upsert。 + +分段是后端的职责(voxgate 按 VAD、fun-asr 按句),本装配器不切段,只做三件事:双色 +(后端改写未定文本,最长公共前缀=已听准/亮,尾巴=修正中/暗,定稿整段转亮)、翻译(定稿强制翻 +一次,当前段节流翻译,译文用同一 ``seg_id`` 回填)、定稿守卫(段 ``is_final`` 后忽略迟到更新)。 + +当前段与它定稿后的历史段共享 ``seg_id``,「当前 → 历史」在 UI 端是同一条目的状态翻转。 +""" + +from __future__ import annotations + +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import wait as _futures_wait +from typing import Callable, Dict, List, Optional, Set, Tuple + +from videocaptioner.core.realtime.events import CaptionEntry, TranscriptSegment +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_assembler") + +# 当前段翻译节流(秒):新段首帧立即翻一次,其后按节流。 +_ACTIVE_TRANSLATE_INTERVAL = 0.4 + +# 翻译并发度:高延迟 LLM(一句几秒)下,定稿句必须并发翻译才追得上语速;串行(1)会越落越远。 +_TR_WORKERS = 4 + +# 非内容字符(纯标点/空白):纯标点段不该单独成卡片。 +_NONCONTENT = set("。..!?!?…,,、;;::  \t\r\n") + +OnCaption = Callable[[CaptionEntry], None] +TranslateFn = Callable[[str], str] + + +def _noop_caption(_entry: CaptionEntry) -> None: + """close 后替换 on_caption:在飞的翻译任务再 emit 也落到这里,杜绝投递到已销毁浮窗。""" + + +def _has_content(text: str) -> bool: + """是否含「真内容」(除标点/空白外的字符)。纯标点段不该单独成卡片。""" + return any(ch not in _NONCONTENT for ch in text) + + +def _common_prefix_len(a: str, b: str) -> int: + """最长公共前缀长度。后端改写未定文本(非纯追加),公共前缀即稳定段(亮),其后为修正中(暗)。""" + n = min(len(a), len(b)) + i = 0 + while i < n and a[i] == b[i]: + i += 1 + return i + + +class CaptionAssembler: + """按 ``seg_id`` upsert 的双色 + 异步翻译装配器(不做分段,分段在后端)。""" + + def __init__( + self, + on_caption: OnCaption, + translate_fn: Optional[TranslateFn] = None, + ) -> None: + self._on_caption = on_caption + self._translate = translate_fn + self._lock = threading.RLock() + + # 序号 / 起始时间:按 seg_id 分配一次,跨更新保持稳定 + self._seq = 0 + self._meta: Dict[str, Tuple[int, float]] = {} # seg_id -> (seq, started_at_epoch) + self._prev: Dict[str, str] = {} # seg_id -> 上一帧源文(算 stable_len) + self._entries: Dict[str, CaptionEntry] = {} # seg_id -> 最新 entry(译文回填) + self._final: Set[str] = set() # 已定稿段:忽略其后更新/译文 + self._last_active: Optional[str] = None # 最近未定稿段(异常停止时 close 补定稿) + + # 翻译:并发线程池 + 防泛滥,适配高延迟 API(如 LLM,一句几秒)。 + # - 定稿句:每句去重提交到 N-worker 池并发翻译(串行会追不上语速),各句独立回填自己 seg_id。 + # - 当前(未定稿)句:预览翻译,全局限 1 个在飞 + 节流,在飞时新请求丢弃, + # 防止「每 0.4s 一份当前句全文」灌爆池、把定稿句挤到饿死。 + self._last_tr: Dict[str, float] = {} # 当前句翻译节流时刻 + self._final_submitted: Set[str] = set() # 已提交翻译的定稿句(去重,防重复翻译) + self._final_futures: List = [] # 在飞定稿翻译 future(close 限时 drain 用) + self._active_inflight: Optional[str] = None # 当前句在飞的那一句(限 1,防泛滥) + self._closed = False + self._pool = ThreadPoolExecutor( + max_workers=_TR_WORKERS, thread_name_prefix="livecap-tr") + + # ----- 主入口:后端 on_segment ----- + + def ingest(self, segment: TranscriptSegment) -> None: + """消费一个后端段落事件(同 seg_id 多次=同段生长/改写)。线程:后端接收线程。""" + with self._lock: + if self._closed: + return + sid = segment.seg_id + if sid in self._final and not segment.is_final: + # 段已定稿:丢弃迟到的非 final 更新(防回退);同句再来 is_final 则放行覆盖。 + return + text = segment.text or "" + if not _has_content(text): + return # 空 / 纯标点:不画当前段、定稿空段也直接丢 + prev = self._prev.get(sid, "") + stable_len = len(text) if segment.is_final else _common_prefix_len(prev, text) + entry = self._build(sid, text, stable_len, segment.is_final, + segment.start_time, segment.end_time) + self._prev[sid] = text + self._entries[sid] = entry + if segment.is_final: + self._final.add(sid) + # 定稿后该句不再算双色前缀 / 不再节流翻译 → 清掉临时态,防长会话内存线性增长。 + # (_entries 不清:force 译文回填仍要读它。) + self._prev.pop(sid, None) + self._last_tr.pop(sid, None) + if self._last_active == sid: + self._last_active = None + if self._active_inflight == sid: + # 该句定稿后,它那条在飞的当前句翻译已作废,立即释放在飞名额, + # 别让下一句的预览翻译干等它空跑完。 + self._active_inflight = None + else: + self._last_active = sid + self._on_caption(entry) + self._maybe_translate(entry, force=segment.is_final) + + # ----- 构造 / 翻译 ----- + + def _build(self, seg_id: str, source: str, stable_len: int, is_final: bool, + start_time: Optional[float], end_time: Optional[float]) -> CaptionEntry: + seq, started_at = self._meta.get(seg_id, (None, None)) + if seq is None: + seq = self._seq + self._seq += 1 + started_at = time.time() # 段落首次出现的真实时刻(UI 显示 HH:MM) + self._meta[seg_id] = (seq, started_at) + return CaptionEntry( + seg_id=seg_id, + seq=seq, + source_text=source, + source_stable_len=max(0, min(stable_len, len(source))), + target_text="", + is_final=is_final, + started_at=started_at, + start_time=start_time, + end_time=end_time, + ) + + def _maybe_translate(self, entry: CaptionEntry, force: bool) -> None: + # 持 self._lock 调用。定稿句并发提交(去重);当前句节流 + 全局限 1 在飞(防泛滥)。 + if not self._translate: + return + sid = entry.seg_id + if force: + self._submit_final(sid) + return + now = time.monotonic() + if sid in self._last_tr and now - self._last_tr[sid] < _ACTIVE_TRANSLATE_INTERVAL: + return # 节流:别逐字狂翻 + if self._active_inflight is not None or sid in self._final: + return # 已有当前句在飞(合并)/ 该句已定稿 → 不另起 + self._last_tr[sid] = now + self._active_inflight = sid + self._pool.submit(self._do_translate, sid, False) + + def _submit_final(self, sid: str) -> None: + """提交定稿句翻译到并发池(每句一次,去重)。持 self._lock 调用。""" + if sid in self._final_submitted: + return + self._final_submitted.add(sid) + self._final_futures = [f for f in self._final_futures if not f.done()] # 顺手清理已完成 + self._final_futures.append(self._pool.submit(self._do_translate, sid, True)) + + def _do_translate(self, sid: str, is_final: bool) -> None: + """池 worker:翻译该句最新源文并回填(定稿句并发、各自独立;当前句完成后释放在飞名额)。""" + translate = self._translate + try: + with self._lock: + if (self._closed and not is_final) or translate is None: + return + entry = self._entries.get(sid) + if entry is None or (not is_final and sid in self._final): + return + source = entry.source_text + if not source: + return + try: + translated = translate(source) + except Exception as exc: # 网络/接口失败不应中断识别 + logger.debug("实时翻译失败(已忽略):%s", exc) + return + if not translated: + return + with self._lock: + if self._closed and not is_final: + return # 收尾后只补定稿译文,当前句译文丢弃 + cur = self._entries.get(sid) + if cur is None or (not is_final and sid in self._final): + return # 翻译期间该句定稿 → 当前句译文作废(定稿译文为准) + out = cur.with_target(translated) # 用最新源文 + 新译文回填,源文绝不回退 + self._entries[sid] = out + on_caption = self._on_caption + on_caption(out) # 出锁再 emit,避免持锁跨 Qt 信号 + finally: + if not is_final: # 释放「当前句在飞」名额,允许下一次节流请求翻最新当前句 + with self._lock: + if self._active_inflight == sid: + self._active_inflight = None + + def close(self) -> None: + # 1) 异常停止(后端被杀 / 未收到末段定稿)时给最后未定稿段补一次定稿,避免历史丢末段。 + # 必须趁 _on_caption 仍是真回调时做(让 recorder 捕获),并提交它的定稿翻译。 + with self._lock: + sid = self._last_active + if sid is not None and sid not in self._final: + cur = self._entries.get(sid) + if cur is not None and _has_content(cur.source_text): + try: + final = self._build(sid, cur.source_text, len(cur.source_text), + True, cur.start_time, cur.end_time) + self._final.add(sid) + self._entries[sid] = final + self._on_caption(final) + if self._translate is not None: + self._submit_final(sid) + except Exception: + pass + pending = [f for f in self._final_futures if not f.done()] + # 2) 限时 drain 在飞的定稿翻译,使保存的记录尽量带译文(_closed 仍 False、回调仍真, + # 译文经 note_caption 落到 recorder)。并发池下多句并行,drain 很快。 + if pending: + _futures_wait(pending, timeout=6.0) + # 3) 收尾:换 no-op 回调(杜绝在飞任务再向已销毁浮窗 emit)。 + with self._lock: + self._closed = True + self._on_caption = _noop_caption + self._pool.shutdown(wait=False) diff --git a/videocaptioner/core/realtime/check.py b/videocaptioner/core/realtime/check.py new file mode 100644 index 00000000..c3867427 --- /dev/null +++ b/videocaptioner/core/realtime/check.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import shutil +import tempfile +import time +import wave +from dataclasses import dataclass +from pathlib import Path + +from videocaptioner.config import ASSETS_PATH +from videocaptioner.core.realtime.backends.base import SAMPLE_RATE +from videocaptioner.core.realtime.config import LiveCaptionConfig +from videocaptioner.core.realtime.events import TranscriptSegment +from videocaptioner.core.realtime.factory import build_backend +from videocaptioner.core.utils.video_utils import video2audio + +TEST_AUDIO_PATH = ASSETS_PATH / "en.mp3" + +_CHUNK_MS = 100 # 每次喂 100ms PCM +_PACE_S = 0.08 # 略快于实时:给 fun-asr 实时 WS 留节奏,又不至于太久 +_GRACE_S = 12.0 # 喂完后等待后端冲刷出末句文本的上限 + + +@dataclass(frozen=True) +class LiveCaptionCheckResult: + """实时字幕检查结果:成功时 detail 是识别出的文本,失败时是错误信息。""" + + success: bool + detail: str + + +def check_live_caption( + config: LiveCaptionConfig, audio_path: str | Path | None = None +) -> LiveCaptionCheckResult: + """用短音频真实转录一次,验证当前实时字幕引擎可用。""" + path = Path(audio_path) if audio_path else TEST_AUDIO_PATH + if not path.exists(): + return LiveCaptionCheckResult(False, f"测试音频不存在: {path}") + work_dir = Path(tempfile.mkdtemp(prefix="videocaptioner-live-check-")) + try: + # 实时后端只吃 16k/mono/s16le PCM:先抽成标准 WAV 再喂。 + wav_path = work_dir / "check.wav" + if not video2audio(str(path), output=str(wav_path)): + return LiveCaptionCheckResult(False, "ffmpeg 提取测试音频失败") + with wave.open(str(wav_path), "rb") as wf: + pcm = wf.readframes(wf.getnframes()) + if not pcm: + return LiveCaptionCheckResult(False, "测试音频解码为空") + return _run_backend(config, pcm) + except Exception as exc: # noqa: BLE001 —— 各后端抛错类型不一,统一收敛为结果 + return LiveCaptionCheckResult(False, str(exc) or type(exc).__name__) + finally: + shutil.rmtree(work_dir, ignore_errors=True) + + +def _run_backend(config: LiveCaptionConfig, pcm: bytes) -> LiveCaptionCheckResult: + latest = "" + errors: list[str] = [] + + def on_segment(seg: TranscriptSegment) -> None: + nonlocal latest + if seg.text and seg.text.strip(): + latest = seg.text.strip() + + backend = build_backend( + config, + on_segment=on_segment, + on_state=lambda *_: None, + on_error=errors.append, + ) + backend.start() + try: + step = SAMPLE_RATE * _CHUNK_MS // 1000 * 2 # 每块字节数(s16le) + for off in range(0, len(pcm), step): + backend.feed(pcm[off : off + step]) + time.sleep(_PACE_S) + # 喂完后等后端吐字:已有文本就再给 1s 补全末句即收尾,全程无字才等满 _GRACE_S + deadline = time.monotonic() + _GRACE_S + while not errors and time.monotonic() < deadline: + if latest: + time.sleep(1.0) + break + time.sleep(0.1) + finally: + backend.stop() # voxgate: 关 stdin(EOF) 让其冲刷末段并退出;此后 latest 才最终落定 + if latest: + return LiveCaptionCheckResult(True, latest) + if errors: + return LiveCaptionCheckResult(False, errors[-1]) + return LiveCaptionCheckResult(False, "转录请求成功但结果为空") diff --git a/videocaptioner/core/realtime/config.py b/videocaptioner/core/realtime/config.py new file mode 100644 index 00000000..5ed9ad6c --- /dev/null +++ b/videocaptioner/core/realtime/config.py @@ -0,0 +1,51 @@ +"""实时字幕的运行配置(纯数据,无 Qt)。 + +UI / CLI 各自的设置归一成 :class:`LiveCaptionConfig` 交给 session;core 层只认这个 +dataclass,不反向依赖上层配置框架。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from videocaptioner.core.translate.types import TargetLanguage, TranslatorType + + +class LiveCaptionSource(Enum): + """音频来源类型。""" + + MICROPHONE = "microphone" # 麦克风 + SYSTEM = "system" # 系统声音 / 回环(看片场景);依赖回环输入设备 + + +@dataclass(frozen=True) +class LiveCaptionConfig: + """一次实时字幕会话的完整配置。""" + + # --- 后端 --- + backend: str = "voxgate" # "voxgate" | "fun-asr" | "qwen-asr" + # voxgate 子进程:可执行文件路径(空则自动发现 resource/bin → PATH) + voxgate_binary: str = "" + # 云后端(fun-asr 等):API Key + 模型 + 识别语言提示(voxgate 也用 source_language 作 -l) + api_key: str = "" + asr_model: str = "" + source_language: str = "zh" + + # --- 音频 --- + source: LiveCaptionSource = LiveCaptionSource.MICROPHONE + device_index: Optional[int] = None # None = 系统默认输入设备 + # macOS 原生系统声音捕获(ScreenCaptureKit,免装 BlackHole)。为真时忽略 device_index, + # 改用 macsysaudio 子进程采本机播放声。仅 macOS 13+,需「屏幕录制」权限。 + system_audio_native: bool = False + + # --- 翻译 --- + translate_enabled: bool = True + translator_type: TranslatorType = TranslatorType.BING + target_language: TargetLanguage = TargetLanguage.SIMPLIFIED_CHINESE + # LLM 翻译(translator_type=OPENAI)专用:当前 LLM provider 的 key/base/model。 + # LLMTranslator 经环境变量取 key/base,故工厂建它前会把这里的值写进环境变量。 + llm_model: str = "gpt-4o-mini" + llm_api_key: str = "" + llm_api_base: str = "" diff --git a/videocaptioner/core/realtime/events.py b/videocaptioner/core/realtime/events.py new file mode 100644 index 00000000..d6b25852 --- /dev/null +++ b/videocaptioner/core/realtime/events.py @@ -0,0 +1,61 @@ +"""实时字幕的统一事件模型,即本项目自定义的协议。 + +- TranscriptSegment:后端 → 装配器。``text`` 是该段当前全文(后端负责分段与累积),纯转录、不带翻译。 +- CaptionEntry:装配器 → UI 的一条双语字幕。``source_stable_len`` 区分已听准前缀与仍在修正的尾巴,驱动双色渲染。 +- 两者都按 ``seg_id`` upsert:同一 id 多次发来即原地更新(生长 / 译文回填 / 定稿),不新建行。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Optional + + +@dataclass(frozen=True) +class TranscriptSegment: + """后端发给装配器的一个段落(后端无关)。分段是后端的职责,装配器只按 seg_id upsert。 + + Attributes: + seg_id: 段落标识。同 id=同段在生长,不同 id=不同段。 + text: 该段当前全文(后端改写,非纯追加)。 + is_final: 是否已定稿。 + start_time / end_time: 相对会话起点的音频时间区间(秒),后端给不出时为 None。 + """ + + seg_id: str + text: str + is_final: bool + start_time: Optional[float] = None + end_time: Optional[float] = None + + +@dataclass(frozen=True) +class CaptionEntry: + """装配器发给 UI 的一条双语字幕(一个段落),按 ``seg_id`` upsert。 + + Attributes: + seg_id: 段落唯一标识,形如 ``voxgate#3`` / ``funasr#1#12``。 + seq: 单调递增序号,供 UI 稳定排序。 + source_text: 原文(转录文本)。 + source_stable_len: 原文中已听准前缀的字符数,其后为仍在修正的尾巴。 + target_text: 译文,未翻译时为空串。 + is_final: 是否已落定。 + started_at: 段落首次出现的真实时刻(epoch 秒)。UI 用它显示 HH:MM 时钟、并算页内相对 + 位置(``started_at - 会话起点``);录制器仅在无音频时拿它做回退,正常时间轴用 WAV 偏移。 + start_time / end_time: 后端尽力给出的音频时间区间(秒),可能为 None 或不连续(如 + Fun-ASR 重连后每 task 从 0 重计),故录制时间轴不信它、改用已录 WAV 偏移。 + """ + + seg_id: str + seq: int + source_text: str + source_stable_len: int + target_text: str + is_final: bool + started_at: float + start_time: Optional[float] = None + end_time: Optional[float] = None + + def with_target(self, target_text: str) -> "CaptionEntry": + """回填/更新译文,其余不变。""" + return replace(self, target_text=target_text) diff --git a/videocaptioner/core/realtime/factory.py b/videocaptioner/core/realtime/factory.py new file mode 100644 index 00000000..2723a04e --- /dev/null +++ b/videocaptioner/core/realtime/factory.py @@ -0,0 +1,167 @@ +"""把 :class:`LiveCaptionConfig` 装配成具体后端 / 翻译函数。 + +新增后端在 :func:`build_backend` 加分支;翻译服务复用 ``TranslatorFactory``。 +core 层不反向依赖 UI。 +""" + +from __future__ import annotations + +import os +import threading +from typing import Optional + +from videocaptioner.core.entities import SubtitleProcessData +from videocaptioner.core.realtime.backends.base import ( + LiveCaptionError, + LiveTranscriber, + OnError, + OnSegment, + OnState, +) +from videocaptioner.core.realtime.backends.voxgate import VoxgateBackend, find_voxgate_binary +from videocaptioner.core.realtime.caption import TranslateFn +from videocaptioner.core.realtime.config import LiveCaptionConfig +from videocaptioner.core.translate.factory import TranslatorFactory +from videocaptioner.core.translate.types import TranslatorType +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_factory") + + +def _target_lang_code(target) -> str: + """把目标语言枚举归到 zh/en/ja 大类码(用于判断「目标==所说语言」)。 + 按枚举成员名归类、不随显示文案变;其它语言返回 ""(照常翻译)。""" + name = (getattr(target, "name", "") or "").upper() + if "CHINESE" in name or name == "CANTONESE": + return "zh" + if name.startswith("ENGLISH"): + return "en" + if name == "JAPANESE": + return "ja" + return "" + + +class _LazyTranslator: + """首次调用时再构造底层翻译器(构造会联网),失败后静默禁用。""" + + def __init__(self, cfg: LiveCaptionConfig) -> None: + self._cfg = cfg + self._tr = None + self._lock = threading.Lock() + self._failed = False + + def translate(self, text: str) -> str: + if self._failed or not text.strip() or self._looks_same_language(text): + return "" + with self._lock: + if self._tr is None: + try: + # LLM 翻译器(OPENAI)从 OPENAI_API_KEY/BASE_URL 环境变量取凭据, + # 故建器前先把所选 provider 的 key/base 写入环境变量。 + if self._cfg.translator_type == TranslatorType.OPENAI: + if self._cfg.llm_api_key and self._cfg.llm_api_base: + os.environ["OPENAI_API_KEY"] = self._cfg.llm_api_key + os.environ["OPENAI_BASE_URL"] = self._cfg.llm_api_base + else: + logger.warning("实时字幕 LLM 翻译未配置 Key/Base,已禁用译文。") + self._failed = True + return "" + self._tr = TranslatorFactory.create_translator( + translator_type=self._cfg.translator_type, + thread_num=1, + batch_num=1, + target_language=self._cfg.target_language, + model=self._cfg.llm_model, + # 实时翻译关思考求低延迟(仅 LLM 翻译生效;不支持的端点由 call_llm 去掉)。 + disable_thinking=True, + ) + except Exception as exc: + logger.warning("实时翻译器初始化失败,已禁用译文:%s", exc) + self._failed = True + return "" + try: + data = [SubtitleProcessData(index=0, original_text=text)] + self._tr._translate_chunk(data) + out = data[0].translated_text or "" + except Exception as exc: + logger.debug("实时翻译失败(已忽略):%s", exc) + return "" + # 译文≈原文(目标==所说语言)→ 返回空、源文单显。否则同语言时译文滞后于生长中的原文, + # has_tgt 在相等/不等间反复切,导致译文行闪烁后消失。 + if out.strip() == text.strip(): + return "" + return out + + def _looks_same_language(self, text: str) -> bool: + """按文本实际字符构成判断主语言是否 == 目标语言,不依赖用户配置的识别语言 + (可能设错或被多语模型识别成别的语言)。只分 zh / en / ja 三大类。""" + tgt = _target_lang_code(self._cfg.target_language) + if not tgt: + return False + if any("぀" <= c <= "ヿ" for c in text): # 含假名 → 日文 + return tgt == "ja" + cjk = sum(1 for c in text if "一" <= c <= "鿿") + latin = sum(1 for c in text if c.isascii() and c.isalpha()) + if cjk + latin == 0: + return False + return tgt == ("zh" if cjk >= latin else "en") + + def close(self) -> None: + if self._tr is not None: + try: + self._tr.stop() + except Exception: + pass + + +def build_translate_fn(cfg: LiveCaptionConfig) -> Optional[TranslateFn]: + """翻译关闭时返回 None;否则返回 ``translate(text) -> str``。""" + if not cfg.translate_enabled: + return None + return _LazyTranslator(cfg).translate + + +def build_backend( + cfg: LiveCaptionConfig, + on_segment: OnSegment, + on_state: OnState, + on_error: OnError, +) -> LiveTranscriber: + if cfg.backend == "voxgate": + binary = find_voxgate_binary(cfg.voxgate_binary) + if not binary: + raise LiveCaptionError( + "未找到 voxgate 可执行文件。请在设置中指定其路径,或把二进制放入应用 bin 目录。" + ) + return VoxgateBackend( + binary=binary, + # -l 仅是语言提示、不限制实际识别(豆包多语);auto 退化为 zh 提示。 + language=("zh" if cfg.source_language == "auto" else cfg.source_language), + on_segment=on_segment, + on_state=on_state, + on_error=on_error, + ) + if cfg.backend == "fun-asr": + from videocaptioner.core.realtime.backends.fun_asr import FunAsrBackend + + return FunAsrBackend( + api_key=cfg.api_key, + model=cfg.asr_model, + language=cfg.source_language, + on_segment=on_segment, + on_state=on_state, + on_error=on_error, + ) + if cfg.backend == "qwen-asr": + from videocaptioner.core.realtime.backends.qwen_asr import QwenAsrBackend + + # Qwen-ASR 实时只有一个模型(qwen3-asr-flash-realtime),用后端默认即可,无需配置项。 + # source_language="auto" 时后端省略 language → 服务端在 27 种语言里自动检测。 + return QwenAsrBackend( + api_key=cfg.api_key, + language=cfg.source_language, + on_segment=on_segment, + on_state=on_state, + on_error=on_error, + ) + raise LiveCaptionError(f"未知的实时转录后端:{cfg.backend}") diff --git a/videocaptioner/core/realtime/recording/__init__.py b/videocaptioner/core/realtime/recording/__init__.py new file mode 100644 index 00000000..15bab753 --- /dev/null +++ b/videocaptioner/core/realtime/recording/__init__.py @@ -0,0 +1 @@ +"""会话录制与历史存储""" diff --git a/videocaptioner/core/realtime/recording/debug_tap.py b/videocaptioner/core/realtime/recording/debug_tap.py new file mode 100644 index 00000000..8ce4adbb --- /dev/null +++ b/videocaptioner/core/realtime/recording/debug_tap.py @@ -0,0 +1,95 @@ +"""实时字幕调试落盘(``VC_DEBUG=live`` 时启用;默认不实例化,主链路零开销)。 + +每次会话把三路原始数据写进 ``APPDATA/live_captions/_debug/{ts}/``,用于离线定位 +截断/卡住/清空出在后端、装配器还是 UI:后端原生事件 ``backend_events.ndjson``、 +实际喂送 PCM ``fed_audio.wav``、装配器 emit ``captions.jsonl``。 +""" + +from __future__ import annotations + +import json +import threading +import wave +from typing import Optional + +from videocaptioner import config +from videocaptioner.core.realtime.backends.base import CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH +from videocaptioner.core.realtime.events import CaptionEntry +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_debug") + + +class LiveDebugTap: + """把后端原生事件 / 喂送 PCM / 装配器输出三路落盘,便于离线核对原始事件流。线程安全。""" + + def __init__(self, ts: str) -> None: + self.dir = config.APPDATA_PATH / "live_captions" / "_debug" / ts + self.dir.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._events = open(self.dir / "backend_events.ndjson", "w", encoding="utf-8") + self._caps = open(self.dir / "captions.jsonl", "w", encoding="utf-8") + self._wav: Optional[wave.Wave_write] = None + try: + self._wav = wave.open(str(self.dir / "fed_audio.wav"), "wb") + self._wav.setnchannels(CHANNELS) + self._wav.setsampwidth(SAMPLE_WIDTH) + self._wav.setframerate(SAMPLE_RATE) + except Exception: + logger.debug("调试 WAV 打开失败", exc_info=True) + logger.info("实时字幕调试落盘已开启:%s", self.dir) + + def raw_event(self, line: str) -> None: + """后端原生事件原文(voxgate 一行 protocol 报文 / fun-asr 一条消息 JSON)。""" + with self._lock: + try: + self._events.write(line.rstrip("\n") + "\n") + self._events.flush() + except Exception: + pass + + def pcm(self, chunk: bytes) -> None: + """实际喂给后端的一块 PCM。""" + if self._wav is None: + return + try: + self._wav.writeframes(chunk) + except Exception: + pass + + def caption(self, entry: CaptionEntry) -> None: + """装配器 emit 的一条 CaptionEntry。""" + with self._lock: + try: + self._caps.write( + json.dumps( + { + "seg_id": entry.seg_id, + "seq": entry.seq, + "source": entry.source_text, + "stable_len": entry.source_stable_len, + "target": entry.target_text, + "is_final": entry.is_final, + "started_at": entry.started_at, + }, + ensure_ascii=False, + ) + + "\n" + ) + self._caps.flush() + except Exception: + pass + + def close(self) -> None: + with self._lock: + for f in (self._events, self._caps): + try: + f.close() + except Exception: + pass + if self._wav is not None: + try: + self._wav.close() + except Exception: + pass + logger.info("实时字幕调试落盘已保存:%s", self.dir) diff --git a/videocaptioner/core/realtime/recording/history.py b/videocaptioner/core/realtime/recording/history.py new file mode 100644 index 00000000..d64e51a8 --- /dev/null +++ b/videocaptioner/core/realtime/recording/history.py @@ -0,0 +1,291 @@ +"""实时字幕历史记录的持久化(纯数据,无 Qt)。 + +每条记录一个文件夹 ``{root}/{id}/``(root 默认 ``{work_dir}/live-caption``,是用户工作产物): +``transcript.json`` 存元信息+句子,``audio.wav`` 存可选录音。``id`` 是 ``YYYYMMDD-HHMMSS``, +唯一且天然按时间排序,是不可变主键。UI/CLI 只认 :class:`LiveCaptionRecord`,不直接碰文件布局。 +""" + +from __future__ import annotations + +import json +import os +import shutil +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional, Union + +from videocaptioner.config import APPDATA_PATH, WORK_PATH +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_history") + +# 历史曾放 APPDATA,现归入工作目录(属用户工作产物);GUI 启动时一次性迁移,见 migrate_legacy_root。 +_LEGACY_ROOT = APPDATA_PATH / "live_captions" +DEFAULT_DIR_NAME = "live-caption" + + +def default_root(work_dir: Optional[Union[str, Path]]) -> Path: + """实时字幕历史目录 = ``{work_dir}/live-caption``;work_dir 空则回退应用默认工作目录。 + + main.py 的迁移目标与 UI 的读写根都走这里,避免两处各拼一遍、字面量漂移导致「迁过去却读不到」。 + """ + wd = str(work_dir or "").strip() + return (Path(wd) if wd else WORK_PATH) / DEFAULT_DIR_NAME + + +def migrate_legacy_root(new_root: Path) -> None: + """把旧的 ``APPDATA/live_captions`` 一次性迁到 ``new_root``(实时字幕改放工作目录)。 + + 仅当新目录不存在、且旧目录有内容时迁移。可恢复:先 copytree 到临时目录、成功后原子改名,最后 + 才删旧目录——任一步失败就清理临时目录、原样保留旧记录(new_root 仍不存在,下次启动可重试), + 绝不留半截。由 GUI 启动时调用,CLI/测试不触发,避免误动用户真实数据。 + """ + new_root = Path(new_root) + if new_root == _LEGACY_ROOT or new_root.exists() or not _LEGACY_ROOT.is_dir(): + return + staging = new_root.with_name(new_root.name + ".migrating") + try: + shutil.rmtree(staging, ignore_errors=True) # 清掉上次可能残留的半截临时目录 + new_root.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(_LEGACY_ROOT, staging) # 先全量复制(跨盘也安全,非原子) + os.replace(staging, new_root) # 同盘原子改名成正式目录 + shutil.rmtree(_LEGACY_ROOT, ignore_errors=True) # 仅在彻底成功后删旧 + except Exception: + logger.warning("迁移实时字幕历史失败,保留原位置待下次重试", exc_info=True) + shutil.rmtree(staging, ignore_errors=True) + +_TRANSCRIPT = "transcript.json" +AUDIO_NAME = "audio.wav" + + +def _fmt_clock(seconds: float) -> str: + """秒 → mm:ss(超过一小时用 h:mm:ss)。""" + seconds = max(0, int(round(seconds))) + h, rem = divmod(seconds, 3600) + m, s = divmod(rem, 60) + return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}" + + +def _srt_time(seconds: float) -> str: + seconds = max(0.0, seconds) + ms = int(round(seconds * 1000)) + h, ms = divmod(ms, 3_600_000) + m, ms = divmod(ms, 60_000) + s, ms = divmod(ms, 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +@dataclass +class CaptionSegment: + """一句字幕:相对音频起点的时间区间 + 原文 / 译文。""" + + start: float + end: float + source: str + target: str = "" + + def to_dict(self) -> dict: + return { + "start": round(self.start, 3), + "end": round(self.end, 3), + "source": self.source, + "target": self.target, + } + + @classmethod + def from_dict(cls, d: dict) -> "CaptionSegment": + return cls( + start=float(d.get("start", 0.0)), + end=float(d.get("end", 0.0)), + source=str(d.get("source", "")), + target=str(d.get("target", "")), + ) + + +@dataclass +class LiveCaptionRecord: + """一次实时字幕会话的完整记录。""" + + id: str + name: str + source: str # "系统声音" / "麦克风" + translate: str # "微软翻译" / "原文记录" / … + created_at: float # epoch 秒 + duration: float # 秒 + audio: str = "" # 音频文件名(空 = 无音频,不能回放) + segments: List[CaptionSegment] = field(default_factory=list) + dir: Optional[Path] = None # 运行时填充,不入 JSON + + # ----- 序列化 ----- + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "source": self.source, + "translate": self.translate, + "created_at": self.created_at, + "duration": round(self.duration, 3), + "audio": self.audio, + "segments": [s.to_dict() for s in self.segments], + } + + @classmethod + def from_dict(cls, d: dict, dir: Optional[Path] = None) -> "LiveCaptionRecord": + return cls( + id=str(d.get("id", "")), + name=str(d.get("name", "")), + source=str(d.get("source", "")), + translate=str(d.get("translate", "")), + created_at=float(d.get("created_at", 0.0)), + duration=float(d.get("duration", 0.0)), + audio=str(d.get("audio", "")), + segments=[CaptionSegment.from_dict(s) for s in d.get("segments", [])], + dir=dir, + ) + + # ----- 展示便捷属性 ----- + + @property + def audio_path(self) -> Optional[Path]: + if self.audio and self.dir is not None: + p = self.dir / self.audio + return p if p.is_file() else None + return None + + @property + def duration_label(self) -> str: + return _fmt_clock(self.duration) + + @property + def created_label(self) -> str: + """列表用的相对时间:今天 → "今天 HH:MM",否则 "MM-DD HH:MM"。""" + try: + lt = time.localtime(self.created_at) + today = time.localtime() + if (lt.tm_year, lt.tm_yday) == (today.tm_year, today.tm_yday): + return time.strftime("今天 %H:%M", lt) + return time.strftime("%m-%d %H:%M", lt) + except Exception: + return "" + + @property + def summary(self) -> str: + """副标题:``06:18 · 系统声音 · 微软翻译``。""" + parts = [self.duration_label, self.source, self.translate] + return " · ".join(p for p in parts if p) + + def matches(self, query: str) -> bool: + q = query.strip().lower() + if not q: + return True + if q in self.name.lower(): + return True + return any(q in s.source.lower() or q in s.target.lower() for s in self.segments) + + # ----- 导出 ----- + + def export_srt(self, bilingual: bool = True) -> str: + lines: List[str] = [] + for i, seg in enumerate(self.segments, 1): + body = seg.source + if bilingual and seg.target and seg.target.strip() != seg.source.strip(): + body = f"{seg.source}\n{seg.target}" + elif not seg.source and seg.target: + body = seg.target + lines.append(f"{i}\n{_srt_time(seg.start)} --> {_srt_time(seg.end)}\n{body}\n") + return "\n".join(lines) + + def export_txt(self, bilingual: bool = True) -> str: + out: List[str] = [] + for seg in self.segments: + if seg.source: + out.append(seg.source) + if bilingual and seg.target and seg.target.strip() != seg.source.strip(): + out.append(seg.target) + elif not seg.source and seg.target: + out.append(seg.target) + return "\n".join(out) + + +class LiveCaptionStore: + """实时字幕记录目录的读写。线程:保存在工作线程、列出在 GUI 线程,互不共享状态。""" + + def __init__(self, root: Optional[Path] = None) -> None: + self.root = Path(root) if root is not None else WORK_PATH / DEFAULT_DIR_NAME + + # ----- 路径 ----- + + @staticmethod + def make_id(when: Optional[float] = None) -> str: + return time.strftime("%Y%m%d-%H%M%S", time.localtime(when if when is not None else time.time())) + + @staticmethod + def display_name(when: Optional[float] = None) -> str: + return time.strftime("%Y-%m-%d %H:%M 记录", time.localtime(when if when is not None else time.time())) + + def dir_for(self, record_id: str) -> Path: + return self.root / record_id + + def create_dir(self, record_id: str) -> Path: + d = self.dir_for(record_id) + d.mkdir(parents=True, exist_ok=True) + return d + + # ----- 读写 ----- + + def save(self, record: LiveCaptionRecord) -> Path: + d = self.create_dir(record.id) + (d / _TRANSCRIPT).write_text( + json.dumps(record.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8" + ) + record.dir = d + return d + + def _load_dir(self, d: Path) -> Optional[LiveCaptionRecord]: + f = d / _TRANSCRIPT + if not f.is_file(): + return None + try: + data = json.loads(f.read_text(encoding="utf-8")) + except Exception: + return None + return LiveCaptionRecord.from_dict(data, dir=d) + + def load(self, record_id: str) -> Optional[LiveCaptionRecord]: + return self._load_dir(self.dir_for(record_id)) + + def list(self) -> List[LiveCaptionRecord]: + """所有记录,按创建时间倒序(最新在前)。""" + if not self.root.is_dir(): + return [] + out: List[LiveCaptionRecord] = [] + for d in self.root.iterdir(): + if d.is_dir(): + rec = self._load_dir(d) + if rec is not None: + out.append(rec) + out.sort(key=lambda r: r.created_at, reverse=True) + return out + + def search(self, query: str) -> List[LiveCaptionRecord]: + return [r for r in self.list() if r.matches(query)] + + def delete(self, record_id: str) -> None: + d = self.dir_for(record_id) + if d.is_dir(): + shutil.rmtree(d, ignore_errors=True) + + def rename(self, record_id: str, new_name: str) -> Optional[LiveCaptionRecord]: + """只改显示名(transcript.json 的 name),文件夹仍以时间戳 id 为不可变主键。 + + 返回更新后的记录;记录不存在 / 新名为空则返回 None(不动盘)。 + """ + name = (new_name or "").strip() + rec = self.load(record_id) + if rec is None or not name: + return None + rec.name = name + self.save(rec) + return rec diff --git a/videocaptioner/core/realtime/recording/recorder.py b/videocaptioner/core/realtime/recording/recorder.py new file mode 100644 index 00000000..3759f085 --- /dev/null +++ b/videocaptioner/core/realtime/recording/recorder.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import threading +import time +import wave +from typing import Dict, List, Optional + +from videocaptioner.core.realtime.backends.base import CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH +from videocaptioner.core.realtime.events import CaptionEntry +from videocaptioner.core.realtime.recording.history import ( + AUDIO_NAME, + CaptionSegment, + LiveCaptionRecord, + LiveCaptionStore, +) + + +class SessionRecorder: + """录一次会话:写 WAV + 收集段落 → :class:`LiveCaptionRecord`。线程安全。""" + + def __init__( + self, + store: LiveCaptionStore, + source_label: str, + translate_label: str, + when: Optional[float] = None, + ) -> None: + self._store = store + self._created_at = when if when is not None else time.time() + self._id = store.make_id(self._created_at) + self._name = store.display_name(self._created_at) + self._source_label = source_label + self._translate_label = translate_label + self._t0 = self._created_at # entry.started_at 转相对秒的基准 + self._dir = store.create_dir(self._id) + self._lock = threading.Lock() + self._frames = 0 + self._segs: Dict[str, dict] = {} # seg_id -> {seq, start, source, target} + self._finalized = False + self._wav: Optional[wave.Wave_write] = None + try: + wav = wave.open(str(self._dir / AUDIO_NAME), "wb") + wav.setnchannels(CHANNELS) + wav.setsampwidth(SAMPLE_WIDTH) + wav.setframerate(SAMPLE_RATE) + self._wav = wav + except Exception: + self._wav = None + + # ----- 采集 ----- + + def write_pcm(self, pcm: bytes) -> None: + if not pcm: + return + with self._lock: + if self._wav is not None: + try: + self._wav.writeframes(pcm) + self._frames += len(pcm) // (SAMPLE_WIDTH * CHANNELS) + except Exception: + pass + + def note_caption(self, entry: CaptionEntry) -> None: + """记录段落最新文本(同 seg_id 覆盖,含译文回填)+ 首现时定下的相对起点。 + + 起点用已录音频位置(frames/采样率 = 该句在 WAV 里的偏移):与回放对齐、≤ 总时长、且暂停 + 感知(暂停期不录则 frames 不前进);无音频时回退墙钟相对秒。刻意不用后端 start_time: + Fun-ASR begin_time 重连后每 task 归零、句内还会漂移,会让详情页点句全跳到开头。 + """ + with self._lock: + if self._wav is not None: + start = self._frames / float(SAMPLE_RATE) # WAV 偏移 + else: + start = max(0.0, entry.started_at - self._t0) # 回退墙钟相对秒 + cur = self._segs.get(entry.seg_id) + if cur is None: + self._segs[entry.seg_id] = { + "seq": entry.seq, + "start": start, + "source": entry.source_text, + "target": entry.target_text, + } + else: + if entry.source_text: + cur["source"] = entry.source_text + if entry.target_text: + cur["target"] = entry.target_text + + # ----- 状态 ----- + + @property + def duration(self) -> float: + return self._frames / float(SAMPLE_RATE) if self._frames else 0.0 + + # ----- 收尾 ----- + + def finalize(self) -> Optional[LiveCaptionRecord]: + """收尾:关 WAV、整理段落、存盘并返回记录;无内容则删目录返回 None。幂等。""" + with self._lock: + if self._finalized: + return None + self._finalized = True + self._close_wav() + dur = self.duration + ordered = sorted(self._segs.values(), key=lambda s: s["seq"]) + segments: List[CaptionSegment] = [] + for i, s in enumerate(ordered): + if not (s["source"].strip() or s["target"].strip()): + continue + # 段终点 = 下一段起点(末段取总时长),并把 start/end 钳进 [0, dur]: + # 保证点句落在区间内、cue 不越界或重叠。 + nxt = ordered[i + 1]["start"] if i + 1 < len(ordered) else dur + start = min(s["start"], dur) if dur else s["start"] + end = min(nxt, dur) if dur else nxt + # 末句在 stop 期才首现时 start 会被钉到 ≈dur 而退化成零长 cue(点句直接跳结尾): + # 回退到上一段终点,保证仍是可点击的非零区间。 + if start >= end and segments: + start = segments[-1].end + segments.append( + CaptionSegment(start=start, end=max(end, start), + source=s["source"], target=s["target"]) + ) + has_audio = self._frames > 0 + if not segments: + self._store.delete(self._id) + return None + record = LiveCaptionRecord( + id=self._id, + name=self._name, + source=self._source_label, + translate=self._translate_label, + created_at=self._created_at, + duration=dur, + audio=AUDIO_NAME if has_audio else "", + segments=segments, + ) + self._store.save(record) + return record + + def _close_wav(self) -> None: + if self._wav is not None: + try: + self._wav.close() + except Exception: + pass + self._wav = None diff --git a/videocaptioner/core/realtime/session.py b/videocaptioner/core/realtime/session.py new file mode 100644 index 00000000..2d5cfab5 --- /dev/null +++ b/videocaptioner/core/realtime/session.py @@ -0,0 +1,226 @@ +"""一次实时字幕会话的编排:音频采集 + 转录后端 + 装配器。""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Callable, Optional, Union + +from videocaptioner.core.realtime import factory +from videocaptioner.core.realtime.audio.capture import AudioCapture + +if TYPE_CHECKING: + from videocaptioner.core.realtime.audio.system_mac import MacSystemAudioCapture +from videocaptioner.core.realtime.backends.base import LiveTranscriber, TranscriberState +from videocaptioner.core.realtime.caption import CaptionAssembler, OnCaption +from videocaptioner.core.realtime.config import LiveCaptionConfig, LiveCaptionSource +from videocaptioner.core.realtime.events import CaptionEntry +from videocaptioner.core.realtime.recording.history import LiveCaptionRecord, LiveCaptionStore +from videocaptioner.core.realtime.recording.recorder import SessionRecorder +from videocaptioner.core.translate.types import TranslatorType +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("live_caption_session") + +OnState = Callable[[TranscriberState], None] +OnError = Callable[[str], None] +OnRecord = Callable[[LiveCaptionRecord], None] + +_TRANSLATE_LABELS = { + TranslatorType.BING: "微软翻译", + TranslatorType.GOOGLE: "谷歌翻译", +} + + +def _source_label(cfg: LiveCaptionConfig) -> str: + return "系统声音" if cfg.source == LiveCaptionSource.SYSTEM else "麦克风" + + +def _translate_label(cfg: LiveCaptionConfig) -> str: + if not cfg.translate_enabled: + return "原文记录" + return _TRANSLATE_LABELS.get(cfg.translator_type, "AI 翻译") + + +def _rms_level(pcm: bytes) -> float: + """把一块 s16le PCM 折算成 0~1 的音量电平(给会话页波形/拾音指示用)。""" + if not pcm: + return 0.0 + import numpy as np + + a = np.frombuffer(pcm, dtype=np.int16) + if a.size == 0: + return 0.0 + rms = float(np.sqrt(np.mean(a.astype(np.float32) ** 2))) / 32768.0 + return min(1.0, rms * 4.0) # 语音 RMS 偏小,放大让电平条更直观 + + +class LiveCaptionSession: + def __init__( + self, + config: LiveCaptionConfig, + on_caption: OnCaption, + on_state: Optional[OnState] = None, + on_error: Optional[OnError] = None, + on_record: Optional[OnRecord] = None, + on_level: Optional[Callable[[float], None]] = None, + store: Optional[LiveCaptionStore] = None, + record_enabled: bool = True, + ) -> None: + self._config = config + self._on_caption = on_caption + self._on_state = on_state + self._on_error = on_error + self._on_record = on_record + self._on_level = on_level + self._store = store if store is not None else LiveCaptionStore() + self._record_enabled = record_enabled + + self._backend: Optional[LiveTranscriber] = None + self._audio: Optional[Union[AudioCapture, "MacSystemAudioCapture"]] = None + self._assembler: Optional[CaptionAssembler] = None + self._translator_fn = None + self._recorder: Optional[SessionRecorder] = None + self._debug = None # LiveDebugTap(VC_DEBUG=live 时;落原生事件/PCM/字幕便于排查) + + self._paused = False + self._stopped = False # 让 pump 尽快退出(request_stop 置位) + self._torn_down = False # 链路是否已收尾(stop 的幂等标记,与 _stopped 解耦) + self._fatal_error: Optional[str] = None + + @property + def fatal_error(self) -> Optional[str]: + """后端致命失败(如并发配额满)的消息;非 None 表示会话已不可用。""" + return self._fatal_error + + # ----- 生命周期 ----- + + def start(self) -> None: + cfg = self._config + + translate_fn = factory.build_translate_fn(cfg) + self._translator_fn = translate_fn + # 录制器:把音频录成 WAV、收集段落,结束时存成可回放的历史记录(无内容自动丢弃)。 + if self._record_enabled: + self._recorder = SessionRecorder( + self._store, _source_label(cfg), _translate_label(cfg) + ) + + # 调试落盘(VC_DEBUG=live 或全开时):原生事件 + 喂送 PCM + 装配器输出,便于排查。 + from videocaptioner.core.realtime.recording.debug_tap import LiveDebugTap + from videocaptioner.core.utils.debug import debug_enabled + if debug_enabled("live"): + try: + self._debug = LiveDebugTap(time.strftime("%Y%m%d-%H%M%S")) + except Exception: + logger.exception("实时字幕调试落盘初始化失败(忽略)") + + recorder = self._recorder + debug = self._debug + ui_caption = self._on_caption + + def assembler_caption(entry: CaptionEntry) -> None: + if recorder is not None: + recorder.note_caption(entry) + if debug is not None: + debug.caption(entry) + ui_caption(entry) + + self._assembler = CaptionAssembler(assembler_caption, translate_fn) + + self._backend = factory.build_backend( + cfg, + on_segment=self._assembler.ingest, + on_state=self._forward_state, + on_error=self._forward_error, + ) + if self._debug is not None: # 后端把原生事件原文回调出来落盘 + self._backend.on_raw = self._debug.raw_event + + # 先开采集(入队缓冲),再起后端(voxgate 自起子进程 / fun-asr 连云端)。 + # macOS 原生系统声音走 ScreenCaptureKit 子进程;否则走 PortAudio 输入设备。 + if cfg.system_audio_native: + from videocaptioner.core.realtime.audio.system_mac import MacSystemAudioCapture + + self._audio = MacSystemAudioCapture() + else: + self._audio = AudioCapture(device_index=cfg.device_index) + self._audio.start() + self._backend.start() + + def pump(self, cancel_check: Callable[[], None]) -> None: + """阻塞音频泵:读 PCM → 喂后端,直到 stop / 取消。""" + assert self._audio is not None and self._backend is not None + while not self._stopped and self._fatal_error is None: + cancel_check() # 取消点:抛 WorkerCancelled + chunk = self._audio.read(timeout=0.1) + if chunk is None: + continue + if not self._paused: + self._backend.feed(chunk) + if self._on_level is not None: + self._on_level(_rms_level(chunk)) + if self._recorder is not None: + self._recorder.write_pcm(chunk) + if self._debug is not None: + self._debug.pcm(chunk) + + def request_stop(self) -> None: + """非阻塞:只置停止标记让 pump 尽快退出;真正的链路收尾在 _work 的 finally。""" + self._stopped = True + + def set_paused(self, paused: bool) -> None: + self._paused = paused + + def stop(self) -> None: + # 用独立的 _torn_down 幂等,不能复用 _stopped:request_stop 已把 _stopped 置 True, + # 若以 _stopped 为门会跳过整段收尾,残余回调向已销毁浮窗 emit 致硬 abort。 + if self._torn_down: + return + self._torn_down = True + self._stopped = True + audio, backend = self._audio, self._backend + if audio is not None: + audio.stop() # 先停采集(不再有新帧入队) + # 停后端前排空队列残留尾音,否则 pump 未拉完的尾巴被丢弃 → 末句被截。 + if backend is not None: + while True: + chunk = audio.read(timeout=0.0) + if chunk is None: + break + backend.feed(chunk) + if self._recorder is not None: + self._recorder.write_pcm(chunk) + if self._debug is not None: + self._debug.pcm(chunk) + if backend is not None: + backend.stop() + if self._assembler is not None: + self._assembler.close() + if self._translator_fn is not None: + close = getattr(self._translator_fn, "__self__", None) + if close is not None and hasattr(close, "close"): + close.close() + # 录制收尾:后端/装配器已 flush 末段(is_final),此时段落齐全;存盘并回调(空则丢弃)。 + if self._recorder is not None: + recorder, self._recorder = self._recorder, None + try: + record = recorder.finalize() + except Exception: + logger.exception("保存实时字幕记录失败") + record = None + if record is not None and self._on_record is not None: + self._on_record(record) + if self._debug is not None: + self._debug.close() # 调试落盘收尾(flush + 打印路径) + + # ----- 回调转发 ----- + + def _forward_state(self, state: TranscriberState) -> None: + if self._on_state is not None: + self._on_state(state) + + def _forward_error(self, message: str) -> None: + # 后端致命失败:latch 后让 pump 尽快退出,由上层(线程)作为终态处理并停链路。 + self._fatal_error = message + if self._on_error is not None: + self._on_error(message) diff --git a/videocaptioner/core/speech/models.py b/videocaptioner/core/speech/models.py index 8e7a5e1a..240e87bc 100644 --- a/videocaptioner/core/speech/models.py +++ b/videocaptioner/core/speech/models.py @@ -23,6 +23,14 @@ class SpeechProviderConfig: timeout: int = 90 style_prompt: str = "" + def __post_init__(self): + self.api_key = self.api_key.strip() + self.base_url = self.base_url.strip() + self.model = self.model.strip() + self.default_voice = self.default_voice.strip() + self.response_format = self.response_format.strip() # type: ignore[attr-defined] + self.style_prompt = self.style_prompt.strip() + @dataclass class SynthesisRequest: diff --git a/videocaptioner/core/split/split_by_llm.py b/videocaptioner/core/split/split_by_llm.py index ae281f73..a9c0189c 100644 --- a/videocaptioner/core/split/split_by_llm.py +++ b/videocaptioner/core/split/split_by_llm.py @@ -67,7 +67,6 @@ def _split_with_agent_loop( response = call_llm( messages=messages, model=model, - temperature=0.1, ) result_text = response.choices[0].message.content diff --git a/videocaptioner/core/subtitle/__init__.py b/videocaptioner/core/subtitle/__init__.py index c1876515..cefc3446 100644 --- a/videocaptioner/core/subtitle/__init__.py +++ b/videocaptioner/core/subtitle/__init__.py @@ -19,12 +19,21 @@ ) from .rounded_renderer import render_preview, render_rounded_video from .style_manager import ( + AssSecondaryStyle, + AssSubtitleStyle, + RoundedSubtitleStyle, SecondaryStyle, StyleMode, + StyleSource, + SubtitleRenderer, SubtitleStyle, + SubtitleStylePreset, available_style_names, + delete_user_style, list_styles, load_style, + normalize_style_id, + save_user_style, ) from .styles import RoundedBgStyle from .text_utils import hex_to_rgba, is_mainly_cjk, wrap_text @@ -35,7 +44,7 @@ def get_subtitle_style(style_name: str) -> Optional[str]: Uses the unified style_manager (JSON-first, .txt fallback). """ - style = load_style(style_name) + style = load_style(style_name, renderer=SubtitleRenderer.ASS) if style is None: return None return style.to_ass_string() @@ -54,11 +63,20 @@ def get_subtitle_style(style_name: str) -> Optional[str]: "RoundedBgStyle", "get_subtitle_style", "SubtitleStyle", + "SubtitleStylePreset", + "SubtitleRenderer", + "StyleSource", + "AssSubtitleStyle", + "AssSecondaryStyle", + "RoundedSubtitleStyle", "SecondaryStyle", "StyleMode", "load_style", "list_styles", "available_style_names", + "save_user_style", + "delete_user_style", + "normalize_style_id", "FontType", "get_font", "get_ass_to_pil_ratio", diff --git a/videocaptioner/core/subtitle/ass_renderer.py b/videocaptioner/core/subtitle/ass_renderer.py index 608003d0..a6beb08c 100644 --- a/videocaptioner/core/subtitle/ass_renderer.py +++ b/videocaptioner/core/subtitle/ass_renderer.py @@ -9,16 +9,23 @@ from PIL import Image -from videocaptioner.config import CACHE_PATH, FONTS_PATH, RESOURCE_PATH +from videocaptioner.config import FONTS_PATH, RESOURCE_PATH from videocaptioner.core.entities import SubtitleLayoutEnum from videocaptioner.core.utils.logger import setup_logger +from videocaptioner.core.utils.media_info import probe_media from .ass_utils import auto_wrap_ass_file +from .preview_cache import preview_path +from .preview_cache import prune as prune_preview_cache if TYPE_CHECKING: from videocaptioner.core.asr.asr_data import ASRData logger = setup_logger("subtitle.ass") +ASS_FILTER_ERROR = ( + "当前 FFmpeg 不支持 ASS 字幕渲染滤镜,无法使用“ASS 样式”生成硬字幕视频。" + "请安装带 libass 的完整 FFmpeg,或在字幕样式里切换为“圆角背景”。" +) ASS_TEMPLATE = """[Script Info] ; Script generated by VideoCaptioner ScriptType: v4.00+ @@ -39,6 +46,37 @@ def _check_cuda_available() -> bool: return check_cuda_available() +def ffmpeg_supports_ass_filter() -> bool: + """Return whether the active ffmpeg binary exposes the ASS subtitle filter.""" + try: + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-filters"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + creationflags=( + getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + ), + ) + except (OSError, subprocess.SubprocessError): + return False + + if result.returncode != 0: + return False + + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[1] == "ass": + return True + return False + + +def _ensure_ass_filter_supported() -> None: + if not ffmpeg_supports_ass_filter(): + raise RuntimeError(ASS_FILTER_ERROR) + + def _scale_ass_style(style_str: str, scale_factor: float) -> str: """ 缩放 ASS 样式中的数值参数 @@ -66,6 +104,9 @@ def _scale_ass_style(style_str: str, scale_factor: float) -> str: parts[13] = str(float(parts[13]) * scale_factor) # parts[16]: Outline parts[16] = str(float(parts[16]) * scale_factor) + # parts[19]/parts[20]: MarginL/MarginR(最大宽度边距) + parts[19] = str(int(float(parts[19]) * scale_factor)) + parts[20] = str(int(float(parts[20]) * scale_factor)) # parts[21]: MarginV (垂直间距) parts[21] = str(int(float(parts[21]) * scale_factor)) line = ",".join(parts) @@ -74,6 +115,32 @@ def _scale_ass_style(style_str: str, scale_factor: float) -> str: return "\n".join(scaled_lines) +def top_line_margin_v(style_str: str, line_gap: int) -> Optional[int]: + """双语时上行(Default)距底部的 MarginV = 底边距 + 副字幕行高估算 + 主副间距。 + + line_gap<=0 时返回 None,表示沿用 libass 默认的紧贴堆叠(对存量样式零回归)。 + style_str 应为已按视频高度缩放后的样式串,line_gap 也应是已缩放的像素值。 + """ + if line_gap <= 0: + return None + base: Optional[int] = None + sec_size: Optional[int] = None + for line in style_str.splitlines(): + if not line.startswith("Style:"): + continue + parts = line.split(",") + if len(parts) < 23: + continue + name = parts[0].split(":", 1)[1].strip() + if name == "Default": + base = int(float(parts[21])) + elif name == "Secondary": + sec_size = int(float(parts[2])) + if base is None: + return None + return base + int((sec_size or 30) * 1.2) + line_gap + + def render_ass_preview( style_str: str, preview_text: Tuple[str, Optional[str]], @@ -81,6 +148,7 @@ def render_ass_preview( width: Optional[int] = None, height: Optional[int] = None, reference_height: int = 720, + line_gap: int = 0, ) -> str: """ 生成 ASS 样式字幕预览图 @@ -109,30 +177,32 @@ def render_ass_preview( original_text, translate_text = preview_text - # 构建对话行 + # 内容寻址缓存:同样的样式 + 文字 + 背景 + 尺寸只渲染一次,来回切换/重复编辑直接命中 + output_path = preview_path( + f"ass|{style_str}|{original_text}|{translate_text}|{bg_image_path}" + f"|{width}x{height}|ref{reference_height}|gap{line_gap}" + ) + if output_path.exists(): + return str(output_path) + + # 先按图片高度缩放样式,主副间距也同比缩放,再据此构建对话行 + scale_factor = height / reference_height + style_str = _scale_ass_style(style_str, scale_factor) + + # 双语时给上行(Default)一个绝对 MarginV,制造可控的主副间距 + top_mv = top_line_margin_v(style_str, int(line_gap * scale_factor)) + top_mv_field = top_mv if top_mv is not None else 0 if translate_text: dialogue = [ f"Dialogue: 0,0:00:00.00,0:00:01.00,Secondary,,0,0,0,,{translate_text}", - f"Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,{original_text}", + f"Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,{top_mv_field},,{original_text}", ] else: dialogue = [ f"Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,{original_text}" ] - # 生成 ASS 内容 - ass_content = ASS_TEMPLATE.format( - style_str=style_str, - dialogue=os.linesep.join(dialogue), - video_width=width, - video_height=height, - ) - - # 从 ASS 内容中提取参考高度,根据图片高度自动缩放样式 - scale_factor = height / reference_height - style_str = _scale_ass_style(style_str, scale_factor) - - # 重新生成缩放后的 ASS 内容 + # 生成缩放后的 ASS 内容 ass_content = ASS_TEMPLATE.format( style_str=style_str, dialogue=os.linesep.join(dialogue), @@ -169,6 +239,8 @@ def render_ass_preview( f"color=c=black:s={width}x{height}", "-frames:v", "1", + "-update", + "1", str(default_bg), ], capture_output=True, @@ -180,8 +252,6 @@ def render_ass_preview( ) bg_path_obj = default_bg - # 生成预览图 - output_path = CACHE_PATH / "ass_preview.png" output_path.parent.mkdir(parents=True, exist_ok=True) # 处理 ASS 文件路径(Windows 兼容) @@ -193,25 +263,35 @@ def render_ass_preview( cmd = [ "ffmpeg", "-y", + "-hide_banner", + "-loglevel", + "error", "-i", str(bg_path_obj), "-vf", f"ass='{ass_file_escaped}':fontsdir='{fonts_dir_escaped}'", "-frames:v", "1", + "-update", + "1", str(output_path), ] result = subprocess.run( cmd, capture_output=True, + text=True, + encoding="utf-8", + errors="replace", creationflags=( getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 ), ) if result.returncode != 0: - logger.error(f"FFmpeg preview generation failed: {result.stderr}") + logger.error("FFmpeg preview generation failed: %s", result.stderr.strip()) + else: + prune_preview_cache() return str(output_path) @@ -223,22 +303,11 @@ def render_ass_preview( def _get_video_resolution(video_path: str) -> Tuple[int, int]: - """获取视频分辨率""" - result = subprocess.run( - ["ffmpeg", "-i", video_path], - capture_output=True, - text=True, - creationflags=( - getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 - ), - ) - - # 从 ffmpeg 输出中解析分辨率 - pattern = r"(\d{2,5})x(\d{2,5})" - match = re.search(pattern, result.stderr) - if match: - return int(match.group(1)), int(match.group(2)) - return 1920, 1080 # 默认返回 1080P + """获取视频分辨率;探测失败按 1080P 处理。""" + info = probe_media(video_path) + if info is not None and info.has_video: + return info.resolution + return 1920, 1080 def render_ass_video( @@ -251,6 +320,7 @@ def render_ass_video( preset: str = "medium", progress_callback: Optional[Callable] = None, reference_height: int = 720, + line_gap: int = 0, ) -> None: """ 渲染 ASS 样式字幕到视频(硬字幕) @@ -270,6 +340,8 @@ def render_ass_video( if not asr_data or not asr_data.segments: raise ValueError("Empty subtitle data, cannot render video") + _ensure_ass_filter_supported() + # 获取视频分辨率 width, height = _get_video_resolution(video_path) @@ -287,6 +359,7 @@ def render_ass_video( save_path=None, video_width=width, video_height=height, + line_gap=int(line_gap * scale_factor), ) temp_file.write(ass_content) temp_ass_path = temp_file.name diff --git a/videocaptioner/core/subtitle/preview_cache.py b/videocaptioner/core/subtitle/preview_cache.py new file mode 100644 index 00000000..541b543a --- /dev/null +++ b/videocaptioner/core/subtitle/preview_cache.py @@ -0,0 +1,35 @@ +"""字幕样式预览图的内容寻址缓存。 + +预览渲染是输入的纯函数(样式 + 文字 + 背景 → 一张图)。ASS 走 ffmpeg/libass +(约 250ms),圆角走 PIL(约 70ms)。来回切换 ASS / 圆角、或反复微调后又调回原值时, +同一组输入会被重复渲染。这里按输入内容做哈希缓存:命中即直接返回已渲染的图(约 1ms), +不再启动 ffmpeg / 重绘。缓存目录按数量上限滚动清理,不会无限增长。 +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from videocaptioner.config import CACHE_PATH + +_PREVIEW_DIR = Path(CACHE_PATH) / "subtitle_preview" +_MAX_FILES = 24 # 够覆盖 ASS/圆角来回切换 + 最近若干次编辑 + + +def preview_path(signature: str) -> Path: + """内容签名 -> 缓存图路径(不保证文件已存在)。""" + digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:16] + _PREVIEW_DIR.mkdir(parents=True, exist_ok=True) + return _PREVIEW_DIR / f"{digest}.png" + + +def prune(keep: int = _MAX_FILES) -> None: + """按修改时间保留最近 keep 个预览图,清掉更早的。""" + if not _PREVIEW_DIR.exists(): + return + files = sorted( + _PREVIEW_DIR.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True + ) + for stale in files[keep:]: + stale.unlink(missing_ok=True) diff --git a/videocaptioner/core/subtitle/rounded_renderer.py b/videocaptioner/core/subtitle/rounded_renderer.py index b9fda706..66d5a873 100644 --- a/videocaptioner/core/subtitle/rounded_renderer.py +++ b/videocaptioner/core/subtitle/rounded_renderer.py @@ -1,7 +1,6 @@ """Rounded background subtitle renderer""" import os -import re import subprocess import tempfile from dataclasses import replace @@ -11,7 +10,10 @@ from PIL import Image, ImageDraw from videocaptioner.core.entities import SubtitleLayoutEnum +from videocaptioner.core.subtitle.preview_cache import preview_path +from videocaptioner.core.subtitle.preview_cache import prune as prune_preview_cache from videocaptioner.core.utils.logger import setup_logger +from videocaptioner.core.utils.media_info import probe_media from .font_utils import FontType, get_font from .styles import RoundedBgStyle @@ -24,37 +26,19 @@ def _get_video_info(video_path: str) -> Tuple[int, int, float]: - """获取视频分辨率和时长""" - result = subprocess.run( - ["ffmpeg", "-i", video_path], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - creationflags=(getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0), - ) - - # 解析分辨率 - width, height = 0, 0 - if match := re.search(r"Stream.*Video:.* (\d{2,5})x(\d{2,5})", result.stderr): - width, height = int(match.group(1)), int(match.group(2)) - else: + """获取视频分辨率和时长;无视频流时抛 ValueError(圆角渲染必须知道画布尺寸)。""" + info = probe_media(video_path) + if info is None or not info.has_video: raise ValueError(f"Cannot get video resolution: {video_path}") - - # 解析时长 - duration = 0.0 - if match := re.search(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", result.stderr): - h, m, s = match.groups() - duration = int(h) * 3600 + int(m) * 60 + float(s) - - return width, height, duration + return info.width, info.height, info.duration_seconds def render_text_block( draw: ImageDraw.ImageDraw, texts: List[str], font: FontType, - center_x: int, + usable_left: int, + usable_right: int, top_y: float, style: RoundedBgStyle, ) -> float: @@ -65,7 +49,8 @@ def render_text_block( draw: PIL ImageDraw 对象 texts: 文本行列表 font: 字体对象 - center_x: 水平中心位置 + usable_left: 可用区域左边界(受最大宽度约束) + usable_right: 可用区域右边界 top_y: 顶部 y 坐标 style: 样式配置 @@ -90,14 +75,19 @@ def render_text_block( line_sizes.append((text_width, bbox[3] - bbox[1])) line_offsets.append(bbox[1]) # 记录垂直偏移,用于居中对齐 - max_width = max(w for w, h in line_sizes) + block_width = max(w for w, h in line_sizes) line_height = max(h for w, h in line_sizes) total_height = line_height * len(texts) + style.line_spacing * (len(texts) - 1) - # 绘制共享背景 - bg_width = max_width + style.padding_h * 2 + # 绘制共享背景:按对齐方式把气泡贴到可用区域的左/中/右 + bg_width = block_width + style.padding_h * 2 bg_height = total_height + style.padding_v * 2 - bg_left = center_x - bg_width // 2 + if style.align == "left": + bg_left = usable_left + elif style.align == "right": + bg_left = usable_right - bg_width + else: + bg_left = (usable_left + usable_right) // 2 - bg_width // 2 bg_top = top_y draw.rounded_rectangle( @@ -106,11 +96,12 @@ def render_text_block( fill=bg_color, ) - # 绘制文本(补偿字体垂直偏移) + # 绘制文本(文字在气泡内水平居中,补偿字体垂直偏移) + text_center = bg_left + bg_width // 2 y = bg_top + style.padding_v for i, text in enumerate(texts): w, h = line_sizes[i] - x = center_x - w // 2 + x = text_center - w // 2 y_offset = line_offsets[i] text_y = y - y_offset # 补偿垂直偏移,使文本视觉居中 @@ -154,8 +145,12 @@ def render_subtitle_image( draw = ImageDraw.Draw(image) font = get_font(style.font_size, style.font_name) - # 换行处理(额外留 40px 边距防止文字贴边) - extra_margin = int(width * 0.1) + # 可用区域:受最大宽度百分比约束,超出部分作为换行的额外边距 + max_pct = style.max_width if 0 < style.max_width <= 100 else 90 + usable_w = max(1, int(width * max_pct / 100)) + usable_left = (width - usable_w) // 2 + usable_right = usable_left + usable_w + extra_margin = width - usable_w primary_lines = ( wrap_text(primary_text, font, width, style.padding_h, extra_margin=extra_margin) if primary_text @@ -167,8 +162,6 @@ def render_subtitle_image( else [] ) - center_x = width // 2 - # 计算总高度 def calc_block_height(lines: List[str]) -> float: if not lines: @@ -189,10 +182,10 @@ def calc_block_height(lines: List[str]) -> float: # 渲染文本块 current_y = start_y if primary_lines: - h = render_text_block(draw, primary_lines, font, center_x, current_y, style) + h = render_text_block(draw, primary_lines, font, usable_left, usable_right, current_y, style) current_y += h + gap if secondary_lines: - render_text_block(draw, secondary_lines, font, center_x, current_y, style) + render_text_block(draw, secondary_lines, font, usable_left, usable_right, current_y, style) return image @@ -223,23 +216,33 @@ def render_preview( if style is None: style = RoundedBgStyle() + # 先解析尺寸(懒探测,不解码像素)以构建缓存签名 + has_bg = bool(bg_image_path) and Path(bg_image_path).exists() + if width is None or height is None: + if has_bg: + with Image.open(bg_image_path) as probe: + bw, bh = probe.size + width = width or bw + height = height or bh + else: + width = width or 1920 + height = height or 1080 + assert width is not None and height is not None + + # 内容寻址缓存:同样的样式 + 文字 + 背景 + 尺寸只渲染一次,命中即直接返回 + output_path = preview_path( + f"rounded|{primary_text}|{secondary_text}|{width}x{height}" + f"|{bg_image_path}|ref{reference_height}|{style!r}" + ) + if output_path.exists(): + return str(output_path) + # 加载或创建背景 - if bg_image_path and Path(bg_image_path).exists(): + if has_bg: background = Image.open(bg_image_path).convert("RGB") - # 如果未提供尺寸,从图片获取 - if width is None or height is None: - width, height = background.size else: - # 没有背景图片,使用默认尺寸或提供的尺寸 - if width is None: - width = 1920 - if height is None: - height = 1080 background = Image.new("RGB", (width, height), (20, 20, 20)) - # 确保 width 和 height 不为 None(类型收窄) - assert width is not None and height is not None - # 从样式中获取参考高度,根据图片高度自动缩放样式 scale_factor = height / reference_height @@ -259,10 +262,9 @@ def render_preview( subtitle_img = render_subtitle_image(primary_text, secondary_text, width, height, style) background.paste(subtitle_img, (0, 0), subtitle_img) - # 保存到临时目录 - with tempfile.NamedTemporaryFile(mode="wb", suffix=".png", delete=False) as tmp_file: - background.save(tmp_file, "PNG") - return tmp_file.name + background.save(output_path, "PNG") + prune_preview_cache() + return str(output_path) def render_rounded_video( diff --git a/videocaptioner/core/subtitle/style_manager.py b/videocaptioner/core/subtitle/style_manager.py index 2877c356..be5660f4 100644 --- a/videocaptioner/core/subtitle/style_manager.py +++ b/videocaptioner/core/subtitle/style_manager.py @@ -1,89 +1,106 @@ -"""Unified subtitle style management. +"""Subtitle visual style registry. -Single source of truth for loading, converting, and listing subtitle styles. -Both CLI and UI import from here. +This module owns the distinction between: + +- subtitle file formats such as SRT/ASS/VTT; +- hard-subtitle renderers such as ASS and rounded boxes; +- visual style presets for each renderer. + +The rest of the app should load styles through this module instead of reading +JSON files or resource directories directly. """ +from __future__ import annotations + import json import logging +import re from dataclasses import asdict, dataclass from enum import Enum from pathlib import Path -from typing import List, Optional +from typing import Optional, Union logger = logging.getLogger(__name__) -class StyleMode(Enum): +class SubtitleRenderer(Enum): + """Hard-subtitle rendering backend.""" + ASS = "ass" ROUNDED = "rounded" -@dataclass -class SecondaryStyle: - """Secondary (bilingual) subtitle style for ASS mode.""" +class StyleSource(Enum): + """Where a style preset comes from.""" + + BUILTIN = "builtin" + USER = "user" + + +# Backward-compatible names for older call sites. The values now describe a +# renderer, not a subtitle file format. +StyleMode = SubtitleRenderer + +# 对齐字符串 -> ASS Alignment(数字小键盘布局,底部一行)。 +_ASS_ALIGNMENT = {"left": 1, "center": 2, "right": 3} +# ASS 样式以 720p(约 1280 宽)为基准编写,渲染时再按视频高度缩放。 +_ASS_REFERENCE_WIDTH = 1280 + + +def _ass_margin_lr(max_width: int) -> int: + """最大宽度百分比 -> ASS 左右边距(基准宽度像素)。100% 保留历史的 10px 安全边距。""" + if max_width >= 100: + return 10 + return int(_ASS_REFERENCE_WIDTH * (100 - max_width) / 200) - font_name: str = "Arial" + +@dataclass(frozen=True) +class AssSecondaryStyle: + """Secondary line style for bilingual ASS rendering.""" + + font_name: str = "Noto Sans SC" font_size: int = 30 color: str = "#ffffff" outline_color: str = "#000000" outline_width: float = 2.0 spacing: float = 0.8 + bold: bool = True -@dataclass -class SubtitleStyle: - """Unified subtitle style definition. +SecondaryStyle = AssSecondaryStyle - A single dataclass that covers both ASS and rounded modes. - Fields irrelevant to the current mode are simply ignored. - """ - # -- Metadata -- - name: str = "" - description: str = "" - mode: StyleMode = StyleMode.ASS +@dataclass(frozen=True) +class AssSubtitleStyle: + """Visual fields used by the ASS hard-subtitle renderer.""" - # -- Common -- font_name: str = "Noto Sans SC" font_size: int = 42 - - # -- ASS mode fields -- - primary_color: str = "#65ff5a" + primary_color: str = "#ffffff" outline_color: str = "#000000" outline_width: float = 2.0 bold: bool = True - spacing: float = 3.2 + spacing: float = 0.0 margin_bottom: int = 30 - secondary: Optional[SecondaryStyle] = None - - # -- Rounded mode fields -- - text_color: str = "#000000" - bg_color: str = "#0de3ffe5" - corner_radius: int = 14 - padding_h: int = 24 - padding_v: int = 18 - line_spacing: int = 12 - letter_spacing: int = 1 - margin_bottom_rounded: int = 40 - - # ------------------------------------------------------------------ # - # Conversion helpers - # ------------------------------------------------------------------ # + max_width: int = 100 # 文本最大宽度(占画面宽度百分比);100=不额外限制 + align: str = "center" # 水平对齐:left / center / right + line_gap: int = 0 # 主副字幕之间的额外间距(仅双语时生效,作用于上行) + secondary: AssSecondaryStyle | None = None def to_ass_string(self) -> str: - """Render as ASS V4+ Styles section (for FFmpeg).""" primary = _hex_to_ass(self.primary_color) outline = _hex_to_ass(self.outline_color) bold_flag = -1 if self.bold else 0 - - sec = self.secondary or SecondaryStyle( + align = _ASS_ALIGNMENT.get(self.align, 2) + margin_lr = _ass_margin_lr(self.max_width) + secondary = self.secondary or AssSecondaryStyle( font_name=self.font_name, - font_size=int(self.font_size * 0.7), + font_size=max(8, int(self.font_size * 0.72)), + bold=self.bold, ) - sec_color = _hex_to_ass(sec.color) - sec_outline = _hex_to_ass(sec.outline_color) - + sec_bold_flag = -1 if secondary.bold else 0 + sec_color = _hex_to_ass(secondary.color) + sec_outline = _hex_to_ass(secondary.outline_color) header = ( "[V4+ Styles]\n" "Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour," @@ -95,129 +112,304 @@ def to_ass_string(self) -> str: f"Style: Default,{self.font_name},{self.font_size}," f"{primary},&H000000FF,{outline},&H00000000," f"{bold_flag},0,0,0,100,100,{self.spacing},0,1," - f"{self.outline_width},0,2,10,10,{self.margin_bottom},1,\\q1" + f"{self.outline_width},0,{align},{margin_lr},{margin_lr},{self.margin_bottom},1,\\q1" ) secondary_line = ( - f"Style: Secondary,{sec.font_name},{sec.font_size}," + f"Style: Secondary,{secondary.font_name},{secondary.font_size}," f"{sec_color},&H000000FF,{sec_outline},&H00000000," - f"{bold_flag},0,0,0,100,100,{sec.spacing},0,1," - f"{sec.outline_width},0,2,10,10,{self.margin_bottom},1,\\q1" + f"{sec_bold_flag},0,0,0,100,100,{secondary.spacing},0,1," + f"{secondary.outline_width},0,{align},{margin_lr},{margin_lr},{self.margin_bottom},1,\\q1" ) return f"{header}\n{default_line}\n{secondary_line}" + def to_dict(self) -> dict: + data = asdict(self) + if self.secondary is None: + data.pop("secondary", None) + return data + + +@dataclass(frozen=True) +class RoundedSubtitleStyle: + """Visual fields used by the rounded-background hard-subtitle renderer.""" + + font_name: str = "Noto Sans SC" + font_size: int = 52 + text_color: str = "#ffffff" + bg_color: str = "#191919c8" + corner_radius: int = 12 + padding_h: int = 28 + padding_v: int = 14 + margin_bottom: int = 60 + line_spacing: int = 10 # 主副两个气泡之间的间距(也用于块内换行) + letter_spacing: int = 0 + max_width: int = 90 # 文本块最大宽度(占画面宽度百分比) + align: str = "center" # 水平对齐:left / center / right + + def to_dict(self) -> dict: + return asdict(self) + + +StylePayload = Union[AssSubtitleStyle, RoundedSubtitleStyle] + + +@dataclass(frozen=True) +class SubtitleStylePreset: + """A named visual style preset for one renderer.""" + + id: str + name: str + renderer: SubtitleRenderer + source: StyleSource + style: StylePayload + description: str = "" + version: int = 1 + path: Path | None = None + + @property + def mode(self) -> SubtitleRenderer: + return self.renderer + + @property + def editable(self) -> bool: + return self.source == StyleSource.USER + + @property + def short_id(self) -> str: + return self.id.split("/", 1)[-1] + + def to_ass_string(self) -> str: + if not isinstance(self.style, AssSubtitleStyle): + raise TypeError(f"Style '{self.id}' is not an ASS style") + return self.style.to_ass_string() + def to_rounded_dict(self) -> dict: - """Return the dict expected by the rounded renderer.""" - return { - "font_name": self.font_name, - "font_size": self.font_size, - "text_color": self.text_color, - "bg_color": self.bg_color, - "corner_radius": self.corner_radius, - "padding_h": self.padding_h, - "padding_v": self.padding_v, - "margin_bottom": self.margin_bottom_rounded, - "line_spacing": self.line_spacing, - "letter_spacing": self.letter_spacing, - } + if not isinstance(self.style, RoundedSubtitleStyle): + raise TypeError(f"Style '{self.id}' is not a rounded style") + return self.style.to_dict() def to_json_dict(self) -> dict: - """Serialize to a JSON-friendly dict (for saving).""" - d: dict = {"name": self.name, "description": self.description, "mode": self.mode.value} - if self.mode == StyleMode.ROUNDED: - d.update(self.to_rounded_dict()) - else: - d.update({ - "font_name": self.font_name, - "font_size": self.font_size, - "primary_color": self.primary_color, - "outline_color": self.outline_color, - "outline_width": self.outline_width, - "bold": self.bold, - "spacing": self.spacing, - "margin_bottom": self.margin_bottom, - }) - if self.secondary: - d["secondary"] = asdict(self.secondary) - return d - - # ------------------------------------------------------------------ # - # Factory methods - # ------------------------------------------------------------------ # - - @classmethod - def from_json(cls, data: dict) -> "SubtitleStyle": - """Create from a parsed JSON dict. Auto-detects mode if not specified.""" - if "mode" in data: - mode = StyleMode(data["mode"]) - elif any(k in data for k in ("bg_color", "text_color", "corner_radius")): - mode = StyleMode.ROUNDED - else: - mode = StyleMode.ASS - sec_data = data.get("secondary") - secondary = SecondaryStyle(**sec_data) if isinstance(sec_data, dict) else None - - if mode == StyleMode.ROUNDED: - return cls( - name=data.get("name", ""), - description=data.get("description", ""), - mode=mode, - font_name=data.get("font_name", cls.font_name), - font_size=data.get("font_size", cls.font_size), - text_color=data.get("text_color", cls.text_color), - bg_color=data.get("bg_color", cls.bg_color), - corner_radius=data.get("corner_radius", cls.corner_radius), - padding_h=data.get("padding_h", cls.padding_h), - padding_v=data.get("padding_v", cls.padding_v), - margin_bottom_rounded=data.get("margin_bottom", cls.margin_bottom_rounded), - line_spacing=data.get("line_spacing", cls.line_spacing), - letter_spacing=data.get("letter_spacing", cls.letter_spacing), - ) - - return cls( - name=data.get("name", ""), - description=data.get("description", ""), - mode=mode, - font_name=data.get("font_name", cls.font_name), - font_size=data.get("font_size", cls.font_size), - primary_color=data.get("primary_color", cls.primary_color), - outline_color=data.get("outline_color", cls.outline_color), - outline_width=data.get("outline_width", cls.outline_width), - bold=data.get("bold", cls.bold), - spacing=data.get("spacing", cls.spacing), - margin_bottom=data.get("margin_bottom", cls.margin_bottom), - secondary=secondary, - ) + data = { + "version": self.version, + "id": self.id, + "name": self.name, + "description": self.description, + "renderer": self.renderer.value, + "source": self.source.value, + } + data.update(self.style.to_dict()) + # Older callers expect a ``mode`` field. Keep it as an alias in files. + data["mode"] = self.renderer.value + return data + + +# Older call sites used SubtitleStyle as the object returned by load_style(). +SubtitleStyle = SubtitleStylePreset + + +BUILTIN_STYLE_DATA: tuple[dict, ...] = ( + { + "id": "ass/default", + "name": "默认描边", + "description": "通用白字黑边,适合多数横屏视频", + "renderer": "ass", + "font_name": "LXGW WenKai", + "font_size": 40, + "primary_color": "#ffffff", + "outline_color": "#000000", + "outline_width": 2.0, + "bold": True, + "spacing": 0.2, + "margin_bottom": 28, + "secondary": { + "font_name": "Noto Sans SC", + "font_size": 30, + "color": "#ffffff", + "outline_color": "#000000", + "outline_width": 2.0, + "spacing": 0.8, + }, + }, + { + "id": "ass/anime", + "name": "暖色动漫", + "description": "偏亮的暖色描边,适合二创和短视频", + "renderer": "ass", + "font_name": "Noto Sans SC", + "font_size": 46, + "primary_color": "#fff5f3", + "outline_color": "#f58709", + "outline_width": 2.6, + "bold": True, + "spacing": 2.6, + "margin_bottom": 20, + "secondary": { + "font_name": "Noto Sans SC", + "font_size": 26, + "color": "#ffffff", + "outline_color": "#f58709", + "outline_width": 2.0, + "spacing": 0.0, + }, + }, + { + "id": "ass/vertical", + "name": "竖屏留白", + "description": "更高底部边距,适合 9:16 视频", + "renderer": "ass", + "font_name": "Noto Sans SC", + "font_size": 34, + "primary_color": "#65ff5a", + "outline_color": "#000000", + "outline_width": 2.0, + "bold": True, + "spacing": 4.0, + "margin_bottom": 182, + "secondary": { + "font_name": "Noto Sans SC", + "font_size": 18, + "color": "#ffffff", + "outline_color": "#000000", + "outline_width": 2.0, + "spacing": 0.8, + }, + }, + { + "id": "rounded/default", + "name": "圆角胶囊", + "description": "半透明深色圆角背景,适合信息密度较高的视频", + "renderer": "rounded", + "font_name": "LXGW WenKai", + "font_size": 52, + "text_color": "#ffffff", + "bg_color": "#191919c8", + "corner_radius": 12, + "padding_h": 28, + "padding_v": 14, + "margin_bottom": 60, + "line_spacing": 10, + "letter_spacing": 0, + }, +) + + +def normalize_renderer(value: object | None) -> SubtitleRenderer: + if isinstance(value, SubtitleRenderer): + return value + raw = str(value or "").strip().lower() + if raw in {"ass", "ass_style", "ass-style", "ass 样式", "ass样式"}: + return SubtitleRenderer.ASS + if raw in {"rounded", "rounded_bg", "rounded-bg", "圆角背景"}: + return SubtitleRenderer.ROUNDED + return SubtitleRenderer.ASS + + +def normalize_style_id( + style_id: str | None, + renderer: SubtitleRenderer | str | None = None, +) -> str: + renderer_enum = normalize_renderer(renderer) + raw = str(style_id or "").strip() + if not raw: + return f"{renderer_enum.value}/default" + raw = raw.replace("\\", "/") + if raw.startswith("ass-"): + return f"ass/{raw[4:]}" + if raw.startswith("rounded-"): + return f"rounded/{raw[8:]}" + if "/" in raw: + left, right = raw.split("/", 1) + return f"{normalize_renderer(left).value}/{_slugify(right)}" + return f"{renderer_enum.value}/{_slugify(raw)}" + + +def list_styles( + styles_dir: Optional[Path] = None, + renderer: SubtitleRenderer | str | None = None, + include_builtin: bool = True, + include_user: bool = True, +) -> list[SubtitleStylePreset]: + """List available visual presets.""" + renderer_filter = normalize_renderer(renderer) if renderer is not None else None + result: list[SubtitleStylePreset] = [] + if include_builtin: + result.extend(_builtin_styles()) + if include_user: + result.extend(_load_user_styles(styles_dir)) + if renderer_filter is not None: + result = [style for style in result if style.renderer == renderer_filter] + return sorted(result, key=lambda item: (item.renderer.value, item.source.value, item.short_id)) - @classmethod - def from_file(cls, path: Path) -> "SubtitleStyle": - """Load from a file (.json or legacy .txt).""" - content = path.read_text(encoding="utf-8") - if path.suffix == ".json": - return cls.from_json(json.loads(content)) +def load_style( + name: str | None, + styles_dir: Optional[Path] = None, + mode: Optional[str] = None, + renderer: SubtitleRenderer | str | None = None, +) -> Optional[SubtitleStylePreset]: + """Load a style preset by full id or short name. + + ``mode`` is accepted as a compatibility alias for ``renderer``. + """ + renderer_hint = renderer if renderer is not None else mode + wanted_id = normalize_style_id(name, renderer_hint) + renderer_filter = normalize_renderer(renderer_hint) if renderer_hint is not None else None + candidates = list_styles(styles_dir, renderer_filter) + + # Prefer user styles when the full ID matches, but built-ins are read-only + # and cannot be overwritten on disk. + for source in (StyleSource.USER, StyleSource.BUILTIN): + for preset in candidates: + if preset.source == source and preset.id == wanted_id: + return preset + + short = wanted_id.split("/", 1)[-1] + for preset in candidates: + if preset.short_id == short or preset.name == str(name or ""): + return preset + return None - # Legacy .txt fallback: parse ASS V4+ Style lines - if "[V4+ Styles]" in content or "Style:" in content: - return _parse_ass_txt(content, path.stem) - raise ValueError(f"Unrecognized style file format: {path}") +def save_user_style( + preset: SubtitleStylePreset, + styles_dir: Optional[Path] = None, +) -> Path: + """Persist a user-owned preset and return its path.""" + if preset.source != StyleSource.USER: + preset = SubtitleStylePreset( + id=preset.id, + name=preset.name, + renderer=preset.renderer, + source=StyleSource.USER, + style=preset.style, + description=preset.description, + version=preset.version, + ) + styles_root = styles_dir or _user_styles_dir() + target = styles_root / preset.renderer.value / f"{preset.short_id}.json" + target.parent.mkdir(parents=True, exist_ok=True) + data = preset.to_json_dict() + data.pop("source", None) + target.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return target - @classmethod - def from_rounded_dict(cls, data: dict) -> "SubtitleStyle": - """Create a rounded style from a flat dict (used by UI config).""" - data_with_mode = {**data, "mode": "rounded"} - return cls.from_json(data_with_mode) +def delete_user_style(style_id: str, styles_dir: Optional[Path] = None) -> bool: + normalized = normalize_style_id(style_id) + renderer, short = normalized.split("/", 1) + path = (styles_dir or _user_styles_dir()) / renderer / f"{short}.json" + if path.exists(): + path.unlink() + return True + return False -# ------------------------------------------------------------------ # -# Module-level helpers -# ------------------------------------------------------------------ # -def style_id_from_filename(filename: str) -> str: - """Extract user-facing style ID from filename. +def available_style_names(styles_dir: Optional[Path] = None) -> list[str]: + return sorted({style.id for style in list_styles(styles_dir)}) - 'ass-default.json' -> 'default', 'rounded-dark.json' -> 'dark' - """ + +def style_id_from_filename(filename: str) -> str: + """Return a short style id from legacy or new filenames.""" stem = Path(filename).stem for prefix in ("ass-", "rounded-"): if stem.startswith(prefix): @@ -225,105 +417,150 @@ def style_id_from_filename(filename: str) -> str: return stem -def list_styles(styles_dir: Optional[Path] = None) -> List[SubtitleStyle]: - """List all available styles in the directory.""" - if styles_dir is None: - styles_dir = _default_styles_dir() - if not styles_dir.exists(): - return [] +def preset_from_json( + data: dict, + *, + source: StyleSource, + path: Path | None = None, + renderer_hint: SubtitleRenderer | str | None = None, +) -> SubtitleStylePreset: + renderer = normalize_renderer(data.get("renderer") or data.get("mode") or renderer_hint) + raw_id = data.get("id") or (path.stem if path else "default") + short_id = style_id_from_filename(str(raw_id)) + if "/" in short_id: + style_id = normalize_style_id(short_id, renderer) + short_id = style_id.split("/", 1)[-1] + else: + style_id = f"{renderer.value}/{_slugify(short_id)}" + display_name = str(data.get("name") or short_id) + description = str(data.get("description") or "") + version = int(data.get("version") or 1) + + if renderer == SubtitleRenderer.ROUNDED: + style = RoundedSubtitleStyle( + font_name=str(data.get("font_name") or "Noto Sans SC"), + font_size=int(data.get("font_size") or 52), + text_color=str(data.get("text_color") or "#ffffff"), + bg_color=str(data.get("bg_color") or "#191919c8"), + corner_radius=int(data.get("corner_radius") or 12), + padding_h=int(data.get("padding_h") or 28), + padding_v=int(data.get("padding_v") or 14), + margin_bottom=int(data.get("margin_bottom") or 60), + line_spacing=int(data.get("line_spacing") or 10), + letter_spacing=int(data.get("letter_spacing") or 0), + max_width=int(data.get("max_width") or 90), + align=str(data.get("align") or "center"), + ) + else: + primary_bold = bool(data.get("bold", True)) + secondary_data = data.get("secondary") + if isinstance(secondary_data, dict): + # 旧样式只存主字幕 bold(当时主副共用),副字幕缺省时沿用主字幕加粗, + # 保证历史样式渲染不变;新样式可独立设置副字幕加粗。 + secondary_data = {"bold": primary_bold, **secondary_data} + secondary = AssSecondaryStyle(**secondary_data) + else: + secondary = None + style = AssSubtitleStyle( + font_name=str(data.get("font_name") or "Noto Sans SC"), + font_size=int(data.get("font_size") or 42), + primary_color=str(data.get("primary_color") or "#ffffff"), + outline_color=str(data.get("outline_color") or "#000000"), + outline_width=float(data.get("outline_width") or 2.0), + bold=primary_bold, + spacing=float(data.get("spacing") or 0.0), + margin_bottom=int(data.get("margin_bottom") or 30), + max_width=int(data.get("max_width") or 100), + align=str(data.get("align") or "center"), + line_gap=int(data.get("line_gap") or 0), + secondary=secondary, + ) - result: List[SubtitleStyle] = [] - for f in sorted(styles_dir.glob("*.json")): - try: - style = SubtitleStyle.from_file(f) - # Use filename-derived ID if name not set in JSON - if not style.name: - style.name = style_id_from_filename(f.name) - result.append(style) - except Exception: - logger.warning("Failed to load style %s", f) - return result + return SubtitleStylePreset( + id=style_id, + name=display_name, + renderer=renderer, + source=source, + style=style, + description=description, + version=version, + path=path, + ) + + +def preset_from_file(path: Path, *, source: StyleSource) -> SubtitleStylePreset: + content = path.read_text(encoding="utf-8") + if path.suffix == ".json": + renderer_hint = path.parent.name if path.parent.name in {"ass", "rounded"} else None + return preset_from_json( + json.loads(content), + source=source, + path=path, + renderer_hint=renderer_hint, + ) + if "[V4+ Styles]" in content or "Style:" in content: + return SubtitleStylePreset( + id=f"ass/{_slugify(path.stem)}", + name=path.stem, + renderer=SubtitleRenderer.ASS, + source=source, + style=_parse_ass_txt(content), + path=path, + ) + raise ValueError(f"Unrecognized style file format: {path}") -def load_style( - name: str, - styles_dir: Optional[Path] = None, - mode: Optional[str] = None, -) -> Optional[SubtitleStyle]: - """Load a style by name (e.g. 'default', 'anime', 'rounded'). - - Args: - name: Style preset name. - styles_dir: Directory containing style JSON files. - mode: Preferred render mode ('ass' or 'rounded'). When set, the - matching prefix is tried first so that ``load_style("default", - mode="rounded")`` finds ``rounded-default.json`` before - ``ass-default.json``. - - Searches by: exact filename match, prefixed filename (ass-X, rounded-X), - or JSON 'name' field. - """ - if styles_dir is None: - styles_dir = _default_styles_dir() - if not styles_dir.exists(): - return None - - # Try exact filename: .json - exact = styles_dir / f"{name}.json" - if exact.exists(): - try: - return SubtitleStyle.from_file(exact) - except Exception: - pass - - # Try prefixed filenames: ass-.json, rounded-.json - # When *mode* is given, try the preferred prefix first. - prefixes = ["ass-", "rounded-"] - if mode == "rounded": - prefixes = ["rounded-", "ass-"] - for prefix in prefixes: - prefixed = styles_dir / f"{prefix}{name}.json" - if prefixed.exists(): +def _builtin_styles() -> list[SubtitleStylePreset]: + root = _builtin_styles_dir() + if root.exists(): + presets: list[SubtitleStylePreset] = [] + for path in sorted(root.glob("*/*.json")): try: - return SubtitleStyle.from_file(prefixed) + presets.append(preset_from_file(path, source=StyleSource.BUILTIN)) except Exception: - pass + logger.warning("Failed to load builtin subtitle style %s", path, exc_info=True) + if presets: + return presets + return [preset_from_json(data, source=StyleSource.BUILTIN) for data in BUILTIN_STYLE_DATA] + - # Fallback: scan all files and match by JSON 'name' field - for f in styles_dir.glob("*.json"): +def _load_user_styles(styles_dir: Optional[Path] = None) -> list[SubtitleStylePreset]: + root = styles_dir or _user_styles_dir() + if not root.exists(): + return [] + files = sorted(root.glob("*/*.json")) + sorted(root.glob("*.json")) + result: list[SubtitleStylePreset] = [] + for path in files: try: - style = SubtitleStyle.from_file(f) - if style.name == name or style_id_from_filename(f.name) == name: - return style + result.append(preset_from_file(path, source=StyleSource.USER)) except Exception: - pass + logger.warning("Failed to load subtitle style %s", path, exc_info=True) + return result - return None +def _user_styles_dir() -> Path: + from videocaptioner.config import USER_SUBTITLE_STYLE_PATH -def available_style_names(styles_dir: Optional[Path] = None) -> List[str]: - """Return sorted list of unique style names.""" - names = [] - for s in list_styles(styles_dir): - names.append(s.name) - return sorted(set(names)) + return USER_SUBTITLE_STYLE_PATH -# ------------------------------------------------------------------ # -# Internal helpers -# ------------------------------------------------------------------ # +def _builtin_styles_dir() -> Path: + from videocaptioner.config import BUILTIN_SUBTITLE_STYLE_PATH -def _default_styles_dir() -> Path: - from videocaptioner.config import SUBTITLE_STYLE_PATH - return SUBTITLE_STYLE_PATH + return BUILTIN_SUBTITLE_STYLE_PATH + + +def _slugify(value: str) -> str: + raw = value.strip().lower().replace("\\", "/").split("/")[-1] + raw = re.sub(r"[^a-z0-9._-]+", "-", raw) + raw = raw.strip("-._") + return raw or "default" def _hex_to_ass(hex_color: str) -> str: - """Convert #RRGGBB to ASS &H00BBGGRR format. Only used for ASS style colors.""" - h = hex_color.lstrip("#") + h = hex_color.strip().lstrip("#") if len(h) == 8: - # #AARRGGBB - a, r, g, b = h[0:2], h[2:4], h[4:6], h[6:8] + r, g, b, a = h[0:2], h[2:4], h[4:6], h[6:8] return f"&H{a}{b}{g}{r}" if len(h) == 6: r, g, b = h[0:2], h[2:4], h[4:6] @@ -332,7 +569,6 @@ def _hex_to_ass(hex_color: str) -> str: def _ass_color_to_hex(ass_color: str) -> str: - """Convert ASS &HAABBGGRR to #RRGGBB hex.""" c = ass_color.strip().lstrip("&Hh") if len(c) == 8: b, g, r = c[2:4], c[4:6], c[6:8] @@ -343,9 +579,8 @@ def _ass_color_to_hex(ass_color: str) -> str: return f"#{r}{g}{b}" -def _parse_ass_txt(content: str, stem: str = "") -> SubtitleStyle: - """Parse a legacy .txt file containing ASS V4+ Style lines.""" - kwargs: dict = {"name": stem, "mode": StyleMode.ASS} +def _parse_ass_txt(content: str) -> AssSubtitleStyle: + kwargs: dict = {} secondary_kwargs: dict = {} for line in content.splitlines(): @@ -360,17 +595,39 @@ def _parse_ass_txt(content: str, stem: str = "") -> SubtitleStyle: kwargs["spacing"] = float(parts[13]) kwargs["outline_width"] = float(parts[16]) kwargs["margin_bottom"] = int(parts[21]) - elif line.startswith("Style: Secondary,"): parts = line.split(",") secondary_kwargs["font_name"] = parts[1] secondary_kwargs["font_size"] = int(parts[2]) secondary_kwargs["color"] = _ass_color_to_hex(parts[3]) secondary_kwargs["outline_color"] = _ass_color_to_hex(parts[5]) + secondary_kwargs["bold"] = parts[7].strip() == "-1" secondary_kwargs["spacing"] = float(parts[13]) secondary_kwargs["outline_width"] = float(parts[16]) if secondary_kwargs: - kwargs["secondary"] = SecondaryStyle(**secondary_kwargs) - - return SubtitleStyle(**kwargs) + kwargs["secondary"] = AssSecondaryStyle(**secondary_kwargs) + return AssSubtitleStyle(**kwargs) + + +__all__ = [ + "AssSecondaryStyle", + "AssSubtitleStyle", + "RoundedSubtitleStyle", + "SecondaryStyle", + "StyleMode", + "StyleSource", + "SubtitleRenderer", + "SubtitleStyle", + "SubtitleStylePreset", + "available_style_names", + "delete_user_style", + "list_styles", + "load_style", + "normalize_renderer", + "normalize_style_id", + "preset_from_file", + "preset_from_json", + "save_user_style", + "style_id_from_filename", +] diff --git a/videocaptioner/core/subtitle/styles.py b/videocaptioner/core/subtitle/styles.py index d80272c3..defb6e9e 100644 --- a/videocaptioner/core/subtitle/styles.py +++ b/videocaptioner/core/subtitle/styles.py @@ -21,8 +21,12 @@ class RoundedBgStyle: padding_h: int = 28 # 水平内边距 padding_v: int = 14 # 垂直内边距 margin_bottom: int = 60 # 底部外边距 - line_spacing: int = 10 # 行间距 + line_spacing: int = 10 # 行间距(也是主副两个气泡之间的间距) letter_spacing: int = 0 # 字符间距 + # 排版 + max_width: int = 90 # 文本块最大宽度(占画面宽度百分比,超出则换行) + align: str = "center" # 水平对齐:left / center / right + # 字幕布局 layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ONLY_ORIGINAL diff --git a/videocaptioner/core/translate/factory.py b/videocaptioner/core/translate/factory.py index e24eb5e6..11a8ffec 100644 --- a/videocaptioner/core/translate/factory.py +++ b/videocaptioner/core/translate/factory.py @@ -26,8 +26,9 @@ def create_translator( custom_prompt: str = "", is_reflect: bool = False, update_callback: Optional[Callable] = None, + disable_thinking: bool = False, ) -> BaseTranslator: - """创建翻译器实例""" + """创建翻译器实例。disable_thinking:LLM 翻译关思考求快(仅 OPENAI/LLM 类生效)。""" try: # 如果没有指定目标语言,使用默认值 if target_language is None: @@ -42,6 +43,7 @@ def create_translator( custom_prompt=custom_prompt, is_reflect=is_reflect, update_callback=update_callback, + disable_thinking=disable_thinking, ) elif translator_type == TranslatorType.GOOGLE: batch_num = 5 diff --git a/videocaptioner/core/translate/llm_translator.py b/videocaptioner/core/translate/llm_translator.py index d0fab4d0..5d6346c9 100644 --- a/videocaptioner/core/translate/llm_translator.py +++ b/videocaptioner/core/translate/llm_translator.py @@ -27,6 +27,7 @@ def __init__( custom_prompt: str, is_reflect: bool, update_callback: Optional[Callable], + disable_thinking: bool = False, ): super().__init__( thread_num=thread_num, @@ -38,6 +39,13 @@ def __init__( self.model = model self.custom_prompt = custom_prompt self.is_reflect = is_reflect + # 关思考求快(混合推理模型如 DeepSeek/Qwen3 默认开思考、单请求十几秒):实时翻译尤其需要。 + # 经 extra_body 传 enable_thinking=False;不支持该参数的端点(如 OpenAI 官方)由 call_llm 自动去掉。 + self.disable_thinking = disable_thinking + + @property + def _llm_extra(self) -> dict: + return {"extra_body": {"enable_thinking": False}} if self.disable_thinking else {} def _translate_chunk( self, subtitle_chunk: List[SubtitleProcessData] @@ -54,13 +62,13 @@ def _translate_chunk( if self.is_reflect: prompt = get_prompt( "translate/reflect", - target_language=self.target_language, + target_language=self.target_language.value, custom_prompt=self.custom_prompt, ) else: prompt = get_prompt( "translate/standard", - target_language=self.target_language, + target_language=self.target_language.value, custom_prompt=self.custom_prompt, ) @@ -108,7 +116,7 @@ def _agent_loop( last_response_dict = None # llm 反馈循环 for _ in range(self.MAX_STEPS): - response = call_llm(messages=messages, model=self.model) + response = call_llm(messages=messages, model=self.model, **self._llm_extra) response_dict = json_repair.loads( response.choices[0].message.content.strip() ) @@ -193,7 +201,7 @@ def _translate_chunk_single( ) -> List[SubtitleProcessData]: """单条翻译模式""" single_prompt = get_prompt( - "translate/single", target_language=self.target_language + "translate/single", target_language=self.target_language.value ) for data in subtitle_chunk: @@ -204,7 +212,7 @@ def _translate_chunk_single( {"role": "user", "content": data.original_text}, ], model=self.model, - temperature=0.7, + **self._llm_extra, ) translated_text = response.choices[0].message.content.strip() data.translated_text = translated_text diff --git a/videocaptioner/core/tts/base.py b/videocaptioner/core/tts/base.py index 9be9495c..04b3bc47 100644 --- a/videocaptioner/core/tts/base.py +++ b/videocaptioner/core/tts/base.py @@ -100,18 +100,17 @@ def _synthesize_segment(self, segment: TTSDataSeg, output_path: str) -> None: # 检查缓存 if self.config.use_cache and is_cache_enabled(): - cached_audio_data = cast(Optional[bytes], self.cache.get(cache_key)) + cached_entry = self.cache.get(cache_key) - if cached_audio_data: + if cached_entry: logger.debug(f"Using cache: {segment.text[:50]}...") - # 将缓存的二进制数据写入文件 + audio_data, metadata = self._unpack_cached_audio(cached_entry) Path(output_path).parent.mkdir(parents=True, exist_ok=True) with open(output_path, "wb") as f: - f.write(cached_audio_data) + f.write(audio_data) - # 更新 segment segment.audio_path = output_path - # TODO: 从缓存元数据中获取 audio_duration + self._restore_segment_metadata(segment, metadata) return # 调用子类实现的核心方法 @@ -122,7 +121,11 @@ def _synthesize_segment(self, segment: TTSDataSeg, output_path: str) -> None: try: with open(output_path, "rb") as f: audio_data = f.read() - self.cache.set(cache_key, audio_data, expire=self.config.cache_ttl) + self.cache.set( + cache_key, + self._pack_cached_audio(segment, audio_data), + expire=self.config.cache_ttl, + ) except Exception as e: logger.warning(f"Cache save failed: {str(e)}") @@ -139,8 +142,11 @@ def _synthesize(self, segment: TTSDataSeg, output_path: str) -> None: def _generate_cache_key_for_segment(self, segment: TTSDataSeg) -> str: """为 segment 生成缓存键(考虑声音克隆)""" content_parts = [ + self.__class__.__name__, segment.text, self.config.model, + self.config.base_url, + self.config.response_format, str(self.config.speed), str(self.config.gain), ] @@ -164,6 +170,37 @@ def _generate_cache_key_for_segment(self, segment: TTSDataSeg) -> str: content = "_".join(content_parts) return hashlib.md5(content.encode()).hexdigest() + def _pack_cached_audio(self, segment: TTSDataSeg, audio_data: bytes) -> dict[str, object]: + return { + "audio": audio_data, + "metadata": { + "audio_duration": segment.audio_duration, + "voice": segment.voice, + "clone_voice_uri": segment.clone_voice_uri, + }, + } + + def _unpack_cached_audio(self, cached_entry: object) -> tuple[bytes, dict[str, object]]: + if isinstance(cached_entry, dict): + audio_data = cached_entry.get("audio") + if isinstance(audio_data, bytes): + metadata = cached_entry.get("metadata") + return audio_data, metadata if isinstance(metadata, dict) else {} + return cast(bytes, cached_entry), {} + + def _restore_segment_metadata( + self, segment: TTSDataSeg, metadata: dict[str, object] + ) -> None: + audio_duration = metadata.get("audio_duration") + if isinstance(audio_duration, (int, float)): + segment.audio_duration = float(audio_duration) + voice = metadata.get("voice") + if isinstance(voice, str): + segment.voice = voice + clone_voice_uri = metadata.get("clone_voice_uri") + if isinstance(clone_voice_uri, str): + segment.clone_voice_uri = clone_voice_uri + def _generate_filename(self, text: str, index: int) -> str: """生成音频文件名 diff --git a/videocaptioner/core/tts/siliconflow.py b/videocaptioner/core/tts/siliconflow.py index 51e18b34..cc3b31a1 100644 --- a/videocaptioner/core/tts/siliconflow.py +++ b/videocaptioner/core/tts/siliconflow.py @@ -23,8 +23,8 @@ def __init__(self, api_key: str, base_url: str): api_key: API 密钥 base_url: API 基础 URL """ - self.api_key = api_key - self.base_url = base_url + self.api_key = api_key.strip() + self.base_url = base_url.strip().rstrip("/") self.cache = get_tts_cache() def upload_voice( diff --git a/videocaptioner/core/tts/tts_data.py b/videocaptioner/core/tts/tts_data.py index 1949e502..39bdbcc1 100644 --- a/videocaptioner/core/tts/tts_data.py +++ b/videocaptioner/core/tts/tts_data.py @@ -27,6 +27,14 @@ class TTSConfig: timeout: int = 60 # 超时时间(秒) use_cache: bool = True # 是否Using cache + def __post_init__(self): + self.model = self.model.strip() + self.api_key = self.api_key.strip() + self.base_url = self.base_url.strip().rstrip("/") + self.voice = self.voice.strip() if self.voice else self.voice + self.custom_prompt = self.custom_prompt.strip() if self.custom_prompt else self.custom_prompt + self.response_format = self.response_format.strip() # type: ignore[attr-defined] + @dataclass class TTSDataSeg: diff --git a/videocaptioner/core/update/__init__.py b/videocaptioner/core/update/__init__.py new file mode 100644 index 00000000..8fa9c6b1 --- /dev/null +++ b/videocaptioner/core/update/__init__.py @@ -0,0 +1,29 @@ +"""软件自动更新:调后端检查 → 后台下载(校验)→ 退出后替换重启。无 PyQt 依赖。""" + +from videocaptioner.core.update.client import ( + Announcement, + CheckResult, + UpdateInfo, + app_channel, + check_update, +) +from videocaptioner.core.update.installer import ( + apply_update, + can_self_update, + download_update, + install_root, + is_frozen, +) + +__all__ = [ + "UpdateInfo", + "Announcement", + "CheckResult", + "check_update", + "app_channel", + "download_update", + "apply_update", + "install_root", + "can_self_update", + "is_frozen", +] diff --git a/videocaptioner/core/update/client.py b/videocaptioner/core/update/client.py new file mode 100644 index 00000000..85494222 --- /dev/null +++ b/videocaptioner/core/update/client.py @@ -0,0 +1,151 @@ +"""更新检查:调后端 ``/api/update/check``,拿 block / update / announcement。无 PyQt。 + +后端(自建,飞书多维表格驱动)决定一切:版本封禁、选最新版、公告时间窗。客户端只渲染。 +二进制仍在 GitHub Release,响应给直链;下载时套国内镜像兜底 + sha256 校验(见 installer)。 + +请求头带匿名设备信息(与反馈复用同一个 client_id)供后端统计。任何失败返回 None, +调用方静默忽略、不阻断启动。 +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import requests + +from videocaptioner.config import APP_NAME, VERSION +from videocaptioner.core.feedback.diagnostics import get_or_create_client_id, platform_tag +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("update_check") + +# GitHub 资产国内直连慢/被墙:ghproxy 镜像优先,原直链兜底(与 download 模块同策略)。 +_GH_MIRRORS = ("https://ghproxy.com/", "https://mirror.ghproxy.com/") + + +def app_channel() -> str: + """渠道:desktop=打包版 / dev=源码或 editable 安装 / pip=正式 wheel 安装。 + + 后端据此决定是否下发自更新:dev 能看到更新与公告(便于调试),pip 交给 pip 自己升级、 + 后端不下发 update。frozen 用 sys.frozen 判;非 frozen 时按包是否在 site-packages 区分 + (源码/editable 在仓库目录 = dev,wheel 装进 site-packages = pip)——比版本号可靠, + 因为 editable 安装的源码运行也报真实版本号而非 0.0.0。 + """ + if bool(getattr(sys, "frozen", False)): + return "desktop" + if VERSION.startswith("0.0.0"): + return "dev" + parts = Path(__file__).resolve().parts + return "pip" if ("site-packages" in parts or "dist-packages" in parts) else "dev" + + +def _mirror_urls(url: str) -> tuple[str, ...]: + return tuple(m + url for m in _GH_MIRRORS) + (url,) + + +@dataclass(frozen=True) +class UpdateInfo: + version: str + notes: str + url: str + sha256: str + size: int + + @property + def urls(self) -> tuple[str, ...]: + """资产下载地址:镜像优先 + 直连兜底。""" + return _mirror_urls(self.url) + + +@dataclass(frozen=True) +class Announcement: + id: str + title: str + content: str + + +@dataclass(frozen=True) +class CheckResult: + """一次检查的结果,三者互相独立、都可为空。""" + + block: Optional[str] # 非空=当前版本被封禁,文案直接展示给用户 + update: Optional[UpdateInfo] + announcement: Optional[Announcement] + + +def _parse_update(data: object) -> Optional[UpdateInfo]: + if not isinstance(data, dict): + return None + url = str(data.get("url") or "").strip() + version = str(data.get("version") or "").strip() + if not url or not version: # 缺关键字段视为无更新,不冒进 + return None + try: + size = int(data.get("size") or 0) + except (TypeError, ValueError): + size = 0 + return UpdateInfo( + version=version, + notes=str(data.get("notes") or ""), + url=url, + sha256=str(data.get("sha256") or "").strip(), + size=size, + ) + + +def _parse_announcement(data: object) -> Optional[Announcement]: + if not isinstance(data, dict): + return None + ann_id = str(data.get("id") or "").strip() + content = str(data.get("content") or "").strip() + if not ann_id or not content: # 无 id(无法去重)或无正文 → 不弹 + return None + return Announcement(id=ann_id, title=str(data.get("title") or "").strip(), content=content) + + +def check_update( + url: str, + *, + session: Optional[requests.Session] = None, + timeout: int = 10, +) -> Optional[CheckResult]: + """调后端更新检查。网络/解析/错误响应一律返回 None(调用方静默忽略,不阻断启动)。""" + http = session or requests.Session() + plat = platform_tag() + channel = app_channel() + headers = { + "X-App-Version": VERSION, + "X-App-Platform": plat, + "X-App-Channel": channel, + "X-Client-Id": get_or_create_client_id(), + "User-Agent": f"{APP_NAME}/{VERSION} ({plat})", + } + try: + resp = http.get(url, headers=headers, timeout=timeout) + resp.raise_for_status() + data = resp.json() + except (requests.RequestException, ValueError) as exc: + logger.info("更新检查不可达:%s", exc) + return None + if not isinstance(data, dict) or data.get("ok") is False: + logger.info("更新检查返回错误:%s", str(data)[:200]) + return None + + raw_block = data.get("block") + block = str(raw_block).strip() if isinstance(raw_block, str) and raw_block.strip() else None + result = CheckResult( + block=block, + update=_parse_update(data.get("update")), + announcement=_parse_announcement(data.get("announcement")), + ) + logger.info( + "更新检查:channel=%s blocked=%s update=%s announcement=%s", + channel, + bool(result.block), + result.update.version if result.update else None, + result.announcement.id if result.announcement else None, + ) + return result diff --git a/videocaptioner/core/update/installer.py b/videocaptioner/core/update/installer.py new file mode 100644 index 00000000..ba4ef96d --- /dev/null +++ b/videocaptioner/core/update/installer.py @@ -0,0 +1,162 @@ +"""下载更新包并在退出后替换+重启(onedir 运行中无法原地覆盖自身,故走 helper 脚本)。 + +- 下载:复用 core/download/downloader(镜像兜底 + 续传 + sha256 校验 + 进度 + 取消)。 +- 应用:解压到临时目录 → 写平台 helper(等本进程退出 → 换掉安装目录 → 重启)→ 由调用方退出应用。 + Windows:替换 onedir 目录(VideoCaptioner/);macOS:替换 .app 包并清 quarantine。 + +纯逻辑 + 子进程,无 PyQt 依赖(UI 通过 ui/thread/update_thread.py 接入)。 +真正的「替换运行中的自己」无法在单测里安全跑;helper 脚本生成与安装根定位是可测的。 +""" + +from __future__ import annotations + +import os +import platform +import subprocess +import sys +import zipfile +from pathlib import Path + +from videocaptioner.core.download.downloader import download_file +from videocaptioner.core.update.client import UpdateInfo +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("update_installer") + + +def is_frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def install_root() -> Path | None: + """运行中的安装位置(可替换的根): + + - macOS .app:返回 .app 包目录(…/VideoCaptioner.app)。 + - Windows / Linux onedir:返回可执行文件所在目录(…/VideoCaptioner/)。 + - 开发态(非 frozen):返回 None(不能自更新)。 + """ + if not is_frozen(): + return None + exe = Path(sys.executable).resolve() + if platform.system() == "Darwin": + for parent in exe.parents: + if parent.suffix == ".app": + return parent + return None + return exe.parent + + +def can_self_update() -> bool: + """frozen + 能定位安装根 + 该位置可写(不可写需提权,MVP 视为不可自更新)。""" + root = install_root() + return bool(root and os.access(root.parent, os.W_OK)) + + +def download_update( + info: UpdateInfo, + dest_dir: Path, + *, + on_progress=None, + should_cancel=None, +) -> Path: + """下载更新资产到 dest_dir,sha256 校验通过后返回 zip 路径。""" + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / f"VideoCaptioner-{info.version}.zip" + return download_file( + info.urls, + dest, + sha256=info.sha256 or None, + on_progress=on_progress, + should_cancel=should_cancel, + ) + + +def _extract(zip_path: Path, into: Path) -> Path: + """解压更新包,返回顶层产物路径(Windows: VideoCaptioner/;macOS: VideoCaptioner.app/)。""" + into.mkdir(parents=True, exist_ok=True) + if platform.system() == "Darwin": + # 与打包侧 ditto 对应:保留 .app 的符号链接/可执行位/代码签名。zipfile 会把软链拍平、 + # 丢可执行位,解压出的 .app 无法启动 —— 这是 macOS 更新换装必须用 ditto 的原因。 + subprocess.run(["ditto", "-x", "-k", str(zip_path), str(into)], check=True) + else: + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(into) + want = "VideoCaptioner.app" if platform.system() == "Darwin" else "VideoCaptioner" + cand = into / want + if cand.exists(): + return cand + # 兜底:取唯一顶层目录 + tops = [p for p in into.iterdir() if p.is_dir()] + if len(tops) == 1: + return tops[0] + raise FileNotFoundError(f"更新包结构异常,未找到 {want} 于 {into}") + + +def _win_helper(new_dir: Path, target: Path, pid: int) -> str: + exe = target / "VideoCaptioner.exe" + # Inno Setup 安装的副本带卸载器 unins000.exe/.dat;rmdir 会连它一起删,导致更新后 + # 「卸载」入口失效。换装前把卸载器挪进新目录,move 时一并带回(便携版没有,if exist 跳过)。 + return ( + "@echo off\r\n" + "chcp 65001 >nul\r\n" + f':wait\r\n' + f'tasklist /FI "PID eq {pid}" 2>nul | find "{pid}" >nul && (timeout /t 1 /nobreak >nul & goto wait)\r\n' + f'if exist "{target}\\unins000.exe" move /y "{target}\\unins000.*" "{new_dir}\\" >nul\r\n' + f'rmdir /s /q "{target}"\r\n' + f'move "{new_dir}" "{target}" >nul\r\n' + f'start "" "{exe}"\r\n' + '(goto) 2>nul & del "%~f0"\r\n' + ) + + +def _unix_helper(new_app: Path, target: Path, pid: int, *, is_mac: bool) -> str: + relaunch = f'open "{target}"' if is_mac else f'"{target}/VideoCaptioner" &' + quarantine = f'xattr -dr com.apple.quarantine "{target}" 2>/dev/null || true\n' if is_mac else "" + return ( + "#!/bin/bash\n" + f"while kill -0 {pid} 2>/dev/null; do sleep 0.5; done\n" + f'rm -rf "{target}"\n' + f'mv "{new_app}" "{target}"\n' + f"{quarantine}" + f"{relaunch}\n" + 'rm -- "$0"\n' + ) + + +def apply_update(zip_path: Path, *, staging_dir: Path | None = None) -> None: + """解压更新包并启动 helper(等本进程退出后替换安装目录并重启)。 + + 调用方必须在本函数返回后立即退出应用(QApplication.quit / sys.exit), + 否则 helper 会一直等待本进程结束。不能自更新(非 frozen / 不可写)时抛 RuntimeError。 + """ + target = install_root() + if target is None: + raise RuntimeError("非打包运行,无法自更新") + staging = staging_dir or (zip_path.parent / "staging") + if staging.exists(): + _rmtree(staging) + new_artifact = _extract(zip_path, staging) + pid = os.getpid() + system = platform.system() + + if system == "Windows": + helper = staging / "apply_update.cmd" + helper.write_text(_win_helper(new_artifact, target, pid), encoding="utf-8") + subprocess.Popen( + ["cmd", "/c", "start", "", "/min", str(helper)], + creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + | getattr(subprocess, "DETACHED_PROCESS", 0), + close_fds=True, + ) + else: + helper = staging / "apply_update.sh" + helper.write_text(_unix_helper(new_artifact, target, pid, is_mac=system == "Darwin"), encoding="utf-8") + helper.chmod(0o755) + subprocess.Popen(["/bin/bash", str(helper)], start_new_session=True, close_fds=True) + logger.info("update helper launched: %s → %s", new_artifact, target) + + +def _rmtree(path: Path) -> None: + import shutil + + shutil.rmtree(path, ignore_errors=True) diff --git a/videocaptioner/core/utils/debug.py b/videocaptioner/core/utils/debug.py new file mode 100644 index 00000000..41ad9194 --- /dev/null +++ b/videocaptioner/core/utils/debug.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import os +from typing import Optional + +_ENV = "VC_DEBUG" +_OFF = {"", "0", "false", "no", "off"} +_ALL = {"1", "true", "yes", "on", "all"} + + +def debug_enabled(subsystem: Optional[str] = None) -> bool: + """VC_DEBUG 是否为 ``subsystem`` 开启调试;不传则只看总开关。""" + val = os.environ.get(_ENV, "").strip().lower() + if val in _OFF: + return False + if val in _ALL or subsystem is None: + return True + selected = {s.strip() for s in val.split(",") if s.strip()} + return subsystem.strip().lower() in selected diff --git a/videocaptioner/core/utils/media_info.py b/videocaptioner/core/utils/media_info.py new file mode 100644 index 00000000..4ecd9aac --- /dev/null +++ b/videocaptioner/core/utils/media_info.py @@ -0,0 +1,178 @@ +"""统一的媒体信息探测:解析 ``ffmpeg -i`` 的 stderr 元数据,不依赖 ffprobe。 + +ffmpeg 对只有 ``-i`` 的调用固定以非 0 退出,并把媒体元数据打印到 stderr; +这里只做文本解析,因此桌面包只需携带 ffmpeg 一个二进制。 +全项目的「这个文件是什么媒体」问题都应经由 :func:`probe_media` 回答, +不要再散落新的 ffmpeg/ffprobe 输出解析。 +""" + +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass + +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("media_info") + +_CREATIONFLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + +_DURATION_RE = re.compile(r"Duration: (\d+):(\d+):(\d+\.?\d*)") +_BITRATE_RE = re.compile(r"bitrate: (\d+) kb/s") +_VIDEO_RE = re.compile(r"Stream #\d+:\d+.*?: Video: (\w+).*?(\d{2,5})x(\d{2,5})") +_FPS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*fps") +# 裸流/VFR 容器可能只报 tbr 不报 fps;tbn 是时基(常为 90k/16k)不能当帧率 +_TBR_RE = re.compile(r"(\d+(?:\.\d+)?)\s*tbr") +_AUDIO_RE = re.compile( + r"Stream #\d+:(\d+)(?:\[0x[0-9a-fA-F]+\])?(?:\(([a-z]{3})\))?.*?: Audio: (\w+)" +) +_HZ_RE = re.compile(r"(\d+) Hz") + + +@dataclass(frozen=True) +class AudioStream: + """媒体中的一条音频流(多音轨选择用)。""" + + index: int + codec: str + language: str = "" + + +@dataclass(frozen=True) +class MediaInfo: + """一次 :func:`probe_media` 的结果。纯音频文件的视频字段为 0/空。""" + + duration_seconds: float = 0.0 + bitrate_kbps: int = 0 + width: int = 0 + height: int = 0 + fps: float = 0.0 + video_codec: str = "" + audio_streams: tuple[AudioStream, ...] = () + audio_codec: str = "" + audio_sampling_rate: int = 0 + + @property + def has_video(self) -> bool: + return bool(self.video_codec) + + @property + def has_audio(self) -> bool: + return bool(self.audio_streams) + + @property + def resolution(self) -> tuple[int, int]: + return self.width, self.height + + +def probe_media(file_path: str) -> MediaInfo | None: + """探测媒体文件信息;不是有效媒体(无任何音视频流)或探测失败时返回 None。 + + 调用方无法从 None 区分「ffmpeg 不可用」与「文件无效」;需要区分时 + 自行先 ``shutil.which("ffmpeg")``(如 UI 报错文案),原因也会写日志。 + """ + try: + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-i", file_path], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + creationflags=_CREATIONFLAGS, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + # OSError 覆盖 FileNotFoundError/PermissionError(如杀软锁二进制)——探测失败按无处理 + logger.error("媒体探测失败(ffmpeg 不可用或超时): %s", exc) + return None + info = _parse_ffmpeg_output(result.stderr or "") + if info.has_video or info.has_audio: + return info + logger.warning("文件没有任何音视频流: %s", file_path) + return None + + +def _parse_ffmpeg_output(text: str) -> MediaInfo: + """逐行解析 ffmpeg -i 的 stderr(行级解析,避免跨行正则误匹配)。""" + duration = 0.0 + bitrate = 0 + width = height = 0 + fps = 0.0 + video_codec = "" + audio_codec = "" + audio_hz = 0 + audio_streams: list[AudioStream] = [] + + for line in text.splitlines(): + if not video_codec and (m := _VIDEO_RE.search(line)): + # 音频文件的内嵌封面也是一条 Video 流(mjpeg/png, attached pic),不算视频 + if "attached pic" in line: + continue + video_codec = m.group(1) + width, height = int(m.group(2)), int(m.group(3)) + if fm := _FPS_RE.search(line) or _TBR_RE.search(line): + fps = float(fm.group(1)) + elif m := _AUDIO_RE.search(line): + audio_streams.append( + AudioStream(index=int(m.group(1)), codec=m.group(3), language=m.group(2) or "") + ) + if not audio_codec: + audio_codec = m.group(3) + if hz := _HZ_RE.search(line): + audio_hz = int(hz.group(1)) + elif m := _DURATION_RE.search(line): + h, mi, s = m.groups() + duration = int(h) * 3600 + int(mi) * 60 + float(s) + if bm := _BITRATE_RE.search(line): + bitrate = int(bm.group(1)) + + return MediaInfo( + duration_seconds=duration, + bitrate_kbps=bitrate, + width=width, + height=height, + fps=fps, + video_codec=video_codec, + audio_streams=tuple(audio_streams), + audio_codec=audio_codec, + audio_sampling_rate=audio_hz, + ) + + +def extract_thumbnail(video_path: str, thumbnail_path: str, seek_seconds: float) -> bool: + """在 ``seek_seconds`` 处抽一帧存为缩略图;成功返回 True。""" + try: + os.makedirs(os.path.dirname(os.path.abspath(thumbnail_path)), exist_ok=True) + result = subprocess.run( + [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-ss", + str(max(0.0, seek_seconds)), + "-i", + video_path, + "-vframes", + "1", + "-q:v", + "2", + thumbnail_path, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + creationflags=_CREATIONFLAGS, + timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + logger.warning("缩略图提取失败: %s", exc) + return False + if result.returncode != 0: + logger.warning("缩略图提取失败: %s", (result.stderr or "").strip()[-200:]) + return False + return os.path.exists(thumbnail_path) and os.path.getsize(thumbnail_path) > 0 diff --git a/videocaptioner/core/utils/platform_utils.py b/videocaptioner/core/utils/platform_utils.py index 4b6383e6..e7210731 100644 --- a/videocaptioner/core/utils/platform_utils.py +++ b/videocaptioner/core/utils/platform_utils.py @@ -79,23 +79,6 @@ def open_file(path): logger.warning(f"Cannot open file on current system: {path}") -def get_subprocess_kwargs(): - """ - 获取跨平台的subprocess参数 - - Returns: - dict: subprocess参数字典 - """ - kwargs = {} - - # 仅在Windows上添加CREATE_NO_WINDOW标志 - if platform.system() == "Windows": - if hasattr(subprocess, "CREATE_NO_WINDOW"): - kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) - - return kwargs - - def is_macos() -> bool: """ 检测是否为 macOS 系统 @@ -106,26 +89,6 @@ def is_macos() -> bool: return platform.system() == "Darwin" -def is_windows() -> bool: - """ - 检测是否为 Windows 系统 - - Returns: - bool: 如果是 Windows 返回 True,否则返回 False - """ - return platform.system() == "Windows" - - -def is_linux() -> bool: - """ - 检测是否为 Linux 系统 - - Returns: - bool: 如果是 Linux 返回 True,否则返回 False - """ - return platform.system() == "Linux" - - def get_available_transcribe_models() -> list[TranscribeModelEnum]: """ 获取当前平台可用的转录模型列表 @@ -144,20 +107,3 @@ def get_available_transcribe_models() -> list[TranscribeModelEnum]: ] return all_models - - -def is_model_available(model: TranscribeModelEnum) -> bool: - """ - 检查指定模型是否在当前平台可用 - - Args: - model: 要检查的转录模型 - - Returns: - bool: 如果模型可用返回 True,否则返回 False - """ - # FasterWhisper 在 macOS 上不可用 - if is_macos() and model == TranscribeModelEnum.FASTER_WHISPER: - return False - - return True diff --git a/videocaptioner/core/utils/subprocess_helper.py b/videocaptioner/core/utils/subprocess_helper.py index 67d91bab..0e41c923 100644 --- a/videocaptioner/core/utils/subprocess_helper.py +++ b/videocaptioner/core/utils/subprocess_helper.py @@ -127,6 +127,7 @@ def handle_stderr(line): "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", + "errors": "replace", # 坏字节降级为占位符,不让解码错误杀掉读取线程 "bufsize": 1, # 行缓冲 } default_kwargs.update(popen_kwargs) diff --git a/videocaptioner/core/utils/video_utils.py b/videocaptioner/core/utils/video_utils.py index 2474a96f..658b29bd 100644 --- a/videocaptioner/core/utils/video_utils.py +++ b/videocaptioner/core/utils/video_utils.py @@ -8,10 +8,8 @@ from typing import TYPE_CHECKING, Callable, Literal, Optional from ..entities import ( - AudioStreamInfo, SubtitleLayoutEnum, SubtitleRenderModeEnum, - VideoInfo, ) from ..subtitle.ass_renderer import render_ass_video from ..subtitle.ass_utils import auto_wrap_ass_file @@ -136,6 +134,8 @@ def check_cuda_available() -> bool: ["ffmpeg", "-hwaccels"], capture_output=True, text=True, + encoding="utf-8", + errors="replace", creationflags=( getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 ), @@ -149,6 +149,8 @@ def check_cuda_available() -> bool: ["ffmpeg", "-hide_banner", "-init_hw_device", "cuda"], capture_output=True, text=True, + encoding="utf-8", + errors="replace", creationflags=( getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 ), @@ -192,6 +194,10 @@ def add_subtitles( ) -> None: assert Path(input_file).is_file(), "输入文件不存在" assert Path(subtitle_file).is_file(), "字幕文件不存在" + # 与硬字幕渲染器一致的前置校验:空字幕直接给干净报错, + # 否则 ffmpeg 会以一长串命令行 dump 的形式失败 + if not Path(subtitle_file).read_text(encoding="utf-8", errors="replace").strip("\ufeff \t\r\n"): + raise ValueError("Empty subtitle data, cannot render video") # 使用临时文件上下文管理器处理字幕(自动清理) with temporary_subtitle_file(subtitle_file) as temp_subtitle_path: @@ -369,162 +375,6 @@ def add_subtitles( raise -def get_video_info( - file_path: str, thumbnail_path: Optional[str] = None -) -> Optional["VideoInfo"]: - """获取媒体文件信息(支持视频和音频文件) - - Args: - file_path: 媒体文件路径(视频或音频) - thumbnail_path: 缩略图保存路径(可选,仅对视频文件有效) - - Returns: - VideoInfo 对象,失败返回 None - 对于纯音频文件,视频相关字段(width/height/fps)将为 0 - """ - try: - # 执行 ffmpeg 获取视频信息 - result = subprocess.run( - ["ffmpeg", "-i", file_path], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - creationflags=( - getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 - ), - ) - info = result.stderr - - # 提取时长 - duration_seconds = 0.0 - if duration_match := re.search(r"Duration: (\d+):(\d+):(\d+\.\d+)", info): - hours, minutes, seconds = map(float, duration_match.groups()) - duration_seconds = hours * 3600 + minutes * 60 + seconds - - # 提取比特率 - bitrate_kbps = 0 - if bitrate_match := re.search(r"bitrate: (\d+) kb/s", info): - bitrate_kbps = int(bitrate_match.group(1)) - - # 提取视频流信息 - width, height, fps, video_codec = 0, 0, 0.0, "" - has_video_stream = False - if video_stream_match := re.search( - r"Stream #.*?Video: (\w+)(?:\s*\([^)]*\))?.* (\d+)x(\d+).*?(?:(\d+(?:\.\d+)?)\s*(?:fps|tb[rn]))", - info, - re.DOTALL, - ): - video_codec = video_stream_match.group(1) - width = int(video_stream_match.group(2)) - height = int(video_stream_match.group(3)) - fps = float(video_stream_match.group(4)) - has_video_stream = True - - # 提取第一条音频流信息(用于兼容性) - audio_codec, audio_sampling_rate = "", 0 - if audio_stream_match := re.search( - r"Stream #\d+:\d+.*Audio: (\w+).* (\d+) Hz", info - ): - audio_codec = audio_stream_match.group(1) - audio_sampling_rate = int(audio_stream_match.group(2)) - - # 提取All音频流信息(用于多音轨选择) - audio_streams: list[AudioStreamInfo] = [] - for match in re.finditer( - r"Stream #\d+:(\d+)(?:\[0x[0-9a-fA-F]+\])?(?:\(([a-z]{3})\))?: Audio: (\w+)", - info, - ): - audio_streams.append( - AudioStreamInfo( - index=int(match.group(1)), - codec=match.group(3), - language=match.group(2) or "", - ) - ) - - if audio_streams: - logger.debug(f"Detected {len(audio_streams)} audio tracks") - - # 验证文件是否包含有效的媒体流 - if not has_video_stream and not audio_streams: - logger.error("File has no video or audio streams") - return None - - # 提取缩略图(如果指定了路径且有视频流) - final_thumbnail_path = "" - if thumbnail_path and duration_seconds > 0 and has_video_stream: - if _extract_thumbnail(file_path, duration_seconds * 0.3, thumbnail_path): - final_thumbnail_path = thumbnail_path - - # 构造并返回 VideoInfo 对象 - return VideoInfo( - file_name=Path(file_path).stem, - file_path=file_path, - width=width, - height=height, - fps=fps, - duration_seconds=duration_seconds, - bitrate_kbps=bitrate_kbps, - video_codec=video_codec, - audio_codec=audio_codec, - audio_sampling_rate=audio_sampling_rate, - thumbnail_path=final_thumbnail_path, - audio_streams=audio_streams, - ) - except Exception as e: - logger.exception(f"获取视频信息时出错: {str(e)}") - return None - - -def _extract_thumbnail(video_path: str, seek_time: float, thumbnail_path: str) -> bool: - """提取视频缩略图 - - Args: - video_path: 视频文件路径 - seek_time: 截取时间点(秒) - thumbnail_path: 缩略图保存路径 - - Returns: - 是否成功 - """ - if not Path(video_path).is_file(): - logger.error(f"视频文件不存在: {video_path}") - return False - - try: - timestamp = f"{int(seek_time // 3600):02}:{int((seek_time % 3600) // 60):02}:{seek_time % 60:06.3f}" - Path(thumbnail_path).parent.mkdir(parents=True, exist_ok=True) - - result = subprocess.run( - [ - "ffmpeg", - "-ss", - timestamp, - "-i", - Path(video_path).as_posix(), - "-vframes", - "1", - "-q:v", - "2", - "-y", - Path(thumbnail_path).as_posix(), - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - creationflags=( - getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 - ), - ) - return result.returncode == 0 - - except Exception as e: - logger.exception(f"提取缩略图时出错: {str(e)}") - return False - - def add_subtitles_with_style( video_path: str, asr_data: "ASRData", @@ -536,6 +386,7 @@ def add_subtitles_with_style( crf: int = 23, preset: PresetType = "medium", progress_callback: Optional[Callable] = None, + ass_line_gap: int = 0, ) -> None: """ 根据渲染模式选择合成方式 @@ -576,4 +427,5 @@ def add_subtitles_with_style( crf=crf, preset=preset, progress_callback=progress_callback, + line_gap=ass_line_gap, ) diff --git a/videocaptioner/resources/fonts/NotoSansSC-Regular.ttf b/videocaptioner/resources/fonts/NotoSansSC-Regular.ttf deleted file mode 100644 index 176f1134..00000000 Binary files a/videocaptioner/resources/fonts/NotoSansSC-Regular.ttf and /dev/null differ diff --git a/videocaptioner/ui/common/app_icons.py b/videocaptioner/ui/common/app_icons.py new file mode 100644 index 00000000..46034f05 --- /dev/null +++ b/videocaptioner/ui/common/app_icons.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from enum import Enum +from functools import lru_cache +from pathlib import Path +from typing import Any + +from PyQt5.QtCore import QByteArray, Qt +from PyQt5.QtGui import QColor, QIcon, QPainter, QPixmap +from PyQt5.QtSvg import QSvgRenderer +from PyQt5.QtWidgets import QApplication +from qfluentwidgets.common.icon import FluentIconBase + +from videocaptioner.config import ASSETS_PATH + +CUSTOM_ICON_DIR = ASSETS_PATH / "icons" + + +class AppIcon(str, Enum): + """App-owned SVG icons in resource/assets/icons. + + ``str, Enum`` 而非 ``enum.StrEnum``:后者仅 Python 3.11+,而本项目 + ``requires-python = ">=3.10"``,3.10 下 ``import StrEnum`` 会直接 ImportError, + 连带所有用到 AppIcon 的 UI 模块全部加载失败。``__str__`` 返回值以保持与 + StrEnum 一致(``str(AppIcon.X)`` 取图标名,供路径/缓存键使用)。 + """ + + ADD = "add" + ALIGNMENT = "alignment" + ARROW_LEFT = "arrow-left" + BRUSH = "brush" + CANCEL = "cancel" + CHEVRON_DOWN = "chevron-down" + CLOSE = "close" + COPY = "copy" + DELETE = "delete" + DIAGNOSTIC = "diagnostic" + DOCUMENT = "document" + DOWNLOAD = "download" + EDIT = "edit" + FILE = "file" + FOLDER = "folder" + FOLDER_ADD = "folder_add" + FONT = "font" + FONT_SIZE = "font_size" + GITHUB = "github" + GLOBE = "globe" + HARDSUB = "hardsub" + HEART = "heart" + HISTORY = "history" + HOME = "home" + LANGUAGE = "language" + LAYOUT = "layout" + LINK = "link" + MENU = "menu" + MESSAGE = "message" + MICROPHONE = "microphone" + MUSIC = "music" + PALETTE = "palette" + PHOTO = "photo" + PLAY = "play" + RIGHT_ARROW = "right_arrow" + ROBOT = "robot" + SAVE = "save" + SEARCH = "search" + SETTING = "setting" + SUBTITLE = "subtitle" + SYNC = "sync" + TERMINAL = "terminal" + VIDEO = "video" + VIEW = "view" + VOLUME = "volume" + ZOOM = "zoom" + + def __str__(self) -> str: + return self.value + + +class AppFluentIcon(FluentIconBase): + """把 app 自有 SVG 包成 FluentIconBase,使其能像 FluentIcon 一样随主题/选中态着色, + + 可直接传给 qfluent 导航栏(addSubInterface)等需要 FluentIconBase 的接口。""" + + def __init__(self, name: AppIcon | str): + self._name = str(name) + + def path(self, theme=None) -> str: # type: ignore[override] + return str(custom_icon_path(self._name)) + + def render(self, painter, rect, theme=None, indexes=None, **attributes): # type: ignore[override] + from qfluentwidgets.common.icon import Theme, getIconColor + if theme is None: + theme = Theme.AUTO + # 我们的 SVG 用 currentColor,需主动按主题填色(亮/暗对应黑/白),否则渲染成黑色不可见。 + attributes.setdefault("fill", getIconColor(theme)) + super().render(painter, rect, theme, indexes, **attributes) + + def icon(self, theme=None, color=None): # type: ignore[override] + from qfluentwidgets.common.icon import SvgIconEngine, Theme, getIconColor, writeSvg + if theme is None: + theme = Theme.AUTO + fill = QColor(color).name() if color is not None else getIconColor(theme) + return QIcon(SvgIconEngine(writeSvg(self.path(theme), fill=fill))) + + +def custom_icon_path(name: str | Path) -> Path: + """Return the canonical path for an app-owned SVG icon.""" + path = Path(str(name)) + if not path.is_absolute(): + path = CUSTOM_ICON_DIR / path + if not path.suffix: + path = path.with_suffix(".svg") + return path + + +def _color_name(color: str | QColor) -> str: + qcolor = color if isinstance(color, QColor) else QColor(str(color)) + return qcolor.name() if qcolor.isValid() else str(color) + + +def _device_pixel_ratio() -> float: + """Highest screen DPR; SVG icons must rasterize at physical resolution + or they come out blurry on Retina displays.""" + app = QApplication.instance() + if app is None: + return 1.0 + ratios = [screen.devicePixelRatio() for screen in app.screens()] + return max(ratios, default=1.0) + + +@lru_cache(maxsize=256) +def _render_svg_pixmap_cached(name: str, color: str, size: int, dpr: float) -> QPixmap: + path = custom_icon_path(name) + svg = path.read_text(encoding="utf-8").replace("currentColor", color) + renderer = QSvgRenderer(QByteArray(svg.encode("utf-8"))) + + logical_size = max(1, int(size)) + physical_size = max(1, round(logical_size * dpr)) + pixmap = QPixmap(physical_size, physical_size) + pixmap.fill(Qt.transparent) + if renderer.isValid(): + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + renderer.render(painter) + painter.end() + pixmap.setDevicePixelRatio(dpr) + return pixmap + + +def render_svg_pixmap( + icon: AppIcon | str | Path, color: str | QColor, size: int = 24 +) -> QPixmap: + """Render an app-owned SVG icon to a DPR-aware pixmap (crisp on Retina).""" + return _render_svg_pixmap_cached( + str(icon), _color_name(color), int(size), _device_pixel_ratio() + ) + + +def render_svg_icon(icon: AppIcon | str | Path, color: str | QColor, size: int = 24) -> QIcon: + """Render an app-owned SVG icon using the requested theme color.""" + path = custom_icon_path(icon) + if not path.exists(): + return QIcon(str(path)) + return QIcon(render_svg_pixmap(icon, color, size)) + + +def to_qicon(icon: Any, color: str | QColor | None = None, size: int = 24) -> QIcon: + """Convert a FluentIcon, QIcon, or app-owned SVG name/path to QIcon.""" + if isinstance(icon, QIcon): + return icon + + icon_factory = getattr(icon, "icon", None) + if callable(icon_factory): + return icon_factory() + + if color is not None: + return render_svg_icon(icon, color, size) + + return QIcon(str(custom_icon_path(icon))) diff --git a/videocaptioner/ui/common/config.py b/videocaptioner/ui/common/config.py index 3aeb3d33..2e55d1cd 100644 --- a/videocaptioner/ui/common/config.py +++ b/videocaptioner/ui/common/config.py @@ -1,25 +1,30 @@ # coding:utf-8 +from dataclasses import dataclass from enum import Enum +from typing import Any, Callable from PyQt5.QtCore import QLocale from PyQt5.QtGui import QColor -from qfluentwidgets import ( - BoolValidator, - ConfigItem, - ConfigSerializer, - EnumSerializer, - FolderValidator, - OptionsConfigItem, - OptionsValidator, - QConfig, - RangeConfigItem, - RangeValidator, - Theme, - qconfig, -) -from videocaptioner.config import SETTINGS_PATH, WORK_PATH +from videocaptioner.config import WORK_PATH +from videocaptioner.core.application.app_config import ( + enum_by_value, + layout_from_cli, + quality_from_cli, + render_mode_from_cli, + target_language_from_code, + transcribe_model_from_cli, + transcribe_output_format_from_cli, + translator_from_cli, +) +from videocaptioner.core.application.config_store import ( + build_config, + get, + save_many, +) +from videocaptioner.core.dubbing import available_dubbing_presets from videocaptioner.core.entities import ( + LANGUAGES, FasterWhisperModelEnum, LLMServiceEnum, SubtitleLayoutEnum, @@ -32,8 +37,30 @@ VideoQualityEnum, WhisperModelEnum, ) -from videocaptioner.core.translate.types import TargetLanguage +from videocaptioner.core.realtime.backends.languages import all_source_lang_codes, source_lang_codes +from videocaptioner.core.translate.types import BING_LANG_MAP, TargetLanguage from videocaptioner.core.utils.platform_utils import get_available_transcribe_models +from videocaptioner.ui.common.settings_state import ( + BoolValidator, + ChoiceSettingField, + ChoiceValidator, + EnumSettingSerializer, + FolderValidator, + RangeSettingField, + RangeValidator, + SettingField, + SettingSerializer, + SettingsState, +) +from videocaptioner.ui.i18n import tr + +DEFAULT_THEME_COLOR = "#ff00e889" + + +class ThemeMode(Enum): + LIGHT = "Light" + DARK = "Dark" + AUTO = "Auto" class Language(Enum): @@ -45,7 +72,7 @@ class Language(Enum): AUTO = QLocale() -class LanguageSerializer(ConfigSerializer): +class LanguageSerializer(SettingSerializer): """Language serializer""" def serialize(self, language): @@ -55,7 +82,7 @@ def deserialize(self, value: str): return Language(QLocale(value)) if value != "Auto" else Language.AUTO -class PlatformAwareTranscribeModelValidator(OptionsValidator): +class PlatformAwareTranscribeModelValidator(ChoiceValidator): """平台相关的转录模型验证器,在 macOS 上自动过滤掉 FasterWhisper""" def __init__(self): @@ -73,262 +100,871 @@ def correct(self, value): return value if self.validate(value) else self._options[0] -class Config(QConfig): +# 实时字幕识别语言:哪个 provider 支持哪些 code 是后端事实,单一来源在 +# core/realtime/backends/languages.py(voxgate=中/英、fun-asr=多语种 7、qwen-asr=27,三家都含「自动识别」)。 +# 这里只维护 code → 中文显示名(UI 文案),按 provider 取子集组装下拉,避免标签/选项漂移。 +_SOURCE_LANG_LABELS = { + "auto": "自动识别", "zh": "中文", "yue": "粤语", "en": "英语", "es": "西班牙语", + "ja": "日语", "ko": "韩语", "fr": "法语", "de": "德语", "pt": "葡萄牙语", + "ru": "俄语", "it": "意大利语", "ar": "阿拉伯语", "hi": "印地语", "id": "印尼语", + "th": "泰语", "vi": "越南语", "tr": "土耳其语", "uk": "乌克兰语", "ms": "马来语", + "fil": "菲律宾语", "pl": "波兰语", "cs": "捷克语", "sv": "瑞典语", "da": "丹麦语", + "no": "挪威语", "fi": "芬兰语", "is": "冰岛语", +} + + +def source_language_i18n_map() -> dict[str, str]: + """识别语言 lclang.→基准中文。key 动态拼成、pybabel 抽不到,由 i18n 工具链注入。""" + return {f"lclang.{code}": label for code, label in _SOURCE_LANG_LABELS.items()} + + +def source_language_options(provider: str) -> list[tuple[str, str]]: + """该 provider 的识别语言下拉项 (code, 译文标签);第一项总是「自动识别」。 + + label 走 i18n(key=lclang.);_SOURCE_LANG_LABELS 为 zh 基准,未命中回落 code。 + """ + return [ + (code, tr(f"lclang.{code}") if code in _SOURCE_LANG_LABELS else code) + for code in source_lang_codes(provider) + ] + + +# 校验器接受所有 provider 支持语言的并集(任一 provider 的选择都能持久化);UI 按当前 provider 取子集。 +_LIVE_CAPTION_LANG_CODES = all_source_lang_codes() + + +class Config(SettingsState): """应用配置""" + # ------------------- UI 外观配置 ------------------- + themeMode = ChoiceSettingField( + "UI", + "ThemeMode", + ThemeMode.DARK, + ChoiceValidator(ThemeMode), + EnumSettingSerializer(ThemeMode), + ) + themeColor = SettingField("UI", "ThemeColor", QColor(DEFAULT_THEME_COLOR)) + # 取色器「最近使用」颜色(#AARRGGBB 字符串列表,最新在前) + recent_colors = SettingField("UI", "RecentColors", []) + # 工作台页面右栏折叠状态(持久化) + transcribe_panel_collapsed = SettingField( + "UI", "TranscribePanelCollapsed", False, BoolValidator() + ) + synthesis_panel_collapsed = SettingField( + "UI", "SynthesisPanelCollapsed", False, BoolValidator() + ) + subtitle_panel_collapsed = SettingField( + "UI", "SubtitlePanelCollapsed", False, BoolValidator() + ) + # 批量处理页:处理模式(full / trans_sub / transcribe / subtitle)与并发数 + batch_mode = SettingField("UI", "BatchMode", "full") + batch_concurrency = RangeSettingField("UI", "BatchConcurrency", 1, RangeValidator(1, 3)) + # LLM配置 - llm_service = OptionsConfigItem( + llm_service = ChoiceSettingField( "LLM", "LLMService", LLMServiceEnum.OPENAI, - OptionsValidator(LLMServiceEnum), - EnumSerializer(LLMServiceEnum), + ChoiceValidator(LLMServiceEnum), + EnumSettingSerializer(LLMServiceEnum), ) - openai_model = ConfigItem("LLM", "OpenAI_Model", "gpt-4o-mini") - openai_api_key = ConfigItem("LLM", "OpenAI_API_Key", "") - openai_api_base = ConfigItem("LLM", "OpenAI_API_Base", "https://api.openai.com/v1") + openai_model = SettingField("LLM", "OpenAI_Model", "gpt-4o-mini") + openai_model_options = SettingField("LLM", "OpenAI_ModelOptions", []) + openai_api_key = SettingField("LLM", "OpenAI_API_Key", "") + openai_api_base = SettingField("LLM", "OpenAI_API_Base", "https://api.openai.com/v1") - silicon_cloud_model = ConfigItem("LLM", "SiliconCloud_Model", "gpt-4o-mini") - silicon_cloud_api_key = ConfigItem("LLM", "SiliconCloud_API_Key", "") - silicon_cloud_api_base = ConfigItem( + silicon_cloud_model = SettingField("LLM", "SiliconCloud_Model", "gpt-4o-mini") + silicon_cloud_model_options = SettingField("LLM", "SiliconCloud_ModelOptions", []) + silicon_cloud_api_key = SettingField("LLM", "SiliconCloud_API_Key", "") + silicon_cloud_api_base = SettingField( "LLM", "SiliconCloud_API_Base", "https://api.siliconflow.cn/v1" ) - deepseek_model = ConfigItem("LLM", "DeepSeek_Model", "deepseek-chat") - deepseek_api_key = ConfigItem("LLM", "DeepSeek_API_Key", "") - deepseek_api_base = ConfigItem( - "LLM", "DeepSeek_API_Base", "https://api.deepseek.com/v1" - ) + deepseek_model = SettingField("LLM", "DeepSeek_Model", "deepseek-chat") + deepseek_model_options = SettingField("LLM", "DeepSeek_ModelOptions", []) + deepseek_api_key = SettingField("LLM", "DeepSeek_API_Key", "") + deepseek_api_base = SettingField("LLM", "DeepSeek_API_Base", "https://api.deepseek.com/v1") - ollama_model = ConfigItem("LLM", "Ollama_Model", "llama2") - ollama_api_key = ConfigItem("LLM", "Ollama_API_Key", "ollama") - ollama_api_base = ConfigItem("LLM", "Ollama_API_Base", "http://localhost:11434/v1") + ollama_model = SettingField("LLM", "Ollama_Model", "llama2") + ollama_model_options = SettingField("LLM", "Ollama_ModelOptions", []) + ollama_api_key = SettingField("LLM", "Ollama_API_Key", "ollama") + ollama_api_base = SettingField("LLM", "Ollama_API_Base", "http://localhost:11434/v1") - lm_studio_model = ConfigItem("LLM", "LmStudio_Model", "qwen2.5:7b") - lm_studio_api_key = ConfigItem("LLM", "LmStudio_API_Key", "lmstudio") - lm_studio_api_base = ConfigItem( - "LLM", "LmStudio_API_Base", "http://localhost:1234/v1" - ) + lm_studio_model = SettingField("LLM", "LmStudio_Model", "qwen2.5:7b") + lm_studio_model_options = SettingField("LLM", "LmStudio_ModelOptions", []) + lm_studio_api_key = SettingField("LLM", "LmStudio_API_Key", "lmstudio") + lm_studio_api_base = SettingField("LLM", "LmStudio_API_Base", "http://localhost:1234/v1") - gemini_model = ConfigItem("LLM", "Gemini_Model", "gemini-pro") - gemini_api_key = ConfigItem("LLM", "Gemini_API_Key", "") - gemini_api_base = ConfigItem( + gemini_model = SettingField("LLM", "Gemini_Model", "gemini-pro") + gemini_model_options = SettingField("LLM", "Gemini_ModelOptions", []) + gemini_api_key = SettingField("LLM", "Gemini_API_Key", "") + gemini_api_base = SettingField( "LLM", "Gemini_API_Base", "https://generativelanguage.googleapis.com/v1beta/openai/", ) - chatglm_model = ConfigItem("LLM", "ChatGLM_Model", "glm-4") - chatglm_api_key = ConfigItem("LLM", "ChatGLM_API_Key", "") - chatglm_api_base = ConfigItem( - "LLM", "ChatGLM_API_Base", "https://open.bigmodel.cn/api/paas/v4" - ) + chatglm_model = SettingField("LLM", "ChatGLM_Model", "glm-4") + chatglm_model_options = SettingField("LLM", "ChatGLM_ModelOptions", []) + chatglm_api_key = SettingField("LLM", "ChatGLM_API_Key", "") + chatglm_api_base = SettingField("LLM", "ChatGLM_API_Base", "https://open.bigmodel.cn/api/paas/v4") # ------------------- 翻译配置 ------------------- - translator_service = OptionsConfigItem( + translator_service = ChoiceSettingField( "Translate", "TranslatorServiceEnum", TranslatorServiceEnum.BING, - OptionsValidator(TranslatorServiceEnum), - EnumSerializer(TranslatorServiceEnum), + ChoiceValidator(TranslatorServiceEnum), + EnumSettingSerializer(TranslatorServiceEnum), ) - need_reflect_translate = ConfigItem( - "Translate", "NeedReflectTranslate", False, BoolValidator() - ) - deeplx_endpoint = ConfigItem("Translate", "DeeplxEndpoint", "") - batch_size = RangeConfigItem("Translate", "BatchSize", 10, RangeValidator(5, 50)) - thread_num = RangeConfigItem("Translate", "ThreadNum", 10, RangeValidator(1, 50)) + need_reflect_translate = SettingField("Translate", "NeedReflectTranslate", False, BoolValidator()) + deeplx_endpoint = SettingField("Translate", "DeeplxEndpoint", "") + batch_size = RangeSettingField("Translate", "BatchSize", 10, RangeValidator(5, 50)) + thread_num = RangeSettingField("Translate", "ThreadNum", 10, RangeValidator(1, 50)) # ------------------- 转录配置 ------------------- - transcribe_model = OptionsConfigItem( + transcribe_model = ChoiceSettingField( "Transcribe", "TranscribeModel", TranscribeModelEnum.BIJIAN, PlatformAwareTranscribeModelValidator(), - EnumSerializer(TranscribeModelEnum), + EnumSettingSerializer(TranscribeModelEnum), ) - transcribe_output_format = OptionsConfigItem( + transcribe_output_format = ChoiceSettingField( "Transcribe", "OutputFormat", TranscribeOutputFormatEnum.SRT, - OptionsValidator(TranscribeOutputFormatEnum), - EnumSerializer(TranscribeOutputFormatEnum), + ChoiceValidator(TranscribeOutputFormatEnum), + EnumSettingSerializer(TranscribeOutputFormatEnum), ) - transcribe_language = OptionsConfigItem( + transcribe_language = ChoiceSettingField( "Transcribe", "TranscribeLanguage", TranscribeLanguageEnum.AUTO, - OptionsValidator(TranscribeLanguageEnum), - EnumSerializer(TranscribeLanguageEnum), + ChoiceValidator(TranscribeLanguageEnum), + EnumSettingSerializer(TranscribeLanguageEnum), + ) + # 默认句级时间轴:单独导出 SRT 时词级会按字分段,不适合直接使用; + # 流水线(智能断句)所需的词级时间戳由 TaskBuilder 自行决定,不受此开关影响。 + transcribe_word_timestamp = SettingField( + "Transcribe", "WordTimestamp", False, BoolValidator() ) # ------------------- Whisper Cpp 配置 ------------------- - whisper_model = OptionsConfigItem( + whisper_model = ChoiceSettingField( "Whisper", "WhisperModel", WhisperModelEnum.TINY, - OptionsValidator(WhisperModelEnum), - EnumSerializer(WhisperModelEnum), + ChoiceValidator(WhisperModelEnum), + EnumSettingSerializer(WhisperModelEnum), ) # ------------------- Faster Whisper 配置 ------------------- - faster_whisper_program = ConfigItem( + faster_whisper_program = SettingField( "FasterWhisper", "Program", "faster-whisper-xxl.exe", ) - faster_whisper_model = OptionsConfigItem( + faster_whisper_model = ChoiceSettingField( "FasterWhisper", "Model", FasterWhisperModelEnum.TINY, - OptionsValidator(FasterWhisperModelEnum), - EnumSerializer(FasterWhisperModelEnum), + ChoiceValidator(FasterWhisperModelEnum), + EnumSettingSerializer(FasterWhisperModelEnum), ) - faster_whisper_model_dir = ConfigItem("FasterWhisper", "ModelDir", "") - faster_whisper_device = OptionsConfigItem( - "FasterWhisper", "Device", "cuda", OptionsValidator(["cuda", "cpu"]) + faster_whisper_model_dir = SettingField("FasterWhisper", "ModelDir", "") + faster_whisper_device = ChoiceSettingField( + "FasterWhisper", "Device", "auto", ChoiceValidator(["auto", "cuda", "cpu"]) ) # VAD 参数 - faster_whisper_vad_filter = ConfigItem( - "FasterWhisper", "VadFilter", True, BoolValidator() - ) - faster_whisper_vad_threshold = RangeConfigItem( + faster_whisper_vad_filter = SettingField("FasterWhisper", "VadFilter", True, BoolValidator()) + faster_whisper_vad_threshold = RangeSettingField( "FasterWhisper", "VadThreshold", 0.4, RangeValidator(0, 1) ) - faster_whisper_vad_method = OptionsConfigItem( + faster_whisper_vad_method = ChoiceSettingField( "FasterWhisper", "VadMethod", VadMethodEnum.SILERO_V4, - OptionsValidator(VadMethodEnum), - EnumSerializer(VadMethodEnum), + ChoiceValidator(VadMethodEnum), + EnumSettingSerializer(VadMethodEnum), ) # 人声提取 - faster_whisper_ff_mdx_kim2 = ConfigItem( - "FasterWhisper", "FfMdxKim2", False, BoolValidator() - ) + faster_whisper_ff_mdx_kim2 = SettingField("FasterWhisper", "FfMdxKim2", False, BoolValidator()) # 文本处理参数 - faster_whisper_one_word = ConfigItem( - "FasterWhisper", "OneWord", True, BoolValidator() - ) + faster_whisper_one_word = SettingField("FasterWhisper", "OneWord", True, BoolValidator()) # 提示词 - faster_whisper_prompt = ConfigItem("FasterWhisper", "Prompt", "") + faster_whisper_prompt = SettingField("FasterWhisper", "Prompt", "") # ------------------- Whisper API 配置 ------------------- - whisper_api_base = ConfigItem("WhisperAPI", "WhisperApiBase", "") - whisper_api_key = ConfigItem("WhisperAPI", "WhisperApiKey", "") - whisper_api_model = OptionsConfigItem("WhisperAPI", "WhisperApiModel", "") - whisper_api_prompt = ConfigItem("WhisperAPI", "WhisperApiPrompt", "") + whisper_api_base = SettingField("WhisperAPI", "WhisperApiBase", "") + whisper_api_key = SettingField("WhisperAPI", "WhisperApiKey", "") + whisper_api_model = SettingField("WhisperAPI", "WhisperApiModel", "") + whisper_api_prompt = SettingField("WhisperAPI", "WhisperApiPrompt", "") + + # ------------------- 百炼 Fun-ASR 配置 ------------------- + fun_asr_api_base = SettingField("FunASR", "FunAsrApiBase", "https://dashscope.aliyuncs.com") + fun_asr_api_key = SettingField("FunASR", "FunAsrApiKey", "") + fun_asr_model = SettingField("FunASR", "FunAsrModel", "fun-asr") # ------------------- 字幕配置 ------------------- - need_optimize = ConfigItem("Subtitle", "NeedOptimize", False, BoolValidator()) - need_translate = ConfigItem("Subtitle", "NeedTranslate", False, BoolValidator()) - need_split = ConfigItem("Subtitle", "NeedSplit", False, BoolValidator()) - target_language = OptionsConfigItem( + need_optimize = SettingField("Subtitle", "NeedOptimize", False, BoolValidator()) + need_translate = SettingField("Subtitle", "NeedTranslate", False, BoolValidator()) + need_split = SettingField("Subtitle", "NeedSplit", False, BoolValidator()) + target_language = ChoiceSettingField( "Subtitle", "TargetLanguage", TargetLanguage.SIMPLIFIED_CHINESE, - OptionsValidator(TargetLanguage), - EnumSerializer(TargetLanguage), + ChoiceValidator(TargetLanguage), + EnumSettingSerializer(TargetLanguage), ) - max_word_count_cjk = ConfigItem( - "Subtitle", "MaxWordCountCJK", 28, RangeValidator(8, 100) - ) - max_word_count_english = ConfigItem( + max_word_count_cjk = SettingField("Subtitle", "MaxWordCountCJK", 28, RangeValidator(8, 100)) + max_word_count_english = SettingField( "Subtitle", "MaxWordCountEnglish", 20, RangeValidator(8, 100) ) - custom_prompt_text = ConfigItem("Subtitle", "CustomPromptText", "") + custom_prompt_text = SettingField("Subtitle", "CustomPromptText", "") # ------------------- 字幕合成配置 ------------------- - soft_subtitle = ConfigItem("Video", "SoftSubtitle", False, BoolValidator()) - need_video = ConfigItem("Video", "NeedVideo", True, BoolValidator()) - video_quality = OptionsConfigItem( + soft_subtitle = SettingField("Video", "SoftSubtitle", False, BoolValidator()) + need_video = SettingField("Video", "NeedVideo", True, BoolValidator()) + video_quality = ChoiceSettingField( "Video", "VideoQuality", VideoQualityEnum.MEDIUM, - OptionsValidator(VideoQualityEnum), - EnumSerializer(VideoQualityEnum), + ChoiceValidator(VideoQualityEnum), + EnumSettingSerializer(VideoQualityEnum), + ) + + # ------------------- 配音配置 ------------------- + dubbing_enabled = SettingField("Dubbing", "Enabled", False, BoolValidator()) + dubbing_provider = ChoiceSettingField( + "Dubbing", + "Provider", + "edge", + ChoiceValidator(["edge", "gemini", "siliconflow"]), + ) + dubbing_preset = ChoiceSettingField( + "Dubbing", + "Preset", + "edge-cn-female", + ChoiceValidator(available_dubbing_presets()), + ) + dubbing_voice = SettingField("Dubbing", "Voice", "zh-CN-XiaoxiaoNeural") + dubbing_text_track = ChoiceSettingField( + "Dubbing", + "TextTrack", + "auto", + ChoiceValidator(["auto", "first", "second"]), + ) + dubbing_timing = ChoiceSettingField( + "Dubbing", + "Timing", + "balanced", + # 含 "none":core/CLI(resolve_timing、--timing none)均支持不变速, + # 此处必须保留该取值,否则从 CLI/TOML 设的 none 被 GUI 矫正成首项、再保存即丢配置。 + ChoiceValidator(["natural", "balanced", "strict", "none"]), + ) + dubbing_audio_mode = ChoiceSettingField( + "Dubbing", + "AudioMode", + "replace", + ChoiceValidator(["replace", "mix", "duck"]), + ) + dubbing_api_key = SettingField("Dubbing", "ApiKey", "") + dubbing_api_base = SettingField("Dubbing", "ApiBase", "") + dubbing_model = SettingField("Dubbing", "Model", "") + dubbing_tts_workers = RangeSettingField("Dubbing", "Workers", 5, RangeValidator(1, 20)) + dubbing_clone_audio = SettingField("Dubbing", "CloneAudio", "") + dubbing_clone_text = SettingField("Dubbing", "CloneText", "") + + # ------------------- 实时字幕配置 ------------------- + # 转录引擎(Provider):voxgate 本地免费无密钥;fun-asr 阿里云实时(中/粤/英/日/泰/越/印尼); + # qwen-asr 阿里云实时(27 语言含西语等,看外语视频选它)。fun-asr/qwen-asr 共用百炼 Key。 + live_caption_provider = ChoiceSettingField( + "LiveCaption", "Provider", "voxgate", + ChoiceValidator(["voxgate", "fun-asr", "qwen-asr"]), + ) + # Fun-ASR 实时模型与识别语言(仅 fun-asr provider 用;API Key 复用 fun_asr_api_key) + live_caption_fun_asr_model = ChoiceSettingField( + "LiveCaption", "FunAsrModel", "fun-asr-mtl-realtime", + ChoiceValidator(["fun-asr-mtl-realtime", "fun-asr-realtime"]), + ) + live_caption_source_language = ChoiceSettingField( + "LiveCaption", "SourceLanguage", "auto", + ChoiceValidator(_LIVE_CAPTION_LANG_CODES), # 接受 Fun-ASR/Qwen 两套并集 + ) + live_caption_translate = SettingField("LiveCaption", "TranslateEnabled", True, BoolValidator()) + live_caption_translator_service = ChoiceSettingField( + "LiveCaption", + "TranslatorServiceEnum", + TranslatorServiceEnum.BING, + ChoiceValidator(TranslatorServiceEnum), + EnumSettingSerializer(TranslatorServiceEnum), ) - use_subtitle_style = ConfigItem("Video", "UseSubtitleStyle", False, BoolValidator()) + live_caption_target_language = ChoiceSettingField( + "LiveCaption", + "TargetLanguage", + TargetLanguage.SIMPLIFIED_CHINESE, + ChoiceValidator(TargetLanguage), + EnumSettingSerializer(TargetLanguage), + ) + live_caption_source = ChoiceSettingField( + "LiveCaption", "Source", "microphone", ChoiceValidator(["microphone", "system"]) + ) + live_caption_device_index = SettingField("LiveCaption", "DeviceIndex", -1) + live_caption_voxgate_binary = SettingField("LiveCaption", "VoxgateBinary", "") + live_caption_show_overlay = SettingField("LiveCaption", "ShowOverlay", True) + live_caption_display_mode = ChoiceSettingField( + "LiveCaption", "DisplayMode", "bilingual", + ChoiceValidator(["bilingual", "target", "source"]), + ) + live_caption_bg_style = ChoiceSettingField( + "LiveCaption", "BgStyle", "translucent", + ChoiceValidator(["translucent", "black"]), + ) + live_caption_font_scale = RangeSettingField("LiveCaption", "FontScale", 60, RangeValidator(0, 100)) + # 浮窗形态与用户拖边尺寸:展开/收纳 + 自己拖的大小,跨会话保持(0=未设,用预设/自适应) + live_caption_overlay_mode = ChoiceSettingField( + "LiveCaption", "OverlayMode", "standard", + ChoiceValidator(["standard", "tall"]), + ) + live_caption_overlay_w = SettingField("LiveCaption", "OverlayWidth", 0) + live_caption_overlay_h = SettingField("LiveCaption", "OverlayHeight", 0) # ------------------- 字幕样式配置 ------------------- - subtitle_style_name = ConfigItem("SubtitleStyle", "StyleName", "default") - subtitle_layout = OptionsConfigItem( + subtitle_style_name = SettingField("SubtitleStyle", "StyleName", "rounded/default") + subtitle_layout = ChoiceSettingField( "SubtitleStyle", "Layout", SubtitleLayoutEnum.TRANSLATE_ON_TOP, - OptionsValidator(SubtitleLayoutEnum), - EnumSerializer(SubtitleLayoutEnum), + ChoiceValidator(SubtitleLayoutEnum), + EnumSettingSerializer(SubtitleLayoutEnum), + ) + subtitle_preview_image = SettingField("SubtitleStyle", "PreviewImage", "") + # 预览示例文字(原文 / 译文),可自定义 + subtitle_preview_source = SettingField( + "SubtitleStyle", "PreviewSource", + "Mathematics is the language in which the laws of the universe are written.", + ) + subtitle_preview_target = SettingField( + "SubtitleStyle", "PreviewTarget", "数学,是书写宇宙规律的语言。", ) - subtitle_preview_image = ConfigItem("SubtitleStyle", "PreviewImage", "") # 字幕渲染模式 - subtitle_render_mode = OptionsConfigItem( + subtitle_render_mode = ChoiceSettingField( "SubtitleStyle", "RenderMode", SubtitleRenderModeEnum.ROUNDED_BG, - OptionsValidator(SubtitleRenderModeEnum), - EnumSerializer(SubtitleRenderModeEnum), - ) - - # 圆角背景模式配置 - rounded_bg_font_name = ConfigItem("RoundedBgStyle", "FontName", "Noto Sans SC") - rounded_bg_font_size = RangeConfigItem( - "RoundedBgStyle", "FontSize", 52, RangeValidator(16, 120) - ) - # 背景色:深灰半透明 (R=25, G=25, B=25, A=200) - rounded_bg_color = ConfigItem("RoundedBgStyle", "BgColor", "#191919C8") - rounded_bg_text_color = ConfigItem("RoundedBgStyle", "TextColor", "#FFFFFF") - rounded_bg_corner_radius = RangeConfigItem( - "RoundedBgStyle", "CornerRadius", 12, RangeValidator(0, 50) - ) - rounded_bg_padding_h = RangeConfigItem( - "RoundedBgStyle", "PaddingH", 28, RangeValidator(4, 100) - ) - rounded_bg_padding_v = RangeConfigItem( - "RoundedBgStyle", "PaddingV", 14, RangeValidator(4, 50) - ) - rounded_bg_margin_bottom = RangeConfigItem( - "RoundedBgStyle", "MarginBottom", 60, RangeValidator(20, 300) - ) - rounded_bg_line_spacing = RangeConfigItem( - "RoundedBgStyle", "LineSpacing", 10, RangeValidator(0, 50) - ) - rounded_bg_letter_spacing = RangeConfigItem( - "RoundedBgStyle", "LetterSpacing", 0, RangeValidator(0, 20) + ChoiceValidator(SubtitleRenderModeEnum), + EnumSettingSerializer(SubtitleRenderModeEnum), ) # ------------------- 保存配置 ------------------- - work_dir = ConfigItem("Save", "Work_Dir", WORK_PATH, FolderValidator()) + work_dir = SettingField("Save", "Work_Dir", WORK_PATH, FolderValidator()) + keep_intermediates = SettingField("Save", "KeepIntermediates", False, BoolValidator()) # ------------------- 软件页面配置 ------------------- - micaEnabled = ConfigItem("MainWindow", "MicaEnabled", False, BoolValidator()) - dpiScale = OptionsConfigItem( + micaEnabled = SettingField("MainWindow", "MicaEnabled", False, BoolValidator()) + dpiScale = ChoiceSettingField( "MainWindow", "DpiScale", "Auto", - OptionsValidator([1, 1.25, 1.5, 1.75, 2, "Auto"]), + ChoiceValidator([1, 1.25, 1.5, 1.75, 2, "Auto"]), restart=True, ) - language = OptionsConfigItem( + language = ChoiceSettingField( "MainWindow", "Language", Language.AUTO, - OptionsValidator(Language), + ChoiceValidator(Language), LanguageSerializer(), restart=True, ) # ------------------- 更新配置 ------------------- - checkUpdateAtStartUp = ConfigItem( - "Update", "CheckUpdateAtStartUp", True, BoolValidator() - ) + checkUpdateAtStartUp = SettingField("Update", "CheckUpdateAtStartUp", True, BoolValidator()) # ------------------- 缓存配置 ------------------- - cache_enabled = ConfigItem("Cache", "CacheEnabled", True, BoolValidator()) + cache_enabled = SettingField("Cache", "CacheEnabled", True, BoolValidator()) + + +@dataclass(frozen=True) +class SharedConfigBinding: + item: SettingField + key: str + to_toml: Callable[[Any], Any] = lambda value: value + from_toml: Callable[[Any], Any] = lambda value: value + + +LLM_SERVICE_KEYS = { + LLMServiceEnum.OPENAI: "openai", + LLMServiceEnum.SILICON_CLOUD: "silicon_cloud", + LLMServiceEnum.DEEPSEEK: "deepseek", + LLMServiceEnum.OLLAMA: "ollama", + LLMServiceEnum.LM_STUDIO: "lm_studio", + LLMServiceEnum.GEMINI: "gemini", + LLMServiceEnum.CHATGLM: "chatglm", + LLMServiceEnum.IMMERSIVE: "immersive", +} +KEY_TO_LLM_SERVICE = {value: key for key, value in LLM_SERVICE_KEYS.items()} + +TRANSCRIBE_MODEL_KEYS = { + TranscribeModelEnum.BIJIAN: "bijian", + TranscribeModelEnum.JIANYING: "jianying", + TranscribeModelEnum.BAILIAN_FUN_ASR: "fun-asr", + TranscribeModelEnum.WHISPER_API: "whisper-api", + TranscribeModelEnum.FASTER_WHISPER: "faster-whisper", + TranscribeModelEnum.WHISPER_CPP: "whisper-cpp", +} + +TRANSLATOR_KEYS = { + TranslatorServiceEnum.OPENAI: "llm", + TranslatorServiceEnum.BING: "bing", + TranslatorServiceEnum.GOOGLE: "google", + TranslatorServiceEnum.DEEPLX: "deeplx", +} + +SUBTITLE_LAYOUT_KEYS = { + SubtitleLayoutEnum.TRANSLATE_ON_TOP: "target-above", + SubtitleLayoutEnum.ORIGINAL_ON_TOP: "source-above", + SubtitleLayoutEnum.ONLY_TRANSLATE: "target-only", + SubtitleLayoutEnum.ONLY_ORIGINAL: "source-only", +} + +RENDER_MODE_KEYS = { + SubtitleRenderModeEnum.ASS_STYLE: "ass", + SubtitleRenderModeEnum.ROUNDED_BG: "rounded", +} + +VIDEO_QUALITY_KEYS = { + VideoQualityEnum.ULTRA_HIGH: "ultra", + VideoQualityEnum.HIGH: "high", + VideoQualityEnum.MEDIUM: "medium", + VideoQualityEnum.LOW: "low", +} + + +def _llm_service_to_key(value: Any) -> str: + return LLM_SERVICE_KEYS.get(value, "openai") + + +def _llm_service_from_key(value: Any) -> LLMServiceEnum: + # 兼容 CLI/手编 TOML 的非下划线别名(siliconcloud/lmstudio),与 + # cli/config_adapter 接受的写法一致;否则 GUI 读不到会回落 openai 丢配置。 + key = str(value or "openai").lower() + key = {"siliconcloud": "silicon_cloud", "lmstudio": "lm_studio"}.get(key, key) + return KEY_TO_LLM_SERVICE.get(key, LLMServiceEnum.OPENAI) + + +def _enum_value(value: Any) -> Any: + return getattr(value, "value", value) + + +def _transcribe_model_to_key(value: Any) -> str: + return TRANSCRIBE_MODEL_KEYS.get(value, "bijian") + + +def _transcribe_language_to_key(value: Any) -> str: + label = _enum_value(value) + return LANGUAGES.get(label, "") or "auto" + + +def _transcribe_language_from_key(value: Any) -> TranscribeLanguageEnum: + raw = str(value or "auto").lower() + if raw in {"", "auto"}: + return TranscribeLanguageEnum.AUTO + + for language in TranscribeLanguageEnum: + if LANGUAGES.get(language.value, "").lower() == raw: + return language + if language.value.lower() == raw or language.name.lower() == raw: + return language + return TranscribeLanguageEnum.AUTO + + +def _output_format_to_key(value: Any) -> str: + return str(_enum_value(value) or "srt").lower() + + +def _target_language_to_key(value: Any) -> str: + return BING_LANG_MAP.get(value, "zh-Hans") + + +def _translator_to_key(value: Any) -> str: + return TRANSLATOR_KEYS.get(value, "bing") + + +def _subtitle_layout_to_key(value: Any) -> str: + return SUBTITLE_LAYOUT_KEYS.get(value, "target-above") + + +def _render_mode_to_key(value: Any) -> str: + return RENDER_MODE_KEYS.get(value, "rounded") + + +def _video_quality_to_key(value: Any) -> str: + return VIDEO_QUALITY_KEYS.get(value, "medium") + + +def _theme_from_toml(value: Any) -> ThemeMode: + raw = str(value or "Dark").strip().lower() + for theme in ThemeMode: + if theme.value.lower() == raw or theme.name.lower() == raw: + return theme + return ThemeMode.DARK + + +def _theme_to_toml(value: Any) -> str: + return _enum_value(value) or "Dark" + + +def _theme_color_from_toml(value: Any) -> QColor: + color = QColor(str(value or "#ff28f08b")) + return color if color.isValid() else QColor("#ff28f08b") + + +def _theme_color_to_toml(value: Any) -> str: + if isinstance(value, QColor): + return value.name(QColor.HexRgb) + color = QColor(str(value)) + return color.name(QColor.HexRgb) if color.isValid() else "#28f08b" + + +def _work_dir_from_toml(value: Any) -> str: + """共享配置里 work_dir 空串表示“未设置”;回退到应用默认工作目录。 + + 不能交给 FolderValidator 纠正:Path("") 会被纠正成当前进程 CWD, + 新配置文件的用户会把工作产物写进启动目录(曾把仓库根当工作目录)。 + """ + return str(value).strip() or str(WORK_PATH) + + +def _language_from_toml(value: Any) -> Language: + try: + return LanguageSerializer().deserialize(str(value or "Auto")) + except Exception: + return Language.AUTO + + +def _language_to_toml(value: Any) -> str: + return LanguageSerializer().serialize(value) + + +def _model_options_from_toml(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return _clean_model_options(value) + + +def _model_options_to_toml(value: Any) -> list[str]: + return _clean_model_options(value) + + +def _clean_model_options(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + options: list[str] = [] + seen: set[str] = set() + for item in value: + model = str(item or "").strip() + if not model or model in seen: + continue + seen.add(model) + options.append(model) + return options + + +def _bindings() -> list[SharedConfigBinding]: + return [ + SharedConfigBinding(cfg.themeMode, "ui.theme_mode", _theme_to_toml, _theme_from_toml), + SharedConfigBinding( + cfg.transcribe_panel_collapsed, "ui.transcribe_panel_collapsed" + ), + SharedConfigBinding(cfg.subtitle_panel_collapsed, "ui.subtitle_panel_collapsed"), + SharedConfigBinding( + cfg.synthesis_panel_collapsed, "ui.synthesis_panel_collapsed" + ), + SharedConfigBinding(cfg.batch_mode, "ui.batch_mode"), + SharedConfigBinding(cfg.batch_concurrency, "ui.batch_concurrency"), + SharedConfigBinding( + cfg.themeColor, "ui.theme_color", _theme_color_to_toml, _theme_color_from_toml + ), + SharedConfigBinding(cfg.dpiScale, "ui.dpi_scale"), + SharedConfigBinding(cfg.language, "ui.language", _language_to_toml, _language_from_toml), + SharedConfigBinding(cfg.micaEnabled, "ui.mica_enabled"), + SharedConfigBinding(cfg.checkUpdateAtStartUp, "ui.check_update_at_startup"), + SharedConfigBinding(cfg.subtitle_preview_image, "ui.subtitle_preview_image"), + SharedConfigBinding(cfg.subtitle_preview_source, "ui.subtitle_preview_source"), + SharedConfigBinding(cfg.subtitle_preview_target, "ui.subtitle_preview_target"), + SharedConfigBinding(cfg.recent_colors, "ui.recent_colors"), + SharedConfigBinding(cfg.work_dir, "app.work_dir", from_toml=_work_dir_from_toml), + SharedConfigBinding(cfg.keep_intermediates, "app.keep_intermediates"), + SharedConfigBinding(cfg.cache_enabled, "app.cache_enabled"), + SharedConfigBinding( + cfg.llm_service, "llm.service", _llm_service_to_key, _llm_service_from_key + ), + SharedConfigBinding(cfg.openai_api_key, "llm.providers.openai.api_key"), + SharedConfigBinding(cfg.openai_api_base, "llm.providers.openai.api_base"), + SharedConfigBinding(cfg.openai_model, "llm.providers.openai.model"), + SharedConfigBinding( + cfg.openai_model_options, + "llm.providers.openai.model_options", + _model_options_to_toml, + _model_options_from_toml, + ), + SharedConfigBinding(cfg.silicon_cloud_api_key, "llm.providers.silicon_cloud.api_key"), + SharedConfigBinding(cfg.silicon_cloud_api_base, "llm.providers.silicon_cloud.api_base"), + SharedConfigBinding(cfg.silicon_cloud_model, "llm.providers.silicon_cloud.model"), + SharedConfigBinding( + cfg.silicon_cloud_model_options, + "llm.providers.silicon_cloud.model_options", + _model_options_to_toml, + _model_options_from_toml, + ), + SharedConfigBinding(cfg.deepseek_api_key, "llm.providers.deepseek.api_key"), + SharedConfigBinding(cfg.deepseek_api_base, "llm.providers.deepseek.api_base"), + SharedConfigBinding(cfg.deepseek_model, "llm.providers.deepseek.model"), + SharedConfigBinding( + cfg.deepseek_model_options, + "llm.providers.deepseek.model_options", + _model_options_to_toml, + _model_options_from_toml, + ), + SharedConfigBinding(cfg.ollama_api_key, "llm.providers.ollama.api_key"), + SharedConfigBinding(cfg.ollama_api_base, "llm.providers.ollama.api_base"), + SharedConfigBinding(cfg.ollama_model, "llm.providers.ollama.model"), + SharedConfigBinding( + cfg.ollama_model_options, + "llm.providers.ollama.model_options", + _model_options_to_toml, + _model_options_from_toml, + ), + SharedConfigBinding(cfg.lm_studio_api_key, "llm.providers.lm_studio.api_key"), + SharedConfigBinding(cfg.lm_studio_api_base, "llm.providers.lm_studio.api_base"), + SharedConfigBinding(cfg.lm_studio_model, "llm.providers.lm_studio.model"), + SharedConfigBinding( + cfg.lm_studio_model_options, + "llm.providers.lm_studio.model_options", + _model_options_to_toml, + _model_options_from_toml, + ), + SharedConfigBinding(cfg.gemini_api_key, "llm.providers.gemini.api_key"), + SharedConfigBinding(cfg.gemini_api_base, "llm.providers.gemini.api_base"), + SharedConfigBinding(cfg.gemini_model, "llm.providers.gemini.model"), + SharedConfigBinding( + cfg.gemini_model_options, + "llm.providers.gemini.model_options", + _model_options_to_toml, + _model_options_from_toml, + ), + SharedConfigBinding(cfg.chatglm_api_key, "llm.providers.chatglm.api_key"), + SharedConfigBinding(cfg.chatglm_api_base, "llm.providers.chatglm.api_base"), + SharedConfigBinding(cfg.chatglm_model, "llm.providers.chatglm.model"), + SharedConfigBinding( + cfg.chatglm_model_options, + "llm.providers.chatglm.model_options", + _model_options_to_toml, + _model_options_from_toml, + ), + SharedConfigBinding( + cfg.transcribe_model, + "transcribe.asr", + _transcribe_model_to_key, + transcribe_model_from_cli, + ), + SharedConfigBinding( + cfg.transcribe_output_format, + "transcribe.output_format", + _output_format_to_key, + transcribe_output_format_from_cli, + ), + SharedConfigBinding( + cfg.transcribe_language, + "transcribe.language", + _transcribe_language_to_key, + _transcribe_language_from_key, + ), + SharedConfigBinding(cfg.transcribe_word_timestamp, "transcribe.word_timestamp"), + SharedConfigBinding( + cfg.whisper_model, + "transcribe.whisper_cpp.model", + _enum_value, + lambda value: enum_by_value(WhisperModelEnum, str(value), WhisperModelEnum.TINY), + ), + SharedConfigBinding(cfg.whisper_api_key, "whisper_api.api_key"), + SharedConfigBinding(cfg.whisper_api_base, "whisper_api.api_base"), + SharedConfigBinding(cfg.whisper_api_model, "whisper_api.model"), + SharedConfigBinding(cfg.whisper_api_prompt, "whisper_api.prompt"), + SharedConfigBinding(cfg.fun_asr_api_key, "fun_asr.api_key"), + SharedConfigBinding(cfg.fun_asr_api_base, "fun_asr.api_base"), + SharedConfigBinding(cfg.fun_asr_model, "fun_asr.model"), + SharedConfigBinding(cfg.faster_whisper_program, "transcribe.faster_whisper.program"), + SharedConfigBinding( + cfg.faster_whisper_model, + "transcribe.faster_whisper.model", + _enum_value, + lambda value: enum_by_value( + FasterWhisperModelEnum, str(value), FasterWhisperModelEnum.TINY + ), + ), + SharedConfigBinding(cfg.faster_whisper_model_dir, "transcribe.faster_whisper.model_dir"), + SharedConfigBinding(cfg.faster_whisper_device, "transcribe.faster_whisper.device"), + SharedConfigBinding(cfg.faster_whisper_vad_filter, "transcribe.faster_whisper.vad_filter"), + SharedConfigBinding( + cfg.faster_whisper_vad_threshold, "transcribe.faster_whisper.vad_threshold" + ), + SharedConfigBinding( + cfg.faster_whisper_vad_method, + "transcribe.faster_whisper.vad_method", + _enum_value, + lambda value: enum_by_value( + VadMethodEnum, str(value).replace("-", "_"), VadMethodEnum.SILERO_V4 + ), + ), + SharedConfigBinding( + cfg.faster_whisper_ff_mdx_kim2, "transcribe.faster_whisper.voice_extraction" + ), + SharedConfigBinding(cfg.faster_whisper_one_word, "transcribe.faster_whisper.one_word"), + SharedConfigBinding(cfg.faster_whisper_prompt, "transcribe.faster_whisper.prompt"), + SharedConfigBinding( + cfg.translator_service, "translate.service", _translator_to_key, translator_from_cli + ), + SharedConfigBinding(cfg.need_reflect_translate, "translate.reflect"), + SharedConfigBinding(cfg.deeplx_endpoint, "translate.deeplx_endpoint"), + SharedConfigBinding(cfg.batch_size, "subtitle.batch_size"), + SharedConfigBinding(cfg.thread_num, "subtitle.thread_num"), + SharedConfigBinding(cfg.need_optimize, "subtitle.optimize"), + SharedConfigBinding(cfg.need_translate, "subtitle.translate"), + SharedConfigBinding(cfg.need_split, "subtitle.split"), + SharedConfigBinding( + cfg.target_language, + "translate.target_language", + _target_language_to_key, + target_language_from_code, + ), + SharedConfigBinding(cfg.max_word_count_cjk, "subtitle.max_word_count_cjk"), + SharedConfigBinding(cfg.max_word_count_english, "subtitle.max_word_count_english"), + SharedConfigBinding(cfg.custom_prompt_text, "subtitle.custom_prompt"), + SharedConfigBinding(cfg.soft_subtitle, "synthesize.soft_subtitle"), + SharedConfigBinding(cfg.need_video, "synthesize.need_video"), + SharedConfigBinding( + cfg.video_quality, "synthesize.quality", _video_quality_to_key, quality_from_cli + ), + SharedConfigBinding(cfg.subtitle_style_name, "synthesize.style"), + SharedConfigBinding( + cfg.subtitle_layout, "synthesize.layout", _subtitle_layout_to_key, layout_from_cli + ), + SharedConfigBinding( + cfg.subtitle_render_mode, + "synthesize.render_mode", + _render_mode_to_key, + render_mode_from_cli, + ), + SharedConfigBinding(cfg.dubbing_enabled, "dubbing.enabled"), + SharedConfigBinding(cfg.dubbing_provider, "dubbing.provider"), + SharedConfigBinding(cfg.dubbing_preset, "dubbing.preset"), + SharedConfigBinding(cfg.dubbing_voice, "dubbing.voice"), + SharedConfigBinding(cfg.dubbing_text_track, "dubbing.text_track"), + SharedConfigBinding(cfg.dubbing_timing, "dubbing.timing"), + SharedConfigBinding(cfg.dubbing_audio_mode, "dubbing.audio_mode"), + SharedConfigBinding(cfg.dubbing_api_key, "dubbing.api_key"), + SharedConfigBinding(cfg.dubbing_api_base, "dubbing.api_base"), + SharedConfigBinding(cfg.dubbing_model, "dubbing.model"), + SharedConfigBinding(cfg.dubbing_tts_workers, "dubbing.tts_workers"), + # 克隆参考音频/文本:绑定后 valueChanged 会触发共享持久化,重启不再丢; + # 与 config_store DEFAULTS、cli/config_adapter 的读取保持闭环。 + SharedConfigBinding(cfg.dubbing_clone_audio, "dubbing.clone_audio"), + SharedConfigBinding(cfg.dubbing_clone_text, "dubbing.clone_text"), + SharedConfigBinding(cfg.live_caption_provider, "live_caption.backend"), + SharedConfigBinding(cfg.live_caption_fun_asr_model, "live_caption.fun_asr_model"), + SharedConfigBinding(cfg.live_caption_source_language, "live_caption.source_language"), + SharedConfigBinding(cfg.live_caption_translate, "live_caption.translate_enabled"), + SharedConfigBinding( + cfg.live_caption_translator_service, + "live_caption.translator_service", + _translator_to_key, + translator_from_cli, + ), + SharedConfigBinding( + cfg.live_caption_target_language, + "live_caption.target_language", + _target_language_to_key, + target_language_from_code, + ), + SharedConfigBinding(cfg.live_caption_source, "live_caption.source"), + SharedConfigBinding(cfg.live_caption_device_index, "live_caption.device_index"), + SharedConfigBinding(cfg.live_caption_voxgate_binary, "live_caption.voxgate_binary"), + SharedConfigBinding(cfg.live_caption_show_overlay, "live_caption.show_overlay"), + SharedConfigBinding(cfg.live_caption_display_mode, "live_caption.display_mode"), + SharedConfigBinding(cfg.live_caption_bg_style, "live_caption.bg_style"), + SharedConfigBinding(cfg.live_caption_font_scale, "live_caption.font_scale"), + SharedConfigBinding(cfg.live_caption_overlay_mode, "live_caption.overlay_mode"), + SharedConfigBinding(cfg.live_caption_overlay_w, "live_caption.overlay_w"), + SharedConfigBinding(cfg.live_caption_overlay_h, "live_caption.overlay_h"), + ] + + +_syncing_shared_config = False + + +def _load_shared_config_to_state() -> None: + global _syncing_shared_config + shared_config = build_config() + _syncing_shared_config = True + try: + # 归一别名(siliconcloud/lmstudio -> silicon_cloud/lm_studio),否则手编/CLI + # 写入的别名拼出的 provider 段键匹配不上 binding,generic_api_key 兜底失效。 + active_provider = _llm_service_to_key( + _llm_service_from_key(get(shared_config, "llm.service", "openai")) + ) + generic_api_key = get(shared_config, "llm.api_key", "") + for binding in _bindings(): + raw_value = get(shared_config, binding.key, None) + if ( + raw_value in (None, "") + and binding.key == f"llm.providers.{active_provider}.api_key" + ): + raw_value = generic_api_key + if raw_value is None: + continue + try: + binding.item.value = binding.from_toml(raw_value) + except Exception: + continue + finally: + _syncing_shared_config = False + + +def _collect_shared_config_values() -> dict[str, Any]: + values = {binding.key: binding.to_toml(binding.item.value) for binding in _bindings()} + provider_key = _llm_service_to_key(cfg.llm_service.value) + values["llm.api_key"] = values.get(f"llm.providers.{provider_key}.api_key", "") + values["llm.api_base"] = values.get(f"llm.providers.{provider_key}.api_base", "") + values["llm.model"] = values.get(f"llm.providers.{provider_key}.model", "") + values["dubbing.use_cache"] = values["app.cache_enabled"] + values["output.format"] = values["transcribe.output_format"] + values["synthesize.subtitle_mode"] = "soft" if bool(cfg.soft_subtitle.value) else "hard" + return values + + +def _save_shared_config() -> None: + if _syncing_shared_config: + return + try: + save_many(_collect_shared_config_values()) + except OSError: + return + + +def _install_shared_config_sync() -> None: + for binding in _bindings(): + binding.item.valueChanged.connect(lambda _value, _binding=binding: _save_shared_config()) cfg = Config() -cfg.themeMode.value = Theme.DARK -cfg.themeColor.value = QColor("#ff28f08b") -qconfig.load(SETTINGS_PATH, cfg) +cfg.themeMode.value = ThemeMode.DARK +cfg.themeColor.value = QColor(DEFAULT_THEME_COLOR) +_load_shared_config_to_state() +_install_shared_config_sync() +cfg.save = _save_shared_config diff --git a/videocaptioner/ui/common/dubbing_options.py b/videocaptioner/ui/common/dubbing_options.py new file mode 100644 index 00000000..f9521af4 --- /dev/null +++ b/videocaptioner/ui/common/dubbing_options.py @@ -0,0 +1,203 @@ +from dataclasses import dataclass + +from videocaptioner.ui.i18n import tr + + +@dataclass(frozen=True) +class DubbingProviderOption: + key: str + title: str + description: str + needs_api_key: bool + supports_clone: bool + default_base: str + models: tuple[str, ...] + + +@dataclass(frozen=True) +class DubbingVoiceOption: + preset: str + title: str + description: str + tags: tuple[str, ...] = () + + +DUBBING_PROVIDERS: tuple[DubbingProviderOption, ...] = ( + DubbingProviderOption( + key="edge", + title="Edge 免费配音", + description="免 API Key,适合默认快速生成中文或英文配音。", + needs_api_key=False, + supports_clone=False, + default_base="", + models=("edge-tts",), + ), + DubbingProviderOption( + key="gemini", + title="Gemini TTS", + description="Google Gemini 语音模型,适合英文自然表达。", + needs_api_key=True, + supports_clone=False, + default_base="https://generativelanguage.googleapis.com/v1beta", + models=("gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"), + ), + DubbingProviderOption( + key="siliconflow", + title="SiliconFlow CosyVoice", + description="CosyVoice 中文表现稳定,并支持参考音频克隆。", + needs_api_key=True, + supports_clone=True, + default_base="https://api.siliconflow.cn/v1", + models=("FunAudioLLM/CosyVoice2-0.5B",), + ), +) + + +DUBBING_VOICES: dict[str, tuple[DubbingVoiceOption, ...]] = { + "edge": ( + DubbingVoiceOption("edge-cn-female", "晓晓", "清晰自然的普通话女声", ("中文", "女声", "免费")), + DubbingVoiceOption("edge-cn-male", "云希", "年轻自然的普通话男声", ("中文", "男声", "免费")), + DubbingVoiceOption("edge-cn-xiaoyi", "晓伊", "温和明亮的普通话女声", ("中文", "女声", "免费")), + DubbingVoiceOption("edge-cn-yunjian", "云健", "更适合演讲和旁白的男声", ("中文", "男声", "免费")), + DubbingVoiceOption("edge-cn-yunyang", "云扬", "播报感更强的普通话男声", ("中文", "男声", "免费")), + DubbingVoiceOption("edge-hk-hiugaai", "曉佳", "粤语女声", ("粤语", "女声", "免费")), + DubbingVoiceOption("edge-hk-wanlung", "雲龍", "粤语男声", ("粤语", "男声", "免费")), + DubbingVoiceOption("edge-tw-hsiaoyu", "曉臾", "台湾国语女声", ("中文", "女声", "免费")), + DubbingVoiceOption("edge-tw-yunjhe", "雲哲", "台湾国语男声", ("中文", "男声", "免费")), + DubbingVoiceOption("edge-en-female", "Jenny", "美式英语女声", ("英文", "女声", "免费")), + DubbingVoiceOption("edge-en-male", "Guy", "美式英语男声", ("英文", "男声", "免费")), + DubbingVoiceOption("edge-en-ava", "Ava", "清爽自然的美式英语女声", ("英文", "女声", "免费")), + DubbingVoiceOption("edge-en-andrew", "Andrew", "清晰稳重的美式英语男声", ("英文", "男声", "免费")), + DubbingVoiceOption("edge-en-emma", "Emma", "柔和自然的美式英语女声", ("英文", "女声", "免费")), + DubbingVoiceOption("edge-en-brian", "Brian", "自然稳健的美式英语男声", ("英文", "男声", "免费")), + DubbingVoiceOption("edge-en-libby", "Libby", "英式英语女声", ("英文", "女声", "免费")), + DubbingVoiceOption("edge-en-ryan", "Ryan", "英式英语男声", ("英文", "男声", "免费")), + ), + "gemini": ( + DubbingVoiceOption("gemini-en-friendly", "Achird", "友好自然的英文表达", ("英文", "推荐", "需 Key")), + DubbingVoiceOption("gemini-en-neutral", "Kore", "清晰稳定的自然英文", ("英文", "推荐", "需 Key")), + DubbingVoiceOption("gemini-en-upbeat", "Puck", "更有能量的英文表达", ("英文", "推荐", "需 Key")), + DubbingVoiceOption("gemini-zephyr", "Zephyr", "明亮清爽的英文声音", ("英文", "Bright", "需 Key")), + DubbingVoiceOption("gemini-aoede", "Aoede", "明亮自然的英文声音", ("英文", "需 Key")), + DubbingVoiceOption("gemini-autonoe", "Autonoe", "均衡清晰的英文声音", ("英文", "需 Key")), + DubbingVoiceOption("gemini-callirrhoe", "Callirrhoe", "轻松自然的英文声音", ("英文", "Easy-going", "需 Key")), + DubbingVoiceOption("gemini-charon", "Charon", "更沉稳的英文声音", ("英文", "需 Key")), + DubbingVoiceOption("gemini-despina", "Despina", "平滑自然的英文声音", ("英文", "Smooth", "需 Key")), + DubbingVoiceOption("gemini-enceladus", "Enceladus", "气声感更明显的英文声音", ("英文", "Breathy", "需 Key")), + DubbingVoiceOption("gemini-erinome", "Erinome", "清晰直给的英文声音", ("英文", "Clear", "需 Key")), + DubbingVoiceOption("gemini-fenrir", "Fenrir", "低沉有力的英文声音", ("英文", "需 Key")), + DubbingVoiceOption("gemini-gacrux", "Gacrux", "成熟稳重的英文声音", ("英文", "Mature", "需 Key")), + DubbingVoiceOption("gemini-iapetus", "Iapetus", "清澈稳定的英文声音", ("英文", "Clear", "需 Key")), + DubbingVoiceOption("gemini-laomedeia", "Laomedeia", "轻快活泼的英文声音", ("英文", "Upbeat", "需 Key")), + DubbingVoiceOption("gemini-leda", "Leda", "轻快明亮的英文声音", ("英文", "需 Key")), + DubbingVoiceOption("gemini-orus", "Orus", "旁白感更强的英文声音", ("英文", "需 Key")), + DubbingVoiceOption("gemini-pulcherrima", "Pulcherrima", "前置感更强的英文声音", ("英文", "Forward", "需 Key")), + DubbingVoiceOption("gemini-rasalgethi", "Rasalgethi", "信息感更强的英文声音", ("英文", "Informative", "需 Key")), + DubbingVoiceOption("gemini-sadachbia", "Sadachbia", "生动轻快的英文声音", ("英文", "Lively", "需 Key")), + DubbingVoiceOption("gemini-sadaltager", "Sadaltager", "知识型表达的英文声音", ("英文", "Knowledgeable", "需 Key")), + DubbingVoiceOption("gemini-schedar", "Schedar", "平稳均衡的英文声音", ("英文", "Even", "需 Key")), + DubbingVoiceOption("gemini-sulafat", "Sulafat", "温暖自然的英文声音", ("英文", "Warm", "需 Key")), + DubbingVoiceOption("gemini-umbriel", "Umbriel", "轻松自然的英文声音", ("英文", "Easy-going", "需 Key")), + DubbingVoiceOption("gemini-vindemiatrix", "Vindemiatrix", "温和柔顺的英文声音", ("英文", "Gentle", "需 Key")), + DubbingVoiceOption("gemini-zubenelgenubi", "Zubenelgenubi", "休闲自然的英文声音", ("英文", "Casual", "需 Key")), + DubbingVoiceOption("gemini-achernar", "Achernar", "柔和的英文声音", ("英文", "Soft", "需 Key")), + DubbingVoiceOption("gemini-algenib", "Algenib", "颗粒感更强的英文声音", ("英文", "Gravelly", "需 Key")), + DubbingVoiceOption("gemini-algieba", "Algieba", "平滑的英文声音", ("英文", "Smooth", "需 Key")), + DubbingVoiceOption("gemini-alnilam", "Alnilam", "坚定清晰的英文声音", ("英文", "Firm", "需 Key")), + ), + "siliconflow": ( + DubbingVoiceOption("siliconflow-cn-female", "Anna", "自然中文女声,可配合参考音频克隆", ("中文", "女声", "克隆")), + DubbingVoiceOption("siliconflow-cn-male", "Alex", "自然中文男声,可配合参考音频克隆", ("中文", "男声", "克隆")), + DubbingVoiceOption("siliconflow-cn-deep-male", "Benjamin", "沉稳低沉的中文男声,可克隆", ("中文", "男声", "克隆")), + DubbingVoiceOption("siliconflow-cn-charles", "Charles", "磁性中文男声,可克隆", ("中文", "男声", "克隆")), + DubbingVoiceOption("siliconflow-cn-david", "David", "欢快中文男声,可克隆", ("中文", "男声", "克隆")), + DubbingVoiceOption("siliconflow-cn-bella", "Bella", "热情中文女声,可克隆", ("中文", "女声", "克隆")), + DubbingVoiceOption("siliconflow-cn-claire", "Claire", "温柔中文女声,可克隆", ("中文", "女声", "克隆")), + DubbingVoiceOption("siliconflow-cn-diana", "Diana", "欢快中文女声,可克隆", ("中文", "女声", "克隆")), + ), +} + + +def get_provider_option(provider: str) -> DubbingProviderOption: + for option in DUBBING_PROVIDERS: + if option.key == provider: + return option + return DUBBING_PROVIDERS[0] + + +def is_provider_default_base(value: str) -> bool: + return value in {"", *(option.default_base for option in DUBBING_PROVIDERS)} + + +def get_provider_voices(provider: str) -> tuple[DubbingVoiceOption, ...]: + return DUBBING_VOICES.get(provider, DUBBING_VOICES["edge"]) + + +# ---------------- 显示辅助(i18n key-based) ---------------- +# dataclass 里的中文是 zh 基准 + 逻辑值(tags 参与筛选);UI 展示一律走下面的辅助函数 +# 把 provider/voice/tag 翻成当前语言。key 由 provider.key / voice.preset 动态拼成 +# (pybabel 抽不到,靠翻译目录注入),未命中时回落原中文。 + +# tag 既是逻辑筛选键(不可改原值),又要在 chip 上显示译文:这里只做「中文值 → key」映射。 +_TAG_KEYS = { + "中文": "dubbing.tag.zh", + "英文": "dubbing.tag.en", + "粤语": "dubbing.tag.yue", + "女声": "dubbing.tag.female", + "男声": "dubbing.tag.male", + "免费": "dubbing.tag.free", + "需 Key": "dubbing.tag.need_key", + "克隆": "dubbing.tag.clone", + "推荐": "dubbing.tag.recommended", + "Bright": "dubbing.tag.bright", + "Easy-going": "dubbing.tag.easy_going", + "Smooth": "dubbing.tag.smooth", + "Breathy": "dubbing.tag.breathy", + "Clear": "dubbing.tag.clear", + "Mature": "dubbing.tag.mature", + "Upbeat": "dubbing.tag.upbeat", + "Forward": "dubbing.tag.forward", + "Informative": "dubbing.tag.informative", + "Lively": "dubbing.tag.lively", + "Knowledgeable": "dubbing.tag.knowledgeable", + "Even": "dubbing.tag.even", + "Warm": "dubbing.tag.warm", + "Gentle": "dubbing.tag.gentle", + "Casual": "dubbing.tag.casual", + "Soft": "dubbing.tag.soft", + "Gravelly": "dubbing.tag.gravelly", + "Firm": "dubbing.tag.firm", +} + + +def provider_title(option: DubbingProviderOption) -> str: + return tr(f"dubbing.provider.{option.key}.title") + + +def provider_desc(option: DubbingProviderOption) -> str: + return tr(f"dubbing.provider.{option.key}.desc") + + +def voice_desc(option: DubbingVoiceOption) -> str: + return tr(f"dubbing.voice.{option.preset}.desc") + + +def tag_label(tag: str) -> str: + """标签 chip 的显示译文;未知标签回落原值(逻辑判断仍用原中文 tag)。""" + key = _TAG_KEYS.get(tag) + return tr(key) if key else tag + + +def i18n_base_map() -> dict[str, str]: + """provider/voice/tag 的 i18n key→基准中文。key 由数据动态拼成、pybabel 抽不到, + 由 i18n 工具链注入翻译目录(见 scripts/i18n.py)。""" + m: dict[str, str] = {} + for option in DUBBING_PROVIDERS: + m[f"dubbing.provider.{option.key}.title"] = option.title + m[f"dubbing.provider.{option.key}.desc"] = option.description + for voices in DUBBING_VOICES.values(): + for voice in voices: + m[f"dubbing.voice.{voice.preset}.desc"] = voice.description + for tag, key in _TAG_KEYS.items(): + m[key] = tag + return m diff --git a/videocaptioner/ui/common/enum_labels.py b/videocaptioner/ui/common/enum_labels.py new file mode 100644 index 00000000..2aeebfd0 --- /dev/null +++ b/videocaptioner/ui/common/enum_labels.py @@ -0,0 +1,80 @@ +"""枚举 → UI 显示标签的 i18n 映射。 + +core 枚举只保留 identity(``.value``,是 TOML/CLI 主键);UI 显示走 +``enum.<类名>.<成员名>`` 这个 key → ``tr()``。基准中文 = 去掉装饰 ✨ 的 ``.value`` +(保持原显示),由 ``enum_base_map()`` 程序化生成,供 i18n 工具链抽取/填充。 + +新增需要翻译的枚举:把类加进 ``TRANSLATABLE_ENUMS`` 即可,无需手写 key。 +本模块不依赖 PyQt(tr 来自标准库 gettext 封装),i18n 工具链可直接 import。 +""" + +from __future__ import annotations + +from enum import Enum + +from videocaptioner.core.entities import ( + FasterWhisperModelEnum, + LLMServiceEnum, + SubtitleLayoutEnum, + SubtitleRenderModeEnum, + TranscribeLanguageEnum, + TranscribeModelEnum, + TranscribeOutputFormatEnum, + TranslatorServiceEnum, + VadMethodEnum, + VideoQualityEnum, + WhisperModelEnum, +) +from videocaptioner.core.translate.types import TargetLanguage +from videocaptioner.ui.i18n import tr + +# 在 UI 下拉里展示、需要随语言翻译的枚举(技术型如模型名/VAD 译文=原值,无害)。 +TRANSLATABLE_ENUMS: tuple[type[Enum], ...] = ( + TranscribeModelEnum, + TranscribeOutputFormatEnum, + TranscribeLanguageEnum, + WhisperModelEnum, + FasterWhisperModelEnum, + VadMethodEnum, + LLMServiceEnum, + TranslatorServiceEnum, + TargetLanguage, + SubtitleLayoutEnum, + SubtitleRenderModeEnum, + VideoQualityEnum, +) +_REGISTERED = set(TRANSLATABLE_ENUMS) + + +def enum_key(member: Enum) -> str: + return f"enum.{type(member).__name__}.{member.name}" + + +def enum_base_text(member: Enum) -> str: + """基准中文 = 去掉装饰 ✨ 的 .value。""" + return str(member.value).replace(" ✨", "").strip() + + +def enum_label(value: object) -> str | None: + """已注册枚举成员 → 当前语言标签;其余返回 None(调用方回退原值)。""" + if isinstance(value, Enum) and type(value) in _REGISTERED: + return tr(enum_key(value)) + return None + + +def enum_options(enum_cls: type[Enum]) -> list[str]: + """枚举所有成员的当前语言标签列表(给按文本取值的 PillSelect 等用)。""" + return [tr(enum_key(m)) for m in enum_cls] + + +def enum_from_label(enum_cls: type[Enum], label: str) -> Enum | None: + """当前语言标签 → 枚举成员(PillSelect 回选用)。""" + for m in enum_cls: + if tr(enum_key(m)) == label: + return m + return None + + +def enum_base_map() -> dict[str, str]: + """{enum key: 基准中文},供 i18n 工具链抽取与填充 zh_Hans。""" + return {enum_key(m): enum_base_text(m) for cls in TRANSLATABLE_ENUMS for m in cls} diff --git a/videocaptioner/ui/common/model_options.py b/videocaptioner/ui/common/model_options.py new file mode 100644 index 00000000..40e3d8f0 --- /dev/null +++ b/videocaptioner/ui/common/model_options.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""转录服务的模型候选列表。 + +设置页与转录页共用,避免两处各写一份。 +candidates 只是建议项:字段本身可编辑,用户配置了列表外的值时 +界面会把当前值并入选项展示。 +""" + +from __future__ import annotations + +FUN_ASR_MODEL_OPTIONS = [ + "fun-asr", + "fun-asr-2025-11-07", + "fun-asr-2025-08-25", + "fun-asr-mtl", + "fun-asr-mtl-2025-08-25", +] + +WHISPER_API_MODEL_OPTIONS = [ + "whisper-1", + "whisper-large-v3-turbo", +] diff --git a/videocaptioner/ui/common/settings_state.py b/videocaptioner/ui/common/settings_state.py new file mode 100644 index 00000000..6f8db061 --- /dev/null +++ b/videocaptioner/ui/common/settings_state.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from copy import deepcopy +from enum import Enum +from pathlib import Path +from typing import Any + +from PyQt5.QtCore import QObject, pyqtSignal + + +class SettingValidator: + def validate(self, value: Any) -> bool: + return True + + def correct(self, value: Any) -> Any: + return value + + +class RangeValidator(SettingValidator): + def __init__(self, minimum: int | float, maximum: int | float): + self.min = minimum + self.max = maximum + self.range = (minimum, maximum) + + def validate(self, value: Any) -> bool: + return self.min <= value <= self.max + + def correct(self, value: Any) -> Any: + return min(max(self.min, value), self.max) + + +class ChoiceValidator(SettingValidator): + def __init__(self, options: Any): + if isinstance(options, type) and issubclass(options, Enum): + options = list(options) + self.options = list(options) + if not self.options: + raise ValueError("ChoiceValidator requires at least one option") + + def validate(self, value: Any) -> bool: + return value in self.options + + def correct(self, value: Any) -> Any: + return value if self.validate(value) else self.options[0] + + +class BoolValidator(ChoiceValidator): + def __init__(self): + super().__init__([True, False]) + + +class FolderValidator(SettingValidator): + def validate(self, value: Any) -> bool: + return Path(str(value)).exists() + + def correct(self, value: Any) -> str: + path = Path(str(value)) + path.mkdir(parents=True, exist_ok=True) + return str(path.absolute()).replace("\\", "/") + + +class SettingSerializer: + def serialize(self, value: Any) -> Any: + return value + + def deserialize(self, value: Any) -> Any: + return value + + +class EnumSettingSerializer(SettingSerializer): + def __init__(self, enum_class: type[Enum]): + self.enum_class = enum_class + + def serialize(self, value: Enum) -> Any: + return value.value + + def deserialize(self, value: Any) -> Enum: + return self.enum_class(value) + + +class SettingField(QObject): + valueChanged = pyqtSignal(object) + + def __init__( + self, + group: str, + name: str, + default: Any, + validator: SettingValidator | None = None, + serializer: SettingSerializer | None = None, + restart: bool = False, + ): + super().__init__() + self.group = group + self.name = name + self.validator = validator or SettingValidator() + self.serializer = serializer or SettingSerializer() + self.restart = restart + self.defaultValue = self.validator.correct(default) + self._value = self.defaultValue + + @property + def value(self) -> Any: + return self._value + + @value.setter + def value(self, raw_value: Any) -> None: + if _is_secret_key_field(self) and isinstance(raw_value, str): + raw_value = raw_value.strip() + value = self.validator.correct(raw_value) + old_value = self._value + self._value = value + if old_value != value: + self.valueChanged.emit(value) + + @property + def key(self) -> str: + return f"{self.group}.{self.name}" if self.name else self.group + + def serialize(self) -> Any: + return self.serializer.serialize(self.value) + + def deserializeFrom(self, value: Any) -> None: # noqa: N802 + self.value = self.serializer.deserialize(value) + + +class ChoiceSettingField(SettingField): + @property + def options(self) -> list[Any]: + return self.validator.options + + +class RangeSettingField(SettingField): + @property + def range(self) -> tuple[int | float, int | float]: + return self.validator.range + + +class SettingsState(QObject): + appRestartSig = pyqtSignal() + themeChanged = pyqtSignal(object) + themeColorChanged = pyqtSignal(object) + + def get(self, item: SettingField) -> Any: + return item.value + + def set(self, item: SettingField, value: Any, save: bool = True, copy: bool = True) -> None: + if _is_secret_key_field(item) and isinstance(value, str): + value = value.strip() + if item.value == value: + return + try: + item.value = deepcopy(value) if copy else value + except Exception: + item.value = value + if save: + self.save() + if item.restart: + self.appRestartSig.emit() + if item is getattr(self, "themeMode", None): + self.themeChanged.emit(value) + if item is getattr(self, "themeColor", None): + self.themeColorChanged.emit(value) + + def save(self) -> None: + raise NotImplementedError + + +def _is_secret_key_field(item: SettingField) -> bool: + name = item.name.lower() + return "key" in name or name.endswith("token") or name.endswith("secret") diff --git a/videocaptioner/ui/common/signal_bus.py b/videocaptioner/ui/common/signal_bus.py deleted file mode 100644 index a3859a7b..00000000 --- a/videocaptioner/ui/common/signal_bus.py +++ /dev/null @@ -1,73 +0,0 @@ -from PyQt5.QtCore import QObject, QUrl, pyqtSignal - - -class SignalBus(QObject): - # 字幕排布信号 - subtitle_layout_changed = pyqtSignal(str) - # 字幕优化信号 - subtitle_optimization_changed = pyqtSignal(bool) - # 字幕翻译信号 - subtitle_translation_changed = pyqtSignal(bool) - # 翻译语言 - target_language_changed = pyqtSignal(str) - # 转录模型 - transcription_model_changed = pyqtSignal(str) - # 软字幕信号 - soft_subtitle_changed = pyqtSignal(bool) - # 视频合成信号 - need_video_changed = pyqtSignal(bool) - # 视频质量信号 - video_quality_changed = pyqtSignal(str) - # 使用样式信号 - use_subtitle_style_changed = pyqtSignal(bool) - # 渲染模式变更信号 - subtitle_render_mode_changed = pyqtSignal(str) - - # 新增视频控制相关信号 - video_play = pyqtSignal() # 播放信号 - video_pause = pyqtSignal() # 暂停信号 - video_stop = pyqtSignal() # 停止信号 - video_source_changed = pyqtSignal(QUrl) # 视频源改变信号 - video_segment_play = pyqtSignal(int, int) # 播放片段信号,参数为开始和结束时间(ms) - video_subtitle_added = pyqtSignal(str) # 添加字幕文件信号 - - # 新增视频控制相关方法 - def play_video(self): - """触发视频播放""" - self.video_play.emit() - - def pause_video(self): - """触发视频暂停""" - self.video_pause.emit() - - def stop_video(self): - """触发视频停止""" - self.video_stop.emit() - - def set_video_source(self, url: QUrl): - """设置视频源 - - Args: - url: 视频文件的URL - """ - self.video_source_changed.emit(url) - - def play_video_segment(self, start_time: int, end_time: int): - """播放指定时间段的视频 - - Args: - start_time: 开始时间(毫秒) - end_time: 结束时间(毫秒) - """ - self.video_segment_play.emit(start_time, end_time) - - def add_subtitle(self, subtitle_file: str): - """添加字幕文件 - - Args: - subtitle_file: 字幕文件路径 - """ - self.video_subtitle_added.emit(subtitle_file) - - -signalBus = SignalBus() diff --git a/videocaptioner/ui/common/theme_tokens.py b/videocaptioner/ui/common/theme_tokens.py new file mode 100644 index 00000000..f1dd9832 --- /dev/null +++ b/videocaptioner/ui/common/theme_tokens.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass + +from PyQt5.QtGui import QColor +from PyQt5.QtWidgets import QApplication + + +@dataclass(frozen=True) +class AppPalette: + accent: str + accent_fg: str + # 用作"文字/小图标"的主题色:深色 = accent 原色;浅色加深到 + # 白底可读(亮薄荷绿在白底上对比率只有 ~1.5,不能直接当文字色) + accent_text: str + bg: str + bg_alt: str + panel: str + panel_deep: str + field: str + control: str + control_hover: str + control_border: str + control_border_strong: str + header: str + line: str + line_soft: str + accent_soft: str + accent_border: str + text: str + muted: str + subtle: str + faint: str + disabled: str + warn: str + warn_fg: str + warn_soft: str + danger: str + danger_fg: str + danger_soft: str + selected: str + # 内层卡/行的极淡叠色(在 panel 之上再抬一层)。过去各页内联 rgba 漂移成 + # 6 种相近值(0.025/0.024/0.022/0.018…),统一到这两个 token。 + card_surface: str + card_surface_hover: str + + +# 窗口/页面底色(深、浅主题)。main_window.setCustomBackgroundColor 与 +# app_palette().bg 必须取同一对值,否则页面区域像浮在窗口上的色块。 +BG_DARK = "#111514" +BG_LIGHT = "#f5f5f5" + + +def app_palette() -> AppPalette: + """Design-language tokens, dark values aligned with docs/dev page mocks.""" + accent = theme_color_hex() + dark = is_dark_theme() + return AppPalette( + accent=accent, + accent_fg="#06110d", + accent_text=accent if dark else _darken_for_light_text(accent), + bg=BG_DARK if dark else BG_LIGHT, + bg_alt="#0f1312" if dark else "#f7f8f8", + panel="#202624" if dark else "#ffffff", + panel_deep="#1a201f" if dark else "#f1f4f3", + field="#242b29" if dark else "#f8faf9", + control="#252c2a" if dark else "#f0f1f1", + control_hover=rgba("#ffffff", 0.075) if dark else rgba("#000000", 0.06), + control_border=rgba("#ffffff", 0.24) if dark else rgba("#000000", 0.10), + control_border_strong=rgba("#ffffff", 0.26) if dark else rgba("#000000", 0.16), + header="#171b1a" if dark else "#eef1f0", + line="#3a4440" if dark else "#d8dddd", + line_soft="#2c3531" if dark else "#e5e8e8", + accent_soft=rgba(accent, 0.14), + accent_border=rgba(accent, 0.72), + text="#f4f7f5" if dark else "#1f2523", + muted="#cbd4d0" if dark else "#64706b", + subtle="#9ba7a2" if dark else "#68736e", + faint="#71807a" if dark else "#8d9792", + disabled="#2a312f" if dark else "#eef1f0", + warn="#e8c96a", + warn_fg="#f2d77b" if dark else "#8a6d1f", + warn_soft=rgba("#e8c96a", 0.14), + danger="#ff7f6d", + danger_fg="#ffcfc8" if dark else "#7a211d", + danger_soft=rgba("#ff7f6d", 0.15), + selected=rgba(accent, 0.14 if dark else 0.13), + card_surface=rgba("#ffffff", 0.025) if dark else rgba("#000000", 0.02), + card_surface_hover=rgba("#ffffff", 0.04) if dark else rgba("#000000", 0.035), + ) + + +def _darken_for_light_text(accent: str) -> str: + """把主题色加深到在白底上达到正文可读对比(WCAG ≥ 4.5)。""" + color = QColor(accent) + for factor in (170, 200, 230, 260): + candidate = QColor(accent).darker(factor) + if _contrast_on_white(candidate) >= 4.5: + return candidate.name() + color = candidate + return color.name() + + +def _contrast_on_white(color: QColor) -> float: + def channel(value: int) -> float: + c = value / 255 + return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 + + luminance = ( + 0.2126 * channel(color.red()) + + 0.7152 * channel(color.green()) + + 0.0722 * channel(color.blue()) + ) + return 1.05 / (luminance + 0.05) + + +def theme_color_hex() -> str: + from videocaptioner.ui.common.config import cfg + + color = cfg.themeColor.value + if isinstance(color, QColor): + return color.name(QColor.HexRgb) + parsed = QColor(str(color)) + return parsed.name(QColor.HexRgb) if parsed.isValid() else "#28f08b" + + +def is_dark_theme() -> bool: + from videocaptioner.ui.common.config import ThemeMode, cfg + + theme = cfg.themeMode.value + if theme == ThemeMode.DARK: + return True + if theme == ThemeMode.LIGHT: + return False + + app = QApplication.instance() + if app is None: + return True + return app.palette().window().color().lightness() < 128 + + +def rgba(hex_color: str, alpha: float) -> str: + color = QColor(hex_color) + if not color.isValid(): + color = QColor("#28f08b") + return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha:.2f})" diff --git a/videocaptioner/ui/components/DonateDialog.py b/videocaptioner/ui/components/DonateDialog.py deleted file mode 100644 index a5200694..00000000 --- a/videocaptioner/ui/components/DonateDialog.py +++ /dev/null @@ -1,87 +0,0 @@ -import os - -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QPixmap -from PyQt5.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout -from qfluentwidgets import BodyLabel, MessageBoxBase - -from videocaptioner.config import ASSETS_PATH - - -class DonateDialog(MessageBoxBase): - def __init__(self, parent=None): - super().__init__(parent) - # 定义二维码路径 - self.WECHAT_QR_PATH = os.path.join(ASSETS_PATH, "donate_green.jpg") - self.ALIPAY_QR_PATH = os.path.join(ASSETS_PATH, "donate_blue.jpg") - - self.setup_ui() - self.setWindowTitle(self.tr("支持作者")) - - def setup_ui(self): - # 创建标题标签 - self.titleLabel = BodyLabel(self.tr("感谢支持"), self) - - # 创建说明文本 - self.descLabel = BodyLabel( - self.tr( - "目前本人精力有限,您的支持让我有动力继续折腾这个项目!\n感谢您对开源事业的热爱与支持!" - ), - self, - ) - self.descLabel.setAlignment(Qt.AlignCenter) # type: ignore - - # 创建水平布局放置两个二维码 - self.qrLayout = QHBoxLayout() - - # 创建支付宝二维码标签 - self.alipayContainer = QVBoxLayout() - self.alipayQR = QLabel() - self.alipayQR.setPixmap( - QPixmap(self.ALIPAY_QR_PATH).scaled( - 300, - 300, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.SmoothTransformation, # type: ignore - ) - ) - self.alipayLabel = BodyLabel(self.tr("支付宝")) - self.alipayLabel.setAlignment(Qt.AlignCenter) # type: ignore - self.alipayContainer.addWidget(self.alipayQR, alignment=Qt.AlignCenter) # type: ignore - self.alipayContainer.addWidget(self.alipayLabel) - - # 创建微信二维码标签 - self.wechatContainer = QVBoxLayout() - self.wechatQR = QLabel() - self.wechatQR.setPixmap( - QPixmap(self.WECHAT_QR_PATH).scaled( - 300, - 300, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.SmoothTransformation, # type: ignore - ) - ) - self.wechatLabel = BodyLabel(self.tr("微信")) - self.wechatLabel.setAlignment(Qt.AlignCenter) # type: ignore - self.wechatContainer.addWidget(self.wechatQR, alignment=Qt.AlignCenter) # type: ignore - self.wechatContainer.addWidget(self.wechatLabel) - - # 将二维码添加到水平布局 - self.qrLayout.addLayout(self.alipayContainer) - self.qrLayout.addLayout(self.wechatContainer) - - self.viewLayout.setSpacing(30) - # 添加到主布局 - self.viewLayout.addWidget(self.titleLabel) - self.viewLayout.addWidget(self.descLabel) - # 添加垂直间距 - self.viewLayout.addLayout(self.qrLayout) - - # 设置对话框最小宽度 - self.widget.setMinimumWidth(800) - # 设置对话框最小高度 - self.widget.setMinimumHeight(500) - - # 隐藏是按钮,只显示取消按钮 - self.yesButton.hide() - self.cancelButton.setText(self.tr("关闭")) diff --git a/videocaptioner/ui/components/EditComboBoxSettingCard.py b/videocaptioner/ui/components/EditComboBoxSettingCard.py deleted file mode 100644 index 410ea888..00000000 --- a/videocaptioner/ui/components/EditComboBoxSettingCard.py +++ /dev/null @@ -1,84 +0,0 @@ -from typing import List, Optional, Union - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QCompleter -from qfluentwidgets import EditableComboBox, SettingCard -from qfluentwidgets.common.config import ConfigItem, qconfig - - -class EditComboBoxSettingCard(SettingCard): - """可编辑的下拉框设置卡片""" - - currentTextChanged = pyqtSignal(str) - - def __init__( - self, - configItem: ConfigItem, - icon: Union[str, QIcon], - title: str, - content: Optional[str] = None, - items: Optional[List[str]] = None, - parent=None, - ): - super().__init__(icon, title, content, parent) - - self.configItem = configItem - self.items = items or [] - - # 创建可编辑的组合框 - self.comboBox = EditableComboBox(self) - for item in self.items: - self.comboBox.addItem(item) - - # 设置搜索功能 - self._setupCompleter() - - # 设置布局 - self.hBoxLayout.addWidget(self.comboBox, 1, Qt.AlignRight) # type: ignore - self.hBoxLayout.addSpacing(16) - - # 设置最小宽度 - self.comboBox.setMinimumWidth(280) - - # 设置初始值 - self.setValue(qconfig.get(configItem)) - - # 连接信号 - self.comboBox.currentTextChanged.connect(self.__onTextChanged) - configItem.valueChanged.connect(self.setValue) - - def _setupCompleter(self): - """设置搜索自动完成功能""" - if not self.items: - return - - completer = QCompleter(self.items, self) - completer.setCaseSensitivity(Qt.CaseInsensitive) # type: ignore # 不区分大小写 - completer.setFilterMode(Qt.MatchContains) # type: ignore # 包含匹配 - self.comboBox.setCompleter(completer) - - def __onTextChanged(self, text: str): - """当文本改变时触发""" - self.setValue(text) - self.currentTextChanged.emit(text) - - def setValue(self, value: str): - """设置值""" - qconfig.set(self.configItem, value) - self.comboBox.setText(value) - - def addItems(self, items: List[str]): - """添加选项""" - for item in items: - self.comboBox.addItem(item) - self.items.extend(items) - self._setupCompleter() - - def setItems(self, items: List[str]): - """重新设置选项列表""" - self.comboBox.clear() - self.items = items - for item in items: - self.comboBox.addItem(item) - self._setupCompleter() diff --git a/videocaptioner/ui/components/FasterWhisperSettingWidget.py b/videocaptioner/ui/components/FasterWhisperSettingWidget.py deleted file mode 100644 index dca7ea28..00000000 --- a/videocaptioner/ui/components/FasterWhisperSettingWidget.py +++ /dev/null @@ -1,910 +0,0 @@ -import os -import subprocess -from pathlib import Path - -from PyQt5.QtCore import Qt, QThread, pyqtSignal -from PyQt5.QtGui import QShowEvent -from PyQt5.QtWidgets import ( - QHBoxLayout, - QHeaderView, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) -from qfluentwidgets import ( - BodyLabel, - ComboBox, - ComboBoxSettingCard, - HyperlinkButton, - HyperlinkCard, - InfoBar, - InfoBarPosition, - MessageBoxBase, - ProgressBar, - PushButton, - SettingCardGroup, - SingleDirectionScrollArea, - SubtitleLabel, - SwitchSettingCard, - TableItemDelegate, - TableWidget, -) -from qfluentwidgets import FluentIcon as FIF - -from videocaptioner.config import BIN_PATH, MODEL_PATH -from videocaptioner.core.entities import ( - FasterWhisperModelEnum, - TranscribeLanguageEnum, - VadMethodEnum, -) -from videocaptioner.core.utils.platform_utils import open_folder -from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.components.LineEditSettingCard import LineEditSettingCard -from videocaptioner.ui.components.SpinBoxSettingCard import DoubleSpinBoxSettingCard -from videocaptioner.ui.thread.file_download_thread import FileDownloadThread -from videocaptioner.ui.thread.modelscope_download_thread import ModelscopeDownloadThread - -# 在文件开头添加常量定义 -FASTER_WHISPER_PROGRAMS = [ - { - "label": "GPU(cuda) + CPU 版本", - "value": "faster-whisper-gpu.7z", - "type": "GPU", - "size": "1.35 GB", - "downloadLink": "https://modelscope.cn/models/bkfengg/whisper-cpp/resolve/master/Faster-Whisper-XXL_r245.2_windows.7z", - }, - { - "label": "CPU版本", - "value": "faster-whisper.exe", - "type": "CPU", - "size": "78.7 MB", - "downloadLink": "https://modelscope.cn/models/bkfengg/whisper-cpp/resolve/master/whisper-faster.exe", - }, -] - -FASTER_WHISPER_MODELS = [ - { - "label": "Tiny", - "value": "faster-whisper-tiny", - "size": "77824", - "downloadLink": "https://huggingface.co/Systran/faster-whisper-tiny", - "modelScopeLink": "pengzhendong/faster-whisper-tiny", - }, - { - "label": "Base", - "value": "faster-whisper-base", - "size": "148480", - "downloadLink": "https://huggingface.co/Systran/faster-whisper-base", - "modelScopeLink": "pengzhendong/faster-whisper-base", - }, - { - "label": "Small", - "value": "faster-whisper-small", - "size": "495616", - "downloadLink": "https://huggingface.co/Systran/faster-whisper-small", - "modelScopeLink": "pengzhendong/faster-whisper-small", - }, - { - "label": "Medium", - "value": "faster-whisper-medium", - "size": "1572864", - "downloadLink": "https://huggingface.co/Systran/faster-whisper-medium", - "modelScopeLink": "pengzhendong/faster-whisper-medium", - }, - { - "label": "Large-v1", - "value": "faster-whisper-large-v1", - "size": "3145728", - "downloadLink": "https://huggingface.co/Systran/faster-whisper-large-v1", - "modelScopeLink": "pengzhendong/faster-whisper-large-v1", - }, - { - "label": "Large-v2", - "value": "faster-whisper-large-v2", - "size": "3145728", - "downloadLink": "https://huggingface.co/Systran/faster-whisper-large-v2", - "modelScopeLink": "pengzhendong/faster-whisper-large-v2", - }, - { - "label": "Large-v3", - "value": "faster-whisper-large-v3", - "size": "3145728", - "downloadLink": "https://huggingface.co/Systran/faster-whisper-large-v3", - "modelScopeLink": "pengzhendong/faster-whisper-large-v3", - }, - { - "label": "Large-v3-turbo", - "value": "faster-whisper-large-v3-turbo", - "size": "1720320", - "downloadLink": "https://huggingface.co/Systran/faster-whisper-large-v3-turbo", - "modelScopeLink": "pengzhendong/faster-whisper-large-v3-turbo", - }, -] - - -# 在类外添加这个工具函数 -def check_faster_whisper_exists() -> tuple[bool, list[str]]: - """检查 faster-whisper 程序是否存在 - - 检查以下两种情况: - 1. bin目录下是否有 faster-whisper.exe - 2. bin目录下是否有 Faster-Whisper-XXL/faster-whisper-xxl.exe - - Returns: - tuple[bool, list[str]]: (是否存在程序, 已安装的版本列表) - """ - bin_path = Path(BIN_PATH) - installed_versions = [] - - # 检查 faster-whisper.exe(CPU版本) - if (bin_path / "faster-whisper.exe").exists(): - installed_versions.append("CPU") - - # 检查 Faster-Whisper-XXL/faster-whisper-xxl.exe(GPU版本) - xxl_path = bin_path / "Faster-Whisper-XXL" / "faster-whisper-xxl.exe" - if xxl_path.exists(): - installed_versions.extend(["GPU", "CPU"]) - installed_versions = list(set(installed_versions)) - - return bool(installed_versions), installed_versions - - -# 添加新的解压线程类 -class UnzipThread(QThread): - """7z解压线程""" - - finished = pyqtSignal() # 解压完成信号 - error = pyqtSignal(str) # 解压错误信号 - - def __init__(self, zip_file, extract_path): - super().__init__() - self.zip_file = zip_file - self.extract_path = extract_path - - def run(self): - try: - subprocess.run( - ["7z", "x", self.zip_file, f"-o{self.extract_path}", "-y"], - check=True, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - ) - # 删除压缩包 - os.remove(self.zip_file) - self.finished.emit() - except subprocess.CalledProcessError as e: - self.error.emit(f"解压失败: {str(e)}") - except Exception as e: - self.error.emit(str(e)) - - -class FasterWhisperDownloadDialog(MessageBoxBase): - """Faster Whisper 下载对话框""" - - # 添加类变量跟踪下载状态 - is_downloading = False - - def __init__(self, parent=None, setting_widget=None): - super().__init__(parent) - self.widget.setMinimumWidth(600) - self.program_download_thread = None - self.model_download_thread = None - self._setup_ui() - self._connect_signals() - self.setting_widget = setting_widget - - def _setup_ui(self): - """设置UI""" - layout = QVBoxLayout() - self._setup_program_section(layout) - layout.addSpacing(20) - self._setup_model_section(layout) - self._setup_progress_section(layout) - - self.viewLayout.addLayout(layout) - self.cancelButton.setText(self.tr("关闭")) - self.yesButton.hide() - - def _setup_program_section(self, layout): - """设置程序下载部分UI""" - # 标题和按钮的水平布局 - title_layout = QHBoxLayout() - - # 标题 - faster_whisper_title = SubtitleLabel(self.tr("Faster Whisper 下载"), self) - title_layout.addWidget(faster_whisper_title) - - # 添加打开文件夹按钮 - open_folder_btn = HyperlinkButton("", self.tr("打开程序文件夹"), parent=self) - open_folder_btn.setIcon(FIF.FOLDER) - open_folder_btn.clicked.connect(self._open_program_folder) - title_layout.addStretch() - title_layout.addWidget(open_folder_btn) - - layout.addLayout(title_layout) - layout.addSpacing(8) - - # 检查已安装的版本 - has_program, installed_versions = check_faster_whisper_exists() - - if has_program: - # 显示已安装版本 - versions_text = " + ".join(installed_versions) - program_status = BodyLabel(self.tr(f"已安装版本: {versions_text}"), self) - program_status.setStyleSheet("color: green") - layout.addWidget(program_status) - - # 添加说明标签 - if len(installed_versions) == 1: - desc_label = BodyLabel(self.tr("您可以继续下载其他版本:"), self) - layout.addWidget(desc_label) - else: - desc_label = BodyLabel(self.tr("未下载Faster Whisper 程序"), self) - layout.addWidget(desc_label) - - # 下载控件 - program_layout = QHBoxLayout() - self.program_combo = ComboBox(self) - self.program_combo.setFixedWidth(300) - self.program_combo.hide() - - # 只显示未安装的版本 - for program in FASTER_WHISPER_PROGRAMS: - version_type = program["type"] - if version_type not in installed_versions: - self.program_combo.addItem(f"{program['label']} ({program['size']})") - - # 如果还有可下载的版本,显示下载控件 - if self.program_combo.count() > 0: - self.program_combo.show() - self.program_download_btn = PushButton(self.tr("下载程序"), self) - self.program_download_btn.clicked.connect(self._start_download) - program_layout.addWidget(self.program_combo) - program_layout.addWidget(self.program_download_btn) - program_layout.addStretch() - layout.addLayout(program_layout) - - def _setup_model_section(self, layout): - """设置模型下载部分UI""" - # 标题和按钮的水平布局 - title_layout = QHBoxLayout() - - # 标题 - model_title = SubtitleLabel(self.tr("模型下载"), self) - title_layout.addWidget(model_title) - - # 添加打开文件夹按钮 - open_folder_btn = HyperlinkButton("", self.tr("打开模型文件夹"), parent=self) - open_folder_btn.setIcon(FIF.FOLDER) - open_folder_btn.clicked.connect(self._open_model_folder) - title_layout.addStretch() - title_layout.addWidget(open_folder_btn) - - layout.addLayout(title_layout) - layout.addSpacing(8) - - # 模型表格 - self.model_table = self._create_model_table() - self._populate_model_table() - layout.addWidget(self.model_table) - - def _create_model_table(self): - """创建模型表格""" - table = TableWidget(self) - table.setEditTriggers(TableWidget.NoEditTriggers) - table.setSelectionMode(TableWidget.NoSelection) - table.setColumnCount(4) - table.setHorizontalHeaderLabels( - [self.tr("模型名称"), self.tr("大小"), self.tr("状态"), self.tr("操作")] - ) - - # 设置表格样式 - table.setBorderVisible(True) - table.setBorderRadius(8) - table.setItemDelegate(TableItemDelegate(table)) - - # 设置列宽 - header = table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.Stretch) - header.setSectionResizeMode(1, QHeaderView.Fixed) - header.setSectionResizeMode(2, QHeaderView.Fixed) - header.setSectionResizeMode(3, QHeaderView.Fixed) - - table.setColumnWidth(1, 100) - table.setColumnWidth(2, 80) - table.setColumnWidth(3, 150) - - # 设置行高 - row_height = 45 - table.verticalHeader().setDefaultSectionSize(row_height) - - # 设置表格高度 - header_height = 20 - max_visible_rows = 6 - table_height = row_height * max_visible_rows + header_height + 15 - table.setFixedHeight(table_height) - - return table - - def _setup_progress_section(self, layout): - """设置进度显示部分UI""" - self.progress_bar = ProgressBar(self) - self.progress_label = BodyLabel("", self) - self.progress_bar.hide() - self.progress_label.hide() - - layout.addWidget(self.progress_bar) - layout.addWidget(self.progress_label) - - def _populate_model_table(self): - """填充模型表格数据""" - self.model_table.setRowCount(len(FASTER_WHISPER_MODELS)) - for i, model in enumerate(FASTER_WHISPER_MODELS): - self._add_model_row(i, model) - - def _add_model_row(self, row, model): - """添加模型表格行""" - # 模型名称 - name_item = QTableWidgetItem(model["label"]) - name_item.setTextAlignment(Qt.AlignCenter) # type: ignore - self.model_table.setItem(row, 0, name_item) - - # 大小 - size_item = QTableWidgetItem(f"{int(model['size']) / 1024:.1f} MB") - size_item.setTextAlignment(Qt.AlignCenter) # type: ignore - self.model_table.setItem(row, 1, size_item) - - # 状态 - 检查model.bin文件是否存在 - model_path = os.path.join(MODEL_PATH, model["value"]) - model_bin_path = os.path.join(model_path, "model.bin") - is_downloaded = os.path.exists(model_bin_path) - - status_item = QTableWidgetItem( - self.tr("已下载") if is_downloaded else self.tr("未下载") - ) - if is_downloaded: - status_item.setForeground(Qt.green) # type: ignore - status_item.setTextAlignment(Qt.AlignCenter) # type: ignore - self.model_table.setItem(row, 2, status_item) - - # 下载按钮 - button_container = QWidget() - button_layout = QHBoxLayout(button_container) - button_layout.setContentsMargins(4, 4, 4, 4) - - download_btn = HyperlinkButton( - "", - self.tr("重新下载") if is_downloaded else self.tr("下载"), - parent=self, - ) - download_btn.setIcon(FIF.DOWNLOAD) - download_btn.clicked.connect(lambda checked, r=row: self._download_model(r)) - - button_layout.addStretch() - button_layout.addWidget(download_btn) - button_layout.addStretch() - self.model_table.setCellWidget(row, 3, button_container) - - def _connect_signals(self): - """连接信号""" - self.rejected.connect(self._on_dialog_reject) - - def _start_download(self): - """开始下载""" - if FasterWhisperDownloadDialog.is_downloading: - InfoBar.warning( - self.tr("下载进行中"), - self.tr("请等待当前下载任务完成"), - duration=3000, - parent=self, - ) - return - - FasterWhisperDownloadDialog.is_downloading = True - # 禁用所有下载按钮 - self._set_all_download_buttons_enabled(False) - - # 获取选中的文本 - selected_text = self.program_combo.currentText() - - # 从显示文本中提取程序标签 - selected_label = selected_text.split(" (")[0] - - # 根据标签找到对应的程序配置 - program = next( - (p for p in FASTER_WHISPER_PROGRAMS if p["label"] == selected_label), None - ) - - if not program: - InfoBar.error( - self.tr("下载错误"), - self.tr("未找到对应的程序配置"), - duration=3000, - parent=self, - ) - FasterWhisperDownloadDialog.is_downloading = False - self._set_all_download_buttons_enabled(True) - return - - # 确保 BIN_PATH 目录存在 - os.makedirs(BIN_PATH, exist_ok=True) - - self.progress_bar.show() - self.progress_label.show() - self.program_download_btn.setEnabled(False) - self.program_combo.setEnabled(False) - - # 直接下载到bin目录 - save_path = os.path.join(BIN_PATH, program["value"]) - - self.program_download_thread = FileDownloadThread( - program["downloadLink"], save_path - ) - self.program_download_thread.progress.connect( - self._on_program_download_progress - ) - self.program_download_thread.finished.connect( - lambda: self._on_program_download_finished(save_path) - ) - self.program_download_thread.error.connect(self._on_program_download_error) - self.program_download_thread.start() - - def _on_program_download_progress(self, value, status_msg): - """更新程序下载进度""" - self.progress_bar.setValue(int(value)) - self.progress_label.setText(status_msg) - - def _on_program_download_finished(self, save_path): - """程序下载完成处理""" - try: - # 检查是否是 CPU 版本的直接下载 - if save_path.endswith(".exe"): - # 如果是exe文件,重命名为faster-whisper.exe - os.rename(save_path, os.path.join(BIN_PATH, "faster-whisper.exe")) - self._finish_program_installation() - else: - # GPU 版本需要解压 - self.progress_label.setText(self.tr("正在解压文件...")) - - # 创建并启动解压线程 - self.unzip_thread = UnzipThread(save_path, BIN_PATH) - self.unzip_thread.finished.connect(self._finish_program_installation) - self.unzip_thread.error.connect(self._on_unzip_error) - self.unzip_thread.start() - return # 提前返回,等待解压完成 - - except Exception as e: - InfoBar.error(self.tr("安装失败"), str(e), duration=3000, parent=self) - self._cleanup_installation() - - def _on_program_download_error(self, error): - """程序下载错误处理""" - InfoBar.error(self.tr("下载失败"), error, duration=3000, parent=self) - FasterWhisperDownloadDialog.is_downloading = False - self._set_all_download_buttons_enabled(True) - self.program_download_btn.setEnabled(True) - self.program_combo.setEnabled(True) - self.progress_bar.hide() - self.progress_label.hide() - - def _on_dialog_reject(self): - """对话框关闭处理""" - if self.program_download_thread and self.program_download_thread.isRunning(): - self.program_download_thread.stop() - if self.model_download_thread and self.model_download_thread.isRunning(): - self.model_download_thread.terminate() - FasterWhisperDownloadDialog.is_downloading = False - self.reject() - - def closeEvent(self, event): - """窗口关闭事件处理""" - self._on_dialog_reject() - super().closeEvent(event) - - def _download_model(self, row): - """下载选中的模型""" - if FasterWhisperDownloadDialog.is_downloading: - InfoBar.warning( - self.tr("下载进行中"), - self.tr("请等待当前下载任务完成"), - duration=3000, - parent=self, - ) - return - - FasterWhisperDownloadDialog.is_downloading = True - self._set_all_download_buttons_enabled(False) - - model = FASTER_WHISPER_MODELS[row] - self.progress_bar.show() - self.progress_label.show() - self.progress_label.setText(self.tr(f"正在下载 {model['label']} 模型...")) - - # 禁用当前行的下载按钮 - button_container = self.model_table.cellWidget(row, 3) - download_btn = button_container.findChild(HyperlinkButton) - if download_btn: - download_btn.setEnabled(False) - - # 创建并启动下载线程,保存到类属性 - self.model_download_thread = ModelscopeDownloadThread( - model["modelScopeLink"], os.path.join(MODEL_PATH, model["value"]) - ) - - def _on_model_download_progress(value, msg): - self.progress_bar.setValue(value) - self.progress_label.setText(msg) - - def _on_model_download_finished(): - FasterWhisperDownloadDialog.is_downloading = False - self._set_all_download_buttons_enabled(True) - # 更新状态 - status_item = QTableWidgetItem(self.tr("已下载")) - status_item.setForeground(Qt.green) # type: ignore - status_item.setTextAlignment(Qt.AlignCenter) # type: ignore - self.model_table.setItem(row, 2, status_item) - - # 更新下载按钮文本 - if download_btn: - download_btn.setText(self.tr("重新下载")) - download_btn.setEnabled(True) - - model = FASTER_WHISPER_MODELS[row] - - # 更新主设置对话框的模型选择 - if self.setting_widget: - # 保存当前值并清空 - current_value = cfg.faster_whisper_model.value - combo = self.setting_widget.model_card.comboBox - combo.clear() - - # 找出已下载的模型 - available = [] - model_map = { - m["label"].lower(): m["value"] for m in FASTER_WHISPER_MODELS - } - for enum_val in FasterWhisperModelEnum: - if enum_val.value in model_map: - if (MODEL_PATH / model_map[enum_val.value]).exists(): - available.append(enum_val) - - # 重建下拉框 - self.setting_widget.model_card.optionToText = { - e: e.value for e in available - } - for enum_val in available: - combo.addItem(enum_val.value, userData=enum_val) - - # 恢复选择 - if current_value in available: - combo.setCurrentText(current_value.value) - elif combo.count() > 0: - combo.setCurrentIndex(0) - - InfoBar.success( - self.tr("下载成功"), - self.tr(f"{model['label']} 模型已下载完成"), - duration=3000, - parent=self, - ) - self.progress_bar.hide() - self.progress_label.hide() - - def _on_model_download_error(error): - FasterWhisperDownloadDialog.is_downloading = False - self._set_all_download_buttons_enabled(True) - if download_btn: - download_btn.setEnabled(True) - - InfoBar.error(self.tr("下载失败"), str(error), duration=3000, parent=self) - self.progress_bar.hide() - self.progress_label.hide() - - self.model_download_thread.progress.connect(_on_model_download_progress) - self.model_download_thread.finished.connect(_on_model_download_finished) - self.model_download_thread.error.connect(_on_model_download_error) - self.model_download_thread.start() - - def _set_all_download_buttons_enabled(self, enabled: bool): - """设置所有下载按钮的启用状态""" - # 设置程序下载按钮 - if hasattr(self, "program_download_btn"): - self.program_download_btn.setEnabled(enabled) - self.program_combo.setEnabled(enabled) - - # 设置所有模型下载按钮 - for row in range(self.model_table.rowCount()): - button_container = self.model_table.cellWidget(row, 3) - if button_container: - download_btn = button_container.findChild(HyperlinkButton) - if download_btn: - download_btn.setEnabled(enabled) - - def _open_model_folder(self): - """打开模型文件夹""" - if os.path.exists(MODEL_PATH): - # 根据操作系统打开文件夹 - open_folder(str(MODEL_PATH)) - - def _open_program_folder(self): - """打开程序文件夹""" - if os.path.exists(BIN_PATH): - # 根据操作系统打开文件夹 - open_folder(str(BIN_PATH)) - - def _finish_program_installation(self): - """完成程序安装""" - InfoBar.success( - self.tr("安装完成"), - self.tr("Faster Whisper 程序已安装成功"), - duration=3000, - parent=self, - ) - self.accept() - self._cleanup_installation() - - def _on_unzip_error(self, error_msg): - """处理解压错误""" - InfoBar.error(self.tr("安装失败"), error_msg, duration=3000, parent=self) - self._cleanup_installation() - - def _cleanup_installation(self): - """清理安装状态""" - FasterWhisperDownloadDialog.is_downloading = False - self._set_all_download_buttons_enabled(True) - self.progress_bar.hide() - self.progress_label.hide() - - -class FasterWhisperSettingWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - self._connect_signals() - - def showEvent(self, a0: QShowEvent) -> None: - super().showEvent(a0) - # 检查Faster Whisper模型是否存在 - is_faster_whisper_exists, _ = check_faster_whisper_exists() - if not is_faster_whisper_exists: - self.show_error_info(self.tr("Faster Whisper程序不存在,请先下载程序")) - self._show_model_manager() - return - - def setup_ui(self): - self.main_layout = QVBoxLayout(self) - - # 创建单向滚动区域和容器 - self.scrollArea = SingleDirectionScrollArea(orient=Qt.Vertical, parent=self) # type: ignore - self.scrollArea.setStyleSheet( - "QScrollArea{background: transparent; border: none}" - ) - - self.container = QWidget(self) - self.container.setStyleSheet("QWidget{background: transparent}") - self.containerLayout = QVBoxLayout(self.container) - - self.setting_group = SettingCardGroup( - self.tr("Faster Whisper 设置"), self - ) - - # 模型选择 - self.model_card = ComboBoxSettingCard( - cfg.faster_whisper_model, - FIF.ROBOT, - self.tr("模型"), - self.tr("选择 Faster Whisper 模型"), - [model.value for model in FasterWhisperModelEnum], - self.setting_group, - ) - - # 检查未下载的模型并从下拉框中移除 - for i in range(self.model_card.comboBox.count() - 1, -1, -1): - model_text = self.model_card.comboBox.itemText(i).lower() - model_config = next( - ( - model - for model in FASTER_WHISPER_MODELS - if model["label"].lower() == model_text - ), - None, - ) - if model_config: - model_path = Path(MODEL_PATH) / model_config["value"] - model_bin_path = model_path / "model.bin" - if model_bin_path.exists(): - continue - self.model_card.comboBox.removeItem(i) - - # 创建管理模型卡片 - self.manage_model_card = HyperlinkCard( - "", # 无链接 - self.tr("管理模型"), - FIF.DOWNLOAD, # 使用下载图标 - self.tr("模型管理"), - self.tr("下载或更新 Faster Whisper 模型"), - self.setting_group, # 添加到设置组 - ) - - # 语言选择 - self.language_card = ComboBoxSettingCard( - cfg.transcribe_language, - FIF.LANGUAGE, - self.tr("源语言"), - self.tr("音视频中说话的语言,默认根据前30秒自动识别"), - [lang.value for lang in TranscribeLanguageEnum], - self.setting_group, - ) - self.language_card.comboBox.setMaxVisibleItems(6) - - # 设备选择 - self.device_card = ComboBoxSettingCard( - cfg.faster_whisper_device, - FIF.IOT, - self.tr("运行设备"), - self.tr("模型运行设备"), - ["cuda", "cpu"], - self.setting_group, - ) - # _, available_devices = check_faster_whisper_exists() - # if "GPU" not in available_devices: - # self.device_card.comboBox.removeItem(0) - - # VAD设置组 - self.vad_group = SettingCardGroup(self.tr("VAD设置"), self) - - # VAD过滤开关 - self.vad_filter_card = SwitchSettingCard( - FIF.CHECKBOX, - self.tr("VAD过滤"), - self.tr("过滤无人声语音片断,减少幻觉"), - cfg.faster_whisper_vad_filter, - self.vad_group, - ) - - # VAD阈值 - self.vad_threshold_card = DoubleSpinBoxSettingCard( - cfg.faster_whisper_vad_threshold, - FIF.VOLUME, # type: ignore - self.tr("VAD阈值"), - self.tr("语音概率阈值,高于此值视为语音"), - minimum=0.00, - maximum=1.00, - decimals=2, - step=0.05, - ) - - # VAD方法 - self.vad_method_card = ComboBoxSettingCard( - cfg.faster_whisper_vad_method, - FIF.MUSIC, - self.tr("VAD方法"), - self.tr("选择VAD检测方法"), - [method.value for method in VadMethodEnum], - self.vad_group, - ) - - # 其他设置组 - self.other_group = SettingCardGroup(self.tr("其他设置"), self) - - # 音频降噪 - self.ff_mdx_kim2_card = SwitchSettingCard( - FIF.MUSIC, - self.tr("人声分离"), - self.tr("处理前使用MDX-Net降噪,分离人声和背景音乐"), - cfg.faster_whisper_ff_mdx_kim2, - self.other_group, - ) - - # 单词时间戳 - self.one_word_card = SwitchSettingCard( - FIF.UNIT, - self.tr("单字时间戳"), - self.tr("开启生成单字级时间戳;关闭后使用原始分段断句"), - cfg.faster_whisper_one_word, - self.other_group, - ) - - # 提示词 - self.prompt_card = LineEditSettingCard( - cfg.faster_whisper_prompt, - FIF.CHAT, - self.tr("提示词"), - self.tr("可选的提示词,默认空"), - "", - self.other_group, - ) - - # 添加模型设置组的卡片 - self.setting_group.addSettingCard(self.model_card) - self.setting_group.addSettingCard(self.manage_model_card) - self.setting_group.addSettingCard(self.device_card) - self.setting_group.addSettingCard(self.language_card) - - # 添加VAD设置组的卡片 - self.vad_group.addSettingCard(self.vad_filter_card) - self.vad_group.addSettingCard(self.vad_threshold_card) - self.vad_group.addSettingCard(self.vad_method_card) - - # 添加其他设置的卡片 - self.other_group.addSettingCard(self.ff_mdx_kim2_card) - self.other_group.addSettingCard(self.one_word_card) - self.other_group.addSettingCard(self.prompt_card) - - # 将所有设置组添加到容器布局 - self.containerLayout.addWidget(self.setting_group) - self.containerLayout.addWidget(self.vad_group) - self.containerLayout.addWidget(self.other_group) - self.containerLayout.addStretch(1) - - # 设置组件最小宽度 - self.model_card.comboBox.setMinimumWidth(200) - self.device_card.comboBox.setMinimumWidth(200) - self.language_card.comboBox.setMinimumWidth(200) - self.vad_method_card.comboBox.setMinimumWidth(200) - self.prompt_card.lineEdit.setMinimumWidth(200) - - # 设置滚动区域 - self.scrollArea.setWidget(self.container) - self.scrollArea.setWidgetResizable(True) - - # 将滚动区域添加到主布局 - self.main_layout.addWidget(self.scrollArea) - - def _connect_signals(self): - """连接信号""" - self.manage_model_card.linkButton.clicked.connect(self._show_model_manager) - self.vad_filter_card.checkedChanged.connect(self._on_vad_filter_changed) - - def _on_vad_filter_changed(self, checked: bool): - """VAD过滤开关状态改变时的处理""" - self.vad_threshold_card.setEnabled(checked) - self.vad_method_card.setEnabled(checked) - - def _show_model_manager(self): - """显示模型管理对话框""" - dialog = FasterWhisperDownloadDialog(self.window(), self) - dialog.exec_() - - def show_error_info(self, error_msg): - """显示错误信息""" - InfoBar.error( - title=self.tr("错误"), - content=error_msg, - parent=self.window(), - duration=5000, - position=InfoBarPosition.BOTTOM, - ) - - def check_faster_whisper_model(self): - """检查选定的Faster Whisper模型是否存在 - - Returns: - bool: 如果模型存在且配置正确返回True,否则返回False - """ - # 检查程序是否存在 - has_program, _ = check_faster_whisper_exists() - if not has_program: - self.show_error_info(self.tr("Faster Whisper程序不存在,请先下载程序")) - return False - - model_value = cfg.faster_whisper_model.value.value - # 检查模型配置是否存在 - model_config = next( - ( - m - for m in FASTER_WHISPER_MODELS - if m["label"].lower() == model_value.lower() - ), - None, - ) - if not model_config: - self.show_error_info(self.tr("模型配置不存在")) - return False - - model_path = MODEL_PATH / model_config["value"] - model_files = model_path / "model.bin" - # 检查模型文件是否存在 - if not model_path.exists() and not model_files.exists(): - self.show_error_info(self.tr("模型文件不存在: ") + model_value) - return False - return True diff --git a/videocaptioner/ui/components/LanguageSettingDialog.py b/videocaptioner/ui/components/LanguageSettingDialog.py deleted file mode 100644 index c361e8ab..00000000 --- a/videocaptioner/ui/components/LanguageSettingDialog.py +++ /dev/null @@ -1,104 +0,0 @@ -from PyQt5.QtWidgets import QVBoxLayout -from qfluentwidgets import ( - ComboBox, - InfoBar, - InfoBarPosition, - MessageBoxBase, - SettingCard, -) -from qfluentwidgets import FluentIcon as FIF - -from videocaptioner.core.entities import ( - TranscribeLanguageEnum, - TranscribeModelEnum, - get_asr_language_capability, -) -from videocaptioner.ui.common.config import cfg - - -class LanguageSettingDialog(MessageBoxBase): - """语言设置对话框""" - - def __init__(self, model: TranscribeModelEnum, parent=None): - self.model = model - super().__init__(parent) - self.widget.setMinimumWidth(500) - self._setup_ui() - self._connect_signals() - - def _get_available_languages(self) -> list[str]: - """获取当前模型支持的语言列表""" - capability = get_asr_language_capability(self.model) - languages = [lang.value for lang in capability.supported_languages] - if capability.supports_auto: - languages.insert(0, TranscribeLanguageEnum.AUTO.value) - return languages - - def _setup_ui(self): - """设置UI""" - self.yesButton.setText(self.tr("确定")) - self.cancelButton.setText(self.tr("取消")) - - # 主布局 - layout = QVBoxLayout() - - # 使用自定义 SettingCard 代替 ComboBoxSettingCard(因为需要动态选项) - self.language_card = SettingCard( - FIF.LANGUAGE, - self.tr("源语言"), - self.tr("音视频中说话的语言,默认根据前30秒自动识别"), - self, - ) - - # 创建 ComboBox - self.language_combo = ComboBox(self) - available_languages = self._get_available_languages() - self.language_combo.addItems(available_languages) - self.language_combo.setMaxVisibleItems(6) - self.language_combo.setMinimumWidth(160) - - # 设置当前值 - current_lang = cfg.transcribe_language.value - if current_lang.value in available_languages: - self.language_combo.setCurrentText(current_lang.value) - elif available_languages: - # 当前选择的语言不在可选列表中,选择第一个 - self.language_combo.setCurrentIndex(0) - - # 添加 ComboBox 到卡片 - self.language_card.hBoxLayout.addWidget(self.language_combo) - self.language_card.hBoxLayout.addSpacing(16) - - layout.addWidget(self.language_card) - layout.addStretch(1) - - self.viewLayout.addLayout(layout) - - def _connect_signals(self): - """连接信号""" - self.yesButton.clicked.connect(self.__onYesButtonClicked) - - def __onYesButtonClicked(self): - # 保存选中的语言到配置 - selected_text = self.language_combo.currentText() - for lang in TranscribeLanguageEnum: - if lang.value == selected_text: - cfg.set(cfg.transcribe_language, lang) - break - - self.accept() - InfoBar.success( - self.tr("设置已保存"), - self.tr("语言设置已更新"), - duration=3000, - parent=self.window(), - position=InfoBarPosition.BOTTOM, - ) - if cfg.transcribe_language.value == TranscribeLanguageEnum.JAPANESE: - InfoBar.warning( - self.tr("请注意身体!!"), - self.tr("小心肝儿,注意身体哦~"), - duration=2000, - parent=self.window(), - position=InfoBarPosition.BOTTOM, - ) diff --git a/videocaptioner/ui/components/LineEditSettingCard.py b/videocaptioner/ui/components/LineEditSettingCard.py deleted file mode 100644 index 020d0e3a..00000000 --- a/videocaptioner/ui/components/LineEditSettingCard.py +++ /dev/null @@ -1,44 +0,0 @@ -from typing import Optional - -from PyQt5.QtCore import Qt, pyqtSignal -from qfluentwidgets import LineEdit, SettingCard -from qfluentwidgets.common.config import ConfigItem, qconfig - - -class LineEditSettingCard(SettingCard): - """行输入卡片""" - - textChanged = pyqtSignal(str) - - def __init__( - self, - configItem: ConfigItem, - icon, - title: str, - content: Optional[str] = None, - placeholder: str = "", - parent=None, - ): - super().__init__(icon, title, content, parent) - - self.configItem = configItem - - self.lineEdit = LineEdit(self) - self.lineEdit.setPlaceholderText(placeholder) - self.hBoxLayout.addWidget(self.lineEdit, 1, Qt.AlignRight) # type: ignore - self.hBoxLayout.addSpacing(16) - - self.lineEdit.setMinimumWidth(280) - - self.setValue(qconfig.get(configItem)) - - self.lineEdit.textChanged.connect(self.__onTextChanged) - configItem.valueChanged.connect(self.setValue) - - def __onTextChanged(self, text: str): - self.setValue(text) - self.textChanged.emit(text) - - def setValue(self, value: str): - qconfig.set(self.configItem, value) - self.lineEdit.setText(value) diff --git a/videocaptioner/ui/components/MySettingCard.py b/videocaptioner/ui/components/MySettingCard.py deleted file mode 100644 index 7e1dab24..00000000 --- a/videocaptioner/ui/components/MySettingCard.py +++ /dev/null @@ -1,347 +0,0 @@ -# coding:utf-8 -from typing import List, Optional, Union - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QColor, QIcon, QPainter -from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QToolButton, QVBoxLayout -from qfluentwidgets import ColorDialog, ComboBox, CompactDoubleSpinBox, CompactSpinBox -from qfluentwidgets.common.config import isDarkTheme -from qfluentwidgets.common.icon import FluentIconBase, drawIcon -from qfluentwidgets.common.style_sheet import FluentStyleSheet -from qfluentwidgets.components.widgets.icon_widget import IconWidget - - -class SettingIconWidget(IconWidget): - def paintEvent(self, e): - painter = QPainter(self) - - if not self.isEnabled(): - painter.setOpacity(0.36) - - painter.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform) - drawIcon(self._icon, painter, self.rect()) - - -class SettingCard(QFrame): - """Setting card""" - - def __init__( - self, icon: Union[str, QIcon, FluentIconBase], title, content=None, parent=None - ): - """ - Parameters - ---------- - icon: str | QIcon | FluentIconBase - the icon to be drawn - - title: str - the title of card - - content: str - the content of card - - parent: QWidget - parent widget - """ - super().__init__(parent=parent) - self.iconLabel = SettingIconWidget(icon, self) - self.titleLabel = QLabel(title, self) - self.contentLabel = QLabel(content or "", self) - self.hBoxLayout = QHBoxLayout(self) - self.vBoxLayout = QVBoxLayout() - - if not content: - self.contentLabel.hide() - - self.setFixedHeight(70 if content else 50) - self.iconLabel.setFixedSize(16, 16) - - # initialize layout - self.hBoxLayout.setSpacing(0) - self.hBoxLayout.setContentsMargins(16, 0, 0, 0) - self.hBoxLayout.setAlignment(Qt.AlignVCenter) # type: ignore - self.vBoxLayout.setSpacing(0) - self.vBoxLayout.setContentsMargins(0, 0, 0, 0) - self.vBoxLayout.setAlignment(Qt.AlignVCenter) # type: ignore - - self.hBoxLayout.addWidget(self.iconLabel, 0, Qt.AlignLeft) # type: ignore - self.hBoxLayout.addSpacing(16) - - self.hBoxLayout.addLayout(self.vBoxLayout) - self.vBoxLayout.addWidget(self.titleLabel, 0, Qt.AlignLeft) # type: ignore - self.vBoxLayout.addWidget(self.contentLabel, 0, Qt.AlignLeft) # type: ignore - - self.hBoxLayout.addSpacing(16) - self.hBoxLayout.addStretch(1) - - self.contentLabel.setObjectName("contentLabel") - FluentStyleSheet.SETTING_CARD.apply(self) - - def setTitle(self, title: str): - """set the title of card""" - self.titleLabel.setText(title) - - def setContent(self, content: str): - """set the content of card""" - self.contentLabel.setText(content) - self.contentLabel.setVisible(bool(content)) - - def setValue(self, value): - """set the value of config item""" - pass - - def setIconSize(self, width: int, height: int): - """set the icon fixed size""" - self.iconLabel.setFixedSize(width, height) - - def paintEvent(self, e): - painter = QPainter(self) - painter.setRenderHints(QPainter.Antialiasing) - - if isDarkTheme(): - painter.setBrush(QColor(255, 255, 255, 13)) - painter.setPen(QColor(0, 0, 0, 50)) - else: - painter.setBrush(QColor(255, 255, 255, 170)) - painter.setPen(QColor(0, 0, 0, 19)) - - painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 6, 6) - - -class DoubleSpinBoxSettingCard(SettingCard): - """小数输入设置卡片""" - - valueChanged = pyqtSignal(float) - - def __init__( - self, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Optional[str] = None, - minimum: float = 0.0, - maximum: float = 100.0, - decimals: int = 1, - parent=None, - ): - super().__init__(icon, title, content, parent) - - # 创建CompactDoubleSpinBox - self.spinBox = CompactDoubleSpinBox(self) - self.spinBox.setRange(minimum, maximum) - self.spinBox.setDecimals(decimals) - self.spinBox.setMinimumWidth(60) - self.spinBox.setSingleStep(0.2) - - # 添加到布局 - self.hBoxLayout.addWidget(self.spinBox, 0, Qt.AlignRight) # type: ignore - self.hBoxLayout.addSpacing(8) - - # 设置初始值和连接信号 - self.spinBox.valueChanged.connect(self.__onValueChanged) - - def __onValueChanged(self, value: float): - """数值改变时的槽函数""" - self.setValue(value) - self.valueChanged.emit(value) - - def setValue(self, value: float): - """设置数值""" - self.spinBox.setValue(value) - - -class SpinBoxSettingCard(SettingCard): - """数值输入设置卡片""" - - valueChanged = pyqtSignal(int) - - def __init__( - self, - icon: Union[str, QIcon], - title: str, - content: Optional[str] = None, - minimum: int = 0, - maximum: int = 100, - step: int = 2, - parent=None, - ): - super().__init__(icon, title, content, parent) - - # 创建SpinBox - self.spinBox = CompactSpinBox(self) - self.spinBox.setRange(minimum, maximum) - self.spinBox.setMinimumWidth(60) - self.spinBox.setSingleStep(step) - - # 添加到布局 - self.hBoxLayout.addWidget(self.spinBox, 0, Qt.AlignRight) # type: ignore - self.hBoxLayout.addSpacing(8) - - # 设置初始值和连接信号 - self.spinBox.valueChanged.connect(self.__onValueChanged) - - def __onValueChanged(self, value: int): - """数值改变时的槽函数""" - self.setValue(value) - self.valueChanged.emit(value) - - def setValue(self, value: int): - """设置数值""" - self.spinBox.setValue(value) - - -class ComboBoxSettingCard(SettingCard): - """下拉框设置卡片""" - - currentTextChanged = pyqtSignal(str) - currentIndexChanged = pyqtSignal(int) - - def __init__( - self, - icon: Union[str, QIcon], - title: str, - content: Optional[str] = None, - texts: Optional[List[str]] = None, - parent=None, - ): - super().__init__(icon, title, content, parent) - - # 创建ComboBox - self.comboBox = ComboBox(self) - self.hBoxLayout.addWidget(self.comboBox, 0, Qt.AlignRight) # type: ignore - self.hBoxLayout.addSpacing(16) - - # 添加选项 - if texts: - for text in texts: - self.comboBox.addItem(text) - - # 连接信号 - self.comboBox.currentTextChanged.connect(self.__onCurrentTextChanged) - self.comboBox.currentIndexChanged.connect(self.__onCurrentIndexChanged) - - def __onCurrentTextChanged(self, text: str): - """当前文本改变时的槽函数""" - self.currentTextChanged.emit(text) - - def __onCurrentIndexChanged(self, index: int): - """当前索引改变时的槽函数""" - self.currentIndexChanged.emit(index) - - def setCurrentText(self, text: str): - """设置当前文本""" - self.comboBox.setCurrentText(text) - - def setCurrentIndex(self, index: int): - """设置当前索引""" - self.comboBox.setCurrentIndex(index) - - def addItem(self, text: str): - """添加选项""" - self.comboBox.addItem(text) - - def addItems(self, texts: List[str]): - """添加多个选项""" - self.comboBox.addItems(texts) - - def clear(self): - """清空所有选项""" - self.comboBox.clear() - - -class ColorSettingCard(SettingCard): - """带颜色选择器的设置卡片""" - - colorChanged = pyqtSignal(QColor) - - def __init__( - self, - color: QColor, - icon: Union[str, QIcon, FluentIconBase], - title: str, - content: Optional[str] = None, - parent=None, - enableAlpha=False, - ): - """ - 参数 - ---------- - color: QColor - 初始颜色 - - icon: str | QIcon | FluentIconBase - 要绘制的图标 - - title: str - 卡片标题 - - content: str - 卡片内容 - - parent: QWidget - 父组件 - - enableAlpha: bool - 是否启用透明通道 - """ - super().__init__(icon, title, content, parent) - self.colorPicker = ColorPickerButton(color, title, self, enableAlpha) - self.colorPicker.setFixedWidth(60) - self.hBoxLayout.addWidget(self.colorPicker, 0, Qt.AlignRight) # type: ignore - self.hBoxLayout.addSpacing(16) - self.colorPicker.colorChanged.connect(self.__onColorChanged) - - def __onColorChanged(self, color: QColor): - """颜色改变时的槽函数""" - self.colorChanged.emit(color) - - def setColor(self, color: QColor): - """设置颜色""" - self.colorPicker.setColor(color) - - -class ColorPickerButton(QToolButton): - """Color picker button""" - - colorChanged = pyqtSignal(QColor) - - def __init__(self, color: QColor, title: str, parent=None, enableAlpha=False): - super().__init__(parent=parent) - self.title = title - self.enableAlpha = enableAlpha - self.setFixedSize(96, 32) - self.setAttribute(Qt.WA_TranslucentBackground) # type: ignore - - self.setColor(color) - self.setCursor(Qt.PointingHandCursor) # type: ignore - self.clicked.connect(self.__showColorDialog) - - def __showColorDialog(self): - """show color dialog""" - w = ColorDialog( - self.color, self.tr("Choose ") + self.title, self.window(), self.enableAlpha - ) - w.colorChanged.connect(self.__onColorChanged) - w.exec() - - def __onColorChanged(self, color): - """color changed slot""" - self.setColor(color) - self.colorChanged.emit(color) - - def setColor(self, color): - """set color""" - self.color = QColor(color) - self.update() - - def paintEvent(self, e): - painter = QPainter(self) - painter.setRenderHints(QPainter.Antialiasing) - pc = QColor(255, 255, 255, 10) if isDarkTheme() else QColor(234, 234, 234) - painter.setPen(pc) - - color = QColor(self.color) - if not self.enableAlpha: - color.setAlpha(255) - - painter.setBrush(color) - painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 5, 5) diff --git a/videocaptioner/ui/components/MyVideoWidget.py b/videocaptioner/ui/components/MyVideoWidget.py deleted file mode 100644 index 285aa043..00000000 --- a/videocaptioner/ui/components/MyVideoWidget.py +++ /dev/null @@ -1,677 +0,0 @@ -# coding:utf-8 -import sys -from enum import Enum -from pathlib import Path -from typing import Optional - -import vlc # type: ignore -from PyQt5.QtCore import QObject, Qt, QTimer, QUrl, pyqtSignal -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget - -# from qfluentwidgets.multimedia.media_player import MediaPlayer, MediaPlayerBase -from qfluentwidgets.common.icon import FluentIcon -from qfluentwidgets.common.style_sheet import FluentStyleSheet -from qfluentwidgets.components.widgets.label import CaptionLabel -from qfluentwidgets.multimedia.media_play_bar import ( - MediaPlayBarBase, - MediaPlayBarButton, -) - -from videocaptioner.config import RESOURCE_PATH -from videocaptioner.ui.common.signal_bus import signalBus - - -class MediaStatus(Enum): - NoMedia = 0 - LoadingMedia = 1 - LoadedMedia = 2 - BufferingMedia = 3 - BufferedMedia = 4 - EndOfMedia = 5 - InvalidMedia = 6 - UnknownMediaStatus = 7 - - -class PlaybackState(Enum): - StoppedState = 0 - PlayingState = 1 - PausedState = 2 - - -class MediaPlayerBase(QObject): - """Media player base class""" - - mediaStatusChanged = pyqtSignal(MediaStatus) - playbackRateChanged = pyqtSignal(float) - positionChanged = pyqtSignal(int) - durationChanged = pyqtSignal(int) - sourceChanged = pyqtSignal(QUrl) - volumeChanged = pyqtSignal(int) - mutedChanged = pyqtSignal(bool) - - def __init__(self, parent=None): - super().__init__(parent=parent) - - def isPlaying(self): - """Whether the media is playing""" - raise NotImplementedError - - def mediaStatus(self) -> MediaStatus: - """Return the status of the current media stream""" - raise NotImplementedError - - def playbackState(self) -> PlaybackState: - """Return the playback status of the current media stream""" - raise NotImplementedError - - def duration(self): - """Returns the duration of the current media in ms""" - raise NotImplementedError - - def position(self): - """Returns the current position inside the media being played back in ms""" - raise NotImplementedError - - def volume(self): - """Return the volume of player""" - raise NotImplementedError - - def source(self) -> QUrl: - """Return the active media source being used""" - raise NotImplementedError - - def pause(self): - """Pause playing the current source""" - raise NotImplementedError - - def play(self): - """Start or resume playing the current source""" - raise NotImplementedError - - def stop(self): - """Stop playing, and reset the play position to the beginning""" - raise NotImplementedError - - def playbackRate(self) -> float: - """Return the playback rate of the current media""" - raise NotImplementedError - - def setPosition(self, position: int): - """Sets the position of media in ms""" - raise NotImplementedError - - def setSource(self, media: QUrl): - """Sets the current source""" - raise NotImplementedError - - def setPlaybackRate(self, rate: float): - """Sets the playback rate of player""" - raise NotImplementedError - - def setVolume(self, volume: int): - """Sets the volume of player""" - raise NotImplementedError - - def setMuted(self, isMuted: bool): - raise NotImplementedError - - def videoOutput(self) -> QObject: - """Return the video output to be used by the media player""" - raise NotImplementedError - - def setVideoOutput(self, output: QObject) -> None: - """Sets the video output to be used by the media player""" - raise NotImplementedError - - -class MediaPlayer(MediaPlayerBase): - def __init__(self, parent=None): - # 确保在主线程中初始化 - if parent: - super().__init__(parent) - else: - super().__init__() - - # 修改 VLC 参数以减少警告 - vlc_args = [ - "--no-xlib", - "--quiet", - ] - - # 在主线程中创建 VLC 实例 - self.moveToThread(QApplication.instance().thread()) - self.instance = vlc.Instance(vlc_args) - self._player = self.instance.media_player_new() - self._media = None - self._source = None - self._playback_rate = 1.0 - - # 创建定时器用于更新状态 - self._update_timer = QTimer(self) - self._update_timer.setInterval(100) # 100ms更新一次 - self._update_timer.timeout.connect(self._on_timer_update) - self._update_timer.start() - - # 保存上一次的状态,用于检测变化 - self._last_position = 0 - self._last_duration = 0 - self._last_volume = 100 - - def _on_timer_update(self): - """定时更新状态并发送信号""" - if self._player: - # 更新位置 - position = self._player.get_time() - if position != self._last_position: - self._last_position = position - self.positionChanged.emit(position) - - # 更新时长 - duration = self._player.get_length() - if duration != self._last_duration: - self._last_duration = duration - self.durationChanged.emit(duration) - - # 更新音量 - volume = self._player.audio_get_volume() - if volume != self._last_volume: - self._last_volume = volume - self.volumeChanged.emit(volume) - - def isPlaying(self): - return bool(self._player and self._player.is_playing()) - - def mediaStatus(self) -> MediaStatus: - if not self._player: - return MediaStatus.NoMedia - - state = self._player.get_state() - if state == vlc.State.NothingSpecial: - return MediaStatus.NoMedia - elif state == vlc.State.Opening: - return MediaStatus.LoadingMedia - elif state == vlc.State.Playing: - return MediaStatus.BufferedMedia - elif state == vlc.State.Paused: - return MediaStatus.BufferedMedia - elif state == vlc.State.Stopped: - return MediaStatus.LoadedMedia - elif state == vlc.State.Ended: - return MediaStatus.EndOfMedia - elif state == vlc.State.Error: - return MediaStatus.InvalidMedia - return MediaStatus.UnknownMediaStatus - - def playbackState(self) -> PlaybackState: - if not self._player: - return PlaybackState.StoppedState - - if self._player.is_playing(): - return PlaybackState.PlayingState - elif self._player.get_state() == vlc.State.Paused: - return PlaybackState.PausedState - return PlaybackState.StoppedState - - def duration(self): - return self._player.get_length() if self._player else 0 - - def position(self): - return self._player.get_time() if self._player else 0 - - def volume(self): - return self._player.audio_get_volume() if self._player else 0 - - def source(self) -> QUrl: - return self._source - - def get_subtitle(self): - """获取当前使用的字幕文件路径 - - Returns: - str: 当前字幕文件路径,如果没有字幕则返回 None - """ - if not self._player: - return None - - try: - # 获取当前字幕轨道ID - current_spu = self._player.video_get_spu() - if current_spu <= 0: # 0 表示禁用字幕,-1 表示错误 - return None - - # 获取字幕轨道描述信息 - spu_description = self._player.video_get_spu_description() - if not spu_description: - return None - - # 遍历查找当前使用的字幕轨道 - for spu in spu_description: - if spu[0] == current_spu: - # 返回字幕文件路径 - return spu[1].decode("utf-8") - - return None - except Exception: - return None - - def pause(self): - self._player.pause() - - def play(self): - self._player.play() - - def stop(self): - self._player.stop() - - def playbackRate(self) -> float: - return self._playback_rate - - def setPosition(self, position: int): - if self._player: - self._player.set_time(position) - self.positionChanged.emit(position) - - def setSource(self, media: QUrl): - """设置媒体源时重置状态""" - path = media.toLocalFile() or media.toString() - self._media = self.instance.media_new(path) - self._player.set_media(self._media) - self._source = media - self.sourceChanged.emit(media) - self.mediaStatusChanged.emit(self.mediaStatus()) - - def setPlaybackRate(self, rate: float): - if self._player: - self._player.set_rate(rate) - self._playback_rate = rate - self.playbackRateChanged.emit(rate) - - def setVolume(self, volume: int): - if self._player: - self._player.audio_set_volume(volume) - self.volumeChanged.emit(volume) - - def setMuted(self, isMuted: bool): - if self._player: - self._player.audio_set_mute(isMuted) - self.mutedChanged.emit(isMuted) - - def videoOutput(self) -> Optional[QObject]: - return None # VLC不需要这个 - - def setVideoOutput(self, output: QObject) -> None: - if isinstance(output, QWidget) and hasattr(output, "winId"): # type: ignore - self._player.set_hwnd(output.winId()) - - def hasMedia(self): - """检查是否有媒体文件加载""" - return bool(self._media and self._player) - - def playSegment(self, start_time: int, end_time: int): - """播放指定时间段的视频片段 - - Args: - start_time: 开始时间(毫秒) - end_time: 结束时间(毫秒) - """ - if not self._player or not self.hasMedia(): - return - - # 确保时间范围有效 - if start_time < 0 or end_time > self.duration() or start_time >= end_time: - return - - # 创建事件管理器 - event_manager = self._player.event_manager() - - def on_time_changed(event): - # 当播放位置到达结束时间时停止播放 - if self.position() >= end_time: - self.pause() - # 移除事件监听器 - event_manager.event_detach(vlc.EventType.MediaPlayerTimeChanged) - - # 注册时间变化事件 - event_manager.event_attach( - vlc.EventType.MediaPlayerTimeChanged, on_time_changed - ) - - # 设置开始位置并播放 - self.setPosition(start_time) - self.play() - - def add_subtitle(self, subtitle_file: str) -> bool: - """添加字幕文件 - - Args: - subtitle_file: 字幕文件的路径 - - Returns: - bool: 是否成功添加字幕 - """ - if not self._player or not self.hasMedia(): - return False - - try: - # 将路径转换为 URI 格式 - - subtitle_uri = Path(subtitle_file).as_uri() - - # 添加字幕轨道 - result = self._player.add_slave( - vlc.MediaSlaveType.subtitle, subtitle_uri, True - ) - - # 获取字幕轨道信息 (unused but potentially useful for debugging) - # spu_description = self._player.video_get_spu_description() - - return result == 0 - except Exception: - return False - - def get_subtitle_tracks(self) -> list: - """获取所有可用的字幕轨道""" - if not self._player: - return [] - - tracks = [] - spu_count = self._player.video_get_spu_count() - for i in range(spu_count): - track_info = self._player.video_get_spu_description()[i] - tracks.append(track_info) - return tracks - - def set_subtitle_track(self, track_id: int): - """设置当前使用的字幕轨道 - - Args: - track_id: 字幕轨道ID,-1 表示禁用字幕 - """ - if self._player: - self._player.video_set_spu(track_id) - - -class StandardMediaPlayBar(MediaPlayBarBase): - """Standard media play bar""" - - def __init__(self, parent=None): - super().__init__(parent) - self.vBoxLayout = QVBoxLayout(self) - self.timeLayout = QHBoxLayout() - self.buttonLayout = QHBoxLayout() - self.leftButtonContainer = QWidget() - self.centerButtonContainer = QWidget() - self.rightButtonContainer = QWidget() - self.leftButtonLayout = QHBoxLayout(self.leftButtonContainer) - self.centerButtonLayout = QHBoxLayout(self.centerButtonContainer) - self.rightButtonLayout = QHBoxLayout(self.rightButtonContainer) - - self.skipBackButton = MediaPlayBarButton(FluentIcon.SKIP_BACK, self) - self.skipForwardButton = MediaPlayBarButton(FluentIcon.SKIP_FORWARD, self) - - self.currentTimeLabel = CaptionLabel("0:00:00", self) - self.remainTimeLabel = CaptionLabel("0:00:00", self) - - self.__initWidgets() - - def __initWidgets(self): - self.setFixedHeight(102) - self.vBoxLayout.setSpacing(6) - self.vBoxLayout.setContentsMargins(5, 9, 5, 9) - self.vBoxLayout.addWidget(self.progressSlider, 1, Qt.AlignTop) # type: ignore - - self.vBoxLayout.addLayout(self.timeLayout) - self.timeLayout.setContentsMargins(10, 0, 10, 0) - self.timeLayout.addWidget(self.currentTimeLabel, 0, Qt.AlignLeft) # type: ignore - self.timeLayout.addWidget(self.remainTimeLabel, 0, Qt.AlignRight) # type: ignore - - self.vBoxLayout.addStretch(1) - self.vBoxLayout.addLayout(self.buttonLayout, 1) - self.buttonLayout.setContentsMargins(0, 0, 0, 0) - self.leftButtonLayout.setContentsMargins(4, 0, 0, 0) - self.centerButtonLayout.setContentsMargins(0, 0, 0, 0) - self.rightButtonLayout.setContentsMargins(0, 0, 4, 0) - - self.leftButtonLayout.addWidget(self.volumeButton, 0, Qt.AlignLeft) # type: ignore - self.centerButtonLayout.addWidget(self.skipBackButton) - self.centerButtonLayout.addWidget(self.playButton) - self.centerButtonLayout.addWidget(self.skipForwardButton) - - self.buttonLayout.addWidget(self.leftButtonContainer, 0, Qt.AlignLeft) # type: ignore - self.buttonLayout.addWidget(self.centerButtonContainer, 0, Qt.AlignHCenter) # type: ignore - self.buttonLayout.addWidget(self.rightButtonContainer, 0, Qt.AlignRight) # type: ignore - - self.skipBackButton.clicked.connect(lambda: self.skipBack(5000)) - self.skipForwardButton.clicked.connect(lambda: self.skipForward(5000)) - - def skipBack(self, ms: int): - """Back up for specified milliseconds""" - self.player.setPosition(self.player.position() - ms) - - def skipForward(self, ms: int): - """Fast forward specified milliseconds""" - self.player.setPosition(self.player.position() + ms) - - def _onPositionChanged(self, position: int): - super()._onPositionChanged(position) - self.currentTimeLabel.setText(self._formatTime(position)) - self.remainTimeLabel.setText( - self._formatTime(self.player.duration() - position) - ) - - def _formatTime(self, time: int): - time = int(time / 1000) - s = time % 60 - m = (time // 60) % 60 - h = time // 3600 - return f"{h}:{m:02}:{s:02}" - - def closeEvent(self, event): - self.release() - super().closeEvent(event) - - -class MyVideoWidget(QWidget): - """Video widget""" - - def __init__(self, parent=None): - super().__init__(parent) - - # 设置初始窗口大小 - self.resize(800, 600) - self.setWindowTitle("VideoCaptioner") - self.setWindowIcon(QIcon(str(RESOURCE_PATH / "assets" / "logo.png"))) - - # 创建一个专门用于视频输出的 widget - self.videoWidget = QWidget(self) - self.videoWidget.setStyleSheet("background-color: rgb(24, 24, 24);") - - # 添加提示标签 - self.tipLabel = CaptionLabel("请拖入视频文件", self.videoWidget) - self.tipLabel.setStyleSheet( - """ - color: rgba(255, 255, 255, 0.5); - font-size: 20px; - font-weight: bold; - letter-spacing: 2px; - """ - ) - - # 创建布局使标签居中 - tipLayout = QVBoxLayout(self.videoWidget) - tipLayout.addWidget(self.tipLabel, 0, Qt.AlignCenter) # type: ignore - - # 创建播放控制栏 - self.playBar = StandardMediaPlayBar(self) - self.playBar.setAttribute(Qt.WA_TranslucentBackground) # type: ignore - - # 设置字幕文件 - self.subtitle_file = None - - # 创建垂直布局 - self.vBoxLayout = QVBoxLayout(self) - self.vBoxLayout.setContentsMargins(0, 0, 0, 0) - self.vBoxLayout.setSpacing(0) - self.vBoxLayout.addWidget(self.videoWidget, 1) - self.vBoxLayout.addWidget(self.playBar, 0) - - # 创建播放器并传入优化参数 - self.vlc_player = MediaPlayer(self) - - # 设置新的播放器 - self.playBar.setMediaPlayer(self.vlc_player) # type: ignore - self.playBar.setVolume(80) - self.vlc_player.setVideoOutput(self.videoWidget) - FluentStyleSheet.MEDIA_PLAYER.apply(self) - - # 设置焦点和事件过滤 - self.setFocusPolicy(Qt.StrongFocus) # type: ignore - self.videoWidget.setFocusPolicy(Qt.StrongFocus) # type: ignore - - # 安装事件过滤器 - self.videoWidget.installEventFilter(self) - self.playBar.installEventFilter(self) - - FluentStyleSheet.MEDIA_PLAYER.apply(self) - self.setAcceptDrops(True) - - # 连接 SignalBus 信号 - self._connectSignals() - - def _connectSignals(self): - """连接 SignalBus 的信号""" - # 视频控制信号 - signalBus.video_play.connect(self.play) - signalBus.video_pause.connect(self.pause) - signalBus.video_stop.connect(self.stop) - signalBus.video_source_changed.connect(self.setVideo) - signalBus.video_segment_play.connect(self.playSegment) - signalBus.video_subtitle_added.connect(self.addSubtitle) - - def addSubtitle(self, subtitle_file: str): - """添加字幕文件的内部方法""" - self.subtitle_file = subtitle_file - self.vlc_player.add_subtitle(subtitle_file) - - def setVideo(self, url: QUrl): - """设置视频源 - - Args: - url: 视频文件的 QUrl - """ - self.setWindowTitle(url.fileName()) - self.vlc_player.setSource(url) - if self.subtitle_file: - self.vlc_player.add_subtitle(self.subtitle_file) - # 隐藏提示标签 - self.tipLabel.hide() - - def play(self): - """播放视频""" - self.playBar.play() - - def pause(self): - """暂停视频""" - self.playBar.pause() - - def stop(self): - """停止视频""" - self.playBar.stop() - - def playSegment(self, start_time: int, end_time: int): - """播放指定时间段的视频 - - Args: - start_time: 开始时间(毫秒) - end_time: 结束时间(毫秒) - """ - self.vlc_player.playSegment(start_time, end_time) - - def hideEvent(self, e): - self.stop() - e.accept() - - def wheelEvent(self, e): - return - - def togglePlayState(self): - """toggle play state""" - if self.vlc_player.isPlaying(): - self.pause() - else: - self.play() - - @property - def player(self): - return self.playBar.player - - def keyPressEvent(self, event): - """处理键盘事件""" - if event.key() == Qt.Key_Space: # type: ignore - self.playBar.togglePlayState() - elif event.key() == Qt.Key_Left: # type: ignore - self.playBar.skipBack(3000) - elif event.key() == Qt.Key_Right: # type: ignore - self.playBar.skipForward(3000) - else: - super().keyPressEvent(event) - - def dragEnterEvent(self, event): - """处理拖入事件""" - if event.mimeData().hasUrls(): - urls = event.mimeData().urls() - # 检查是否为视频文件或字幕文件 - if any( - url.toLocalFile() - .lower() - .endswith( - (".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".srt", ".ass") - ) - for url in urls - ): - event.acceptProposedAction() - - def dropEvent(self, event): - """处理放下事件""" - urls = event.mimeData().urls() - for url in urls: - file_path = url.toLocalFile().lower() - if file_path.endswith((".srt", ".ass")): - # 处理字幕文件 - self.vlc_player.add_subtitle(url.toLocalFile()) - elif file_path.endswith((".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv")): - # 处理视频文件 - self.setVideo(url) - self.play() - break # 只处理第一个视频文件 - - def eventFilter(self, obj, event): - """事件过滤器,用于捕获所有子部件的按键事件""" - if event.type() == event.KeyPress: - if event.key() in (Qt.Key_Left, Qt.Key_Right): # type: ignore - self.keyPressEvent(event) - return True - return super().eventFilter(obj, event) - - def showEvent(self, event): - """窗口显示时设置焦点""" - super().showEvent(event) - self.setFocus() - - -if __name__ == "__main__": - app = QApplication(sys.argv) - window = MyVideoWidget() - # 设置视频源 - 请替换为您的测试视频路径 - # video_path = r"path/to/your/test/video.mp4" - # window.setVideo(QUrl.fromLocalFile(video_path)) - - # 确保窗口显示在屏幕中央 - window.show() - window.activateWindow() - window.raise_() - - # 开始播放视频 - # window.play() - sys.exit(app.exec_()) diff --git a/videocaptioner/ui/components/SimpleSettingCard.py b/videocaptioner/ui/components/SimpleSettingCard.py deleted file mode 100644 index 574382d2..00000000 --- a/videocaptioner/ui/components/SimpleSettingCard.py +++ /dev/null @@ -1,85 +0,0 @@ -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QHBoxLayout -from qfluentwidgets import ( - CaptionLabel, - CardWidget, - ComboBox, - SwitchButton, - ToolTipFilter, - ToolTipPosition, -) - - -class SimpleSettingCard(CardWidget): - """基础设置卡片类""" - - def __init__(self, title, content, parent=None): - super().__init__(parent) - self.title = title - self.content = content - self.setup_ui() - - def setup_ui(self): - self.hBoxLayout = QHBoxLayout(self) - self.hBoxLayout.setContentsMargins(16, 10, 8, 10) - self.hBoxLayout.setSpacing(8) - - self.label = CaptionLabel(self) - self.label.setText(self.title) - self.hBoxLayout.addWidget(self.label) - - self.hBoxLayout.addStretch(1) - - self.setToolTip(self.content) - self.installEventFilter(ToolTipFilter(self, 100, ToolTipPosition.BOTTOM)) - - -class ComboBoxSimpleSettingCard(SimpleSettingCard): - """下拉框设置卡片""" - - valueChanged = pyqtSignal(str) - - def __init__(self, title, content, items=None, parent=None): - super().__init__(title, content, parent) - self.items = items or [] - self.setup_combobox() - - def setup_combobox(self): - self.comboBox = ComboBox(self) - self.comboBox.addItems(self.items) - self.comboBox.setMaxVisibleItems(6) - self.comboBox.currentTextChanged.connect(self.valueChanged) # type: ignore - self.hBoxLayout.addWidget(self.comboBox) - - def setValue(self, value): - self.comboBox.setCurrentIndex(self.items.index(value)) - - def value(self): - return self.comboBox.currentText() - - -class SwitchButtonSimpleSettingCard(SimpleSettingCard): - """开关设置卡片""" - - checkedChanged = pyqtSignal(bool) - - def __init__(self, title, content, parent=None): - super().__init__(title, content, parent) - self.setup_switch() - - def setup_switch(self): - self.switchButton = SwitchButton(self) - self.switchButton.setOnText("开") - self.switchButton.setOffText("关") - self.switchButton.checkedChanged.connect(self.checkedChanged) # type: ignore - self.hBoxLayout.addWidget(self.switchButton) - - self.clicked.connect( # type: ignore - lambda: self.switchButton.setChecked(not self.switchButton.isChecked()) - ) - - def setChecked(self, checked): - self.switchButton.setChecked(checked) - - def isChecked(self): - return self.switchButton.isChecked() diff --git a/videocaptioner/ui/components/SpinBoxSettingCard.py b/videocaptioner/ui/components/SpinBoxSettingCard.py deleted file mode 100644 index 67881023..00000000 --- a/videocaptioner/ui/components/SpinBoxSettingCard.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Optional, Union - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QIcon -from qfluentwidgets import CompactDoubleSpinBox, CompactSpinBox, SettingCard -from qfluentwidgets.common.config import ConfigItem, qconfig - - -class DoubleSpinBoxSettingCard(SettingCard): - """小数输入设置卡片""" - - valueChanged = pyqtSignal(float) - - def __init__( - self, - configItem: ConfigItem, - icon: Union[str, QIcon], - title: str, - content: Optional[str] = None, - minimum: float = 0.0, - maximum: float = 100.0, - decimals: int = 1, - step: float = 0.1, - parent=None, - ): - super().__init__(icon, title, content, parent) - - self.configItem = configItem - - # 创建CompactDoubleSpinBox - self.spinBox = CompactDoubleSpinBox(self) - self.spinBox.setRange(minimum, maximum) - self.spinBox.setDecimals(decimals) - self.spinBox.setMinimumWidth(60) - self.spinBox.setSingleStep(step) # 设置步长为0.2 - - # 添加到布局 - self.hBoxLayout.addWidget(self.spinBox, 0, Qt.AlignRight) # type: ignore - self.hBoxLayout.addSpacing(8) - - # 设置初始值和连接信号 - self.setValue(qconfig.get(configItem)) - self.spinBox.valueChanged.connect(self.__onValueChanged) - configItem.valueChanged.connect(self.setValue) - - def __onValueChanged(self, value: float): - """数值改变时的槽函数""" - self.setValue(value) - self.valueChanged.emit(value) - - def setValue(self, value: float): - """设置数值""" - qconfig.set(self.configItem, value) - self.spinBox.setValue(value) - - -class SpinBoxSettingCard(SettingCard): - """数值输入设置卡片""" - - valueChanged = pyqtSignal(int) - - def __init__( - self, - configItem: ConfigItem, - icon: Union[str, QIcon], - title: str, - content: Optional[str] = None, - minimum: int = 0, - maximum: int = 100, - parent=None, - ): - super().__init__(icon, title, content, parent) - - self.configItem = configItem - - # 创建SpinBox - self.spinBox = CompactSpinBox(self) - self.spinBox.setRange(minimum, maximum) - self.spinBox.setMinimumWidth(60) - - # 添加到布局 - self.hBoxLayout.addWidget(self.spinBox, 0, Qt.AlignRight) # type: ignore - self.hBoxLayout.addSpacing(8) - - # 设置初始值和连接信号 - self.setValue(qconfig.get(configItem)) - self.spinBox.valueChanged.connect(self.__onValueChanged) - configItem.valueChanged.connect(self.setValue) - - def __onValueChanged(self, value: int): - """数值改变时的槽函数""" - self.setValue(value) - self.valueChanged.emit(value) - - def setValue(self, value: int): - """设置数值""" - qconfig.set(self.configItem, value) - self.spinBox.setValue(value) diff --git a/videocaptioner/ui/components/SubtitleSettingDialog.py b/videocaptioner/ui/components/SubtitleSettingDialog.py deleted file mode 100644 index 09c9a584..00000000 --- a/videocaptioner/ui/components/SubtitleSettingDialog.py +++ /dev/null @@ -1,62 +0,0 @@ -from qfluentwidgets import ( - BodyLabel, - MessageBoxBase, - SwitchSettingCard, -) -from qfluentwidgets import FluentIcon as FIF - -from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.components.SpinBoxSettingCard import SpinBoxSettingCard - - -class SubtitleSettingDialog(MessageBoxBase): - """字幕设置对话框""" - - def __init__(self, parent=None): - super().__init__(parent) - self.titleLabel = BodyLabel(self.tr("字幕设置"), self) - - # 创建设置卡片 - self.split_card = SwitchSettingCard( - FIF.ALIGNMENT, - self.tr("字幕分割"), - self.tr("字幕是否使用大语言模型进行智能断句"), - cfg.need_split, - self, - ) - - self.word_count_cjk_card = SpinBoxSettingCard( - cfg.max_word_count_cjk, - FIF.TILES, # type: ignore - self.tr("中文最大字数"), - self.tr("单条字幕的最大字数 (对于中日韩等字符)"), - minimum=8, - maximum=50, - parent=self, - ) - - self.word_count_english_card = SpinBoxSettingCard( - cfg.max_word_count_english, - FIF.TILES, # type: ignore - self.tr("英文最大单词数"), - self.tr("单条字幕的最大单词数 (英文)"), - minimum=8, - maximum=50, - parent=self, - ) - - # 添加到布局 - self.viewLayout.addWidget(self.titleLabel) - self.viewLayout.addWidget(self.split_card) - self.viewLayout.addWidget(self.word_count_cjk_card) - self.viewLayout.addWidget(self.word_count_english_card) - # 设置间距 - self.viewLayout.setSpacing(10) - - # 设置窗口标题和宽度 - self.setWindowTitle(self.tr("字幕设置")) - self.widget.setMinimumWidth(380) - - # 只显示取消按钮 - self.yesButton.hide() - self.cancelButton.setText(self.tr("关闭")) diff --git a/videocaptioner/ui/components/TranscriptionOutputDialog.py b/videocaptioner/ui/components/TranscriptionOutputDialog.py deleted file mode 100644 index 71c21441..00000000 --- a/videocaptioner/ui/components/TranscriptionOutputDialog.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -from qfluentwidgets import ( - BodyLabel, - ComboBoxSettingCard, - MessageBoxBase, -) -from qfluentwidgets import FluentIcon as FIF - -from videocaptioner.core.entities import TranscribeOutputFormatEnum -from videocaptioner.ui.common.config import cfg - - -class TranscriptionSettingDialog(MessageBoxBase): - """转录设置对话框""" - - def __init__(self, parent=None): - super().__init__(parent) - self.titleLabel = BodyLabel(self.tr("转录设置"), self) - - # 创建输出格式选择卡片 - self.output_format_card = ComboBoxSettingCard( - cfg.transcribe_output_format, - FIF.SAVE, - self.tr("输出格式"), - self.tr("选择转录字幕的输出格式"), - texts=[fmt.value for fmt in TranscribeOutputFormatEnum], - parent=self, - ) - self.output_format_card.setMinimumWidth(420) - - # 添加到布局 - self.viewLayout.addWidget(self.titleLabel) - self.viewLayout.addWidget(self.output_format_card) - # 设置间距 - self.viewLayout.setSpacing(10) - - # 设置窗口标题 - self.setWindowTitle(self.tr("转录设置")) - - # 只显示取消按钮 - self.yesButton.hide() - self.cancelButton.setText(self.tr("关闭")) - diff --git a/videocaptioner/ui/components/TranscriptionSettingDialog.py b/videocaptioner/ui/components/TranscriptionSettingDialog.py deleted file mode 100644 index 07b4ec01..00000000 --- a/videocaptioner/ui/components/TranscriptionSettingDialog.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -from qfluentwidgets import ( - BodyLabel, - ComboBoxSettingCard, - MessageBoxBase, -) -from qfluentwidgets import FluentIcon as FIF - -from videocaptioner.core.entities import TranscribeOutputFormatEnum -from videocaptioner.ui.common.config import cfg - - -class TranscriptionSettingDialog(MessageBoxBase): - """转录设置对话框""" - - def __init__(self, parent=None): - super().__init__(parent) - self.titleLabel = BodyLabel(self.tr("转录设置"), self) - - # 创建输出格式选择卡片 - self.output_format_card = ComboBoxSettingCard( - cfg.transcribe_output_format, - FIF.SAVE, - self.tr("输出格式"), - self.tr("选择转录字幕的输出格式"), - texts=[fmt.value for fmt in TranscribeOutputFormatEnum], - parent=self, - ) - - # 添加到布局 - self.viewLayout.addWidget(self.titleLabel) - self.viewLayout.addWidget(self.output_format_card) - # 设置间距 - self.viewLayout.setSpacing(10) - - # 设置窗口标题和宽度 - self.setWindowTitle(self.tr("转录设置")) - self.widget.setMinimumWidth(380) - - # 只显示取消按钮 - self.yesButton.hide() - self.cancelButton.setText(self.tr("关闭")) - diff --git a/videocaptioner/ui/components/WhisperAPISettingWidget.py b/videocaptioner/ui/components/WhisperAPISettingWidget.py deleted file mode 100644 index 909a0cd0..00000000 --- a/videocaptioner/ui/components/WhisperAPISettingWidget.py +++ /dev/null @@ -1,218 +0,0 @@ -from PyQt5.QtCore import Qt, QThread, pyqtSignal -from PyQt5.QtWidgets import ( - QVBoxLayout, - QWidget, -) -from qfluentwidgets import ( - ComboBoxSettingCard, - InfoBar, - InfoBarPosition, - PushSettingCard, - SettingCardGroup, - SingleDirectionScrollArea, -) -from qfluentwidgets import FluentIcon as FIF - -from videocaptioner.core.constant import INFOBAR_DURATION_ERROR, INFOBAR_DURATION_SUCCESS -from videocaptioner.core.entities import TranscribeLanguageEnum -from videocaptioner.core.llm import check_whisper_connection - -from ..common.config import cfg -from .EditComboBoxSettingCard import EditComboBoxSettingCard -from .LineEditSettingCard import LineEditSettingCard - - -class WhisperAPISettingWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - self.main_layout = QVBoxLayout(self) - - # 创建单向滚动区域和容器 - self.scrollArea = SingleDirectionScrollArea(orient=Qt.Vertical, parent=self) # type: ignore - self.scrollArea.setStyleSheet( - "QScrollArea{background: transparent; border: none}" - ) - - self.container = QWidget(self) - self.container.setStyleSheet("QWidget{background: transparent}") - self.containerLayout = QVBoxLayout(self.container) - - self.setting_group = SettingCardGroup(self.tr("Whisper API 设置"), self) - - # API Base URL - self.base_url_card = LineEditSettingCard( - cfg.whisper_api_base, - FIF.LINK, - self.tr("API Base URL"), - self.tr("输入 Whisper API Base URL"), - "https://api.openai.com/v1", - self.setting_group, - ) - - # API Key - self.api_key_card = LineEditSettingCard( - cfg.whisper_api_key, - FIF.FINGERPRINT, - self.tr("API Key"), - self.tr("输入 Whisper API Key"), - "sk-", - self.setting_group, - ) - - # Model - self.model_card = EditComboBoxSettingCard( - cfg.whisper_api_model, - FIF.ROBOT, # type: ignore - self.tr("Whisper 模型"), - self.tr("选择 Whisper 模型"), - ["whisper-large-v3", "whisper-large-v3-turbo", "whisper-1"], - self.setting_group, - ) - - # 添加 Language 选择 - self.language_card = ComboBoxSettingCard( - cfg.transcribe_language, - FIF.LANGUAGE, - self.tr("源语言"), - self.tr("音视频中说话的语言,默认根据前30秒自动识别"), - [lang.value for lang in TranscribeLanguageEnum], - self.setting_group, - ) - - # 添加 Prompt - self.prompt_card = LineEditSettingCard( - cfg.whisper_api_prompt, - FIF.CHAT, - self.tr("提示词"), - self.tr("可选的提示词,默认空"), - "", - self.setting_group, - ) - - # 添加测试连接按钮 - self.check_connection_card = PushSettingCard( - self.tr("测试连接"), - FIF.CONNECT, - self.tr("测试 Whisper API 连接"), - self.tr("点击测试 API 连接是否正常"), - self.setting_group, - ) - - # 设置最小宽度 - self.base_url_card.lineEdit.setMinimumWidth(200) - self.api_key_card.lineEdit.setMinimumWidth(200) - self.model_card.comboBox.setMinimumWidth(200) - self.language_card.comboBox.setMinimumWidth(200) - self.prompt_card.lineEdit.setMinimumWidth(200) - - # 使用 addSettingCard 添加所有卡片到组 - self.setting_group.addSettingCard(self.base_url_card) - self.setting_group.addSettingCard(self.api_key_card) - self.setting_group.addSettingCard(self.model_card) - self.setting_group.addSettingCard(self.language_card) - self.setting_group.addSettingCard(self.prompt_card) - self.setting_group.addSettingCard(self.check_connection_card) - - # 连接测试按钮信号 - self.check_connection_card.clicked.connect(self.on_check_connection) - - # 将设置组添加到容器布局 - self.containerLayout.addWidget(self.setting_group) - self.containerLayout.addStretch(1) - - # 设置滚动区域 - self.scrollArea.setWidget(self.container) - self.scrollArea.setWidgetResizable(True) - - # 将滚动区域添加到主布局 - self.main_layout.addWidget(self.scrollArea) - - def on_check_connection(self): - """测试 Whisper API 连接""" - # 获取配置 - base_url = self.base_url_card.lineEdit.text().strip() - api_key = self.api_key_card.lineEdit.text().strip() - model = self.model_card.comboBox.currentText().strip() - - # 验证必填字段 - if not base_url or not api_key or not model: - InfoBar.warning( - self.tr("配置不完整"), - self.tr("请输入 API Base URL、API Key 和 model"), - duration=INFOBAR_DURATION_ERROR, - position=InfoBarPosition.TOP, - parent=self.window(), - ) - return - - # 禁用按钮,显示加载状态 - self.check_connection_card.button.setEnabled(False) - self.check_connection_card.button.setText(self.tr("正在测试...")) - - # 创建并启动测试线程 - self.connection_thread = WhisperConnectionThread(base_url, api_key, model) - self.connection_thread.finished.connect(self.on_connection_check_finished) - self.connection_thread.error.connect(self.on_connection_check_error) - self.connection_thread.start() - - def on_connection_check_finished(self, success, result): - """处理连接检查完成事件""" - # 恢复按钮状态 - self.check_connection_card.button.setEnabled(True) - self.check_connection_card.button.setText(self.tr("测试连接")) - - if success: - InfoBar.success( - self.tr("连接成功"), - self.tr("Whisper API 连接成功!") + "\n" + result, - duration=INFOBAR_DURATION_SUCCESS, - position=InfoBarPosition.BOTTOM, - parent=self.window(), - ) - else: - InfoBar.error( - self.tr("连接失败"), - self.tr(f"Whisper API 连接失败!\n{result}"), - duration=INFOBAR_DURATION_ERROR, - position=InfoBarPosition.BOTTOM, - parent=self.window(), - ) - - def on_connection_check_error(self, message): - """处理连接检查错误事件""" - # 恢复按钮状态 - self.check_connection_card.button.setEnabled(True) - self.check_connection_card.button.setText(self.tr("测试连接")) - InfoBar.error( - self.tr("测试错误"), - message, - duration=INFOBAR_DURATION_ERROR, - position=InfoBarPosition.BOTTOM, - parent=self.window(), - ) - - -class WhisperConnectionThread(QThread): - """Whisper API 连接测试线程""" - - finished = pyqtSignal(bool, str) - error = pyqtSignal(str) - - def __init__(self, base_url, api_key, model): - super().__init__() - self.base_url = base_url - self.api_key = api_key - self.model = model - - def run(self): - """执行连接测试""" - try: - success, result = check_whisper_connection( - self.base_url, self.api_key, self.model - ) - self.finished.emit(success, result) - except Exception as e: - self.error.emit(str(e)) diff --git a/videocaptioner/ui/components/WhisperCppSettingWidget.py b/videocaptioner/ui/components/WhisperCppSettingWidget.py deleted file mode 100644 index f7d2915e..00000000 --- a/videocaptioner/ui/components/WhisperCppSettingWidget.py +++ /dev/null @@ -1,602 +0,0 @@ -import os - -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( - QHBoxLayout, - QHeaderView, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) -from qfluentwidgets import ( - BodyLabel, - ComboBox, - ComboBoxSettingCard, - HyperlinkButton, - HyperlinkCard, - InfoBar, - MessageBoxBase, - ProgressBar, - PushButton, - SettingCardGroup, - SingleDirectionScrollArea, - SubtitleLabel, - TableItemDelegate, - TableWidget, -) -from qfluentwidgets import FluentIcon as FIF - -from videocaptioner.config import MODEL_PATH -from videocaptioner.core.entities import ( - TranscribeLanguageEnum, - WhisperModelEnum, -) -from videocaptioner.core.utils.logger import setup_logger -from videocaptioner.core.utils.platform_utils import open_folder -from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.thread.file_download_thread import FileDownloadThread - -logger = setup_logger("whisper_download") - -# 使用阿里云镜像定义模型配置 -# https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-tiny.bin -# "mirrorLink": "https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin?download=true" - -# 使用阿里云镜像定义模型配置 -WHISPER_CPP_MODELS = [ - { - "label": "Tiny", - "value": "ggml-tiny.bin", - "size": "77.7 MB", - "downloadLink": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin", - "mirrorLink": "https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-tiny.bin", - "sha": "bd577a113a864445d4c299885e0cb97d4ba92b5f", - }, - { - "label": "Base", - "value": "ggml-base.bin", - "size": "148 MB", - "downloadLink": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin", - "mirrorLink": "https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-base.bin", - "sha": "465707469ff3a37a2b9b8d8f89f2f99de7299dac", - }, - { - "label": "Small", - "value": "ggml-small.bin", - "size": "488 MB", - "downloadLink": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin", - "mirrorLink": "https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-small.bin", - "sha": "55356645c2b361a969dfd0ef2c5a50d530afd8d5", - }, - { - "label": "Medium", - "value": "ggml-medium.bin", - "size": "1.53 GB", - "downloadLink": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin", - "mirrorLink": "https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-medium.bin", - "sha": "fd9727b6e1217c2f614f9b698455c4ffd82463b4", - }, - { - "label": "large-v1", - "value": "ggml-large-v1.bin", - "size": "3.09 GB", - "downloadLink": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v1.bin", - "mirrorLink": "https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-large-v1.bin", - "sha": "b1caaf735c4cc1429223d5a74f0f4d0b9b59a299", - }, - { - "label": "large-v2", - "value": "ggml-large-v2.bin", - "size": "3.09 GB", - "downloadLink": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v2.bin", - "mirrorLink": "https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-large-v2.bin", - "sha": "0f4c8e34f21cf1a914c59d8b3ce882345ad349d6", - }, - # { - # "label": "Large(v3)", - # "value": "ggml-large-v3.bin", - # "size": "3.09 GB", - # "downloadLink": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin", - # "mirrorLink": "https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-large-v3.bin", - # "sha": "ad82bf6a9043ceed055076d0fd39f5f186ff8062" - # }, - # { - # "label": "Distil Large(v3)", - # "value": "ggml-distil-large-v3.bin", - # "size": "1.52 GB", - # "downloadLink": "https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/main/ggml-distil-large-v3.bin?download=true", - # "mirrorLink": "https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-distil-large-v3.bin", - # "sha": "5e61e98bdcf3b9a78516c59bf7d1a10d64cae67a" - # } -] - - -def check_whisper_cpp_exists(): - """检查WhisperCpp程序是否存在""" - return True, [] - - -class DownloadDialog(MessageBoxBase): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - self.setWindowTitle(self.tr("下载模型")) - self.download_thread = None - - def setup_ui(self): - self.titleLabel = BodyLabel(self.tr("下载模型"), self) - - # 添加模型选择下拉框 - self.model_combo = ComboBox(self) - self.model_combo.setFixedWidth(300) - for model in WHISPER_CPP_MODELS: - # 检查模型是否已下载 - model_path = os.path.join(MODEL_PATH, model["value"]) - downloaded = "✓ " if os.path.exists(model_path) else " " - self.model_combo.addItem(f"{downloaded}{model['label']} ({model['size']})") - - # 进度条 - self.progress_bar = ProgressBar() - self.progress_bar.hide() - - # 进度标签 - self.progress_label = BodyLabel() - self.progress_label.hide() - - # 下载按钮 - self.download_button = PushButton(self.tr("下载"), self) - self.download_button.clicked.connect(self.start_download) - - # 添加到布局 - self.viewLayout.addWidget(self.titleLabel) - self.viewLayout.addWidget(self.model_combo) - self.viewLayout.addWidget(self.progress_bar) - self.viewLayout.addWidget(self.progress_label) - self.viewLayout.addWidget(self.download_button) - # 设置间距 - self.viewLayout.setSpacing(10) - - # 只显示取消按钮 - self.yesButton.hide() - self.cancelButton.setText(self.tr("关闭")) - - def start_download(self): - selected_index = self.model_combo.currentIndex() - model = WHISPER_CPP_MODELS[selected_index] - save_path = os.path.join(MODEL_PATH, model["value"]) - - # 检查模型文件是否已存在 - if os.path.exists(save_path): - InfoBar.warning( - title=self.tr("提示"), - content=self.tr("模型文件已存在,无需重复下载"), - parent=self.window(), - duration=3000, - ) - return - - self.progress_bar.show() - self.progress_label.show() - self.download_button.setEnabled(False) - - self.download_thread = FileDownloadThread(model["mirrorLink"], save_path) - self.download_thread.progress.connect(self.update_progress) - self.download_thread.finished.connect(self.download_finished) - self.download_thread.error.connect(self.download_error) - self.download_thread.start() - - def update_progress(self, value, status_msg): - self.progress_bar.setValue(int(value)) - self.progress_label.setText(status_msg) - - def download_finished(self): - InfoBar.success( - title=self.tr("完成"), - content=self.tr("模型下载完成!"), - parent=self.window(), - duration=3000, - ) - self.download_button.setEnabled(True) - self.progress_label.setText(self.tr("下载完成")) - - def download_error(self, error): - InfoBar.error( - title=self.tr("下载错误"), - content=error, - parent=self.window(), - duration=5000, - ) - self.download_button.setEnabled(True) - self.progress_label.hide() - - def reject(self): - if self.download_thread and self.download_thread.isRunning(): - logger.info("关闭下载对话框,终止下载") - self.download_thread.stop() - super().reject() - - -class WhisperCppDownloadDialog(MessageBoxBase): - """WhisperCpp 下载对话框""" - - # 添加类变量跟踪下载状态 - is_downloading = False - - def __init__(self, parent=None, setting_widget=None): - super().__init__(parent) - self.widget.setMinimumWidth(600) - self.program_download_thread = None - self.model_download_thread = None - self._setup_ui() - self.setting_widget = setting_widget - - def _setup_ui(self): - """设置UI""" - layout = QVBoxLayout() - self._setup_program_section(layout) - layout.addSpacing(20) - self._setup_model_section(layout) - self._setup_progress_section(layout) - - self.viewLayout.addLayout(layout) - self.cancelButton.setText(self.tr("关闭")) - self.yesButton.hide() - - def _setup_program_section(self, layout): - """设置程序下载部分UI""" - # 标题 - whisper_cpp_title = SubtitleLabel(self.tr("WhisperCpp程序"), self) - layout.addWidget(whisper_cpp_title) - layout.addSpacing(8) - - # 检查已安装的版本 - has_program, installed_versions = check_whisper_cpp_exists() - - if has_program: - # 显示已安装版本 - versions_text = " + ".join(installed_versions) - program_status = BodyLabel(self.tr(f"已安装版本: {versions_text}"), self) - program_status.setStyleSheet("color: green") - layout.addWidget(program_status) - else: - desc_label = BodyLabel(self.tr("未下载 WhisperCpp 程序"), self) - layout.addWidget(desc_label) - - def _setup_model_section(self, layout): - """设置模型下载部分UI""" - # 标题和按钮的水平布局 - title_layout = QHBoxLayout() - - # 标题 - model_title = SubtitleLabel(self.tr("模型下载"), self) - title_layout.addWidget(model_title) - - # 添加打开文件夹按钮 - open_folder_btn = HyperlinkButton("", self.tr("打开模型文件夹"), parent=self) - open_folder_btn.setIcon(FIF.FOLDER) - open_folder_btn.clicked.connect(self._open_model_folder) - title_layout.addStretch() - title_layout.addWidget(open_folder_btn) - - layout.addLayout(title_layout) - layout.addSpacing(8) - - # 模型表格 - self.model_table = self._create_model_table() - self._populate_model_table() - layout.addWidget(self.model_table) - - def _create_model_table(self): - """创建模型表格""" - table = TableWidget(self) - table.setEditTriggers(TableWidget.NoEditTriggers) - table.setSelectionMode(TableWidget.NoSelection) - table.setColumnCount(4) - table.setHorizontalHeaderLabels( - [self.tr("模型名称"), self.tr("大小"), self.tr("状态"), self.tr("操作")] - ) - - # 设置表格样式 - table.setBorderVisible(True) - table.setBorderRadius(8) - table.setItemDelegate(TableItemDelegate(table)) - - # 设置列宽 - header = table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.Stretch) - header.setSectionResizeMode(1, QHeaderView.Fixed) - header.setSectionResizeMode(2, QHeaderView.Fixed) - header.setSectionResizeMode(3, QHeaderView.Fixed) - - table.setColumnWidth(1, 100) - table.setColumnWidth(2, 80) - table.setColumnWidth(3, 150) - - # 设置行高 - row_height = 45 - table.verticalHeader().setDefaultSectionSize(row_height) - - # 设置表格高度 - header_height = 20 - max_visible_rows = 6 - table_height = row_height * max_visible_rows + header_height + 15 - table.setFixedHeight(table_height) - - return table - - def _setup_progress_section(self, layout): - """设置进度显示部分UI""" - self.progress_bar = ProgressBar(self) - self.progress_label = BodyLabel("", self) - self.progress_bar.hide() - self.progress_label.hide() - - layout.addWidget(self.progress_bar) - layout.addWidget(self.progress_label) - - def _populate_model_table(self): - """填充模型表格数据""" - self.model_table.setRowCount(len(WHISPER_CPP_MODELS)) - for i, model in enumerate(WHISPER_CPP_MODELS): - self._add_model_row(i, model) - - def _add_model_row(self, row, model): - """添加模型表格行""" - # 模型名称 - name_item = QTableWidgetItem(model["label"]) - name_item.setTextAlignment(Qt.AlignCenter) # type: ignore - self.model_table.setItem(row, 0, name_item) - - # 大小 - size_item = QTableWidgetItem(f"{model['size']}") - size_item.setTextAlignment(Qt.AlignCenter) # type: ignore - self.model_table.setItem(row, 1, size_item) - - # 状态 - model_bin_path = os.path.join(MODEL_PATH, model["value"]) - status_item = QTableWidgetItem( - self.tr("已下载") if os.path.exists(model_bin_path) else self.tr("未下载") - ) - if os.path.exists(model_bin_path): - status_item.setForeground(Qt.green) # type: ignore - status_item.setTextAlignment(Qt.AlignCenter) # type: ignore - self.model_table.setItem(row, 2, status_item) - - # 下载按钮 - button_container = QWidget() - button_layout = QHBoxLayout(button_container) - button_layout.setContentsMargins(4, 4, 4, 4) - - download_btn = HyperlinkButton( - "", - self.tr("重新下载") if os.path.exists(model_bin_path) else self.tr("下载"), - parent=self, - ) - download_btn.setIcon(FIF.DOWNLOAD) - download_btn.clicked.connect(lambda checked, r=row: self._download_model(r)) - - button_layout.addStretch() - button_layout.addWidget(download_btn) - button_layout.addStretch() - self.model_table.setCellWidget(row, 3, button_container) - - def _download_model(self, row): - """下载选中的模型""" - if WhisperCppDownloadDialog.is_downloading: - InfoBar.warning( - self.tr("下载进行中"), - self.tr("请等待当前下载任务完成"), - duration=3000, - parent=self, - ) - return - - WhisperCppDownloadDialog.is_downloading = True - self._set_all_download_buttons_enabled(False) - - model = WHISPER_CPP_MODELS[row] - self.progress_bar.show() - self.progress_label.show() - self.progress_label.setText(self.tr(f"正在下载 {model['label']} 模型...")) - - # 禁用当前行的下载按钮 - button_container = self.model_table.cellWidget(row, 3) - download_btn = button_container.findChild(HyperlinkButton) - if download_btn: - download_btn.setEnabled(False) - - def _on_model_download_progress(value, msg): - self.progress_bar.setValue(int(value)) - self.progress_label.setText(msg) - - def _on_model_download_finished(): - WhisperCppDownloadDialog.is_downloading = False - self._set_all_download_buttons_enabled(True) - # 更新状态 - status_item = QTableWidgetItem(self.tr("已下载")) - status_item.setForeground(Qt.green) # type: ignore - status_item.setTextAlignment(Qt.AlignCenter) # type: ignore - self.model_table.setItem(row, 2, status_item) - - # 更新下载按钮文本 - if download_btn: - download_btn.setText(self.tr("重新下载")) - download_btn.setEnabled(True) - - # 获取当前下载的模型信息 - model = WHISPER_CPP_MODELS[row] - - # 更新主设置对话框的模型选择 - if self.setting_widget: - try: - # 保存当前值并清空 - current_value = cfg.whisper_model.value - combo = self.setting_widget.model_card.comboBox - combo.clear() - - # 找出已下载的模型 - available = [] - model_map = { - m["label"].lower(): m["value"] for m in WHISPER_CPP_MODELS - } - for enum_val in WhisperModelEnum: - if enum_val.value in model_map: - if (MODEL_PATH / model_map[enum_val.value]).exists(): - available.append(enum_val) - - # 重建下拉框 - self.setting_widget.model_card.optionToText = { - e: e.value for e in available - } - for enum_val in available: - combo.addItem(enum_val.value, userData=enum_val) - - # 恢复选择 - if current_value in available: - combo.setCurrentText(current_value.value) - elif combo.count() > 0: - combo.setCurrentIndex(0) - except Exception as e: - logger.error(f"更新模型选择失败: {e}") - - InfoBar.success( - self.tr("下载成功"), - self.tr(f"{model['label']} 模型已下载完成"), - duration=3000, - parent=self, - ) - self.progress_bar.hide() - self.progress_label.hide() - - def _on_model_download_error(error): - WhisperCppDownloadDialog.is_downloading = False - self._set_all_download_buttons_enabled(True) - if download_btn: - download_btn.setEnabled(True) - - InfoBar.error(self.tr("下载失败"), str(error), duration=3000, parent=self) - self.progress_bar.hide() - self.progress_label.hide() - - self.model_download_thread = FileDownloadThread( - model["mirrorLink"], os.path.join(MODEL_PATH, model["value"]) - ) - self.model_download_thread.progress.connect(_on_model_download_progress) - self.model_download_thread.finished.connect(_on_model_download_finished) - self.model_download_thread.error.connect(_on_model_download_error) - self.model_download_thread.start() - - def _set_all_download_buttons_enabled(self, enabled: bool): - """设置所有下载按钮的启用状态""" - # 设置程序下载按钮 - if hasattr(self, "program_download_btn"): - self.program_download_btn.setEnabled(enabled) - self.program_combo.setEnabled(enabled) - - # 设置所有模型下载按钮 - for row in range(self.model_table.rowCount()): - button_container = self.model_table.cellWidget(row, 3) - if button_container: - download_btn = button_container.findChild(HyperlinkButton) - if download_btn: - download_btn.setEnabled(enabled) - - def _open_model_folder(self): - """打开模型文件夹""" - if os.path.exists(MODEL_PATH): - # 根据操作系统打开文件夹 - open_folder(str(MODEL_PATH)) - - -class WhisperCppSettingWidget(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - self.setup_signals() - - def setup_ui(self): - self.main_layout = QVBoxLayout(self) - - # 创建单向滚动区域和容器 - self.scrollArea = SingleDirectionScrollArea(orient=Qt.Vertical, parent=self) # type: ignore - self.scrollArea.setStyleSheet( - "QScrollArea{background: transparent; border: none}" - ) - - self.container = QWidget(self) - self.container.setStyleSheet("QWidget{background: transparent}") - self.containerLayout = QVBoxLayout(self.container) - - self.setting_group = SettingCardGroup(self.tr("Whisper CPP 设置"), self) - - # 模型选择 - self.model_card = ComboBoxSettingCard( - cfg.whisper_model, - FIF.ROBOT, - self.tr("模型"), - self.tr("选择Whisper模型"), - [model.value for model in WhisperModelEnum], - self.setting_group, - ) - - # 检查未下载的模型并从下拉框中移除 - for i in range(self.model_card.comboBox.count() - 1, -1, -1): - model_text = self.model_card.comboBox.itemText(i).lower() - model_configs = { - model["label"].lower(): model for model in WHISPER_CPP_MODELS - } - model_config = model_configs.get(model_text) - if model_config and (MODEL_PATH / model_config["value"]).exists(): - continue - self.model_card.comboBox.removeItem(i) - - # 语言选择 - self.language_card = ComboBoxSettingCard( - cfg.transcribe_language, - FIF.LANGUAGE, - self.tr("源语言"), - self.tr("音视频中说话的语言,默认根据前30秒自动识别"), - [language.value for language in TranscribeLanguageEnum], - self.setting_group, - ) - - # 添加模型管理卡片 - self.manage_model_card = HyperlinkCard( - "", # 无链接 - self.tr("管理模型"), - FIF.DOWNLOAD, # 使用下载图标 - self.tr("模型管理"), - self.tr("下载或更新 Whisper CPP 模型"), - self.setting_group, # 添加到设置组 - ) - - # 添加 setMaxVisibleItems - self.language_card.comboBox.setMaxVisibleItems(6) - - # 使用 addSettingCard 添加卡片到组 - self.setting_group.addSettingCard(self.model_card) - self.setting_group.addSettingCard(self.language_card) - self.setting_group.addSettingCard(self.manage_model_card) - - # 将设置组添加到容器布局 - self.containerLayout.addWidget(self.setting_group) - self.containerLayout.addStretch(1) - - # 设置组件最小宽度 - self.model_card.comboBox.setMinimumWidth(200) - self.language_card.comboBox.setMinimumWidth(200) - - # 设置滚动区域 - self.scrollArea.setWidget(self.container) - self.scrollArea.setWidgetResizable(True) - - # 将滚动区域添加到主布局 - self.main_layout.addWidget(self.scrollArea) - - def setup_signals(self): - self.manage_model_card.linkButton.clicked.connect(self.show_download_dialog) - - def show_download_dialog(self): - """显示下载对话框""" - download_dialog = WhisperCppDownloadDialog(self.window(), self) - download_dialog.show() diff --git a/videocaptioner/ui/components/app_dialog.py b/videocaptioner/ui/components/app_dialog.py new file mode 100644 index 00000000..6999508c --- /dev/null +++ b/videocaptioner/ui/components/app_dialog.py @@ -0,0 +1,219 @@ +"""第一方弹窗壳。 + +AppDialog 是应用内所有弹窗统一的外壳:半透明遮罩 + 居中卡片 + +标题栏(图标盒 / 标题 / 圆形关闭钮 / 分隔线)。内容加到 +``self.bodyLayout``,底部按钮加到 ``self.footerLayout``。 + +parent 一律提升为 ``parent.window()``:弹窗永远基于整个程序窗口 +居中与遮罩,而不是触发它的子页面(tab)。 + +ConfirmDialog 是标准确认框(标题 + 正文 + 取消/确认),替代 +qfluent MessageBox;确认返回 1,取消/Esc/关闭返回 0。 +""" + +from __future__ import annotations + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget +from qfluentwidgets.components.dialog_box.mask_dialog_base import MaskDialogBase + +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.workbench import ( + AccentButton, + AppLineEdit, + CompactButton, + DangerButton, + IconBox, + RoundIconButton, + apply_font, +) +from videocaptioner.ui.i18n import tr + +# 哨兵:区分「未传按钮文案」(→ 取当前语言默认)与显式传 None(ConfirmDialog 表示隐藏取消钮) +_UNSET = object() + + +class AppDialog(MaskDialogBase): + """通用弹窗壳:遮罩 + 居中卡片 + 标题栏。""" + + def __init__( + self, + title: str, + icon: AppIcon | None = None, + parent: QWidget | None = None, + width: int = 460, + ): + # 强制基于程序主窗口:遮罩盖满整个程序,卡片相对主窗口居中 + super().__init__(parent.window() if parent is not None else None) + self.setShadowEffect(60, (0, 8), QColor(0, 0, 0, 100)) + self.setMaskColor(QColor(0, 0, 0, 150)) + self.setClosableOnMaskClicked(True) # 点遮罩空白处关闭(等同取消/Esc) + # MaskDialogBase 默认把 widget 拉伸占满遮罩,卡片必须居中按内容收身 + self._hBoxLayout.setAlignment(self.widget, Qt.AlignCenter) # type: ignore[arg-type] + + card = self.widget + card.setObjectName("appDialogCard") + card.setFixedWidth(width) + self.cardLayout = QVBoxLayout(card) + self.cardLayout.setContentsMargins(22, 18, 22, 16) + self.cardLayout.setSpacing(13) + + header = QHBoxLayout() + header.setSpacing(11) + if icon is not None: + header.addWidget(IconBox(icon, card, size=34)) + self.titleLabel = QLabel(title, card) + self.titleLabel.setObjectName("appDialogTitle") + apply_font(self.titleLabel, 16, 860) + header.addWidget(self.titleLabel) + header.addStretch(1) + self.closeButton = RoundIconButton(AppIcon.CLOSE, parent=card) + self.closeButton.clicked.connect(lambda: self.done(0)) + header.addWidget(self.closeButton) + self.cardLayout.addLayout(header) + + self.headDivider = QFrame(card) + self.headDivider.setObjectName("appDialogDivider") + self.headDivider.setFixedHeight(1) + self.cardLayout.addWidget(self.headDivider) + + self.bodyLayout = QVBoxLayout() + self.bodyLayout.setContentsMargins(0, 0, 0, 0) + self.bodyLayout.setSpacing(10) + self.cardLayout.addLayout(self.bodyLayout) + + self.footerLayout = QHBoxLayout() + self.footerLayout.setContentsMargins(0, 3, 0, 0) + self.footerLayout.setSpacing(10) + self.cardLayout.addLayout(self.footerLayout) + + self.syncStyle() + + # ------------------------------------------------------------- helpers + + def addBodyText(self, text: str) -> QLabel: + """正文段落:可换行、可选中的次级文字。""" + label = QLabel(text, self.widget) + label.setObjectName("appDialogBodyText") + label.setWordWrap(True) + label.setTextInteractionFlags(Qt.TextSelectableByMouse) # type: ignore[arg-type] + apply_font(label, 13, 600) + self.bodyLayout.addWidget(label) + return label + + def addFooterButton( + self, text: str, *, kind: str = "plain", icon: AppIcon | None = None + ) -> CompactButton: + """底栏按钮,从左到右排列;kind: plain / accent / danger。""" + cls = {"plain": CompactButton, "accent": AccentButton, "danger": DangerButton}[ + kind + ] + button = cls(text, icon, self.widget) + self.footerLayout.addWidget(button) + return button + + def addFooterStretch(self): + self.footerLayout.addStretch(1) + + # --------------------------------------------------------------- style + + def extraStyleRules(self, palette) -> str: + """子类追加卡片内部样式规则。""" + return "" + + def syncStyle(self): + palette = app_palette() + self.widget.setStyleSheet( + f""" + QWidget#appDialogCard {{ + background: {palette.panel}; + border: 1px solid {palette.line}; + border-radius: 16px; + }} + QLabel#appDialogTitle {{ color: {palette.text}; background: transparent; }} + QFrame#appDialogDivider {{ background: {palette.line_soft}; border: none; }} + QLabel#appDialogBodyText {{ color: {palette.muted}; background: transparent; }} + QLabel#appDialogSectionLabel {{ color: {palette.subtle}; background: transparent; }} + """ + + self.extraStyleRules(palette) + ) + + +class ConfirmDialog(AppDialog): + """标准确认框:确认返回 1,取消/Esc/关闭返回 0。 + + cancel_text 传 None 只留一个确认按钮(公告式)。danger=True 时 + 确认按钮用危险样式(删除/清空等不可逆操作)。 + """ + + def __init__( + self, + title: str, + message: str, + parent: QWidget | None = None, + *, + confirm_text: str | None = None, + cancel_text: str | None = _UNSET, # type: ignore[assignment] + danger: bool = False, + icon: AppIcon | None = None, + width: int = 430, + ): + super().__init__(title, icon=icon, parent=parent, width=width) + # 默认文案在运行时取,跟随当前语言;显式传 None 表示隐藏取消钮 + if confirm_text is None: + confirm_text = tr("common.ok") + if cancel_text is _UNSET: + cancel_text = tr("common.cancel") + self.messageLabel = self.addBodyText(message) + self.addFooterStretch() + self.cancelButton: CompactButton | None = None + if cancel_text is not None: + self.cancelButton = self.addFooterButton(cancel_text) + self.cancelButton.clicked.connect(lambda: self.done(0)) + self.confirmButton = self.addFooterButton( + confirm_text, kind="danger" if danger else "accent" + ) + self.confirmButton.clicked.connect(lambda: self.done(1)) + + +class InputDialog(AppDialog): + """单行文本输入框:确认返回 1(``value()`` 取文本),取消/Esc/关闭返回 0。 + + 回车即确认,打开即聚焦并全选既有文本(重命名场景方便直接改)。 + """ + + def __init__( + self, + title: str, + *, + text: str = "", + placeholder: str = "", + parent: QWidget | None = None, + confirm_text: str | None = None, + cancel_text: str | None = None, + icon: AppIcon | None = None, + width: int = 430, + ): + super().__init__(title, icon=icon, parent=parent, width=width) + # 默认按钮文案在运行时取,跟随当前语言 + if confirm_text is None: + confirm_text = tr("common.ok") + if cancel_text is None: + cancel_text = tr("common.cancel") + self.edit = AppLineEdit(text, self.widget) + if placeholder: + self.edit.setPlaceholderText(placeholder) + self.edit.returnPressed.connect(lambda: self.done(1)) # 回车=确认 + self.bodyLayout.addWidget(self.edit) + self.addFooterStretch() + self.cancelButton = self.addFooterButton(cancel_text) + self.cancelButton.clicked.connect(lambda: self.done(0)) + self.confirmButton = self.addFooterButton(confirm_text, kind="accent") + self.confirmButton.clicked.connect(lambda: self.done(1)) + self.edit.setFocus() + self.edit.selectAll() + + def value(self) -> str: + return self.edit.text().strip() diff --git a/videocaptioner/ui/components/caption_overlay.py b/videocaptioner/ui/components/caption_overlay.py new file mode 100644 index 00000000..a6ffbbcf --- /dev/null +++ b/videocaptioner/ui/components/caption_overlay.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +"""程序外的实时字幕浮窗(无边框、置顶、半透明)。 + +两态:standard(紧凑,只显双色当前段落,适合盖视频)/ tall(时间线转录,看历史)。 +颜色是浮窗自己的暗色玻璃体系,不随程序主题变化。分段由 core/realtime 负责,本模块只显示。 +""" + +from __future__ import annotations + +import sys +import time +from dataclasses import dataclass +from typing import Dict, Optional + +from PyQt5.QtCore import QEvent, QPoint, QRect, QSize, Qt, QTimer, pyqtSignal +from PyQt5.QtGui import QColor, QFontMetrics, QIcon, QPainter, QPainterPath, QPen, QPixmap +from PyQt5.QtSvg import QSvgRenderer +from PyQt5.QtWidgets import ( + QApplication, + QFrame, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from videocaptioner.ui.components.live_caption.typing import TYPE_INTERVAL_MS, next_visible +from videocaptioner.ui.components.workbench import apply_font +from videocaptioner.ui.i18n import tr + +# ----- 暗色玻璃配色 ----- +BRAND = "#28f08b" +BRAND_SOFT = "rgba(40,240,139,0.14)" +WARN = "#f5c451" +C_TEXT = "#f5f7f6" +C_MUTED = "#b9c0bd" +C_SUBTLE = "#8e9692" + +CARD_BG = { + "translucent": QColor(18, 20, 21, 224), # 半透明(默认) + "black": QColor(8, 9, 9, 248), # 纯黑(不透明) +} +CARD_BORDER = QColor(255, 255, 255, 20) +CARD_BORDER_HOVER = QColor(255, 255, 255, 36) +CARD_BORDER_DRAG = QColor(40, 240, 139, 128) + +# 译文恒单色(整句重译);双色只属于原文:亮=已听准、暗=修正中。 +TGT_COLOR = "rgba(245,247,246,0.92)" +SRC_BASE = "rgba(220,226,224,0.58)" +SRC_STABLE = "rgba(220,226,224,0.85)" + +# ----- 模式 ----- +MODE_STANDARD = "standard" +MODE_TALL = "tall" + +DISPLAY_BILINGUAL = "bilingual" +DISPLAY_TARGET = "target" +DISPLAY_SOURCE = "source" + +# 两态同宽:切历史时只变高、不左右抖。 +_WIDTHS = {MODE_STANDARD: 540, MODE_TALL: 540} +_TGT_PX = {MODE_STANDARD: 20, MODE_TALL: 19} +_SRC_PX = {MODE_STANDARD: 15, MODE_TALL: 15} + +MARGIN = 26 # 卡片四周留给阴影的透明边距 +TOOLBAR_H = 40 # 工具条常驻高度 +_STANDARD_H = 140 # 标准态默认/最小高度:容单句 + 工具条 +_STANDARD_MAX_H = 300 # 标准态自适应上限:超出则内部滚动 +_TALL_H = 340 # 转录态默认高度(用户可拖) +_STD_CHROME = TOOLBAR_H + 10 # 标准态非内容高度:工具条 + 历史区上下边距 +_HIST_HMARGIN = 24 # 历史区左右边距(当前段落可用宽 = 卡宽 - 它) + +# ----- 转录卡片配色(段落卡片:暗色玻璃描边;当前段落主题绿点缀) ----- +ITEM_BG = QColor(255, 255, 255, 8) # 历史段落卡片底(极淡) +ITEM_BORDER = QColor(255, 255, 255, 20) +CUR_BG = QColor(40, 240, 139, 22) # 当前段落卡片底(主题绿淡染) +CUR_BORDER = QColor(40, 240, 139, 110) # 当前段落描边(主题绿) +RAIL_LINE = QColor(255, 255, 255, 28) # 时间线连线 +RAIL_DOT = QColor(255, 255, 255, 40) # 历史头像点底 + +# ----- 图标(描边 stroke 风格) ----- +_ICON = { + "pause": '', + "play": '', + "history": '', + "pin": '', + "gear": '', + "close": '', + "copy": '', + "star": '', + "chevron-up": '', + "resize": '', +} + + +def make_icon(key: str, color: str, stroke: float = 1.9, size: int = 18) -> QIcon: + """把描边图标渲染成 DPR 感知的 QIcon。""" + svg = ( + f'{_ICON[key]}' + ) + renderer = QSvgRenderer(bytearray(svg, encoding="utf-8")) + dpr = 2 + pm = QPixmap(size * dpr, size * dpr) + pm.fill(Qt.transparent) # type: ignore[arg-type] + painter = QPainter(pm) + renderer.render(painter) + painter.end() + pm.setDevicePixelRatio(dpr) + icon = QIcon(pm) + return icon + + +def _escape(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _lh(escaped: str, color: str, percent: int = 150) -> str: + """给已转义文本包一层行距+颜色的富文本(QLabel 默认行距太挤)。""" + return f'
{escaped or " "}
' + + +def _dual(stable: str, floaty: str, stable_color: str, floaty_color: str) -> str: + """拼出「已听准(亮)+ 修正中(暗)」的双色富文本。""" + out = "" + if stable: + out += f'{_escape(stable)}' + if floaty: + out += f'{_escape(floaty)}' + return out or '' % stable_color + + +@dataclass +class _Row: + """浮窗内部维护的一条字幕显示数据。""" + + seg_id: str + seq: int + source_text: str + source_stable_len: int + target_text: str + is_final: bool + started_at: float # 段落开始的真实时间(epoch 秒),显示为 HH:MM + starred: bool = False + + +class ToolButton(QPushButton): + """工具条/逐句操作的等距描边图标按钮。""" + + def __init__( + self, + key: str, + size: int = 30, + icon_size: int = 15, + normal: str = C_MUTED, + hover: str = C_TEXT, + hover_bg: str = "rgba(255,255,255,0.08)", + parent=None, + ) -> None: + super().__init__(parent) + self._key = key + self._icon_size = icon_size + self._normal = normal + self._hover = hover + self._active = False + self.setFixedSize(size, size) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + self.setIconSize(self.iconSizeHint(icon_size)) + self.setStyleSheet( + f"QPushButton{{border:0;border-radius:{round(size*0.26)}px;background:transparent;}}" + f"QPushButton:hover{{background:{hover_bg};}}" + ) + self._refresh(self._normal) + + @staticmethod + def iconSizeHint(icon_size: int): + from PyQt5.QtCore import QSize + + return QSize(icon_size, icon_size) + + def _refresh(self, color: str) -> None: + self.setIcon(make_icon(self._key, color, size=self._icon_size)) + + def set_key(self, key: str) -> None: + """换图标(如暂停↔播放),保持当前 active/normal 配色。""" + self._key = key + self._refresh(BRAND if self._active else self._normal) + + def setActive(self, active: bool) -> None: + self._active = active + if active: + self.setStyleSheet( + f"QPushButton{{border:0;border-radius:{round(self.width()*0.26)}px;" + f"background:{BRAND_SOFT};}}" + ) + self._refresh(BRAND) + else: + self.setStyleSheet( + f"QPushButton{{border:0;border-radius:{round(self.width()*0.26)}px;" + f"background:transparent;}}" + f"QPushButton:hover{{background:rgba(255,255,255,0.08);}}" + ) + self._refresh(self._normal) + + def enterEvent(self, event) -> None: + if not self._active: + self._refresh(self._hover) + super().enterEvent(event) + + def leaveEvent(self, event) -> None: + if not self._active: + self._refresh(self._normal) + super().leaveEvent(event) + + +def _fmt_clock(epoch: float) -> str: + """段落起始 epoch 秒 → HH:MM。""" + try: + return time.strftime("%H:%M", time.localtime(epoch)) if epoch else "" + except Exception: + return "" + + +class _TranscriptItem(QWidget): + """时间线转录里的一条段落:左头像轨(连线 + 圆点)+ 右卡片(时间 / 原文 / 译文)。 + + 当前段落(``current``)主题绿高亮 + 双色;历史段落暗色玻璃描边。 + 轨道、卡片底/描边全在 paintEvent 自绘(QWidget 的 QSS 圆角/描边不可靠)。 + """ + + _RAIL_W = 36 # 左侧头像轨宽度 + + def __init__(self, overlay: "CaptionOverlay", current: bool = False) -> None: + super().__init__(overlay) + self._overlay = overlay + self._current = current + self._first = False + self._last = False + self._placeholder = False # 空态占位:不画卡片/轨道,仅一行暗字 + self._bare = False # 标准态:无卡片底/轨道/时间,仅双色文字 + self._content_src = "" # 算自适应高度用的完整原文 + self._content_tgt = "" # 算自适应高度用的完整译文(按整段定高,打字时不抖) + self._shown = 0 # 当前段原文已逐字揭示字数 + self._tgt_text = "" # 译文逐字动画当前可见串(前缀不动、尾字原位改写、只前进) + self._h_floor = 0 # 当前段卡高地板:动画期间只增不减,防重译变短时浮窗跳 + self._h_floor_w = -1 # 地板对应宽度;宽度变则重置 + self._last_seg = "" + self._last_row: Optional["_Row"] = None + self._typer = QTimer(self) + self._typer.setInterval(TYPE_INTERVAL_MS) # 速度集中在 typing.py + self._typer.timeout.connect(self._advance_typing) + self.setMouseTracking(True) + + row = QHBoxLayout(self) + row.setContentsMargins(0, 5, 2, 5) + row.setSpacing(0) + self._rail = QWidget(self) # 头像轨占位(bare 时隐藏;连线/圆点在 paintEvent 自绘) + self._rail.setFixedWidth(self._RAIL_W) + row.addWidget(self._rail) + + self._card = QFrame(self) + self._card.setStyleSheet("background:transparent;border:0;") + self._cl = QVBoxLayout(self._card) + cl = self._cl + cl.setContentsMargins(16, 10, 16, 12) + cl.setSpacing(5) + self._time = QLabel(self._card) + apply_font(self._time, 11, 700) + self._time.setStyleSheet(f"color:{C_SUBTLE};background:transparent;") + self._time.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[operator] + self._src = QLabel(self._card) + self._src.setWordWrap(True) + self._src.setTextFormat(Qt.RichText) # type: ignore[arg-type] + self._src.setStyleSheet("background:transparent;") + self._tgt = QLabel(self._card) + self._tgt.setWordWrap(True) + self._tgt.setTextFormat(Qt.RichText) # type: ignore[arg-type] + self._tgt.setStyleSheet("background:transparent;") + apply_font(self._src, 13, 500) + apply_font(self._tgt, 16, 700) + # 时间在 paintEvent 里画到左侧竖条;_time 仅作数据 holder(存文本/字体),永不可见。 + self._time.hide() + cl.addWidget(self._src) + cl.addWidget(self._tgt) + row.addWidget(self._card, 1) + + # 卡片内容铺满浮窗会吞掉按下;装浮窗为事件过滤器,才能在文字上拖动/缩放。 + overlay._filter_item(self) + + # ----- 内容 ----- + + def set_bare(self, bare: bool) -> None: + """标准态:去掉卡片底/轨道/时间,只留双色文字(紧凑、盖视频)。""" + self._bare = bare + self._rail.setVisible(not bare) + self._cl.setContentsMargins(*((8, 12, 12, 8) if bare else (16, 10, 16, 12))) + self.update() + + def apply_fonts(self, src_px: int, tgt_px: int) -> None: + """随浮窗形态/字号缩放刷新原文、译文字号(历史与当前段落统一)。""" + apply_font(self._src, src_px, 500) + apply_font(self._tgt, tgt_px, 700) + self.update() + + def set_placeholder(self, text: str) -> None: + """空态占位(如「监听中…」):朴素一行暗字,无卡片底/描边/轨道。""" + self._placeholder = True + self._rail.setVisible(False) + self._time.setVisible(False) + self._src.setVisible(False) + # 水平居中(无轨道→占满整宽);竖直居中由 _set_centered 控制 + self._tgt.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self._tgt.setText(_lh(_escape(text), C_SUBTLE, 150)) + self._tgt.setVisible(True) + self._tgt.setMinimumHeight(0) + self._content_src, self._content_tgt = "", text + self._h_floor, self._h_floor_w = 0, -1 # 会话边界:卡高地板清零 + self.update() + + def set_content(self, row: "_Row", first: bool, last: bool) -> None: + self._placeholder = False + self._rail.setVisible(not self._bare) # 从占位态切回内容时恢复轨道 + self._tgt.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) # type: ignore[arg-type] # 占位居中→内容左对齐 + self._first, self._last = first, last + self._last_row = row + # _time 永不显示,文本仅供 paintEvent 在左侧竖条里画。 + self._time.setText("" if self._bare else _fmt_clock(row.started_at)) + if self._current: + if row.seg_id != self._last_seg: # 新句:揭示/译文动画从头,卡高地板重置 + self._last_seg, self._shown, self._tgt_text = row.seg_id, 0, "" + self._h_floor, self._h_floor_w = 0, -1 + self._shown = min(self._shown, len(row.source_text)) + if self._shown < len(row.source_text) or self._tgt_text != row.target_text: + self._typer.start() + else: + self._typer.stop() + else: + self._tgt_text = row.target_text # 历史段:译文整段直接显示 + self._typer.stop() + + display = self._overlay._display + if self._current: # 当前段落:原文双色 + 逐字揭示 + self._paint_src(row) + else: + self._src.setText(_lh(_escape(row.source_text), "rgba(220,226,224,0.56)", 150)) + has_tgt = bool(row.target_text) and row.target_text.strip() != row.source_text.strip() + if has_tgt or self._tgt_text: + self._tgt.setText(_lh(_escape(self._tgt_text), TGT_COLOR, 124)) # 渲染动画串而非整段 + else: + self._tgt.setText("") + + show_src = display != DISPLAY_TARGET and bool(row.source_text) + # 译文按「是否真有译文」显隐、不预留空行。固定窗高 + upsert 保留上次译文,故译文 + # 出现后不消失、不闪;被清空时仍让分叉后缀逐字删完再隐藏。 + show_tgt = display != DISPLAY_SOURCE and (has_tgt or bool(self._tgt_text)) + if not show_src and not show_tgt and row.source_text: + show_src = True + self._src.setVisible(show_src) + self._tgt.setVisible(show_tgt) + self._tgt.setMinimumHeight(0) + self._content_src = row.source_text if show_src else "" + # 高度按完整译文定(打字不抖);清空/删后缀时退用可见串,避免高度先于文字塌 + self._content_tgt = (row.target_text or self._tgt_text) if show_tgt else "" + self.update() + + def _paint_src(self, row: "_Row") -> None: + """当前段原文按已揭示字数 _shown 渲染双色(亮=已听准、暗=修正中)。""" + vis = row.source_text[: self._shown] + cut = min(row.source_stable_len, len(vis)) + self._src.setText(_dual(vis[:cut], vis[cut:], SRC_STABLE, SRC_BASE)) + + def _advance_typing(self) -> None: + row = self._last_row + if row is None or not self._current: + self._typer.stop() + return + busy = False + full = len(row.source_text) + if self._shown < full: # 原文逐字揭示 + remaining = full - self._shown + # 日常 step=1;积压 >24 字时按比例加速消化,保实时 + self._shown = min(full, self._shown + (remaining // 6 if remaining > 24 else 1)) + self._paint_src(row) + busy = True + if self._tgt_text != row.target_text: # 译文逐字补、只前进不缩短 + self._tgt_text = next_visible(self._tgt_text, row.target_text) + self._tgt.setText(_lh(_escape(self._tgt_text), TGT_COLOR, 124)) + busy = True + if not busy: + self._typer.stop() + + def content_height(self, width: int) -> int: + """内容在给定卡片宽度下的真实换行高度,供标准态自适应。 + + 用 QFontMetrics 算而非 heightForWidth(未布局返回 -1);用内容意图字段判可见而非 + isVisible()(窗口未 show 时恒 False)。 + """ + m = self._cl.contentsMargins() + inner = max(1, width - m.left() - m.right()) + h = m.top() + m.bottom() + rows = [] + if self._time.text(): + rows.append((self._time.font(), self._time.text())) + if self._content_src: + rows.append((self._src.font(), self._content_src)) + if self._content_tgt: + rows.append((self._tgt.font(), self._content_tgt)) + for i, (font, text) in enumerate(rows): + rect = QFontMetrics(font).boundingRect( + 0, 0, inner, 100000, Qt.TextWordWrap, text # type: ignore[attr-defined] + ) + h += rect.height() + if i: + h += self._cl.spacing() + # 当前段动画期间卡高只增不减(重译变短/删后缀时不塌);宽度变则重置地板。 + if self._current: + if width != self._h_floor_w: + self._h_floor_w, self._h_floor = width, 0 + self._h_floor = max(self._h_floor, h) + return self._h_floor + return h + + def paintEvent(self, event) -> None: + if self._placeholder or self._bare: # 空态/标准态:不画卡片底与轨道,仅文字 + return + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + cr = self._card.geometry() + # 卡片底 + 描边 + path = QPainterPath() + path.addRoundedRect(cr.x() + 0.5, cr.y() + 0.5, cr.width() - 1, cr.height() - 1, 12, 12) + p.fillPath(path, CUR_BG if self._current else ITEM_BG) + p.setPen(QPen(CUR_BORDER if self._current else ITEM_BORDER, 1)) + p.drawPath(path) + # 时间线:连线 + 圆点 + 圆点下方的时间(连线在时间处让开,不穿字) + cx = self._RAIL_W // 2 + cy = cr.top() + 15 + has_time = (not self._bare) and bool(self._time.text()) + p.setPen(QPen(RAIL_LINE, 2)) + if not self._first: + p.drawLine(cx, 0, cx, cy) + if not self._last: + p.drawLine(cx, cy + (26 if has_time else 8), cx, self.height()) + p.setPen(Qt.NoPen) # type: ignore[arg-type] + if self._current: + p.setBrush(QColor(BRAND)) + p.drawEllipse(QPoint(cx, cy), 7, 7) + p.setBrush(QColor(6, 24, 16)) + p.drawEllipse(QPoint(cx, cy), 3, 3) + else: + p.setBrush(RAIL_DOT) + p.drawEllipse(QPoint(cx, cy), 5, 5) + if has_time: + p.setPen(QColor(C_SUBTLE)) + p.setFont(self._time.font()) + p.drawText(QRect(0, cy + 9, self._RAIL_W, 14), + Qt.AlignHCenter | Qt.AlignTop, self._time.text()) # type: ignore[operator] + + +class _Card(QFrame): + """承载所有内容的圆角半透明卡片(自绘背景 + 边框)。""" + + def __init__(self, overlay: "CaptionOverlay") -> None: + super().__init__(overlay) + self._overlay = overlay + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(46) + shadow.setOffset(0, 16) + shadow.setColor(QColor(0, 0, 0, 150)) + self.setGraphicsEffect(shadow) + # 卡片不透明盖住浮窗本体,鼠标事件落不到浮窗:光标/拖拽全转交浮窗。 + # 开 tracking 才能在悬浮卡片上更新光标(边缘→缩放)。 + self.setMouseTracking(True) + self.setCursor(Qt.OpenHandCursor) # type: ignore[attr-defined] + + def enterEvent(self, event) -> None: + # 进入即定光标:macOS Tool 窗的静态光标要等下次移动才生效,主动 setCursor 才马上变。 + self._overlay._apply_hover_cursor(self.mapToParent(event.pos())) + super().enterEvent(event) + + def mouseMoveEvent(self, event) -> None: + if not (event.buttons() & Qt.LeftButton): # type: ignore[attr-defined] + self._overlay._apply_hover_cursor(self.mapToParent(event.pos())) + super().mouseMoveEvent(event) + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self._overlay._begin_drag_or_resize(self.mapToParent(event.pos())) + return + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event) -> None: + self.setCursor(Qt.OpenHandCursor) # type: ignore[attr-defined] + super().mouseReleaseEvent(event) + + def paintEvent(self, event) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = self.rect().adjusted(0, 0, -1, -1) + radius = 18 + path = QPainterPath() + path.addRoundedRect( + rect.x() + 0.5, rect.y() + 0.5, rect.width() - 1, rect.height() - 1, radius, radius + ) + painter.fillPath(path, CARD_BG[self._overlay.bg_style]) + painter.setPen(QPen(self._overlay.current_border(), 1)) + painter.drawPath(path) + + +# 边缘缩放命中区(卡片坐标系,距边 px)。命中判定外延到 MARGIN 阴影区,让用户在看得见 +# 的圆角边缘就能抓到缩放。 +_EDGE = 14 # 中段边缘命中带(偏大,好抓) +_CORNER = 26 # 角落命中半径(整片圆角触发斜向缩放,对齐 MARGIN) +_LEFT, _RIGHT, _TOP, _BOTTOM = 1, 2, 4, 8 + + +class CaptionOverlay(QWidget): + """实时字幕浮窗。程序外独立顶层窗口,由控制页/线程管理生命周期。""" + + requestClose = pyqtSignal() # 关闭 = 真停止 + pauseToggled = pyqtSignal(bool) + pinToggled = pyqtSignal(bool) + requestSettings = pyqtSignal() + displayModeChanged = pyqtSignal(str) + bgStyleChanged = pyqtSignal(str) + fontScaleChanged = pyqtSignal(int) + # 形态/尺寸持久化:用户切换展开/收纳或拖边缩放后,控制页存到配置,下次启动恢复 + modeChanged = pyqtSignal(str) + sizeChanged = pyqtSignal(int, int) # (卡片宽, 卡片高) 当前形态的用户尺寸 + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setWindowFlags( + Qt.FramelessWindowHint # type: ignore[arg-type] + | Qt.WindowStaysOnTopHint + | Qt.Tool + ) + self.setAttribute(Qt.WA_TranslucentBackground) # type: ignore[arg-type] + self.setMouseTracking(True) + self.setCursor(Qt.OpenHandCursor) # type: ignore[attr-defined] # 本体可拖 → 抓手 + + self._mode = MODE_STANDARD + self._display = DISPLAY_BILINGUAL + self._bg_style = "translucent" + self._hovered = False + self._paused = False + self._pinned = False + + self._rows: Dict[str, _Row] = {} + self._starred: set[str] = set() + self._hist_items: Dict[str, "_TranscriptItem"] = {} # 历史段落卡片,按 seg_id 增量维护 + self._font_scale = 1.0 + self._settings_popover = None + self._last_size: Optional[QSize] = None # 仅在尺寸真变化时才 resize + self._history_sig: tuple = () # 历史集合签名,变化才重建 + self._history_follow = True # 历史是否自动跟到最新(用户上翻则停跟) + self._interacting = False # 拖动/缩放中(用于边框高亮) + # 用户拖边的卡片尺寸按形态各记一份,回到该形态即恢复 + self._user_sizes: Dict[str, tuple] = {} + self._applying_size = False # 区分"我们改尺寸" vs "用户拖动缩放" + self._drag_pos: Optional[QPoint] = None # 原生移动不可用时的兜底 + # macOS 无边框 Tool 窗不支持系统级拖动/缩放(startSystemResize/Move 返回 False)→ + # 手动兜底:抓鼠标 + 按全局鼠标位移改窗几何。 + self._drag_mode: Optional[str] = None # "move" / "resize" / None + self._resize_edge = 0 + self._drag_origin = QPoint() # 起手时的全局鼠标位 + self._drag_geo = QRect() # 起手时的窗口几何 + + # 合批渲染:流式 delta 很密,逐帧 resize 半透明置顶窗(含阴影重绘)会卡 → 聚合到 ~33ms。 + self._render_timer = QTimer(self) + self._render_timer.setSingleShot(True) + self._render_timer.setInterval(33) + self._render_timer.timeout.connect(self._flush_layout) + # 拖边缩放是逐像素事件,落盘去抖:停手 ~500ms 后才发 sizeChanged + self._persist_timer = QTimer(self) + self._persist_timer.setSingleShot(True) + self._persist_timer.setInterval(500) + self._persist_timer.timeout.connect(self._emit_size_changed) + + self._build() + self._apply_mode() + self._update_chrome() + self._render_current() # 立即显示占位,避免一开始是空细条 + + # ----- 对外属性 ----- + + @property + def mode(self) -> str: + return self._mode + + @property + def bg_style(self) -> str: + return self._bg_style + + def current_border(self) -> QColor: + if self._interacting: + return CARD_BORDER_DRAG + return CARD_BORDER_HOVER if self._hovered else CARD_BORDER + + # ----- 构建 ----- + + def _build(self) -> None: + outer = QVBoxLayout(self) + outer.setContentsMargins(MARGIN, MARGIN, MARGIN, MARGIN) + self._card = _Card(self) + outer.addWidget(self._card) + + col = QVBoxLayout(self._card) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(0) + + # 历史区(拉高 / 侧停才显示) + self._history = QScrollArea(self._card) + self._history.setWidgetResizable(True) + self._history.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + self._history.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # type: ignore[arg-type] + self._history.setFrameShape(QFrame.NoFrame) + self._history.setStyleSheet( + "QScrollArea{background:transparent;border:0;}" + "QScrollBar:vertical{width:6px;background:transparent;margin:2px;}" + "QScrollBar::handle:vertical{background:rgba(255,255,255,0.18);border-radius:3px;}" + "QScrollBar::add-line,QScrollBar::sub-line{height:0;}" + ) + self._history_inner = QWidget() + self._history_inner.setStyleSheet("background:transparent;") + self._history_layout = QVBoxLayout(self._history_inner) + self._history_layout.setContentsMargins(12, 4, 12, 6) + self._history_layout.setSpacing(4) + # 顶/底各一条 stretch:转录态底部 stretch=0 → 段落从下往上堆、最新一段贴底(聊天式); + # 标准态底部 stretch=1 → 当前单句在窗口内垂直居中。由 _set_centered 按形态切换底部因子。 + self._history_layout.addStretch(1) + # 当前段落:常驻转录最底部的高亮卡片,随说话原地更新 + self._cur_item = _TranscriptItem(self, current=True) + self._history_layout.addWidget(self._cur_item) + self._history_layout.addStretch(0) + self._history.setWidget(self._history_inner) + col.addWidget(self._history, 1) + # 历史自动跟随:内容增长(rangeChanged)且仍跟随就贴底;用户上翻(valueChanged 离底)停跟, + # 滚回底部再恢复(聊天式)。 + _hsb = self._history.verticalScrollBar() + _hsb.rangeChanged.connect(self._on_history_range) + _hsb.valueChanged.connect(self._on_history_scroll) + + # 历史 / 当前句分隔线(仅拉高·侧停显示) + self._sep = QFrame(self._card) + self._sep.setFixedHeight(1) + self._sep.setStyleSheet("background:rgba(255,255,255,0.09);border:0;") + self._sep.hide() + col.addWidget(self._sep) + + # 底部工具条:常驻、固定高度,hover 只显隐按钮(卡片高度不随 hover 变)。 + # 左 暂停/播放;右 历史 / 设置 / 关闭。 + self._toolbar = QWidget(self._card) + self._toolbar.setFixedHeight(TOOLBAR_H) + bar = QHBoxLayout(self._toolbar) + bar.setContentsMargins(12, 5, 12, 7) + bar.setSpacing(2) + # 暂停指示:暂停时常显(不靠 hover),居中,显隐不挤动左右按钮。 + self._paused_label = QLabel(tr("overlay.paused")) + apply_font(self._paused_label, 12, 750) + self._paused_label.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self._paused_label.setStyleSheet(f"color:{WARN};background:transparent;") + self._paused_label.hide() + self._btn_pause = ToolButton("pause") + self._btn_pause.clicked.connect(self._toggle_pause) + self._btn_hist = ToolButton("history") + self._btn_hist.clicked.connect(self._toggle_history) + self._btn_gear = ToolButton("gear") + self._btn_gear.clicked.connect(self._show_settings) + self._btn_close = ToolButton( + "close", hover="#ff7a7f", hover_bg="rgba(255,122,127,0.12)" + ) + self._btn_close.clicked.connect(self._on_close) + bar.addWidget(self._btn_pause) + bar.addStretch(1) + bar.addWidget(self._paused_label) # 居中 + bar.addStretch(1) + for b in (self._btn_hist, self._btn_gear, self._btn_close): + bar.addWidget(b) + # hover 才显隐的按钮集合(暂停指示另行处理) + self._chrome_buttons = ( + self._btn_pause, self._btn_hist, self._btn_gear, self._btn_close, + ) + col.addWidget(self._toolbar) + # 缩放靠拖任意边缘(见 _install_edge_filter),无右下角手柄角标。 + + # ----- 模式 / 形态 ----- + + def set_mode(self, mode: str) -> None: + changed = mode != self._mode + self._mode = mode + self._apply_mode() + self._update_chrome() + self._render_current() + if changed: + self.modeChanged.emit(mode) # 持久化 + + def _set_centered(self, centered: bool) -> None: + """转录区底部 stretch 因子:=1 与顶部对称 → 内容垂直居中(标准态 / 空态占位); + =0 → 底对齐(转录态时间线,最新一段贴底)。""" + self._history_layout.setStretch(self._history_layout.count() - 1, 1 if centered else 0) + + def _apply_mode(self) -> None: + mode = self._mode + # 标准/转录共用同一滚动转录区(当前段落常驻底部,内容溢出则段内滚动,窗高固定)。 + # 标准态:当前段落 bare(无卡片/轨道,紧凑盖视频)、永不显示滚动条;转录态:完整时间线。 + self._history.setVerticalScrollBarPolicy( + Qt.ScrollBarAlwaysOff if mode == MODE_STANDARD else Qt.ScrollBarAsNeeded # type: ignore[arg-type] + ) + self._sep.setVisible(False) # 转录用卡片间距分隔,不要细线 + self._cur_item.set_bare(mode == MODE_STANDARD) + self._set_centered(mode == MODE_STANDARD) # 标准态居中、转录态底对齐 + self._apply_item_fonts() + self._relayout() + + def _apply_item_fonts(self) -> None: + """把当前形态/字号缩放后的字号刷到所有转录段落卡片(当前 + 历史)。""" + src_px = round(_SRC_PX[self._mode] * self._font_scale) + tgt_px = round(_TGT_PX[self._mode] * self._font_scale) + self._cur_item.apply_fonts(src_px, tgt_px) + for w in self._hist_items.values(): + w.apply_fonts(src_px, tgt_px) + + def restore_size(self, mode: str, cw: int, ch: int) -> None: + """从配置恢复某形态的用户拖边尺寸(>0 才生效),应在 set_mode 之后调用。""" + if cw > 0 and ch > 0: + self._user_sizes[mode] = (cw, ch) + if mode == self._mode: + self._relayout() + + def set_display_mode(self, display: str) -> None: + # 双语/仅译文/仅原文由设置弹层切换 + self._display = display + self._render_current() + self.displayModeChanged.emit(display) + + def set_bg_style(self, style: str) -> None: + if style in CARD_BG: + self._bg_style = style + self._card.update() + self.bgStyleChanged.emit(style) + + # ----- 悬浮门控:静默只剩字幕,hover 才显隐按钮(卡片大小不变) ----- + + def _update_chrome(self) -> None: + show = self._hovered + for btn in self._chrome_buttons: + btn.setVisible(show) + # 暂停指示常显(不靠 hover),位置已由常驻工具条预留,不改变尺寸 + self._paused_label.setVisible(self._paused) + + # ----- 布局重算 / 浮动件定位 ----- + + def _card_size(self) -> tuple: + """卡片尺寸:固定默认 / 用户拖动值,不随内容逐帧跳动(译文出现/定稿/换行都不 resize 整窗, + 内容溢出在转录区内部滚动)。标准态例外:卡高随内容自适应(见下)。""" + mode = self._mode + user = self._user_sizes.get(mode) + cw = (user[0] if user else _WIDTHS.get(mode, 540)) + if mode == MODE_STANDARD and not user: + # 标准态卡高按当前段落真实换行自适应:内容少收回 _STANDARD_H、超上限才内部滚动; + # 译文保留不消失 + 无预留空行,故只随行数变、不逐帧抖。 + content = self._cur_item.content_height(cw - _HIST_HMARGIN) + ch = max(_STANDARD_H, min(content + _STD_CHROME, _STANDARD_MAX_H)) + return cw, ch + default_h = _TALL_H if mode == MODE_TALL else _STANDARD_H + ch = user[1] if user else default_h + return cw, ch + + def _relayout(self) -> None: + cw, ch = self._card_size() + # 显式固定卡片尺寸:_card.width() 立即正确,不依赖外层布局异步刷新 + if self._card.width() != cw or self._card.height() != ch: + self._card.setFixedSize(cw, ch) + # _cur_item 本体也吃「只增不减」地板:它无 sizeHint、高度由内部 QLabel 撑,否则重译 + # 变短时仍会缩(_card_size 的地板只护窗口外框)。 + self._cur_item.setMinimumHeight(self._cur_item.content_height(cw - _HIST_HMARGIN)) + target = QSize(cw + 2 * MARGIN, ch + 2 * MARGIN) + # 尺寸没变就不 resize 顶层窗:逐帧 resize 半透明置顶窗 + 阴影会卡/闪。 + if target != self._last_size: + prev = self._last_size + old_bottom = self.y() + self.height() + self._last_size = target + self._applying_size = True + self.setMinimumSize(260 + 2 * MARGIN, 56 + 2 * MARGIN) + self.resize(target) + # 底对齐增高:当前句+工具条留原地,向上长(不往下顶出屏幕)。首次布局(prev=None)不动。 + if prev is not None and target.height() != prev.height(): + new_y = old_bottom - target.height() + # 多屏:夹到浮窗所在屏顶部,而非全局 0(全局 0 在主屏,会把副屏浮窗拽回主屏)。 + screen = QApplication.screenAt(self.frameGeometry().center()) + top = (screen or QApplication.primaryScreen()).availableGeometry().top() + self.move(self.x(), max(top, new_y)) + self._applying_size = False + self._card.layout().activate() + + # ----- 渲染当前句 / 历史 ----- + + def _current_row(self) -> Optional[_Row]: + if not self._rows: + return None + return max(self._rows.values(), key=lambda r: r.seq) + + def _render_current(self) -> None: + """同步渲染:文字 + 历史 + 布局。供 set_mode/load_demo 等低频路径用。""" + self._render_text() + self._flush_layout() + + def _render_text(self) -> None: + """只刷当前段落(廉价、即时)。高频 delta 走这里,历史重建交给合批。 + + 当前段落是转录区底部的卡片,原地更新(标准 bare、转录完整时间线)。 + """ + row = self._current_row() + if row is None: + self._cur_item.set_placeholder(tr("overlay.listening")) # 空态朴素占位,不画卡片 + self._set_centered(True) # 空态文字水平+垂直居中 + return + self._set_centered(self._mode == MODE_STANDARD) # 标准居中 / 转录底对齐 + # 转录态上方有历史卡片时不画顶端连线(first=False);标准态恒为唯一一段 + has_hist = self._mode == MODE_TALL and any( + r.seg_id != row.seg_id for r in self._rows.values() + ) + self._cur_item.set_content(row, first=not has_hist, last=True) + + def _flush_layout(self) -> None: + """合批:重建历史(带签名守卫)+ 重排(带尺寸守卫)。""" + row = self._current_row() + self._rebuild_history(exclude=row.seg_id if row else "") + self._relayout() + + def _rebuild_history(self, exclude: str) -> None: + """增量维护历史段落卡片:新段落追加到 _cur_item 之前、已有卡片就地更新,不整列重建 + (整列重建会闪、滚动乱跳)。仅转录态显示;seq 单调递增故只追加、已有卡片永不重排。 + """ + if self._mode != MODE_TALL: + if self._hist_items: # 退出转录态:清掉历史卡片,下次进来重建 + for w in self._hist_items.values(): + w.setParent(None) + w.deleteLater() + self._hist_items.clear() + self._history_sig = () + return + rows = sorted( + (r for r in self._rows.values() if r.seg_id != exclude), key=lambda r: r.seq + ) + sig = tuple((r.seg_id, r.target_text, r.is_final) for r in rows) + if sig == self._history_sig: + return + self._history_sig = sig + alive = {r.seg_id for r in rows} + for sid in [s for s in self._hist_items if s not in alive]: # 删掉已不在的(如 clear) + w = self._hist_items.pop(sid) + w.setParent(None) + w.deleteLater() + src_px = round(_SRC_PX[self._mode] * self._font_scale) + tgt_px = round(_TGT_PX[self._mode] * self._font_scale) + for i, r in enumerate(rows): + item = self._hist_items.get(r.seg_id) + if item is None: # 新段落:建卡片插到 _cur_item 之前,永不重排已有卡片 + item = _TranscriptItem(self, current=False) + item.apply_fonts(src_px, tgt_px) + self._hist_items[r.seg_id] = item + # 插到 [_cur_item, 底部 stretch] 之前 = count-2 + self._history_layout.insertWidget(self._history_layout.count() - 2, item) + item.set_content(r, first=(i == 0), last=False) + + def _on_history_scroll(self, value: int) -> None: + # 用户拖到底=跟随;上翻=停跟(让用户安心回看,不被新内容拽走) + sb = self._history.verticalScrollBar() + self._history_follow = value >= sb.maximum() - 8 + + def _on_history_range(self, _min: int, maximum: int) -> None: + # 历史增长时若仍在跟随,自动滚到最新一行 + if self._history_follow: + self._history.verticalScrollBar().setValue(maximum) + + # ----- 公开 API ----- + + def upsert_caption(self, entry) -> None: + """按 seg_id 增量更新一条字幕(来自 CaptionEntry)。 + + 只更新数据 + 触发合批渲染:流式 delta 很密,逐帧渲染会卡,聚合到定时器一次。 + """ + # 防闪:当前帧 target 为空就保留上次译文,等新译文翻完再覆盖(避免译文行空↔有闪烁)。 + target = entry.target_text + if not target: + prev = self._rows.get(entry.seg_id) + if prev is not None and prev.target_text: + target = prev.target_text + self._rows[entry.seg_id] = _Row( + seg_id=entry.seg_id, + seq=entry.seq, + source_text=entry.source_text, + source_stable_len=entry.source_stable_len, + target_text=target, + is_final=entry.is_final, + started_at=entry.started_at, + starred=entry.seg_id in self._starred, + ) + self._render_text() # 文字即时上屏(廉价);历史/滚动/卡高等较重布局合批到 ~33ms 一次 + if not self._render_timer.isActive(): + self._render_timer.start() + + def set_paused(self, paused: bool) -> None: + self._paused = paused + # 暂停→播放三角(点击=恢复)+ 主题绿高亮;运行→暂停两竖条。用图标切换表达状态。 + self._btn_pause.set_key("play" if paused else "pause") + self._btn_pause.setActive(paused) + self._update_chrome() + + def clear(self) -> None: + self._rows.clear() + self._render_current() + + # ----- 工具条动作 ----- + + def _toggle_pause(self) -> None: + self.set_paused(not self._paused) + self.pauseToggled.emit(self._paused) + + def _toggle_history(self) -> None: + self.set_mode(MODE_STANDARD if self._mode == MODE_TALL else MODE_TALL) + + def set_pinned(self, pinned: bool) -> None: + """开/关鼠标穿透(由控制页的开关驱动;穿透后整窗点击失效)。""" + self._pinned = pinned + self.setAttribute(Qt.WA_TransparentForMouseEvents, pinned) # type: ignore[arg-type] + self.pinToggled.emit(pinned) + + def _show_settings(self) -> None: + if self._settings_popover is None: + from videocaptioner.ui.components.caption_overlay_settings import ( + OverlaySettingsPopover, + ) + + pop = OverlaySettingsPopover(self) # 传父:随浮窗 deleteLater 一起回收,不残留孤儿顶层窗口 + pop.displayChanged.connect(self.set_display_mode) + pop.bgStyleChanged.connect(self.set_bg_style) + pop.fontScaleChanged.connect(self._on_font_scale) + self._settings_popover = pop + # 打开前用当前状态刷新弹层各控件高亮(否则永远显示默认值) + font_value = round((self._font_scale - 0.8) / 0.6 * 100) + self._settings_popover.configure( + display=self._display, + bg_style=self._bg_style, + font_value=max(0, min(100, font_value)), + ) + anchor = self._btn_gear.mapToGlobal(QPoint(self._btn_gear.width() // 2, 0)) + self._settings_popover.open_at(anchor) + self.requestSettings.emit() + + def apply_font_scale(self, value: int) -> None: + # 0~100 → 0.8x ~ 1.4x。供控制页从配置初始化(不回写) + self._font_scale = 0.8 + value / 100.0 * 0.6 + self._apply_mode() + self._render_current() + + def _on_font_scale(self, value: int) -> None: + self.apply_font_scale(value) + self.fontScaleChanged.emit(value) # 持久化 + + def _on_close(self) -> None: + self.requestClose.emit() + self.hide() + + # ----- 悬浮 / 拖动 / 缩放 ----- + + def _refresh_hover(self) -> None: + """按全局指针是否落在 frameGeometry 内更新 hover。 + + 不用 Qt 的 enter/leave:指针越过子控件时会在父窗口上误触发 leave → 工具条按钮忽隐忽现。 + """ + from PyQt5.QtGui import QCursor + + hovered = self.frameGeometry().contains(QCursor.pos()) + if hovered != self._hovered: + self._hovered = hovered + self._update_chrome() + self._card.update() # 边框 hover 高亮跟随 + + def enterEvent(self, event) -> None: + self._refresh_hover() + self._apply_hover_cursor(event.pos()) # 进入即定光标(别等移动) + super().enterEvent(event) + + def leaveEvent(self, event) -> None: + self._refresh_hover() # 真移出窗口才隐藏;越过子控件不算离开 + super().leaveEvent(event) + + def _edge_at(self, pos: QPoint) -> int: + r = self._card.geometry() + # 命中区外延到阴影区:鼠标常落在卡片框外的圆角阴影里,外延后那里也算命中,才好抓。 + if not r.adjusted(-_CORNER, -_CORNER, _CORNER, _CORNER).contains(pos): + return 0 + dl = abs(pos.x() - r.left()) + dr = abs(pos.x() - r.right()) + dt = abs(pos.y() - r.top()) + db = abs(pos.y() - r.bottom()) + edge = 0 + if dl <= _EDGE: + edge |= _LEFT + if dr <= _EDGE: + edge |= _RIGHT + if dt <= _EDGE: + edge |= _TOP + if db <= _EDGE: + edge |= _BOTTOM + near_l, near_r = dl <= _CORNER, dr <= _CORNER + near_t, near_b = dt <= _CORNER, db <= _CORNER + if near_t and near_l: # 靠近某角则整片触发斜向缩放(补圆角中段判定抓不到) + edge = _TOP | _LEFT + elif near_t and near_r: + edge = _TOP | _RIGHT + elif near_b and near_l: + edge = _BOTTOM | _LEFT + elif near_b and near_r: + edge = _BOTTOM | _RIGHT + return edge + + def _cursor_for(self, edge: int): + if edge in (_LEFT | _TOP, _RIGHT | _BOTTOM): + return Qt.SizeFDiagCursor # type: ignore[attr-defined] + if edge in (_RIGHT | _TOP, _LEFT | _BOTTOM): + return Qt.SizeBDiagCursor # type: ignore[attr-defined] + if edge & (_LEFT | _RIGHT): + return Qt.SizeHorCursor # type: ignore[attr-defined] + if edge & (_TOP | _BOTTOM): + return Qt.SizeVerCursor # type: ignore[attr-defined] + return Qt.OpenHandCursor # type: ignore[attr-defined] # 本体可拖 → 抓手 + + def _qt_edges(self, edge: int): + """把内部边缘位标志映射成 Qt.Edges(位值不同,必须显式映射)。""" + qe = Qt.Edges() # type: ignore[attr-defined] + if edge & _LEFT: + qe |= Qt.LeftEdge # type: ignore[attr-defined] + if edge & _RIGHT: + qe |= Qt.RightEdge # type: ignore[attr-defined] + if edge & _TOP: + qe |= Qt.TopEdge # type: ignore[attr-defined] + if edge & _BOTTOM: + qe |= Qt.BottomEdge # type: ignore[attr-defined] + return qe + + def _apply_hover_cursor(self, pos) -> None: + """悬浮时按位置设光标:贴边→缩放,本体→抓手。浮窗本体与卡片共用。""" + cursor = self._cursor_for(self._edge_at(pos)) + self.setCursor(cursor) + self._card.setCursor(cursor) + + def _begin_drag_or_resize(self, pos) -> None: + """按下:贴边→缩放,本体→拖动整窗。优先系统级 ``startSystemResize/Move``(Wayland 必须用, + 客户端不能自己 setGeometry);macOS 无边框 Tool 窗返回 False → 手动兜底(grabMouse + 改窗 + 几何);``hasattr`` 守卫让 Qt<5.15 也安全降级。 + """ + from PyQt5.QtGui import QCursor + + handle = self.windowHandle() + edge = self._edge_at(pos) + if edge: + if (handle is not None and hasattr(handle, "startSystemResize") + and handle.startSystemResize(self._qt_edges(edge))): + return # 系统级缩放可用(Win/Linux) + self._drag_mode = "resize" + self._resize_edge = edge + else: + self._card.setCursor(Qt.ClosedHandCursor) # type: ignore[attr-defined] + if (handle is not None and hasattr(handle, "startSystemMove") + and handle.startSystemMove()): + return # 系统级移动可用(Win/Linux) + self._drag_mode = "move" + # 手动兜底:记录起点,抓鼠标(按下可能落在子件上,抓住后 move/release 都来本窗) + self._drag_origin = QCursor.pos() + self._drag_geo = QRect(self.geometry()) + self.grabMouse() + + def _apply_manual_resize(self, delta: QPoint) -> None: + """手动缩放:按起手边缘 + 全局鼠标位移改窗几何,各边各自夹住最小尺寸。""" + geo = QRect(self._drag_geo) + e = self._resize_edge + minw = 260 + 2 * MARGIN + minh = 56 + 2 * MARGIN + if e & _LEFT: + geo.setLeft(min(geo.left() + delta.x(), geo.right() - minw)) + if e & _RIGHT: + geo.setRight(max(geo.right() + delta.x(), geo.left() + minw)) + if e & _TOP: + geo.setTop(min(geo.top() + delta.y(), geo.bottom() - minh)) + if e & _BOTTOM: + geo.setBottom(max(geo.bottom() + delta.y(), geo.top() + minh)) + self.setGeometry(geo) + + def mousePressEvent(self, event) -> None: + # 点在按钮/可交互子件上不会走到这里(子件已消费);卡片本体的点击由 _Card 转交到此。 + if event.button() != Qt.LeftButton: # type: ignore[attr-defined] + return super().mousePressEvent(event) + self._begin_drag_or_resize(event.pos()) + + def mouseMoveEvent(self, event) -> None: + if self._drag_mode is not None: + from PyQt5.QtGui import QCursor + + delta = QCursor.pos() - self._drag_origin + if self._drag_mode == "move": + self.move(self._drag_geo.topLeft() + delta) + else: + self._apply_manual_resize(delta) + return + if not (event.buttons() & Qt.LeftButton): # type: ignore[attr-defined] + self._apply_hover_cursor(event.pos()) + + def mouseReleaseEvent(self, event) -> None: + if self._drag_mode is not None: # 结束手动拖动/缩放:放鼠标、复位光标 + self._drag_mode = None + self._resize_edge = 0 + self.releaseMouse() + self._apply_hover_cursor(event.pos()) + self._drag_pos = None + super().mouseReleaseEvent(event) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + # 跳过「我们自己改的尺寸」:_applying_size 接同步投递;size==_last_size 接 Qt 异步排队的 + # 晚到 resizeEvent(否则误记成用户拖动,把自适应高度冻死)。 + if self._applying_size or self.size() == self._last_size: + return + # 用户原生拖边缩放:按形态记住卡片尺寸(切形态不丢),按新宽度重排(高度交给合批) + cw = max(1, self.width() - 2 * MARGIN) + ch = max(1, self.height() - 2 * MARGIN) + self._user_sizes[self._mode] = (cw, ch) + self._last_size = self.size() + if not self._render_timer.isActive(): + self._render_timer.start() + self._persist_timer.start() # 去抖后落盘 + + def _emit_size_changed(self) -> None: + """拖边缩放停手后发尺寸持久化信号(当前形态的卡片宽高)。""" + user = self._user_sizes.get(self._mode) + if user is not None: + self.sizeChanged.emit(user[0], user[1]) + + def showEvent(self, event) -> None: + super().showEvent(event) + self._configure_native_window() + self._install_edge_filter() + + def _configure_native_window(self) -> None: + """macOS 原生窗口微调(仅 cocoa),让浮窗像系统级 HUD(切 App/跨屏/跨 Space 常显): + + 1. ``setHasShadow_(False)``:已自绘阴影,系统那层在深色桌面像灰玻璃块。 + 2. ``setHidesOnDeactivate_(False)``:``Qt.Tool`` 映射的 NSPanel 默认失焦即隐——「点另一块 + 屏浮窗整个消失」的根因,必须关掉。 + 3. ``setLevel_(3 = NSFloatingWindowLevel)``:浮在普通窗口之上,不挡菜单栏/系统弹窗。 + 4. ``collectionBehavior = AllSpaces|Stationary|FullScreenAux``:每个 Space 都显、切 Space + 不被甩走、可浮在全屏 App 上、停在用户摆放位置不自动搬屏。 + """ + if sys.platform != "darwin": + return + + if QApplication.platformName() != "cocoa": + return # offscreen/测试平台无真正的 NSView/NSWindow,碰 winId 会崩 + try: + import objc # PyObjC,macOS 自带 + + view = objc.objc_object(c_void_p=int(self.winId())) + win = view.window() if view is not None else None + if win is not None: + win.setHasShadow_(False) + win.invalidateShadow() + win.setHidesOnDeactivate_(False) + win.setLevel_(3) # NSFloatingWindowLevel + win.setCollectionBehavior_(1 | 16 | 256) # AllSpaces | Stationary | FullScreenAux + except Exception: + pass # 拿不到原生窗口不影响功能 + + def _install_edge_filter(self) -> None: + """给铺满卡片的子件装事件过滤器(仅一次),否则按在子件上只触发其自身行为(滚动/选中), + 既拖不动也缩放不了。""" + if getattr(self, "_edge_filter_installed", False): + return + self._edge_filter_installed = True + targets = [ + self._card, self._history, self._history.viewport(), self._history_inner, + self._sep, self._toolbar, + ] + for w in targets: + if w is not None: + w.setMouseTracking(True) + w.installEventFilter(self) + + def _filter_item(self, item: "_TranscriptItem") -> None: + """给段落卡片内容(卡片底/文字/时间/轨道)装事件过滤器,让按在文字上也能拖窗/缩放。""" + for w in (item, item._card, item._rail, item._time, item._src, item._tgt): + w.setMouseTracking(True) + w.installEventFilter(self) + + def eventFilter(self, obj, event) -> bool: + # 子件的左键按下统一交给浮窗:贴边→缩放,本体→拖动整窗(纯点击无副作用)。 + # 这样在转录文字/滚动区/卡片空白上都能拖动与缩放;未装过滤器的按钮点击照常生效。 + etype = event.type() + if etype == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self._begin_drag_or_resize(obj.mapTo(self, event.pos())) + return True # 吃掉,不让子件处理 + if etype == QEvent.MouseMove and not (event.buttons() & Qt.LeftButton): # type: ignore[attr-defined] + self.setCursor(self._cursor_for(self._edge_at(obj.mapTo(self, event.pos())))) + self._refresh_hover() # 在子控件上移动也算悬浮,保住工具条按钮显隐稳定 + elif etype in (QEvent.Enter, QEvent.Leave): # type: ignore[attr-defined] + self._refresh_hover() # 进出子控件 → 按真实位置重判 hover,不被误隐 + return super().eventFilter(obj, event) + + # ----- 演示数据(截图 / 预览用) ----- + + def load_demo(self, state: str) -> None: + """复刻各态内容,供离屏截图与对比。state ∈ rest/hover/pause/tall。""" + self._rows.clear() + self._starred.clear() + now = time.time() + # 演示用中性自有文案 + history = [ + ("Hello everyone, welcome back.", "大家好,欢迎回来。", now - 49), + ("Let's quickly review what we discussed last week.", "我们先快速回顾上周聊过的几个问题。", now - 28), + ("The prototype got really positive feedback.", "原型得到了非常正面的反馈。", now - 15), + ] + seq = 0 + if state == "tall": + for src, tgt, t in history: + sid = f"demo#{seq}" + self._rows[sid] = _Row(sid, seq, src, len(src), tgt, True, t) + seq += 1 + # 当前段落 + if state == "pause": + src = "weekends like this are relaxing." + tgt = "这样的周末真让人放松。" + slen = len(src) + self._paused = True + self._btn_pause.set_key("play") # 暂停态显示播放三角 + self._btn_pause.setActive(True) + else: + src = "So the main goal for this sprint is to ship the live caption overlay." + tgt = "这个迭代的主要目标是交付实时字幕浮窗,同时让记录页更好回看。" + slen = len("So the main goal for this sprint is to ship the ") + cur = f"demo#{seq}" + self._rows[cur] = _Row(cur, seq, src, slen, tgt, state == "pause", now) + + mode = { + "rest": MODE_STANDARD, "hover": MODE_STANDARD, "pause": MODE_STANDARD, + "tall": MODE_TALL, + }.get(state, MODE_STANDARD) + self._hovered = state in ("hover", "tall", "pause") + self._mode = mode + self._apply_mode() + self._update_chrome() + self._render_current() + + def card_grab(self) -> QPixmap: + """抓卡片本体(不含透明边距/阴影),与 HTML .cap 截图直接对比。""" + return self._card.grab() diff --git a/videocaptioner/ui/components/caption_overlay_settings.py b/videocaptioner/ui/components/caption_overlay_settings.py new file mode 100644 index 00000000..4a2a719f --- /dev/null +++ b/videocaptioner/ui/components/caption_overlay_settings.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +"""浮窗的"窗内设置"弹层:段控 / 开关 / 滑块,沿用浮窗的暗色玻璃配色。""" + +from __future__ import annotations + +from typing import List + +from PyQt5.QtCore import QPoint, QPointF, QRect, Qt, pyqtSignal +from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPolygonF +from PyQt5.QtWidgets import ( + QApplication, + QFrame, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QPushButton, + QSlider, + QVBoxLayout, + QWidget, +) + +from videocaptioner.ui.components.caption_overlay import ( + BRAND, + C_MUTED, + C_SUBTLE, + make_icon, +) +from videocaptioner.ui.components.workbench import apply_font +from videocaptioner.ui.i18n import tr + +PANEL_BG = "rgba(26,28,29,0.99)" +PANEL_BORDER = "rgba(255,255,255,0.13)" + + +class _Seg(QFrame): + """分段切换控件。""" + + changed = pyqtSignal(int) + + def __init__(self, options: List[str], active: int = 0, parent=None) -> None: + super().__init__(parent) + self.setStyleSheet( + "_Seg{background:#1f2121;border:1px solid #353938;border-radius:8px;}" + ) + lay = QHBoxLayout(self) + lay.setContentsMargins(3, 3, 3, 3) + lay.setSpacing(3) + self._btns: List[QPushButton] = [] + for i, opt in enumerate(options): + b = QPushButton(opt) + b.setCheckable(True) + b.setFixedHeight(28) + b.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + apply_font(b, 12, 700) + b.clicked.connect(lambda _=False, idx=i: self._select(idx)) + lay.addWidget(b, 1) + self._btns.append(b) + self._select(active, emit=False) + + def set_active(self, idx: int) -> None: + """外部设置高亮(不发 changed,用于按当前状态刷新)。""" + if 0 <= idx < len(self._btns): + self._select(idx, emit=False) + + def _select(self, idx: int, emit: bool = True) -> None: + for i, b in enumerate(self._btns): + on = i == idx + if on: + b.setStyleSheet( + f"QPushButton{{border:0;border-radius:5px;color:#061810;" + f"background:{BRAND};}}" + ) + else: + b.setStyleSheet( + f"QPushButton{{border:0;border-radius:5px;color:{C_MUTED};" + f"background:transparent;}}" + "QPushButton:hover{background:rgba(255,255,255,0.05);}" + ) + b.setChecked(on) + if emit: + self.changed.emit(idx) + + +class _Toggle(QWidget): + """药丸开关。""" + + toggled = pyqtSignal(bool) + + def __init__(self, on: bool = False, parent=None) -> None: + super().__init__(parent) + self._on = on + self.setFixedSize(38, 22) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + def isOn(self) -> bool: + return self._on + + def setOn(self, on: bool) -> None: + self._on = on + self.update() + + def mousePressEvent(self, event) -> None: + self._on = not self._on + self.update() + self.toggled.emit(self._on) + + def paintEvent(self, event) -> None: + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) # type: ignore[arg-type] + p.setBrush(QColor(BRAND) if self._on else QColor("#3a3e3d")) + p.drawRoundedRect(self.rect(), 11, 11) + p.setBrush(QColor("#ffffff")) + x = 19 if self._on else 3 + p.drawEllipse(QRect(x, 3, 16, 16)) + + +class OverlaySettingsPopover(QWidget): + """浮窗设置弹层(点齿轮弹出,独立顶层、不被卡片裁剪)。""" + + displayChanged = pyqtSignal(str) # bilingual/target/source + bgStyleChanged = pyqtSignal(str) # translucent/outline/black + fontScaleChanged = pyqtSignal(int) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setWindowFlags( + Qt.Popup | Qt.FramelessWindowHint # type: ignore[arg-type] + ) + self.setAttribute(Qt.WA_TranslucentBackground) # type: ignore[arg-type] + self._build() + + def paintEvent(self, event) -> None: + # 底部朝下的三角尖角,指向字幕条上的齿轮(speech-bubble 尾巴) + card_right = self.width() - 14 + card_bottom = self.height() - 18 + cx = card_right - 40 + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + tri = QPolygonF([ + QPointF(cx - 7, card_bottom - 0.5), + QPointF(cx + 7, card_bottom - 0.5), + QPointF(cx, card_bottom + 7), + ]) + path = QPainterPath() + path.addPolygon(tri) + painter.fillPath(path, QColor(26, 28, 29, 252)) + painter.end() + + def _build(self) -> None: + outer = QVBoxLayout(self) + outer.setContentsMargins(14, 14, 14, 18) + card = QFrame(self) + card.setObjectName("panel") + card.setStyleSheet( + f"#panel{{background:{PANEL_BG};border:1px solid {PANEL_BORDER};border-radius:14px;}}" + ) + shadow = QGraphicsDropShadowEffect(card) + shadow.setBlurRadius(40) + shadow.setOffset(0, 14) + shadow.setColor(QColor(0, 0, 0, 150)) + card.setGraphicsEffect(shadow) + outer.addWidget(card) + self.setFixedWidth(286 + 28) + + lay = QVBoxLayout(card) + lay.setContentsMargins(15, 15, 15, 15) + lay.setSpacing(13) + + # 标题 + head = QHBoxLayout() + head.setSpacing(7) + icon = QLabel() + icon.setPixmap(make_icon("gear", BRAND, size=14).pixmap(14, 14)) + title = QLabel(tr("overlayset.title")) + apply_font(title, 12, 800) + title.setStyleSheet("color:#f5f7f6;background:transparent;") + head.addWidget(icon) + head.addWidget(title) + head.addStretch(1) + lay.addLayout(head) + + lay.addWidget(self._row(tr("overlayset.display"))) + self._disp = _Seg( + [ + tr("overlayset.display.bilingual"), + tr("overlayset.display.target"), + tr("overlayset.display.source"), + ], + 0, + ) + self._disp.changed.connect( + lambda i: self.displayChanged.emit(["bilingual", "target", "source"][i]) + ) + lay.addWidget(self._disp) + + lay.addWidget(self._row(tr("overlayset.bg"))) + self._bg = _Seg( + [ + tr("overlayset.bg.translucent"), + tr("overlayset.bg.outline"), + tr("overlayset.bg.black"), + ], + 0, + ) + self._bg.changed.connect( + lambda i: self.bgStyleChanged.emit(["translucent", "outline", "black"][i]) + ) + lay.addWidget(self._bg) + + lay.addWidget(self._row(tr("overlayset.font_size"), tr("overlayset.font_size.medium"), C_MUTED)) + self._font = QSlider(Qt.Horizontal) # type: ignore[arg-type] + self._font.setRange(0, 100) + self._font.setValue(60) + self._font.setFixedHeight(16) + self._font.setStyleSheet( + "QSlider{background:transparent;}" + "QSlider::groove:horizontal{height:6px;border-radius:3px;background:#3a3e3d;}" + f"QSlider::sub-page:horizontal{{height:6px;border-radius:3px;background:{BRAND};}}" + "QSlider::add-page:horizontal{height:6px;border-radius:3px;background:#3a3e3d;}" + "QSlider::handle:horizontal{width:14px;height:14px;margin:-4px 0;border-radius:7px;background:#fff;}" + ) + self._font.valueChanged.connect(self.fontScaleChanged.emit) + lay.addWidget(self._font) + + def configure(self, *, display: str, bg_style: str, font_value: int) -> None: + """打开前用当前真实状态刷新各控件高亮(避免弹层永远显示默认值)。""" + self._disp.set_active({"bilingual": 0, "target": 1, "source": 2}.get(display, 0)) + self._bg.set_active({"translucent": 0, "outline": 1, "black": 2}.get(bg_style, 0)) + self._font.blockSignals(True) + self._font.setValue(font_value) + self._font.blockSignals(False) + + def _row(self, label: str, hint: str = "", hint_color: str = C_SUBTLE) -> QWidget: + w = QWidget() + h = QHBoxLayout(w) + h.setContentsMargins(0, 0, 0, 0) + lbl = QLabel(label) + apply_font(lbl, 12, 700) + lbl.setStyleSheet(f"color:{C_SUBTLE};background:transparent;") + h.addWidget(lbl) + h.addStretch(1) + if hint: + hl = QLabel(hint) + apply_font(hl, 11, 700) + hl.setStyleSheet(f"color:{hint_color};background:transparent;") + h.addWidget(hl) + return w + + def _toggle_row(self, label: str, on: bool, signal): + w = QWidget() + h = QHBoxLayout(w) + h.setContentsMargins(0, 0, 0, 0) + lbl = QLabel(label) + apply_font(lbl, 12, 650) + lbl.setStyleSheet("color:#f5f7f6;background:transparent;") + toggle = _Toggle(on) + toggle.toggled.connect(signal.emit) + h.addWidget(lbl) + h.addStretch(1) + h.addWidget(toggle) + return w, toggle + + def open_at(self, anchor_global: QPoint) -> None: + """把弹层右下角箭头对准锚点(齿轮按钮)上方弹出,并夹取到锚点所在屏幕。""" + self.adjustSize() + x = anchor_global.x() - self.width() + 40 + y = anchor_global.y() - self.height() + 8 + screen = QApplication.screenAt(anchor_global) # 多屏:用锚点所在屏幕,别飞到主屏 + if screen is not None: + geo = screen.availableGeometry() + x = max(geo.left() + 4, min(x, geo.right() - self.width() - 4)) + y = max(geo.top() + 4, min(y, geo.bottom() - self.height() - 4)) + self.move(x, y) + self.show() diff --git a/videocaptioner/ui/components/color_picker.py b/videocaptioner/ui/components/color_picker.py new file mode 100644 index 00000000..c681c320 --- /dev/null +++ b/videocaptioner/ui/components/color_picker.py @@ -0,0 +1,547 @@ +# coding:utf-8 +"""第一方取色器(替代系统 QColorDialog)。 + +竖向布局:SV 取色方块 + 色相条 +(可选)透明度条 + 预览/HEX + 常用字幕色 + 最近使用。 +与 workbench 设计语言一致,支持带 alpha 的颜色,最近使用持久化到配置。 + +入口:``ColorPickerDialog.get_color(initial, parent, alpha, title)``,用法对齐 +``QColorDialog.getColor``,取消返回 None。 +""" + +from __future__ import annotations + +from typing import List, Optional + +from PyQt5.QtCore import QPointF, QRectF, Qt, pyqtSignal +from PyQt5.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen +from PyQt5.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QSizePolicy, + QWidget, +) + +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.config import cfg +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.app_dialog import AppDialog +from videocaptioner.ui.components.workbench import AppLineEdit, apply_font +from videocaptioner.ui.i18n import tr + +# 常用字幕色:取自真实字幕实践——白是最常见默认色、黑用于描边、经典电影黄(#FFFF00), +# 加上 CEA-608/708 闭合字幕标准色(黄/青/绿/蓝/红/品红)与短视频常见柔色,而非泛色盘。 +SUBTITLE_PRESET_COLORS: tuple[str, ...] = ( + # 基础白黑 + 经典字幕黄(最常用) + "#ffffff", "#f2f2f2", "#000000", "#1a1a1a", "#ffff00", "#ffde00", "#ffd000", "#ffe36b", + # 暖色强调(CC 红 + 橙粉,短视频常用) + "#ffa500", "#ff7a45", "#ff4d4d", "#ff0000", "#ff69b4", "#ff4da6", "#ff00ff", "#b388ff", + # 冷色(CC 青/绿/蓝标准色 + 柔变体) + "#00ffff", "#2ee6a6", "#00ff00", "#08ec88", "#00bfff", "#1e90ff", "#0000ff", "#6bd0ff", +) +_RECENT_CAP = 8 + + +def _paint_checker(painter: QPainter, rect: QRectF, cell: int = 6): + """棋盘格背景,用于表现半透明颜色。""" + painter.fillRect(rect, QColor("#c9c9c9")) + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + painter.setBrush(QColor("#8f8f8f")) + rows = int(rect.height() // cell) + 1 + cols = int(rect.width() // cell) + 1 + for r in range(rows): + for c in range(cols): + if (r + c) % 2: + painter.drawRect( + QRectF(rect.left() + c * cell, rect.top() + r * cell, cell, cell) + ) + + +class _SVPlane(QWidget): + """饱和度(横)/明度(纵)取色方块。""" + + changed = pyqtSignal(float, float) # sat, val + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(168) + self.setCursor(Qt.CrossCursor) # type: ignore[arg-type] + self._hue = 0.13 + self._sat = 0.5 + self._val = 0.9 + + def setHue(self, hue: float): + self._hue = max(0.0, hue) + self.update() + + def setSV(self, sat: float, val: float): + self._sat, self._val = sat, val + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + path = QPainterPath() + path.addRoundedRect(rect, 12, 12) + painter.setClipPath(path) + painter.fillRect(self.rect(), QColor.fromHsvF(self._hue, 1, 1)) + sat_grad = QLinearGradient(rect.left(), 0, rect.right(), 0) + sat_grad.setColorAt(0, QColor(255, 255, 255, 255)) + sat_grad.setColorAt(1, QColor(255, 255, 255, 0)) + painter.fillRect(self.rect(), QBrush(sat_grad)) + val_grad = QLinearGradient(0, rect.top(), 0, rect.bottom()) + val_grad.setColorAt(0, QColor(0, 0, 0, 0)) + val_grad.setColorAt(1, QColor(0, 0, 0, 255)) + painter.fillRect(self.rect(), QBrush(val_grad)) + painter.setClipping(False) + painter.setPen(QPen(QColor(255, 255, 255, 28), 1)) + painter.setBrush(Qt.NoBrush) # type: ignore[arg-type] + painter.drawPath(path) + # 手柄 + x = rect.left() + self._sat * rect.width() + y = rect.top() + (1 - self._val) * rect.height() + painter.setBrush(QColor.fromHsvF(self._hue, self._sat, self._val)) + painter.setPen(QPen(QColor(255, 255, 255, 235), 2)) + painter.drawEllipse(QPointF(x, y), 8, 8) + painter.setPen(QPen(QColor(0, 0, 0, 90), 1)) + painter.setBrush(Qt.NoBrush) # type: ignore[arg-type] + painter.drawEllipse(QPointF(x, y), 9, 9) + + def _set_from_pos(self, pos): + w, h = max(1, self.width()), max(1, self.height()) + self._sat = min(1.0, max(0.0, pos.x() / w)) + self._val = min(1.0, max(0.0, 1 - pos.y() / h)) + self.update() + self.changed.emit(self._sat, self._val) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self._set_from_pos(event.pos()) + event.accept() + + def mouseMoveEvent(self, event): + if event.buttons() & Qt.LeftButton: # type: ignore[attr-defined] + self._set_from_pos(event.pos()) + + +class _Bar(QWidget): + """横向条基类(色相/透明度),共用手柄绘制与拖拽。""" + + changed = pyqtSignal(float) + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(18) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + self._value = 0.0 + + def setValue(self, value: float): + self._value = min(1.0, max(0.0, value)) + self.update() + + def _track_rect(self) -> QRectF: + return QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + + def _paint_knob(self, painter: QPainter, rect: QRectF): + x = rect.left() + self._value * rect.width() + x = min(rect.right() - 4, max(rect.left() + 4, x)) + knob = QRectF(x - 5, rect.top() - 2, 10, rect.height() + 4) + kpath = QPainterPath() + kpath.addRoundedRect(knob, 4, 4) + painter.setPen(QPen(QColor(0, 0, 0, 90), 1)) + painter.setBrush(QColor(255, 255, 255)) + painter.drawPath(kpath) + + def _set_from_pos(self, pos): + w = max(1, self.width()) + self._value = min(1.0, max(0.0, pos.x() / w)) + self.update() + self.changed.emit(self._value) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self._set_from_pos(event.pos()) + event.accept() + + def mouseMoveEvent(self, event): + if event.buttons() & Qt.LeftButton: # type: ignore[attr-defined] + self._set_from_pos(event.pos()) + + +class _HueBar(_Bar): + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = self._track_rect() + path = QPainterPath() + path.addRoundedRect(rect, rect.height() / 2, rect.height() / 2) + painter.setClipPath(path) + grad = QLinearGradient(rect.left(), 0, rect.right(), 0) + for i in range(7): + grad.setColorAt(i / 6, QColor.fromHsvF(i / 6, 1, 1)) + painter.fillRect(self.rect(), QBrush(grad)) + painter.setClipping(False) + self._paint_knob(painter, rect) + + +class _AlphaBar(_Bar): + def __init__(self, parent=None): + super().__init__(parent) + self._color = QColor("#ffe36b") + + def setColor(self, color: QColor): + self._color = QColor(color) + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = self._track_rect() + path = QPainterPath() + path.addRoundedRect(rect, rect.height() / 2, rect.height() / 2) + painter.setClipPath(path) + _paint_checker(painter, rect) + c0 = QColor(self._color) + c0.setAlpha(0) + c1 = QColor(self._color) + c1.setAlpha(255) + grad = QLinearGradient(rect.left(), 0, rect.right(), 0) + grad.setColorAt(0, c0) + grad.setColorAt(1, c1) + painter.fillRect(self.rect(), QBrush(grad)) + painter.setClipping(False) + self._paint_knob(painter, rect) + + +class _Swatch(QWidget): + """可点选色块(常用 / 最近 / 预览)。color=None 表示空位。""" + + clicked = pyqtSignal(QColor) + + def __init__(self, color: Optional[QColor] = None, height: int = 26, parent=None): + super().__init__(parent) + self._color = QColor(color) if color is not None else None + self._selected = False + self.setFixedHeight(height) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setCursor( + Qt.PointingHandCursor if self._color is not None else Qt.ArrowCursor # type: ignore[arg-type] + ) + + def setColor(self, color: Optional[QColor]): + self._color = QColor(color) if color is not None else None + self.setCursor( + Qt.PointingHandCursor if self._color is not None else Qt.ArrowCursor # type: ignore[arg-type] + ) + self.update() + + def setSelected(self, selected: bool): + if selected != self._selected: + self._selected = selected + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + palette = app_palette() + rect = QRectF(self.rect()).adjusted(1, 1, -1, -1) + path = QPainterPath() + path.addRoundedRect(rect, 7, 7) + if self._color is None: + # 稀疏虚线:用自定义点距,看起来像「空槽」而非实线 + pen = QPen(QColor(palette.subtle), 1.2) + pen.setStyle(Qt.CustomDashLine) # type: ignore[attr-defined] + pen.setDashPattern([1.5, 3.5]) + painter.setPen(pen) + painter.setBrush(Qt.NoBrush) # type: ignore[arg-type] + painter.drawPath(path) + return + painter.setClipPath(path) + if self._color.alpha() < 255: + _paint_checker(painter, rect) + painter.fillPath(path, self._color) + painter.setClipping(False) + ring = QColor(palette.accent) if self._selected else QColor(255, 255, 255, 32) + painter.setPen(QPen(ring, 2 if self._selected else 1)) + painter.setBrush(Qt.NoBrush) # type: ignore[arg-type] + painter.drawPath(path) + + def mousePressEvent(self, event): + if self._color is not None and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit(QColor(self._color)) + event.accept() + + +class _PreviewSwatch(QWidget): + """当前颜色预览(带棋盘格表现透明度)。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(46, 40) + self._color = QColor("#ffe36b") + + def setColor(self, color: QColor): + self._color = QColor(color) + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + path = QPainterPath() + path.addRoundedRect(rect, 11, 11) + painter.setClipPath(path) + if self._color.alpha() < 255: + _paint_checker(painter, rect) + painter.fillPath(path, self._color) + painter.setClipping(False) + painter.setPen(QPen(QColor(255, 255, 255, 36), 1)) + painter.setBrush(Qt.NoBrush) # type: ignore[arg-type] + painter.drawPath(path) + + +class _ClickLabel(QLabel): + clicked = pyqtSignal() + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + + +class ColorPickerDialog(AppDialog): + """第一方取色器弹窗。""" + + def __init__( + self, + initial: QColor | str | None = None, + *, + alpha: bool = False, + title: str | None = None, + parent=None, + ): + super().__init__( + title if title is not None else tr("colorpicker.title"), + icon=AppIcon.PALETTE, + parent=parent, + width=420, + ) + self._alpha_enabled = alpha + color = QColor(initial) if initial is not None else QColor("#ffffff") + if not color.isValid(): + color = QColor("#ffffff") + h, s, v, a = color.getHsvF() + self._hue = h if h >= 0 else 0.0 + self._sat, self._val = s, v + self._alpha = a if alpha else 1.0 + self._preset_swatches: List[_Swatch] = [] + self._recent_swatches: List[_Swatch] = [] + + self._build() + self._refresh() + + self.addFooterStretch() + self.cancelButton = self.addFooterButton(tr("common.cancel")) + self.cancelButton.clicked.connect(lambda: self.done(0)) + self.confirmButton = self.addFooterButton(tr("common.ok"), kind="accent") + self.confirmButton.clicked.connect(lambda: self.done(1)) + + # ----------------------------------------------------------------- build + + def _build(self): + self.svPlane = _SVPlane(self.widget) + self.svPlane.changed.connect(self._on_sv) + self.bodyLayout.addWidget(self.svPlane) + + self.hueBar = _HueBar(self.widget) + self.hueBar.changed.connect(self._on_hue) + self.bodyLayout.addWidget(self.hueBar) + + self.alphaBar = _AlphaBar(self.widget) + self.alphaBar.changed.connect(self._on_alpha) + self.alphaBar.setVisible(self._alpha_enabled) + self.bodyLayout.addWidget(self.alphaBar) + + # 预览 + HEX + row = QHBoxLayout() + row.setSpacing(11) + self.preview = _PreviewSwatch(self.widget) + row.addWidget(self.preview) + self.hexEdit = AppLineEdit(parent=self.widget) + self.hexEdit.setMaxLength(7) + self.hexEdit.setClearButtonEnabled(False) + self.hexEdit.textEdited.connect(self._on_hex_edited) + row.addWidget(self.hexEdit, 1) + self.alphaLabel = QLabel(self.widget) + apply_font(self.alphaLabel, 13, 800) + self.alphaLabel.setVisible(self._alpha_enabled) + row.addWidget(self.alphaLabel) + self.bodyLayout.addLayout(row) + + self.bodyLayout.addLayout(self._section(tr("colorpicker.section.presets"))) + self.bodyLayout.addLayout(self._preset_grid()) + + recent_head = self._section(tr("colorpicker.section.recent")) + self.clearRecent = _ClickLabel(tr("colorpicker.clear")) + apply_font(self.clearRecent, 12, 760) + self.clearRecent.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + self.clearRecent.clicked.connect(self._clear_recents) + recent_head.addWidget(self.clearRecent) + self.bodyLayout.addLayout(recent_head) + self.bodyLayout.addLayout(self._recent_grid()) + + self._apply_style() + + def _section(self, text: str) -> QHBoxLayout: + head = QHBoxLayout() + head.setContentsMargins(2, 4, 2, 0) + label = QLabel(text, self.widget) + label.setObjectName("cpSectionLabel") + apply_font(label, 12, 820) + head.addWidget(label) + head.addStretch(1) + return head + + def _preset_grid(self) -> QGridLayout: + grid = QGridLayout() + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(8) + for i, hex_color in enumerate(SUBTITLE_PRESET_COLORS): + sw = _Swatch(QColor(hex_color), parent=self.widget) + sw.clicked.connect(self._on_pick) + grid.addWidget(sw, i // 8, i % 8) + self._preset_swatches.append(sw) + return grid + + def _recent_grid(self) -> QGridLayout: + grid = QGridLayout() + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(8) + recents = self._load_recents() + for i in range(_RECENT_CAP): + color = recents[i] if i < len(recents) else None + sw = _Swatch(color, parent=self.widget) + sw.clicked.connect(self._on_pick) + grid.addWidget(sw, 0, i) + self._recent_swatches.append(sw) + return grid + + # --------------------------------------------------------------- signals + + def _on_sv(self, sat: float, val: float): + self._sat, self._val = sat, val + self._refresh() + + def _on_hue(self, hue: float): + self._hue = hue + self._refresh() + + def _on_alpha(self, alpha: float): + self._alpha = alpha + self._refresh() + + def _on_hex_edited(self, text: str): + raw = text.strip().lstrip("#") + if len(raw) == 6 and all(c in "0123456789abcdefABCDEF" for c in raw): + color = QColor(f"#{raw}") + h, s, v, _ = color.getHsvF() + if h >= 0: + self._hue = h + self._sat, self._val = s, v + self._refresh(skip_hex=True) + + def _on_pick(self, color: QColor): + h, s, v, a = color.getHsvF() + if h >= 0: + self._hue = h + self._sat, self._val = s, v + if self._alpha_enabled: + self._alpha = a + self._refresh() + + # ----------------------------------------------------------------- sync + + def selectedColor(self) -> QColor: + return QColor.fromHsvF( + max(0.0, self._hue), self._sat, self._val, self._alpha if self._alpha_enabled else 1.0 + ) + + def _refresh(self, skip_hex: bool = False): + color = self.selectedColor() + rgb = QColor.fromHsvF(max(0.0, self._hue), self._sat, self._val) + self.svPlane.setHue(self._hue) + self.svPlane.setSV(self._sat, self._val) + self.hueBar.setValue(self._hue) + self.alphaBar.setColor(rgb) + self.alphaBar.setValue(self._alpha) + self.preview.setColor(color) + # 只在「来自 hex 输入框本身的编辑」时跳过回填,否则一旦 hex 框获得焦点, + # 拖动取色方块/色相/透明度时 hex 不刷新 → 看起来「色值没变」。 + if not skip_hex: + if self.hexEdit.hasFocus(): + self.hexEdit.clearFocus() + self.hexEdit.setText(rgb.name(QColor.HexRgb)[1:].upper()) + self.alphaLabel.setText(f"{round(self._alpha * 100)}%") + self._highlight(color) + + def _highlight(self, color: QColor): + target = color.name(QColor.HexArgb) + for sw in self._preset_swatches + self._recent_swatches: + sw_color = sw._color + sw.setSelected(sw_color is not None and sw_color.name(QColor.HexArgb) == target) + + # --------------------------------------------------------------- recents + + @staticmethod + def _load_recents() -> List[QColor]: + colors: List[QColor] = [] + for item in cfg.recent_colors.value or []: + c = QColor(str(item)) + if c.isValid(): + colors.append(c) + return colors + + def _clear_recents(self): + cfg.set(cfg.recent_colors, []) + for sw in self._recent_swatches: + sw.setColor(None) + + @staticmethod + def push_recent(color: QColor): + if not color.isValid(): + return + key = color.name(QColor.HexArgb) + existing = [str(x) for x in (cfg.recent_colors.value or [])] + existing = [x for x in existing if x.lower() != key.lower()] + existing.insert(0, key) + cfg.set(cfg.recent_colors, existing[:_RECENT_CAP]) + + # ----------------------------------------------------------------- style + + def _apply_style(self): + palette = app_palette() + self.widget.setStyleSheet( + self.widget.styleSheet() + + f"\nQLabel#cpSectionLabel {{ color: {palette.muted}; background: transparent; }}" + ) + self.alphaLabel.setStyleSheet(f"color: {palette.muted}; background: transparent;") + self.clearRecent.setStyleSheet( + f"color: {rgba(palette.accent, 0.92)}; background: transparent;" + ) + + # ------------------------------------------------------------- entry API + + @staticmethod + def get_color( + initial: QColor | str | None = None, + parent=None, + alpha: bool = False, + title: str | None = None, + ) -> Optional[QColor]: + """打开取色器,返回选中颜色(取消返回 None)。对齐 QColorDialog.getColor。""" + dialog = ColorPickerDialog(initial, alpha=alpha, title=title, parent=parent) + if dialog.exec(): + color = dialog.selectedColor() + ColorPickerDialog.push_recent(color) + return color + return None diff --git a/videocaptioner/ui/components/dependency_download_dialog.py b/videocaptioner/ui/components/dependency_download_dialog.py new file mode 100644 index 00000000..d5b51706 --- /dev/null +++ b/videocaptioner/ui/components/dependency_download_dialog.py @@ -0,0 +1,322 @@ +"""运行依赖下载弹窗(ffmpeg / voxgate)。 + +把本机缺失的外部二进制一站式补齐:按系统自动选版本、国内走加速镜像、逐行进度/取消/ +重试、可一键安装所有缺失项。所有下载件来自 :mod:`core.download.dependencies` 注册表, +新增依赖无需改本弹窗。 + +约定(对齐 model_manager_dialog):同一时刻只跑一个下载任务;关闭弹窗即取消进行中的 +下载(.part 保留续传)。复用 workbench 设计原子(圆角卡片、进度条、状态胶囊、按钮)。 +""" + +from __future__ import annotations + +from pathlib import Path + +from PyQt5.QtCore import Qt, QUrl, pyqtSignal +from PyQt5.QtGui import QDesktopServices +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from videocaptioner.config import BIN_PATH +from videocaptioner.core.constant import ( + INFOBAR_DURATION_ERROR, + INFOBAR_DURATION_SUCCESS, + INFOBAR_DURATION_WARNING, +) +from videocaptioner.core.download.dependencies import ( + DependencySpec, + asset_for, + is_installed, + iter_dependencies, +) +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.app_dialog import AppDialog +from videocaptioner.ui.components.workbench import ( + AccentButton, + CompactButton, + IconBox, + ProgressBarLine, + StatusPill, + apply_font, + draw_rounded_surface, +) +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.artifact_download_thread import ( + ArtifactDownloadThread, + dependency_download_thread, +) + +_ICON_FOR = {"voxgate": AppIcon.MICROPHONE, "ffmpeg": AppIcon.TERMINAL} + + +class _DependencyRow(QFrame): + """一个依赖行:图标盒 + 名称/说明 +(右侧单槽)状态胶囊 / 下载按钮 / 下载进度。 + + 右侧任一时刻只呈现一件事,且都靠右贴边:已装→绿胶囊;缺失→下载按钮;下载中→ + 进度条+取消;失败→红胶囊+重试。隐藏的控件在布局里自动塌缩,可见件始终顶到右边。 + """ + + actionRequested = pyqtSignal(object) # spec + cancelRequested = pyqtSignal() + + def __init__(self, spec: DependencySpec, parent=None): + super().__init__(parent) + self.spec = spec + self.setObjectName("dependencyRow") + self.setFixedHeight(64) + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 0, 14, 0) + layout.setSpacing(10) + layout.addWidget(IconBox(_ICON_FOR.get(spec.key, AppIcon.FILE), self)) + + # 名称 / 说明:上下两行,整体垂直居中(首尾 stretch 夹住) + column = QVBoxLayout() + column.setSpacing(2) + column.addStretch(1) + self.nameLabel = QLabel(spec.display_name, self) + self.nameLabel.setObjectName("depName") + apply_font(self.nameLabel, 14, 820) + column.addWidget(self.nameLabel) + self.descLabel = QLabel(spec.description, self) + self.descLabel.setObjectName("depDesc") + self.descLabel.setWordWrap(False) + apply_font(self.descLabel, 12, 650) + column.addWidget(self.descLabel) + column.addStretch(1) + layout.addLayout(column, 1) + + # —— 右侧单槽:进度 / 胶囊 / 按钮互斥,隐藏即塌缩,可见件自动贴右边 —— + self.progressLine = ProgressBarLine(self) + self.progressLine.setFixedWidth(132) + self.progressLine.hide() + layout.addWidget(self.progressLine) + self.percentLabel = QLabel("", self) + self.percentLabel.setObjectName("depPercent") + self.percentLabel.setFixedWidth(40) + self.percentLabel.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + apply_font(self.percentLabel, 12, 750) + self.percentLabel.hide() + layout.addWidget(self.percentLabel) + + self.status = StatusPill("", "neutral", self) + self.status.hide() + layout.addWidget(self.status) + + self.actionButton = AccentButton(tr("depdl.btn.download"), AppIcon.DOWNLOAD, self) + self.actionButton.clicked.connect(lambda: self.actionRequested.emit(self.spec)) + self.actionButton.hide() + layout.addWidget(self.actionButton) + self.cancelButton = CompactButton(tr("common.cancel"), None, self) + self.cancelButton.clicked.connect(self.cancelRequested) + self.cancelButton.hide() + layout.addWidget(self.cancelButton) + self.syncStyle() + + # ---- 状态切换 ---- + + def _hide_right(self): + for w in (self.progressLine, self.percentLabel, self.status, + self.actionButton, self.cancelButton): + w.setVisible(False) + + def showState(self, *, installed: bool, supported: bool, busy: bool, failed: bool): + self._hide_right() + self.descLabel.setText(self.spec.description) + if installed: + self.status.setState(tr("depdl.status.installed"), "ok") # 绿胶囊已表态,无需再放按钮 + self.status.setVisible(True) + elif not supported: + self.status.setState(tr("depdl.status.unsupported"), "neutral") + self.status.setVisible(True) + elif failed: + self.status.setState(tr("depdl.status.failed"), "fail") + self.status.setVisible(True) + self.actionButton.setText(tr("common.retry")) + self.actionButton.setIcon(AppIcon.DOWNLOAD) + self.actionButton.setEnabled(not busy) + self.actionButton.setVisible(True) + else: # 缺失、可装 + self.actionButton.setText(tr("depdl.btn.download")) + self.actionButton.setIcon(AppIcon.DOWNLOAD) + self.actionButton.setEnabled(not busy) + self.actionButton.setVisible(True) + + def showDownloading(self): + self._hide_right() + self.progressLine.setValue(0) + self.progressLine.setVisible(True) + self.percentLabel.setText("0%") + self.percentLabel.setVisible(True) + self.descLabel.setText(tr("depdl.status.connecting")) + self.cancelButton.setEnabled(True) + self.cancelButton.setVisible(True) + + def setProgress(self, percent: int, message: str): + if percent >= 0: + self.progressLine.setValue(percent) + self.percentLabel.setText(f"{percent}%") + self.descLabel.setText(message) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f"QLabel#depName {{ color: {palette.text}; background: transparent; }}" + f"QLabel#depDesc {{ color: {palette.subtle}; background: transparent; }}" + f"QLabel#depPercent {{ color: {palette.muted}; background: transparent; }}" + ) + + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.card_surface, palette.line_soft, 12) + super().paintEvent(event) + + +class DependencyDownloadDialog(AppDialog): + """运行依赖下载弹窗:单任务串行,逐行进度/取消/重试,可一键装缺失。""" + + depsChanged = pyqtSignal() + + def __init__(self, parent: QWidget | None = None): + super().__init__(tr("depdl.title"), icon=AppIcon.DOWNLOAD, parent=parent, width=560) + self._thread: ArtifactDownloadThread | None = None + self._active: _DependencyRow | None = None + self._queue: list[DependencySpec] = [] # 一键安装缺失的待装队列 + self._failed: set[str] = set() + self._rows: list[_DependencyRow] = [] + + self.addBodyText(tr("depdl.body.intro")) + for spec in iter_dependencies(): + row = _DependencyRow(spec, self.widget) + row.actionRequested.connect(self._on_row_action) + row.cancelRequested.connect(self._cancel_active) + self.bodyLayout.addWidget(row) + self._rows.append(row) + + self.openDirButton = self.addFooterButton(tr("depdl.btn.open_install_dir"), icon=AppIcon.FOLDER) + self.openDirButton.clicked.connect(self._open_bin_dir) + self.addFooterStretch() + self.installAllButton = self.addFooterButton( + tr("depdl.btn.install_all_missing"), kind="accent", icon=AppIcon.DOWNLOAD + ) + self.installAllButton.clicked.connect(self._install_all_missing) + self.doneButton = self.addFooterButton(tr("depdl.btn.done")) + self.doneButton.clicked.connect(lambda: self.done(0)) + + self._refresh() + + # ------------------------------------------------------------- 状态刷新 + + @property + def _busy(self) -> bool: + return self._thread is not None + + def _refresh(self): + any_missing = False + for row in self._rows: + supported = asset_for(row.spec) is not None + installed = is_installed(row.spec) + if row is self._active: + continue # 下载中由进度回调驱动,别覆盖 + row.showState( + installed=installed, + supported=supported, + busy=self._busy, + failed=row.spec.key in self._failed, + ) + if supported and not installed: + any_missing = True + self.installAllButton.setEnabled(any_missing and not self._busy) + + # ------------------------------------------------------------- 下载 + + def _on_row_action(self, spec: DependencySpec): + if self._busy: + return + self._start(spec) + + def _start(self, spec: DependencySpec): + row = next((r for r in self._rows if r.spec.key == spec.key), None) + if row is None: + return + self._failed.discard(spec.key) + thread = dependency_download_thread(spec, self) + self._thread = thread + self._active = row + row.showDownloading() + thread.progress.connect(row.setProgress) + thread.completed.connect(lambda _path, s=spec: self._on_done(s)) + thread.error.connect(lambda message, s=spec: self._on_error(s, message)) + thread.finished.connect(self._on_finished) + self._refresh() + thread.start() + + def _install_all_missing(self): + if self._busy: + return + missing = [ + spec + for spec in (row.spec for row in self._rows) + if asset_for(spec) is not None and not is_installed(spec) + ] + if not missing: + self._info(tr("depdl.info.nothing_title"), tr("depdl.info.nothing_body")) + return + self._queue = missing[1:] + self._start(missing[0]) + + def _on_done(self, spec: DependencySpec): + self._failed.discard(spec.key) + self._info(tr("depdl.status.installed"), tr("depdl.info.done_body", name=spec.display_name)) + self.depsChanged.emit() + + def _on_error(self, spec: DependencySpec, message: str): + self._failed.add(spec.key) + self._error(tr("depdl.error.download_failed"), message) + + def _on_finished(self): + thread = self._thread + self._thread = None + self._active = None + if thread is not None: + thread.deleteLater() + # 一键安装队列:装下一个(已失败的也跳过,避免卡住) + nxt = None + while self._queue: + candidate = self._queue.pop(0) + if candidate.key not in self._failed and not is_installed(candidate): + nxt = candidate + break + self._refresh() + if nxt is not None: + self._start(nxt) + + def _cancel_active(self): + self._queue.clear() # 取消即放弃整条一键安装队列 + if self._thread is not None: + self._thread.stop() + + def done(self, code: int): # noqa: A003 + self._cancel_active() + super().done(code) + + # ------------------------------------------------------------- 工具 + + def _open_bin_dir(self): + bin_path = Path(BIN_PATH) + bin_path.mkdir(parents=True, exist_ok=True) + QDesktopServices.openUrl(QUrl.fromLocalFile(str(bin_path))) + + def _info(self, title: str, message: str): + from qfluentwidgets import InfoBar + + InfoBar.success(title, message, duration=INFOBAR_DURATION_SUCCESS, parent=self.window()) + + def _warn(self, title: str, message: str): + from qfluentwidgets import InfoBar + + InfoBar.warning(title, message, duration=INFOBAR_DURATION_WARNING, parent=self.window()) + + def _error(self, title: str, message: str): + from qfluentwidgets import InfoBar + + InfoBar.error(title, message, duration=INFOBAR_DURATION_ERROR, parent=self.window()) diff --git a/videocaptioner/ui/components/donate_dialog.py b/videocaptioner/ui/components/donate_dialog.py new file mode 100644 index 00000000..860ee0d1 --- /dev/null +++ b/videocaptioner/ui/components/donate_dialog.py @@ -0,0 +1,76 @@ +"""捐助弹窗:说明 + 微信/支付宝二维码,基于 AppDialog 壳。""" + +from __future__ import annotations + +from pathlib import Path + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout + +from videocaptioner.config import ASSETS_PATH +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.app_dialog import AppDialog +from videocaptioner.ui.components.workbench import apply_font, draw_rounded_surface +from videocaptioner.ui.i18n import tr + + +class _QrCard(QFrame): + """二维码卡:圆角面板包住二维码与渠道名。""" + + def __init__(self, image_path: Path, caption: str, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 12) + layout.setSpacing(10) + qr = QLabel(self) + qr.setPixmap( + QPixmap(str(image_path)).scaled( + 228, + 228, + Qt.KeepAspectRatio, # type: ignore[arg-type] + Qt.SmoothTransformation, # type: ignore[arg-type] + ) + ) + qr.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + qr.setStyleSheet("background: transparent; border: none;") + layout.addWidget(qr) + self.captionLabel = QLabel(caption, self) + self.captionLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.captionLabel, 13, 750) + layout.addWidget(self.captionLabel) + self.syncStyle() + + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.field, palette.line_soft, 12) + super().paintEvent(event) + + def syncStyle(self): + self.captionLabel.setStyleSheet( + f"color: {app_palette().muted}; background: transparent; border: none;" + ) + + +class DonateDialog(AppDialog): + """支持作者:感谢语 + 两个收款二维码。""" + + def __init__(self, parent=None): + super().__init__(tr("donate.title"), icon=AppIcon.HEART, parent=parent, width=620) + desc = self.addBodyText(tr("donate.desc")) + desc.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + + qr_row = QHBoxLayout() + qr_row.setSpacing(13) + qr_row.addWidget( + _QrCard(ASSETS_PATH / "donate_blue.jpg", tr("donate.alipay"), self.widget) + ) + qr_row.addWidget( + _QrCard(ASSETS_PATH / "donate_green.jpg", tr("donate.wechat"), self.widget) + ) + self.bodyLayout.addLayout(qr_row) + + self.addFooterStretch() + self.dismissButton = self.addFooterButton(tr("common.close")) + self.dismissButton.clicked.connect(lambda: self.done(0)) diff --git a/videocaptioner/ui/components/feedback_dialog.py b/videocaptioner/ui/components/feedback_dialog.py new file mode 100644 index 00000000..de617f84 --- /dev/null +++ b/videocaptioner/ui/components/feedback_dialog.py @@ -0,0 +1,498 @@ +"""意见反馈弹窗:分类 + 可粘贴截图的描述编辑器 + 联系方式 → 后台 multipart 提交。 + +复用 AppDialog 外壳 + workbench 控件;类型用整宽 _SelectField、添加截图用与缩略图等大的 _AddImageTile, +让所有控件成一套视觉。截图由用户提供:编辑器里 Ctrl+V 粘贴、拖入,或点 + 块选文件;缩略图可删。 +诊断信息与最近日志默认随提交附带(无开关、不在 UI 提示),均已脱敏绝不含密钥(见 +core/feedback/diagnostics 与 core/feedback/logs)。 +""" + +from __future__ import annotations + +from PyQt5.QtCore import QBuffer, QByteArray, Qt, pyqtSignal +from PyQt5.QtGui import QColor, QImage, QPainter, QPainterPath, QPixmap +from PyQt5.QtWidgets import ( + QFileDialog, + QFrame, + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) +from qfluentwidgets import Action, RoundMenu + +from videocaptioner.core.feedback import ( + CATEGORIES, + MAX_FILE_BYTES, + MAX_FILES, + FeedbackAttachment, + FeedbackReport, + FeedbackValidationError, + collect_recent_logs, + gather_diagnostics, +) +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.app_dialog import AppDialog +from videocaptioner.ui.components.workbench import ( + AppLineEdit, + AppTextEdit, + RoundIconButton, + SectionLabel, + apply_font, + draw_rounded_surface, + icon_pixmap, +) +from videocaptioner.ui.i18n import N_, tr +from videocaptioner.ui.thread.feedback_thread import FeedbackSubmitThread + +_IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg") + +# 动态拼接的 key(tr(f"feedback.category.{...}") / tr(f"feedback.err.{...}"))pybabel 抽不到, +# 在此用 N_ 显式登记,让 extract 收进 .pot(运行时仍走上面的 f-string tr)。 +_REGISTER_I18N = ( + N_("feedback.category.bug"), + N_("feedback.category.feature"), + N_("feedback.category.question"), + N_("feedback.category.other"), + N_("feedback.err.message_required"), + N_("feedback.err.message_too_long"), + N_("feedback.err.contact_too_long"), + N_("feedback.err.too_many_files"), + N_("feedback.err.file_type"), + N_("feedback.err.file_too_large"), + N_("feedback.err.total_too_large"), + N_("feedback.err.category_invalid"), + N_("feedback.err.network"), + N_("feedback.err.unauthorized"), + N_("feedback.err.too_large"), + N_("feedback.err.invalid_request"), + N_("feedback.err.server"), +) + + +def _qimage_to_png(image: QImage) -> bytes: + # QByteArray 必须用局部变量持有:内联传给 QBuffer 的临时对象会被回收,写入即野指针(bus error)。 + store = QByteArray() + buffer = QBuffer(store) + buffer.open(QBuffer.WriteOnly) # type: ignore[attr-defined] + image.save(buffer, "PNG") + buffer.close() + return bytes(store) + + +class _PasteTextEdit(AppTextEdit): + """描述编辑器:拦截粘贴/拖入的图片,转交给弹窗当作截图;文本照常粘贴。""" + + imagePasted = pyqtSignal(QImage) + imageFilePasted = pyqtSignal(str) + + def canInsertFromMimeData(self, source) -> bool: # noqa: N802 + if source.hasImage() or source.hasUrls(): + return True + return super().canInsertFromMimeData(source) + + def insertFromMimeData(self, source) -> None: # noqa: N802 + if source.hasImage(): + image = source.imageData() + if isinstance(image, QImage) and not image.isNull(): + self.imagePasted.emit(image) + return + if source.hasUrls(): + paths = [u.toLocalFile() for u in source.urls() if u.isLocalFile()] + images = [p for p in paths if p.lower().endswith(_IMAGE_SUFFIXES)] + if images: + for path in images: + self.imageFilePasted.emit(path) + return + super().insertFromMimeData(source) + + +class _AttachmentThumb(QFrame): + """56×56 圆角截图缩略图,右上角悬浮删除钮。""" + + removed = pyqtSignal(object) + + def __init__(self, pixmap: QPixmap, parent=None): + super().__init__(parent) + self.setFixedSize(56, 56) + self._pixmap = pixmap + self._remove = RoundIconButton(AppIcon.CLOSE, diameter=18, parent=self) + self._remove.clicked.connect(lambda: self.removed.emit(self)) + self._remove.move(self.width() - 18, 0) + + def paintEvent(self, event): + palette = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = self.rect().adjusted(0, 0, -1, -1) + clip = QPainterPath() + clip.addRoundedRect(rect.x(), rect.y(), rect.width(), rect.height(), 9, 9) + painter.setClipPath(clip) + if not self._pixmap.isNull(): + scaled = self._pixmap.scaled( + self.size(), + Qt.KeepAspectRatioByExpanding, # type: ignore[attr-defined] + Qt.SmoothTransformation, # type: ignore[attr-defined] + ) + painter.drawPixmap( + (self.width() - scaled.width()) // 2, + (self.height() - scaled.height()) // 2, + scaled, + ) + painter.setClipping(False) + painter.setPen(QColor(palette.line_soft)) + painter.setBrush(Qt.NoBrush) # type: ignore[attr-defined] + painter.drawRoundedRect(rect, 9, 9) + + +class _SelectField(QFrame): + """整宽下拉:外观与 AppLineEdit 一致(同高 36 / 圆角 9 / 同边框),点击弹 RoundMenu 选值。 + + 取代 PillSelect 的小胶囊,让「类型」与下面的描述框 / 联系方式框是同一套视觉。 + """ + + currentTextChanged = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._items: list[str] = [] + self._current = "" + self.setFixedHeight(36) + self.setCursor(Qt.PointingHandCursor) # type: ignore[attr-defined] + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 0, 10, 0) + layout.setSpacing(8) + self.textLabel = QLabel(self) + apply_font(self.textLabel, 14, 720) + self.textLabel.setStyleSheet("background: transparent; border: none;") + layout.addWidget(self.textLabel) + layout.addStretch(1) + self.chevron = QLabel(self) + self.chevron.setFixedSize(14, 14) + self.chevron.setStyleSheet("background: transparent; border: none;") + layout.addWidget(self.chevron, 0, Qt.AlignVCenter) # type: ignore[attr-defined] + self._sync() + + def setItems(self, items: list[str], current: str | None = None) -> None: + self._items = list(items) + self.setCurrentText(current if current is not None else (items[0] if items else "")) + + def currentText(self) -> str: + return self._current + + def setCurrentText(self, text: str) -> None: + self._current = text + self.textLabel.setText(text) + self.currentTextChanged.emit(text) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton and self._items: # type: ignore[attr-defined] + menu = RoundMenu(parent=self) + for item in self._items: + action = Action(item) + action.triggered.connect(lambda _=False, text=item: self.setCurrentText(text)) + menu.addAction(action) + menu.exec(self.mapToGlobal(self.rect().bottomLeft())) + event.accept() + return + super().mousePressEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + border = palette.accent_border if (self.underMouse() and self.isEnabled()) else palette.line_soft + draw_rounded_surface(self, palette.field, border, 9) + super().paintEvent(event) + + def _sync(self) -> None: + palette = app_palette() + self.chevron.setPixmap(icon_pixmap(AppIcon.CHEVRON_DOWN, palette.subtle, 14)) + self.textLabel.setStyleSheet(f"color: {palette.text}; background: transparent; border: none;") + + +class _AddImageTile(QFrame): + """与缩略图等大(56×56)的「添加图片」方块,中心 + 号;和缩略图排成一排等大方块。""" + + clicked = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(56, 56) + self.setCursor(Qt.PointingHandCursor) # type: ignore[attr-defined] + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + hovered = self.underMouse() and self.isEnabled() + draw_rounded_surface( + self, + rgba(palette.accent, 0.10 if hovered else 0.05), + rgba(palette.accent, 0.70 if hovered else 0.40), + 9, + ) + size = 22 + pixmap = icon_pixmap(AppIcon.ADD, palette.accent_text, size) + painter = QPainter(self) + painter.drawPixmap((self.width() - size) // 2, (self.height() - size) // 2, pixmap) + + +class FeedbackDialog(AppDialog): + """意见反馈表单弹窗。""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(tr("feedback.title"), icon=AppIcon.MESSAGE, parent=parent, width=540) + self._state = "form" # form | submitting | done + self._attachments: list[tuple[FeedbackAttachment, _AttachmentThumb]] = [] + self._thread: FeedbackSubmitThread | None = None + self._categories = [(value, tr(f"feedback.category.{value}")) for value in CATEGORIES] + self.bodyLayout.setSpacing(15) # 字段组之间留白(组内由 _add_field 收紧) + + # 顶部说明:让用户知道反馈直达开发者、会被处理 + self.intro = self.addBodyText(tr("feedback.intro")) + + # 类型:整宽下拉,与下面的输入框同款 + self.categorySelect = _SelectField(self.widget) + self.categorySelect.setItems([label for _, label in self._categories], current=self._categories[0][1]) + self._add_field(tr("feedback.category.label"), self.categorySelect) + + # 问题描述:定高,避免 QPlainTextEdit 的 Expanding 策略把空输入框撑得过高、显空旷(超出滚动) + self.editor = _PasteTextEdit(parent=self.widget, min_height=112) + self.editor.setFixedHeight(112) + self.editor.setPlaceholderText(tr("feedback.message.placeholder")) + self.editor.imagePasted.connect(self._add_image_qimage) + self.editor.imageFilePasted.connect(self._add_image_file) + self._add_field(tr("feedback.section.message"), self.editor) + + # 截图(选填):计数放小节标题右侧;标题下提示可粘贴;缩略图与「+」添加块排成一排等大方块 + self.countLabel = self._hint_label("") + attach_row = QHBoxLayout() + attach_row.setContentsMargins(0, 0, 0, 0) + attach_row.setSpacing(8) + self.thumbStrip = QHBoxLayout() + self.thumbStrip.setSpacing(8) + self.thumbStrip.setContentsMargins(0, 0, 0, 0) + attach_row.addLayout(self.thumbStrip) + self.addTile = _AddImageTile(self.widget) + self.addTile.clicked.connect(self._pick_images) + attach_row.addWidget(self.addTile, 0, Qt.AlignVCenter) # type: ignore[attr-defined] + attach_row.addStretch(1) + self._add_field( + tr("feedback.section.screenshot"), attach_row, + hint=tr("feedback.screenshot.hint"), header_right=self.countLabel, + ) + + # 联系方式(选填) + self.contactEdit = AppLineEdit("", self.widget) + self.contactEdit.setPlaceholderText(tr("feedback.contact.placeholder")) + self._add_field(tr("feedback.section.contact"), self.contactEdit) + + # 提交状态(默认隐藏) + self.statusLabel = QLabel("", self.widget) + self.statusLabel.setWordWrap(True) + self.statusLabel.setObjectName("feedbackStatus") + apply_font(self.statusLabel, 13, 640) + self.statusLabel.setVisible(False) + self.bodyLayout.addWidget(self.statusLabel) + + # 底栏 + self.addFooterStretch() + self.cancelButton = self.addFooterButton(tr("common.cancel")) + self.cancelButton.clicked.connect(lambda: self.done(0)) + self.submitButton = self.addFooterButton(tr("feedback.submit"), kind="accent") + self.submitButton.clicked.connect(self._on_primary) + + self._refresh_count() + self.syncStyle() + + # ----------------------------------------------------------- helpers + def _add_field(self, label_text: str, content, hint: str | None = None, header_right: QWidget | None = None) -> None: + """一个表单字段:小节标题行(可带右侧元数据)+ 可选提示 + 控件;组内紧凑、组间留白。""" + group = QVBoxLayout() + group.setContentsMargins(0, 0, 0, 0) + group.setSpacing(7) + header = QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + header.addWidget(SectionLabel(label_text, self.widget)) + if header_right is not None: + header.addStretch(1) + header.addWidget(header_right) + group.addLayout(header) + if hint: + group.addWidget(self._hint_label(hint)) + if isinstance(content, QWidget): + group.addWidget(content) + else: + group.addLayout(content) + self.bodyLayout.addLayout(group) + + def _hint_label(self, text: str) -> QLabel: + label = QLabel(text, self.widget) + apply_font(label, 12, 560) + label.setStyleSheet(f"color: {app_palette().subtle}; background: transparent;") + return label + + def extraStyleRules(self, palette) -> str: + return f"QLabel#feedbackStatus {{ color: {palette.text}; background: transparent; }}" + + # ----------------------------------------------------------- attachments + def _add_image_qimage(self, image: QImage) -> None: + if image.isNull(): + return + data = _qimage_to_png(image) + self._add_attachment(f"screenshot-{len(self._attachments) + 1}.png", data, "image/png", QPixmap.fromImage(image)) + + def _add_image_file(self, path: str) -> None: + try: + with open(path, "rb") as fh: + data = fh.read() + except OSError: + return + mime = "image/png" if path.lower().endswith(".png") else "image/jpeg" + pixmap = QPixmap() + pixmap.loadFromData(data) + name = path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] or "screenshot" + self._add_attachment(name, data, mime, pixmap) + + def _pick_images(self) -> None: + paths, _ = QFileDialog.getOpenFileNames( + self, tr("feedback.add_image"), "", "Images (*.png *.jpg *.jpeg)" + ) + for path in paths: + self._add_image_file(path) + + def _add_attachment(self, filename: str, data: bytes, mime: str, pixmap: QPixmap) -> None: + if len(self._attachments) >= MAX_FILES: + self._show_status(tr("feedback.err.too_many_files"), error=True) + return + if len(data) > MAX_FILE_BYTES: + self._show_status(tr("feedback.err.file_too_large"), error=True) + return + attachment = FeedbackAttachment(filename=filename, data=data, mime=mime) + thumb = _AttachmentThumb(pixmap, self.widget) + thumb.removed.connect(self._remove_attachment) + self.thumbStrip.addWidget(thumb) + self._attachments.append((attachment, thumb)) + self.statusLabel.setVisible(False) + self._refresh_count() + + def _remove_attachment(self, thumb: _AttachmentThumb) -> None: + self._attachments = [(a, t) for a, t in self._attachments if t is not thumb] + self.thumbStrip.removeWidget(thumb) + thumb.setParent(None) + thumb.deleteLater() + self._refresh_count() + + def _refresh_count(self) -> None: + self.countLabel.setText(tr("feedback.attach_count", count=len(self._attachments), max=MAX_FILES)) + self.addTile.setVisible(len(self._attachments) < MAX_FILES) # 满 3 张隐藏添加块 + self.addTile.setEnabled(self._state == "form") + + # ----------------------------------------------------------- submit + def _selected_category(self) -> str: + current = self.categorySelect.currentText() + for value, label in self._categories: + if label == current: + return value + return CATEGORIES[0] + + def _on_primary(self) -> None: + if self._state == "done": + self.done(1) + return + if self._state == "submitting": + return + report = FeedbackReport( + category=self._selected_category(), + message=self.editor.toPlainText(), + contact=self.contactEdit.text(), + attachments=[a for a, _ in self._attachments], + logs=collect_recent_logs(), # 默认附带最近日志(已脱敏),帮助定位 + diagnostics=gather_diagnostics(), + ) + try: + report.validate() + except FeedbackValidationError as exc: + self._show_status(self._err_text(exc.code), error=True) + return + self._set_submitting() + self._thread = FeedbackSubmitThread(report, self) + self._thread.succeeded.connect(self._on_succeeded) + self._thread.failed.connect(self._on_failed) + self._thread.start() + + def _set_submitting(self) -> None: + self._state = "submitting" + self.editor.setReadOnly(True) + self.categorySelect.setEnabled(False) + self.contactEdit.setEnabled(False) + self.addTile.setEnabled(False) + self.cancelButton.setEnabled(False) + self.closeButton.setEnabled(False) + self.setClosableOnMaskClicked(False) + self.submitButton.setEnabled(False) + self.submitButton.setText(tr("feedback.submitting")) + self._show_status(tr("feedback.submitting"), error=False) + + def _on_succeeded(self, feedback_id: str) -> None: + # 编号对用户不可查(后端本期无状态查询),不展示;只给"已收到、会处理"的安心反馈。 + self._state = "done" + self._show_status(tr("feedback.success"), error=False) + self.cancelButton.setVisible(False) + self.closeButton.setEnabled(True) + self.submitButton.setEnabled(True) + self.submitButton.setText(tr("feedback.done")) + + def _on_failed(self, code: str, error: str) -> None: + self._state = "form" + self.editor.setReadOnly(False) + self.categorySelect.setEnabled(True) + self.contactEdit.setEnabled(True) + self.cancelButton.setEnabled(True) + self.closeButton.setEnabled(True) + self.setClosableOnMaskClicked(True) + self.submitButton.setEnabled(True) + self.submitButton.setText(tr("feedback.submit")) + self._refresh_count() + self._show_status(self._err_text(code), error=True) + + def _show_status(self, text: str, *, error: bool) -> None: + palette = app_palette() + color = palette.danger if error else palette.accent_text + self.statusLabel.setStyleSheet(f"color: {color}; background: transparent;") + self.statusLabel.setText(text) + self.statusLabel.setVisible(True) + + def _err_text(self, code: str) -> str: + known = { + "message_required", "message_too_long", "contact_too_long", "too_many_files", + "file_type", "file_too_large", "total_too_large", "category_invalid", + "network", "unauthorized", "too_large", "invalid_request", + } + return tr(f"feedback.err.{code}") if code in known else tr("feedback.err.server") + + def done(self, code: int) -> None: + # 提交进行中不允许关闭(Esc/遮罩/关闭钮):避免销毁运行中的线程 + if self._state == "submitting": + return + super().done(code) diff --git a/videocaptioner/ui/components/form_cards.py b/videocaptioner/ui/components/form_cards.py new file mode 100644 index 00000000..fb31cd42 --- /dev/null +++ b/videocaptioner/ui/components/form_cards.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from typing import Any + +from PyQt5.QtCore import QEvent, Qt, pyqtSignal +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget +from qfluentwidgets import BodyLabel, CaptionLabel, IconWidget, PushButton + +from videocaptioner.ui.common.theme_tokens import app_palette + + +class FormGroup(QWidget): + """Grouped form rows with shared card styling.""" + + def __init__(self, title: str, parent=None): + super().__init__(parent) + self._rows: list[tuple[QWidget, QFrame | None]] = [] + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(10) + + self.titleLabel = BodyLabel(title, self) + self.titleLabel.setObjectName("formGroupTitle") + self.layout.addWidget(self.titleLabel) + + self.cardBox = QFrame(self) + self.cardBox.setObjectName("formCardBox") + self.boxLayout = QVBoxLayout(self.cardBox) + self.boxLayout.setContentsMargins(0, 0, 0, 0) + self.boxLayout.setSpacing(0) + self.layout.addWidget(self.cardBox) + + self.syncStyle() + + def addCard(self, card: QWidget) -> None: # noqa: N802 + separator = None + if self._rows: + separator = QFrame(self.cardBox) + separator.setObjectName("formSeparator") + separator.setFixedHeight(1) + self.boxLayout.addWidget(separator) + + self.boxLayout.addWidget(card) + card.installEventFilter(self) + self._rows.append((card, separator)) + self._refresh_separators() + + def eventFilter(self, watched, event): # noqa: N802 + if event.type() in {QEvent.Show, QEvent.Hide}: + self._refresh_separators() + return super().eventFilter(watched, event) + + def _refresh_separators(self) -> None: + has_visible_before = False + for card, separator in self._rows: + is_visible = card.isVisible() + if separator is not None: + separator.setVisible(is_visible and has_visible_before) + if is_visible: + has_visible_before = True + + def syncStyle(self) -> None: # noqa: N802 + palette = app_palette() + self.setStyleSheet( + f""" + QLabel#formGroupTitle {{ + color: {palette.text}; + font-size: 17px; + font-weight: bold; + background: transparent; + }} + QFrame#formCardBox {{ + border: 1px solid {palette.line}; + border-radius: 12px; + background: {palette.panel}; + }} + QFrame#formSeparator {{ + border: 0; + background: {palette.line_soft}; + }} + """ + ) + for card, _separator in self._rows: + sync_style = getattr(card, "syncStyle", None) + if callable(sync_style): + sync_style() + + +class FormCard(QFrame): + clicked = pyqtSignal() + + def __init__(self, icon: Any, title: str, content: str = "", parent=None): + super().__init__(parent) + self._content = content or "" + self.setObjectName("formCard") + self.setMinimumHeight(70) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + self.rootLayout = QHBoxLayout(self) + self.rootLayout.setContentsMargins(18, 12, 18, 12) + self.rootLayout.setSpacing(20) + + self.iconWidget = IconWidget(icon, self) + self.iconWidget.setFixedSize(0, 0) + self.iconWidget.hide() + self.rootLayout.addWidget(self.iconWidget, 0, Qt.AlignVCenter) # type: ignore[arg-type] + + self.textLayout = QVBoxLayout() + self.textLayout.setContentsMargins(0, 0, 0, 0) + self.textLayout.setSpacing(4) + self.titleLabel = BodyLabel(title, self) + self.contentLabel = CaptionLabel(self._content, self) + self.contentLabel.setWordWrap(False) + self.titleLabel.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) + self.contentLabel.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) + self.textLayout.addWidget(self.titleLabel) + if self._content: + self.textLayout.addWidget(self.contentLabel) + self.rootLayout.addLayout(self.textLayout, 1) + + self.controlLayout = QHBoxLayout() + self.controlLayout.setContentsMargins(0, 0, 0, 0) + self.controlLayout.setSpacing(8) + self.controlLayout.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + self.rootLayout.addLayout(self.controlLayout, 0) + + self.syncStyle() + + def setContent(self, content: str) -> None: # noqa: N802 + self._content = content or "" + self.contentLabel.setText(self._content) + self.contentLabel.setVisible(bool(self._content)) + + def mouseReleaseEvent(self, event): # noqa: N802 + if event.button() == Qt.LeftButton: + self.clicked.emit() + super().mouseReleaseEvent(event) + + def syncStyle(self) -> None: # noqa: N802 + self.setStyleSheet( + """ + QFrame#formCard { + border: 0; + border-radius: 0; + background: transparent; + } + QFrame#formCard:hover { + background: transparent; + } + QLabel { + background: transparent; + } + """ + ) + + +class PushFormCard(FormCard): + def __init__(self, text: str, icon: Any, title: str, content: str = "", parent=None): + super().__init__(icon, title, content, parent) + self.button = PushButton(text, self) + self.button.setFixedWidth(112) + self.button.setFixedHeight(40) + self.controlLayout.addWidget(self.button) + self.button.clicked.connect(self.clicked) + self.syncStyle() + + def syncStyle(self) -> None: # noqa: N802 + super().syncStyle() + if not hasattr(self, "button"): + return + palette = app_palette() + self.button.setStyleSheet( + f""" + PushButton {{ + color: {palette.text}; + background: {palette.field}; + border: 1px solid {palette.line_soft}; + border-radius: 8px; + font-weight: bold; + }} + PushButton:hover {{ + border-color: {palette.accent}; + }} + """ + ) diff --git a/videocaptioner/ui/components/inspector_controls.py b/videocaptioner/ui/components/inspector_controls.py new file mode 100644 index 00000000..958816b4 --- /dev/null +++ b/videocaptioner/ui/components/inspector_controls.py @@ -0,0 +1,428 @@ +# coding:utf-8 +"""检查器/参数面板通用控件:参数行 + 参数分组 + 取色控件 + 省略标签 + 样式卡。 + +这里放的是「带标签的参数行、可分组的参数面板、取色控件、省略标签」等可复用组合, +以及样式库卡片 StyleCard。数值步进器、胶囊下拉、分段、状态胶囊等更底层的原子复用 +workbench。 +""" + +from __future__ import annotations + +from typing import Optional + +from PyQt5.QtCore import QRectF, QSize, Qt, pyqtSignal +from PyQt5.QtGui import QColor, QPainter, QPainterPath +from PyQt5.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) + +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.workbench import ( + CompactButton, + DangerButton, + IconBox, + StatusPill, + apply_font, + draw_rounded_surface, + icon_pixmap, +) +from videocaptioner.ui.i18n import tr + + +class ColorSwatch(QFrame): + """抗锯齿色块:自绘圆角填充 + 细描边。QSS border-radius 无抗锯齿会留锯齿, + 色块/色点统一用它。支持带 alpha 的颜色(圆角背景色)。""" + + def __init__(self, color: QColor, size: int = 18, radius: int = 6, parent=None): + super().__init__(parent) + self._color = QColor(color) + self._radius = radius + self.setFixedSize(size, size) + + def setColor(self, color: QColor): + self._color = QColor(color) + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing, True) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + path = QPainterPath() + path.addRoundedRect(rect, self._radius, self._radius) + painter.fillPath(path, self._color) + pen = painter.pen() + pen.setColor(QColor(255, 255, 255, 60)) + pen.setWidthF(1.0) + painter.setPen(pen) + painter.drawPath(path) + + +class ElideLabel(QLabel): + """单行省略标签:文本超出宽度时尾部省略,且最小宽度为 0, + + 不会把所在卡片撑宽到滚动区视口之外(否则在关闭横向滚动的列表里右侧被裁)。 + """ + + def __init__(self, text: str = "", mode=Qt.ElideRight, parent=None): + super().__init__(parent) + self._full = text + self._mode = mode + self._elide() + + def setText(self, text: str): + self._full = text + self._elide() + + def fullText(self) -> str: + return self._full + + def minimumSizeHint(self) -> QSize: + hint = super().minimumSizeHint() + return QSize(0, hint.height()) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._elide() + + def _elide(self): + width = self.width() + if width <= 0: + QLabel.setText(self, self._full) + return + QLabel.setText(self, self.fontMetrics().elidedText(self._full, self._mode, width)) + + +class ColorValueControl(QFrame): + """取色控件:色点 + 取值文本,点击弹系统取色盘。 + + ``alpha=True`` 时显示透明度百分比并允许调整 alpha(圆角背景色用)。 + """ + + colorChanged = pyqtSignal(QColor) + + def __init__(self, color: QColor, title: str = "", alpha: bool = False, parent=None, width: int = 150): + super().__init__(parent) + self.setObjectName("styleColorControl") + self._title = title + self._alpha = alpha + self._color = QColor(color) + self.setFixedHeight(34) + self.setFixedWidth(width) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + layout = QHBoxLayout(self) + layout.setContentsMargins(11, 0, 12, 0) + layout.setSpacing(9) + self.dot = ColorSwatch(self._color, 18, 6, self) + layout.addWidget(self.dot) + self.textLabel = QLabel(self) + self.textLabel.setObjectName("styleColorText") + apply_font(self.textLabel, 13, 800) + layout.addWidget(self.textLabel, 1) + self._refresh() + self.syncStyle() + + def color(self) -> QColor: + return QColor(self._color) + + def setColor(self, color: QColor, *, emit: bool = False): + self._color = QColor(color) + self._refresh() + if emit: + self.colorChanged.emit(self.color()) + + def _refresh(self): + # 色点显示真实颜色(含透明度时叠棋盘格才直观,这里仍以纯色点为主); + # 文本统一显示十六进制色值,透明度由色点 alpha 体现,避免只显示「90%」难以辨色。 + self.dot.setColor(QColor(self._color)) + self.textLabel.setText(self._color.name(QColor.HexRgb).upper()) + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + from videocaptioner.ui.components.color_picker import ColorPickerDialog + + title = (tr("inspector.color.pick_prefix") + self._title) if self._title else tr("inspector.color.pick") + color = ColorPickerDialog.get_color( + self._color, parent=self.window(), alpha=self._alpha, title=title + ) + if color is not None: + self.setColor(color, emit=True) + event.accept() + return + super().mousePressEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + hovered = self.underMouse() and self.isEnabled() + border = rgba(palette.accent, 0.6) if hovered else palette.line + draw_rounded_surface(self, palette.field, border, 9) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet("QFrame#styleColorControl { background: transparent; border: none; }") + self.textLabel.setStyleSheet( + f"color: {palette.text}; background: transparent; border: none;" + ) + self._refresh() + self.update() + + +class InspectorRow(QFrame): + """参数行:[图标] 名称 ……… [控件],右侧控件由调用方提供。""" + + def __init__(self, icon: AppIcon, label: str, control: QWidget, hint: str = "", parent=None): + super().__init__(parent) + self.setObjectName("inspectorRow") + self.setMinimumHeight(50) + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(12) + + self.iconLabel = QLabel(self) + self.iconLabel.setFixedSize(16, 16) + layout.addWidget(self.iconLabel) + self._icon = icon + + text_box = QVBoxLayout() + text_box.setSpacing(1) + self.labelLabel = QLabel(label, self) + self.labelLabel.setObjectName("inspectorRowLabel") + apply_font(self.labelLabel, 14, 830) + text_box.addWidget(self.labelLabel) + self.hintLabel: Optional[QLabel] = None + if hint: + self.hintLabel = QLabel(hint, self) + self.hintLabel.setObjectName("inspectorRowHint") + apply_font(self.hintLabel, 12, 720) + text_box.addWidget(self.hintLabel) + layout.addLayout(text_box, 1) + + self.control = control + control.setParent(self) + layout.addWidget(control, 0, Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + self.syncStyle() + + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.card_surface, palette.line_soft, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet("QFrame#inspectorRow { background: transparent; border: none; }") + self.iconLabel.setPixmap(icon_pixmap(self._icon, palette.muted, 16)) + self.labelLabel.setStyleSheet( + f"color: {palette.text}; background: transparent; border: none;" + ) + if self.hintLabel is not None: + self.hintLabel.setStyleSheet( + f"color: {palette.muted}; background: transparent; border: none;" + ) + self.update() + + +class InspectorGroup(QWidget): + """参数分组:标题 + 侧注 + 若干参数行。""" + + def __init__(self, title: str, side: str = "", parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + + head = QHBoxLayout() + head.setContentsMargins(2, 0, 2, 0) + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("inspectorGroupTitle") + apply_font(self.titleLabel, 15, 880) + head.addWidget(self.titleLabel) + head.addStretch(1) + self.sideLabel = QLabel(side, self) + self.sideLabel.setObjectName("inspectorGroupSide") + apply_font(self.sideLabel, 12, 720) + head.addWidget(self.sideLabel) + layout.addLayout(head) + + self._rows = QVBoxLayout() + self._rows.setContentsMargins(0, 0, 0, 0) + self._rows.setSpacing(8) + layout.addLayout(self._rows) + self.syncStyle() + + def addRow(self, row: QWidget) -> QWidget: + self._rows.addWidget(row) + return row + + def setSide(self, text: str): + self.sideLabel.setText(text) + + def syncStyle(self): + palette = app_palette() + self.titleLabel.setStyleSheet( + f"color: {palette.text}; background: transparent; border: none;" + ) + self.sideLabel.setStyleSheet( + f"color: {palette.muted}; background: transparent; border: none;" + ) + + +class StyleCard(QFrame): + """样式库卡片:图标 + 名称 + 状态胶囊 + 色块 + 分隔线 + 动作按钮。 + + 动作按钮常驻(不再选中才展开):用户样式给「复制 / 重命名 / 删除」,内置样式给 + 「复制」一键 fork。选中态主题色描边 + 胶囊显「当前」。点击卡片主体发 clicked。 + """ + + clicked = pyqtSignal(str) + duplicateRequested = pyqtSignal(str) + renameRequested = pyqtSignal(str) + deleteRequested = pyqtSignal(str) + + def __init__( + self, + style_id: str, + name: str, + swatches: list[str], + editable: bool, + icon: AppIcon, + parent=None, + ): + super().__init__(parent) + self.setObjectName("styleCard") + self.style_id = style_id + self._editable = editable + self._active = False + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + self.setFixedHeight(126) + + outer = QVBoxLayout(self) + outer.setContentsMargins(12, 11, 12, 11) + outer.setSpacing(8) + + # 顶行:图标 + 名称 + 状态胶囊(当前 / 我的 / 内置) + main = QHBoxLayout() + main.setSpacing(10) + self.iconBox = IconBox(icon, self, size=30) + main.addWidget(self.iconBox, 0, Qt.AlignVCenter) # type: ignore[arg-type] + self.nameLabel = ElideLabel(name, parent=self) + self.nameLabel.setObjectName("styleCardName") + apply_font(self.nameLabel, 14, 880) + main.addWidget(self.nameLabel, 1, Qt.AlignVCenter) # type: ignore[arg-type] + self.sourcePill = StatusPill( + tr("inspector.style.source.mine") if editable else tr("inspector.style.source.builtin"), + "neutral", + self, + ) + main.addWidget(self.sourcePill, 0, Qt.AlignVCenter) # type: ignore[arg-type] + outer.addLayout(main) + + # 色块行 + self._swatch_widgets: list[ColorSwatch] = [] + self.swatchRow = QHBoxLayout() + self.swatchRow.setSpacing(5) + for color in swatches[:4]: + dot = ColorSwatch(QColor(color), 16, 5, self) + self.swatchRow.addWidget(dot) + self._swatch_widgets.append(dot) + self.swatchRow.addStretch(1) + outer.addLayout(self.swatchRow) + + # 分隔线 + self.divider = QFrame(self) + self.divider.setFixedHeight(1) + outer.addWidget(self.divider) + + # 动作按钮行(常驻,居中陈列;窄卡片用更紧的内边距 + 图标) + self.actionsRow = QWidget(self) + actions = QHBoxLayout(self.actionsRow) + actions.setContentsMargins(0, 0, 0, 0) + actions.setSpacing(7) + self.duplicateButton = CompactButton(tr("common.copy"), AppIcon.COPY, self.actionsRow, pad_h=8) + self.duplicateButton.clicked.connect(lambda: self.duplicateRequested.emit(self.style_id)) + if editable: + self.renameButton = CompactButton(tr("inspector.style.rename"), AppIcon.EDIT, self.actionsRow, pad_h=8) + self.deleteButton = DangerButton(tr("common.delete"), AppIcon.DELETE, self.actionsRow, pad_h=8) + self.renameButton.clicked.connect(lambda: self.renameRequested.emit(self.style_id)) + self.deleteButton.clicked.connect(lambda: self.deleteRequested.emit(self.style_id)) + self._buttons = (self.duplicateButton, self.renameButton, self.deleteButton) + actions.addStretch(1) + for btn in self._buttons: + actions.addWidget(btn) + actions.addStretch(1) + else: + # 内置只读:仅给「复制」做 fork 入口,整行铺满更有存在感(不留空白) + self.renameButton = None + self.deleteButton = None + self._buttons = (self.duplicateButton,) + actions.addWidget(self.duplicateButton, 1) + outer.addWidget(self.actionsRow) + self.syncStyle() + + def setActive(self, active: bool): + # 选中由卡片描边(paintEvent)表达,不改写标签——保留「我的 / 内置」原始 tag 不变 + self._active = active + self.update() + + def isActive(self) -> bool: + return self._active + + def setSwatches(self, colors: list[str]): + """编辑后颜色变化时刷新色块(多退少补,保持横向陈列宽度不变)。""" + colors = colors[:4] + for idx, color in enumerate(colors): + if idx < len(self._swatch_widgets): + self._swatch_widgets[idx].setColor(QColor(color)) + else: + dot = ColorSwatch(QColor(color), 16, 5, self) + self.swatchRow.insertWidget(idx, dot) + self._swatch_widgets.append(dot) + while len(self._swatch_widgets) > len(colors): + dot = self._swatch_widgets.pop() + dot.setParent(None) + dot.deleteLater() + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + # 动作区内部的点击由各按钮处理,这里只接卡片主体 + child = self.childAt(event.pos()) + if child is None or not self.actionsRow.isAncestorOf(child): + self.clicked.emit(self.style_id) + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + palette = app_palette() + if self._active: + draw_rounded_surface(self, rgba(palette.accent, 0.085), rgba(palette.accent, 0.72), 13) + else: + draw_rounded_surface(self, palette.card_surface, palette.line_soft, 13) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet("QFrame#styleCard { background: transparent; border: none; }") + self.nameLabel.setStyleSheet( + f"color: {palette.text}; background: transparent; border: none;" + ) + self.divider.setStyleSheet(f"background: {palette.line_soft}; border: none;") + self.actionsRow.setStyleSheet("background: transparent; border: none;") + self.iconBox.syncStyle() + self.sourcePill.syncStyle() + for btn in self._buttons: + btn.syncStyle() + self.update() diff --git a/videocaptioner/ui/components/live_caption/__init__.py b/videocaptioner/ui/components/live_caption/__init__.py new file mode 100644 index 00000000..18503dfc --- /dev/null +++ b/videocaptioner/ui/components/live_caption/__init__.py @@ -0,0 +1,4 @@ +"""实时字幕页面的第一方 UI 组件(session / history / detail 三视图共用)。 + +颜色一律走 ``theme_tokens.app_palette()``,组件复用 ``ui/components/workbench``。 +""" diff --git a/videocaptioner/ui/components/live_caption/player.py b/videocaptioner/ui/components/live_caption/player.py new file mode 100644 index 00000000..c471ba32 --- /dev/null +++ b/videocaptioner/ui/components/live_caption/player.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +"""详情页底部的音频播放条:播放/暂停、点击/拖动 seek 的进度条、倍速。 + +按句跳转由转录时间线点句驱动(``seek_sentence``),当前播放句通过 ``sentenceChanged`` +回传给转录时间线高亮。用 QMediaPlayer 播录制的 WAV。 +""" + +from __future__ import annotations + +from typing import List, Optional + +from PyQt5.QtCore import Qt, QUrl, pyqtSignal +from PyQt5.QtGui import QColor, QPainter, QPainterPath +from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget + +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.workbench import apply_font, draw_rounded_surface + + +def _fmt(ms: float) -> str: + s = max(0, int(ms // 1000)) + m, s = divmod(s, 60) + h, m = divmod(m, 60) + return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}" + + +class _PlayButton(QFrame): + """圆形主操作按钮:播放三角 / 暂停双竖条自绘。""" + + clicked = pyqtSignal() + + def __init__(self, parent=None, diameter: int = 44) -> None: + super().__init__(parent) + self._d = diameter + self._playing = False + self.setFixedSize(diameter, diameter) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + def set_playing(self, playing: bool) -> None: + self._playing = playing + self.update() + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + super().mouseReleaseEvent(event) + + def paintEvent(self, event) -> None: # noqa: ARG002 + p = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + from PyQt5.QtCore import QRectF + + path = QPainterPath() + path.addEllipse(QRectF(self.rect().adjusted(1, 1, -1, -1))) + painter.fillPath(path, QColor(p.accent)) + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + painter.setBrush(QColor(p.accent_fg)) + cx, cy = self.width() / 2, self.height() / 2 + if self._playing: + bw, bh, gap = 4, 14, 4 + painter.drawRoundedRect(round(cx - gap / 2 - bw), round(cy - bh / 2), bw, bh, 1.5, 1.5) + painter.drawRoundedRect(round(cx + gap / 2), round(cy - bh / 2), bw, bh, 1.5, 1.5) + else: + tri = QPainterPath() + tri.moveTo(cx - 6, cy - 8) + tri.lineTo(cx + 9, cy) + tri.lineTo(cx - 6, cy + 8) + tri.closeSubpath() + painter.fillPath(tri, QColor(p.accent_fg)) + + +class _ProgressBar(QWidget): + """进度轨道:已播放填充 + 播放头手柄 + 点击/拖动 seek;按句跳转走转录时间线点句(sentenceChanged 联动)。""" + + seekRatio = pyqtSignal(float) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setFixedHeight(18) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + self.setMouseTracking(True) + self._ratio = 0.0 + self._dragging = False + self._hover = False + + def set_ratio(self, ratio: float) -> None: + self._ratio = max(0.0, min(1.0, ratio)) + self.update() + + def _ratio_at(self, x: int) -> float: + return max(0.0, min(1.0, x / max(1, self.width()))) + + def mousePressEvent(self, event) -> None: + if event.button() != Qt.LeftButton: # type: ignore[attr-defined] + return + self._dragging = True + r = self._ratio_at(event.pos().x()) + self.set_ratio(r) + self.seekRatio.emit(r) + + def mouseMoveEvent(self, event) -> None: + if self._dragging: + r = self._ratio_at(event.pos().x()) + self.set_ratio(r) # 拖动时实时预览填充,松手即定位 + self.seekRatio.emit(r) + + def mouseReleaseEvent(self, event) -> None: # noqa: ARG002 + self._dragging = False + + def enterEvent(self, event) -> None: # noqa: ARG002 + self._hover = True + self.update() + + def leaveEvent(self, event) -> None: # noqa: ARG002 + self._hover = False + self.update() + + def paintEvent(self, event) -> None: # noqa: ARG002 + p = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + cy = self.height() / 2 + track_h = 4 + # 底轨 + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + painter.setBrush(QColor(*_t(rgba(p.text, 0.12)))) + painter.drawRoundedRect(0, round(cy - track_h / 2), self.width(), track_h, 2, 2) + # 已播放 + fill = round(self.width() * self._ratio) + if fill > 0: + painter.setBrush(QColor(p.accent)) + painter.drawRoundedRect(0, round(cy - track_h / 2), fill, track_h, 2, 2) + # 播放头手柄(hover / 拖动时放大),固定显示当前位置,支持拖动 scrub + from PyQt5.QtCore import QPointF + + kr = 7.0 if (self._hover or self._dragging) else 5.0 + knob_x = min(max(self.width() * self._ratio, kr), self.width() - kr) + painter.setBrush(QColor(p.accent)) + painter.drawEllipse(QPointF(knob_x, cy), kr, kr) + + +def _t(value: str): + c = QColor() + text = value.strip() + if text.startswith("rgba"): + parts = text[text.index("(") + 1 : text.rindex(")")].split(",") + r, g, b = (int(float(x)) for x in parts[:3]) + return (r, g, b, round(float(parts[3]) * 255)) + c = QColor(text) + return (c.red(), c.green(), c.blue(), c.alpha()) + + +_SPEEDS = [1.0, 1.25, 1.5, 2.0, 0.75] + + +class AudioPlayerBar(QFrame): + """整条播放器面板。""" + + sentenceChanged = pyqtSignal(int) # 当前播放到第 i 句(-1 无) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setObjectName("lcPlayer") + self.setFixedHeight(64) + self._starts: List[float] = [] + self._duration_ms = 0 + self._speed_idx = 0 + self._cur_sentence = -1 + # 媒体未加载完时 setPosition 被 AVFoundation 丢弃 → 暂存目标、加载完成后补跳(否则首次点 + # 句子不跳)。 + self._pending_seek_ms: Optional[int] = None + + self._player = QMediaPlayer(self) + self._player.positionChanged.connect(self._on_position) + self._player.durationChanged.connect(self._on_duration) + self._player.stateChanged.connect(self._on_state) + self._player.mediaStatusChanged.connect(self._on_media_status) + + # 单行播放条:播放/暂停 · 当前时间 · 进度条 · 总时长 · 倍速。 + row = QHBoxLayout(self) + row.setContentsMargins(20, 0, 20, 0) + row.setSpacing(14) + self._play = _PlayButton(self) + self._play.clicked.connect(self.toggle) + row.addWidget(self._play) + self._cur = QLabel("00:00", self) + self._total = QLabel("00:00", self) + for lab in (self._cur, self._total): + apply_font(lab, 13, 800) + self._bar = _ProgressBar(self) + self._bar.seekRatio.connect(self._seek_ratio) + row.addWidget(self._cur) + row.addWidget(self._bar, 1) + row.addWidget(self._total) + + from videocaptioner.ui.components.workbench import CompactButton + + self._speed = CompactButton("1.0x", parent=self) + self._speed.clicked.connect(self._cycle_speed) + row.addWidget(self._speed) + + self._refresh_labels() + + # ----- 装载 ----- + + def load(self, audio_path, starts: List[float], total_s: float) -> None: + self._starts = list(starts) + self._duration_ms = int(total_s * 1000) + self._total.setText(_fmt(self._duration_ms)) + self._cur.setText("00:00") + self._cur_sentence = 0 if self._starts else -1 + self._pending_seek_ms = None + if audio_path is not None: + self._player.setMedia(QMediaContent(QUrl.fromLocalFile(str(audio_path)))) + else: + self._player.setMedia(QMediaContent()) + + def has_audio(self) -> bool: + return not self._player.media().isNull() + + def stop(self) -> None: + self._player.stop() + + # ----- 控制 ----- + + def toggle(self) -> None: + if not self.has_audio(): + return + if self._player.state() == QMediaPlayer.PlayingState: + self._player.pause() + else: + self._player.play() + + def play(self) -> None: + if self.has_audio() and self._player.state() != QMediaPlayer.PlayingState: + self._player.play() + + def _apply_position(self, ms: int) -> None: + """seek 统一入口:媒体已就绪直接定位;未就绪则暂存,由 ``_on_media_status`` 加载完后补跳。""" + ms = int(max(0, ms)) + ready = self._player.mediaStatus() in ( + QMediaPlayer.LoadedMedia, + QMediaPlayer.BufferingMedia, + QMediaPlayer.BufferedMedia, + QMediaPlayer.EndOfMedia, + ) + self._pending_seek_ms = None if ready else ms + self._player.setPosition(ms) + + def _on_media_status(self, status) -> None: + if status in (QMediaPlayer.LoadedMedia, QMediaPlayer.BufferedMedia): + if self._pending_seek_ms is not None: + self._player.setPosition(self._pending_seek_ms) + self._pending_seek_ms = None + + def seek_sentence(self, index: int) -> None: + if 0 <= index < len(self._starts): + self._apply_position(int(self._starts[index] * 1000)) + self._set_sentence(index) + + def seek_relative(self, delta_ms: int) -> None: + """快捷键 ←/→ 相对快退/快进。""" + if not self.has_audio(): + return + pos = self._player.position() + delta_ms + if self._duration_ms: + pos = min(pos, self._duration_ms) + self._apply_position(int(max(0, pos))) + + def step_sentence(self, delta: int) -> None: + """快捷键 ↑/↓ 上一句 / 下一句。""" + if not self._starts: + return + base = self._cur_sentence if self._cur_sentence >= 0 else 0 + self.seek_sentence(max(0, min(len(self._starts) - 1, base + delta))) + + def _seek_ratio(self, ratio: float) -> None: + if self._duration_ms: + self._apply_position(int(ratio * self._duration_ms)) + + def _cycle_speed(self) -> None: + self._speed_idx = (self._speed_idx + 1) % len(_SPEEDS) + rate = _SPEEDS[self._speed_idx] + self._player.setPlaybackRate(rate) + self._speed.setText(f"{rate:g}x") + + # ----- 播放器回调 ----- + + def _on_duration(self, dur: int) -> None: + if dur > 0: + self._duration_ms = dur + self._total.setText(_fmt(dur)) + + def _on_position(self, pos: int) -> None: + self._cur.setText(_fmt(pos)) + if self._duration_ms: + self._bar.set_ratio(pos / self._duration_ms) + # 当前句 = 起点 <= pos 的最后一句 + sec = pos / 1000.0 + idx = -1 + for i, st in enumerate(self._starts): + if st <= sec + 0.05: + idx = i + else: + break + if idx != self._cur_sentence: + self._set_sentence(idx) + + def _on_state(self, state) -> None: + self._play.set_playing(state == QMediaPlayer.PlayingState) + + def _set_sentence(self, index: int) -> None: + self._cur_sentence = index + self.sentenceChanged.emit(index) + + def _refresh_labels(self) -> None: + p = app_palette() + self._cur.setStyleSheet(f"color:{p.text};background:transparent;") + self._total.setStyleSheet(f"color:{p.text};background:transparent;") + + def paintEvent(self, event) -> None: # noqa: ARG002 + p = app_palette() + draw_rounded_surface(self, p.panel, p.line_soft, 14) diff --git a/videocaptioner/ui/components/live_caption/transcript.py b/videocaptioner/ui/components/live_caption/transcript.py new file mode 100644 index 00000000..652d0264 --- /dev/null +++ b/videocaptioner/ui/components/live_caption/transcript.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +"""转录时间线:左轨(声波圆点 + 时间)+ 右气泡(原文 / 译文)。 + +实时会话与历史详情共用:实时态末条为「当前句」(绿描边 + 未定稿尾巴变暗),详情态高亮播放中的一条。 +显示模式双语 / 原文 / 译文,右键逐句复制。颜色走 app_palette。 +""" + +from __future__ import annotations + +from typing import List, Optional + +from PyQt5.QtCore import QSize, Qt, QTimer, pyqtSignal +from PyQt5.QtGui import QColor, QFontMetrics, QPainter +from PyQt5.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.live_caption.typing import TYPE_INTERVAL_MS, next_visible +from videocaptioner.ui.components.workbench import apply_font +from videocaptioner.ui.i18n import tr + +DISPLAY_BILINGUAL = "bilingual" +DISPLAY_SOURCE = "source" +DISPLAY_TARGET = "target" + +_RAIL_W = 54 +_DOT = 36 + + +def _escape(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +class _VoiceDot(QWidget): + """头像轨上的声波圆点:圆形描边 + 4 根主题绿声波条。""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setFixedSize(_DOT, _DOT) + self._tone = "idle" # idle / live / playing + self._bars = (8, 14, 18, 11) + + def set_tone(self, tone: str) -> None: + if tone != self._tone: + self._tone = tone + self.update() + + def paintEvent(self, event) -> None: # noqa: ARG002 + p = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + if self._tone == "live": + bg, border = rgba(p.accent, 0.14), rgba(p.accent, 0.5) + elif self._tone == "playing": + bg, border = rgba(p.accent, 0.11), rgba(p.accent, 0.44) + else: + bg, border = rgba(p.accent, 0.10), rgba(p.accent, 0.32) + from PyQt5.QtCore import QRectF + from PyQt5.QtGui import QPainterPath, QPen + + path = QPainterPath() + path.addEllipse(QRectF(self.rect().adjusted(1, 1, -1, -1))) + painter.fillPath(path, QColor(*_rgba_tuple(bg))) + painter.setPen(QPen(QColor(*_rgba_tuple(border)), 1)) + painter.drawPath(path) + # 4 根声波竖条 + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + painter.setBrush(QColor(p.accent)) + bar_w = 3 + gap = 3 + total = 4 * bar_w + 3 * gap + x = (self.width() - total) / 2 + cy = self.height() / 2 + opac = (0.76, 1.0, 1.0, 0.82) + for i, h in enumerate(self._bars): + painter.setOpacity(opac[i]) + painter.drawRoundedRect(round(x), round(cy - h / 2), bar_w, h, 1.5, 1.5) + x += bar_w + gap + painter.setOpacity(1.0) + + +def _rgba_tuple(value: str): + c = QColor() + text = value.strip() + if text.startswith("rgba"): + parts = text[text.index("(") + 1 : text.rindex(")")].split(",") + r, g, b = (int(float(x)) for x in parts[:3]) + return (r, g, b, round(float(parts[3]) * 255)) + c = QColor(text) + return (c.red(), c.green(), c.blue(), c.alpha()) + + +class TranscriptEntry(QFrame): + """一条字幕气泡(时间线节点)。""" + + clicked = pyqtSignal() + copyAllRequested = pyqtSignal() # 右键「复制全部」→ 由 TranscriptList 处理 + + def __init__(self, parent=None, selectable: bool = False) -> None: + super().__init__(parent) + self.setObjectName("lcEntry") + self._selectable = selectable + self._current = False + self._playing = False + self._last = False + self._display = DISPLAY_BILINGUAL + self._source = "" + self._target = "" + self._stable_len: Optional[int] = None + self._shown = 0 # 当前句原文已逐字揭示的字数 + self._tgt_text = "" # 译文动画当前可见串(公共前缀不动、尾字原位改写、只前进不缩短) + self._typer = QTimer(self) + self._typer.setInterval(TYPE_INTERVAL_MS) # 速度集中在 typing.py 调 + self._typer.timeout.connect(self._advance_typing) + self._vis_src = "" # 算高度用的「完整」原文(按整段定高,打字时不抖) + self._vis_tgt = "" # 算高度用的「完整」译文 + self._h_floor = 0 # 当前句高度地板:动画期间只增不减(重译变短/删后缀时不塌) + self._h_floor_w = -1 # 地板对应宽度;宽度变则按新宽重置 + # 高度按内容精确(sizeHint 自算),避免顶部 stretch 把 wordwrap 误差吸收成「中段一大片空白」 + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + + row = QHBoxLayout(self) + row.setContentsMargins(0, 0, 0, 14) # 条目间距 + row.setSpacing(0) + + rail = QWidget(self) + rail.setFixedWidth(_RAIL_W) + rl = QVBoxLayout(rail) + rl.setContentsMargins(6, 3, 6, 0) + rl.setSpacing(5) + self._dot = _VoiceDot(rail) + rl.addWidget(self._dot, 0, Qt.AlignTop | Qt.AlignHCenter) # type: ignore[operator] + # 时间放在左侧竖条节点下方 + self._time = QLabel(rail) + apply_font(self._time, 11, 760) + self._time.setAlignment(Qt.AlignHCenter) # type: ignore[arg-type] + rl.addWidget(self._time, 0, Qt.AlignHCenter) # type: ignore[arg-type] + rl.addStretch(1) + row.addWidget(rail) + + self._bubble = QFrame(self) + self._bubble.setObjectName("lcBubble") + bl = QVBoxLayout(self._bubble) + bl.setContentsMargins(18, 13, 18, 13) + bl.setSpacing(5) + self._src = QLabel(self._bubble) + self._src.setWordWrap(True) + self._src.setTextFormat(Qt.RichText) # type: ignore[arg-type] + apply_font(self._src, 14, 650) + self._tgt = QLabel(self._bubble) + self._tgt.setWordWrap(True) + self._tgt.setTextFormat(Qt.RichText) # type: ignore[arg-type] + apply_font(self._tgt, 18, 850) + # 换行标签按内容求最小高度,长原文/译文末行不被裁 + for lbl in (self._src, self._tgt): + lbl.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + lbl.setMinimumHeight(0) + bl.addWidget(self._src) + bl.addWidget(self._tgt) + row.addWidget(self._bubble, 1) + + # 整条可点击跳转,需子件透传鼠标事件(否则点击被吞)。selectable 时原文/译文可选中复制 + # (IBeam,不透传),点圆点/竖条/气泡空白仍跳转;非 selectable 全透传,纯点跳转。 + transparent = [self._bubble, self._time, self._dot, rail] + if selectable: + for lbl in (self._src, self._tgt): + lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) # type: ignore[arg-type] + lbl.setCursor(Qt.IBeamCursor) # type: ignore[arg-type] + # 右键透传到本条目,由 contextMenuEvent 弹「复制」菜单 + lbl.setContextMenuPolicy(Qt.PreventContextMenu) # type: ignore[arg-type] + else: + transparent += [self._src, self._tgt] + for w in transparent: + w.setAttribute(Qt.WA_TransparentForMouseEvents, True) # type: ignore[arg-type] + self.setMouseTracking(True) + self._refresh_styles() + + # ----- 复制(右键逐句) ----- + + def _own_text(self) -> str: + """本句纯文本(原文 + 译文)。""" + src = (self._source or "").strip() + tgt = (self._target or "").strip() + return f"{src}\n{tgt}" if (src and tgt and tgt != src) else (src or tgt) + + def contextMenuEvent(self, event) -> None: # noqa: N802 + """右键逐句复制:复制本句 / 复制全部。""" + if not self._selectable: + return super().contextMenuEvent(event) + from PyQt5.QtWidgets import QApplication, QMenu + + menu = QMenu(self) + act_one = menu.addAction(tr("livetx.menu.copy_sentence")) + act_all = menu.addAction(tr("livetx.menu.copy_all")) + chosen = menu.exec_(event.globalPos()) + if chosen is act_one: + text = self._own_text() + if text: + QApplication.clipboard().setText(text) + elif chosen is act_all: + self.copyAllRequested.emit() + + # ----- 数据 ----- + + def set_last(self, last: bool) -> None: + self._last = last + self.update() + + def set_state(self, current: bool = False, playing: bool = False) -> None: + was_current = self._current + self._current, self._playing = current, playing + self._dot.set_tone("live" if current else ("playing" if playing else "idle")) + if not current and was_current: # 当前句变历史:停止揭示、整段立即显示 + self._shown = len(self._source) + self._tgt_text = self._target + self._h_floor, self._h_floor_w = 0, -1 # 沉淀为历史:高度地板清零,收缩到实际 + self._typer.stop() + self._render_text() + self._refresh_styles() + + def set_display(self, display: str) -> None: + self._display = display + self._render_text() + + def set_data( + self, time_text: str, source: str, target: str, stable_len: Optional[int] = None + ) -> None: + self._time.setText(time_text) + self._source, self._target, self._stable_len = source, target, stable_len + if self._current and stable_len is not None: + # 当前生长句:原文逐字揭示 + 译文动画。原文被改写变短/重置时回退 shown,不超过新长度。 + self._shown = min(self._shown, len(source)) + if self._shown < len(source) or self._tgt_text != target: + self._typer.start() + else: + self._typer.stop() + else: + self._shown = len(source) # 历史/定稿句:原文 + 译文整段直接显示 + self._tgt_text = target + self._typer.stop() + self._render_text() + + def _advance_typing(self) -> None: + busy = False + full = len(self._source) + if self._shown < full: # 原文逐字揭示 + remaining = full - self._shown + # 文本大跳(重置/积压 >24 字)按比例加速消化,避免越落越远 + self._shown = min(full, self._shown + (remaining // 6 if remaining > 24 else 1)) + busy = True + if self._tgt_text != self._target: # 译文逐字补,只前进不缩短 + self._tgt_text = next_visible(self._tgt_text, self._target) + busy = True + if not busy: + self._typer.stop() + return + self._render_text() + + def current_target(self) -> str: + return self._target + + # ----- 渲染 ----- + + def _render_text(self) -> None: + p = app_palette() + has_tgt = bool(self._target) and self._target.strip() != self._source.strip() + show_src = self._display != DISPLAY_TARGET and bool(self._source) + # has_tgt or _tgt_text:译文被清空时仍让分叉后缀逐字删完再隐藏,不硬切 + show_tgt = self._display != DISPLAY_SOURCE and (has_tgt or bool(self._tgt_text)) + if not show_src and not show_tgt and self._source: + show_src = True + + if show_src: + src_color = rgba(p.text, 0.92) if not show_tgt else "rgba(220,226,224,0.64)" + if self._current and self._stable_len is not None: + vis = self._source[: self._shown] # 只显示已揭示的字 + cut = min(self._stable_len, len(vis)) + stable = _escape(vis[:cut]) + tail = _escape(vis[cut:]) + html = ( + f'{stable}' + f'{tail}' + ) + else: + html = f'{_escape(self._source)}' + self._src.setText(html) + self._src.setVisible(show_src) + + if show_tgt: + self._tgt.setText(f'{_escape(self._tgt_text)}') + self._tgt.setVisible(show_tgt) + + self._vis_src = self._source if show_src else "" + # 高度按「完整」译文定(打字时不抖);译文被清空、后缀还在删时退用可见串,避免高度先于文字塌 + self._vis_tgt = (self._target or self._tgt_text) if show_tgt else "" + self.updateGeometry() # 内容变则重算 sizeHint + + def _content_h(self, width: int) -> int: + """按可见文本 + 当前宽度算精确高度(QFontMetrics 换行,离屏/真机一致),让 sizeHint 准确。""" + avail = max(1, width - _RAIL_W - 36) # 减 rail 与气泡左右内边距(18+18) + h = 26 # 气泡上下内边距(13+13) + rows = [] + if self._vis_src: + rows.append((self._src.font(), self._vis_src)) + if self._vis_tgt: + rows.append((self._tgt.font(), self._vis_tgt)) + for i, (font, text) in enumerate(rows): + rect = QFontMetrics(font).boundingRect( + 0, 0, avail, 100000, Qt.TextWordWrap, text) # type: ignore[attr-defined] + h += rect.height() + if i: + h += 5 # 气泡内 spacing + h = max(h, _DOT + 6) + 14 # 不低于头像点高;+14 = row 底部条目间距 + # 当前句动画期间高度只增不减(重译变短/删后缀时不塌、不缩一行又涨回引发列表重排); + # 宽度变则重置地板。 + if self._current: + if width != self._h_floor_w: + self._h_floor_w, self._h_floor = width, 0 + self._h_floor = max(self._h_floor, h) + return self._h_floor + return h + + def sizeHint(self) -> QSize: + w = self.width() if self.width() > 1 else 600 + return QSize(w, self._content_h(w)) + + def minimumSizeHint(self) -> QSize: + return self.sizeHint() + + def _refresh_styles(self) -> None: + p = app_palette() + time_color = p.accent_text if (self._current or self._playing) else p.subtle + self._time.setStyleSheet(f"color:{time_color};background:transparent;") + self._src.setStyleSheet("background:transparent;") + self._tgt.setStyleSheet("background:transparent;") + self.update() + + def resizeEvent(self, event) -> None: + self.updateGeometry() # 宽度变化时按新宽重算换行高度,长原文/译文末行不被裁 + super().resizeEvent(event) + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + super().mouseReleaseEvent(event) + + # ----- 自绘:连线 + 气泡底 ----- + + def paintEvent(self, event) -> None: # noqa: ARG002 + p = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + # 时间线连线(节点之间) + if not self._last: + from PyQt5.QtGui import QPen + + painter.setPen(QPen(QColor(*_rgba_tuple(rgba(p.muted, 0.14))), 1)) + cx = _RAIL_W // 2 + top_y = self._time.geometry().bottom() + 5 if self._time.text() else _DOT + 8 + painter.drawLine(cx, top_y, cx, self.height()) + # 气泡底 + 描边 + from PyQt5.QtGui import QPainterPath, QPen + + br = self._bubble.geometry() + path = QPainterPath() + path.addRoundedRect(br.x() + 0.5, br.y() + 0.5, br.width() - 1, br.height() - 1, 15, 15) + if self._current or self._playing: + painter.fillPath(path, QColor(*_rgba_tuple(rgba(p.accent, 0.035)))) + painter.setPen(QPen(QColor(*_rgba_tuple(rgba(p.accent, 0.46))), 1)) + else: + painter.fillPath(path, QColor(*_rgba_tuple(rgba(p.text, 0.022)))) + painter.setPen(QPen(QColor(*_rgba_tuple(p.line_soft)), 1)) + painter.drawPath(path) + + +class TranscriptList(QFrame): + """竖排转录时间线,内含滚动。实时态 ``upsert`` 增量;详情态 ``set_segments`` 静态。""" + + entryActivated = pyqtSignal(int) # 详情:点击第 i 条 → 跳到该句 + + def __init__(self, parent=None, selectable: bool = False) -> None: + super().__init__(parent) + from PyQt5.QtWidgets import QScrollArea + + self._selectable = selectable # 条目文字是否可选中复制(详情/会话页 True) + self._display = DISPLAY_BILINGUAL + self._entries: List[TranscriptEntry] = [] + self._by_seg: dict = {} + self._follow = True + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + self._scroll = QScrollArea(self) + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + self._scroll.setStyleSheet( + "QScrollArea{background:transparent;border:0;}" + "QScrollBar:vertical{width:7px;background:transparent;margin:2px;}" + "QScrollBar::handle:vertical{background:rgba(170,183,178,0.28);border-radius:3px;}" + "QScrollBar::add-line,QScrollBar::sub-line{height:0;}" + ) + self._inner = QWidget() + self._inner.setStyleSheet("background:transparent;") + self._col = QVBoxLayout(self._inner) + self._col.setContentsMargins(4, 2, 8, 2) + self._col.setSpacing(0) + self._col.addStretch(1) + self._scroll.setWidget(self._inner) + outer.addWidget(self._scroll) + + sb = self._scroll.verticalScrollBar() + sb.rangeChanged.connect(self._on_range) + sb.valueChanged.connect(self._on_scroll) + + def set_display(self, display: str) -> None: + self._display = display + for e in self._entries: + e.set_display(display) + + def copy_text(self) -> str: + """整段转录纯文本(每句:原文 + 译文,句间空行),供「复制」按钮一键复制。""" + blocks = [] + for e in self._entries: + src = (e._source or "").strip() + tgt = (e._target or "").strip() + seg = f"{src}\n{tgt}" if (src and tgt and tgt != src) else (src or tgt) + if seg: + blocks.append(seg) + return "\n\n".join(blocks) + + def clear(self) -> None: + for e in self._entries: + self._col.removeWidget(e) + e.deleteLater() + self._entries.clear() + self._by_seg.clear() + + # ----- 详情:静态加载 ----- + + def set_segments(self, segments, time_fmt) -> None: + """segments: 有 .start/.source/.target 的对象列表;time_fmt(start)->字符串。""" + self.clear() + for i, seg in enumerate(segments): + entry = self._make_entry() + entry.set_data(time_fmt(seg.start), seg.source, seg.target) + entry.set_display(self._display) + idx = i + entry.clicked.connect(lambda _i=idx: self.entryActivated.emit(_i)) + self._append(entry) + self._mark_last() + + def set_playing(self, index: int) -> None: + for i, e in enumerate(self._entries): + e.set_state(playing=(i == index)) + + # ----- 实时:增量 ----- + + def upsert(self, entry_data, rel_start: float, time_fmt) -> None: + """entry_data: CaptionEntry;rel_start 是相对会话起点的秒。按 seg_id 原地更新,末条为当前句。""" + seg_id = entry_data.seg_id + ent = self._by_seg.get(seg_id) + if ent is None: + ent = self._make_entry() + self._by_seg[seg_id] = ent + ent.set_display(self._display) + self._append(ent) + # 先标 current 再 set_data:状态先于内容就位,新句才能从 0 逐字揭示 + self._mark_last() + self._mark_current(seg_id) + # 译文防闪:新帧译文为空时保留上次译文(后端会先发空译文),等真有译文再覆盖 + target = entry_data.target_text or ent.current_target() + ent.set_data( + time_fmt(max(0.0, rel_start)), + entry_data.source_text, + target, + stable_len=entry_data.source_stable_len if not entry_data.is_final else None, + ) + + def _mark_current(self, latest_seg: str) -> None: + for sid, e in self._by_seg.items(): + e.set_state(current=(sid == latest_seg)) + + def finalize_all(self) -> None: + """会话结束:所有段落变普通历史条(去掉「当前句」绿高亮)。""" + for e in self._by_seg.values(): + e.set_state(current=False) + + # ----- 共用 ----- + + def _make_entry(self) -> TranscriptEntry: + entry = TranscriptEntry(self._inner, selectable=self._selectable) + entry.copyAllRequested.connect(self._copy_all_to_clipboard) # 右键「复制全部」 + return entry + + def _copy_all_to_clipboard(self) -> None: + from PyQt5.QtWidgets import QApplication + + text = self.copy_text() + if text: + QApplication.clipboard().setText(text) + + def _append(self, entry: TranscriptEntry) -> None: + # 底对齐:stretch 常驻 index 0,条目追加其后 → 内容少时整体贴底(避免下方大片空白), + # 超屏则 stretch 收为 0,正常滚动 + _pin_bottom 贴底。 + self._col.addWidget(entry) + self._entries.append(entry) + + def _mark_last(self) -> None: + for i, e in enumerate(self._entries): + e.set_last(i == len(self._entries) - 1) + + def scroll_to_index(self, index: int) -> None: + if 0 <= index < len(self._entries): + e = self._entries[index] + self._scroll.ensureWidgetVisible(e, 0, 40) + + def _on_scroll(self, value: int) -> None: + sb = self._scroll.verticalScrollBar() + self._follow = value >= sb.maximum() - 8 + + def _on_range(self, _min: int, maximum: int) -> None: + # rangeChanged 的 maximum 可能是布局未结算的旧值(直接 setValue 会停半空),用 singleShot(0) + # 等本轮布局结算后再读最终值贴底。 + if self._follow: + from PyQt5.QtCore import QTimer + QTimer.singleShot(0, self._pin_bottom) + + def _pin_bottom(self) -> None: + if self._follow: + sb = self._scroll.verticalScrollBar() + sb.setValue(sb.maximum()) diff --git a/videocaptioner/ui/components/live_caption/typing.py b/videocaptioner/ui/components/live_caption/typing.py new file mode 100644 index 00000000..48e5ea56 --- /dev/null +++ b/videocaptioner/ui/components/live_caption/typing.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""实时字幕「打字机」逐字步进的共享逻辑(页内转录条 + 浮窗共用,避免两处漂移)。 + +译文每次重译多为前缀相同、后缀改写。关键契约:可见字数只增不减——尾字在原位被新值覆盖而非先删 +再打,避免行数先塌再涨的抖动;只有整句重译变短(少见)才对齐到更短目标。 +""" + +from __future__ import annotations + +# 打字/删除节奏:~25 字/秒,逐字流畅但不过快。 +TYPE_INTERVAL_MS = 40 + + +def common_prefix_len(a: str, b: str) -> int: + n = min(len(a), len(b)) + i = 0 + while i < n and a[i] == b[i]: + i += 1 + return i + + +def next_visible(visible: str, full: str) -> str: + """ + 把 ``visible`` 朝 ``full`` 推进一步并返回新的可见串。 + + 可见长度单调不减:取 ``full`` 的「当前长度 + 一步」前缀,尾字原位覆盖而非先删再打(防抖), + 大段改写按比例加速补。目标比当前可见还短时(少见)直接对齐到更短目标。 + """ + if visible == full: + return full + n = len(visible) + if n >= len(full): + return full # 目标不长于当前可见:直接对齐(同长=原位改写,更短=截断) + rem = len(full) - n + add = max(1, rem // 5) if rem > 24 else 1 + return full[: n + add] diff --git a/videocaptioner/ui/components/live_caption/views.py b/videocaptioner/ui/components/live_caption/views.py new file mode 100644 index 00000000..855991c5 --- /dev/null +++ b/videocaptioner/ui/components/live_caption/views.py @@ -0,0 +1,903 @@ +# -*- coding: utf-8 -*- +"""实时字幕三视图:会话 / 历史 / 详情。 + +颜色走 ``app_palette()``,按钮/胶囊/图标盒复用 ``ui/components/workbench``。视图只负责呈现 + +发信号,线程 / 浮窗 / 录制等生命周期由宿主 ``LiveCaptionInterface`` 编排。 +""" + +from __future__ import annotations + +from collections import deque +from typing import List, Optional + +from PyQt5.QtCore import QRectF, Qt, pyqtSignal +from PyQt5.QtGui import QColor, QPainter +from PyQt5.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from videocaptioner.core.realtime.recording.history import LiveCaptionRecord +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.live_caption.player import AudioPlayerBar +from videocaptioner.ui.components.live_caption.transcript import ( + DISPLAY_BILINGUAL, + DISPLAY_SOURCE, + DISPLAY_TARGET, + TranscriptList, +) +from videocaptioner.ui.components.workbench import ( + CompactButton, + ErrorCard, + FilterTabs, + IconBox, + OptionCard, + PillSelect, + RoundIconButton, + ToggleSwitch, + WorkbenchButton, + apply_font, + draw_rounded_surface, +) +from videocaptioner.ui.i18n import tr + +MODE_READY = "ready" +MODE_LIVE = "live" +MODE_PAUSED = "paused" +MODE_ENDED = "ended" +MODE_ERROR = "error" + + +def _fmt_pos(seconds: float) -> str: + s = max(0, int(round(seconds))) + m, s = divmod(s, 60) + h, m = divmod(m, 60) + return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}" + + +class _Panel(QFrame): + """统一面板底:圆角 14 + panel 底 + line_soft 描边。""" + + def __init__(self, parent=None, radius: int = 14) -> None: + super().__init__(parent) + self._radius = radius + + def paintEvent(self, event) -> None: # noqa: ARG002 + p = app_palette() + draw_rounded_surface(self, p.panel, p.line_soft, self._radius) + + +class _RecentCard(QFrame): + """会话首页「最近记录」一项:记录名 + 元信息 + 打开按钮。""" + + opened = pyqtSignal(object) + + def __init__(self, record: LiveCaptionRecord, parent=None) -> None: + super().__init__(parent) + self._record = record + self._hover = False + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + lay = QHBoxLayout(self) + lay.setContentsMargins(14, 11, 12, 11) + lay.setSpacing(12) + p = app_palette() + col = QVBoxLayout() + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(3) + name = QLabel(record.name, self) + apply_font(name, 14, 820) + name.setStyleSheet(f"color:{p.text};background:transparent;") + meta = QLabel(record.summary, self) + apply_font(meta, 12, 600) + meta.setStyleSheet(f"color:{p.subtle};background:transparent;") + col.addWidget(name) + col.addWidget(meta) + lay.addLayout(col, 1) + self._btn = RoundIconButton(AppIcon.DOCUMENT, 32, self) + self._btn.clicked.connect(lambda: self.opened.emit(self._record)) + lay.addWidget(self._btn, 0, Qt.AlignVCenter) # type: ignore[arg-type] + + def enterEvent(self, event) -> None: # noqa: ARG002 + self._hover = True + self.update() + + def leaveEvent(self, event) -> None: # noqa: ARG002 + self._hover = False + self.update() + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.opened.emit(self._record) + super().mouseReleaseEvent(event) + + def paintEvent(self, event) -> None: # noqa: ARG002 + p = app_palette() + bg = p.control_hover if self._hover else rgba(p.text, 0.022) + draw_rounded_surface(self, bg, p.line_soft, 12) + + +class _HistoryRow(QFrame): + """历史列表一行(卡片式,自有排版,非原始表格)。""" + + opened = pyqtSignal(object) + renameRequested = pyqtSignal(object) + exportRequested = pyqtSignal(object) + deleteRequested = pyqtSignal(object) + + def __init__(self, record: LiveCaptionRecord, parent=None) -> None: + super().__init__(parent) + self._record = record + self._hover = False + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + lay = QHBoxLayout(self) + lay.setContentsMargins(14, 12, 14, 12) + lay.setSpacing(14) + p = app_palette() + self._mark = IconBox(AppIcon.MICROPHONE, self, size=40, tone="accent") + lay.addWidget(self._mark, 0, Qt.AlignVCenter) # type: ignore[arg-type] + col = QVBoxLayout() + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(3) + name = QLabel(record.name, self) + apply_font(name, 14, 820) + name.setStyleSheet(f"color:{p.text};background:transparent;") + meta = QLabel(f"{record.source} · {record.duration_label} · {record.created_label}", self) + apply_font(meta, 12, 600) + meta.setStyleSheet(f"color:{p.subtle};background:transparent;") + col.addWidget(name) + col.addWidget(meta) + lay.addLayout(col, 1) + # 带文字的动作按钮:纯图标看不出干啥,行内也有空间,直接标清「打开/重命名/导出/删除」 + self._open = CompactButton(tr("liveview.action.open"), AppIcon.DOCUMENT, self) + self._open.clicked.connect(lambda: self.opened.emit(self._record)) + self._rename = CompactButton(tr("liveview.action.rename"), AppIcon.EDIT, self) + self._rename.clicked.connect(lambda: self.renameRequested.emit(self._record)) + self._exp = CompactButton(tr("liveview.action.export"), AppIcon.DOWNLOAD, self) + self._exp.clicked.connect(lambda: self.exportRequested.emit(self._record)) + self._del = CompactButton(tr("common.delete"), AppIcon.DELETE, self) + self._del.clicked.connect(lambda: self.deleteRequested.emit(self._record)) + for b in (self._open, self._rename, self._exp, self._del): + lay.addWidget(b, 0, Qt.AlignVCenter) # type: ignore[arg-type] + + def enterEvent(self, event) -> None: # noqa: ARG002 + self._hover = True + self.update() + + def leaveEvent(self, event) -> None: # noqa: ARG002 + self._hover = False + self.update() + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.opened.emit(self._record) + super().mouseReleaseEvent(event) + + def paintEvent(self, event) -> None: # noqa: ARG002 + p = app_palette() + bg = p.control_hover if self._hover else rgba(p.text, 0.022) + draw_rounded_surface(self, bg, p.line_soft, 12) + + +def _h2(text: str, parent=None) -> QLabel: + lab = QLabel(text, parent) + apply_font(lab, 24, 900) + lab.setStyleSheet(f"color:{app_palette().text};background:transparent;") + return lab + + +class _ScrollList(QFrame): + """竖排卡片列表 + 内置滚动(最近记录 / 历史记录共用)。""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + from PyQt5.QtWidgets import QScrollArea + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + self._scroll = QScrollArea(self) + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + self._scroll.setStyleSheet( + "QScrollArea{background:transparent;border:0;}" + "QScrollBar:vertical{width:7px;background:transparent;margin:2px;}" + "QScrollBar::handle:vertical{background:rgba(170,183,178,0.28);border-radius:3px;}" + "QScrollBar::add-line,QScrollBar::sub-line{height:0;}" + ) + self._inner = QWidget() + self._inner.setStyleSheet("background:transparent;") + self._col = QVBoxLayout(self._inner) + self._col.setContentsMargins(0, 0, 6, 0) + self._col.setSpacing(8) + self._col.addStretch(1) + self._scroll.setWidget(self._inner) + outer.addWidget(self._scroll) + self._widgets: List[QWidget] = [] + + def clear(self) -> None: + for w in self._widgets: + self._col.removeWidget(w) + w.deleteLater() + self._widgets.clear() + + def add(self, widget: QWidget) -> None: + self._col.insertWidget(self._col.count() - 1, widget) + self._widgets.append(widget) + + def count(self) -> int: + return len(self._widgets) + + +class _EmptyState(QWidget): + """顶部锚定的空态:图标 + 标题 + 副文。靠上呈现,不在高面板正中漂浮(避免大片空洞像渲染坏了)。""" + + def __init__(self, icon: AppIcon, title: str, detail: str, parent=None, + top: int = 60) -> None: + super().__init__(parent) + p = app_palette() + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(10) + lay.addSpacing(top) + box = IconBox(icon, self, size=52, tone="surface") + lay.addWidget(box, 0, Qt.AlignHCenter) # type: ignore[arg-type] + self._title = QLabel(title, self) + apply_font(self._title, 18, 900) + self._title.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self._title.setStyleSheet(f"color:{p.text};background:transparent;") + self._detail = QLabel(detail, self) + apply_font(self._detail, 13, 600) + self._detail.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self._detail.setWordWrap(True) + self._detail.setStyleSheet(f"color:{p.subtle};background:transparent;") + lay.addWidget(self._title) + lay.addWidget(self._detail) + lay.addStretch(1) + + def set_text(self, title: str, detail: str) -> None: + self._title.setText(title) + self._detail.setText(detail) + + +class _LevelMeter(QWidget): + """会话页拾音电平波形:一排细竖条按近期音量起伏滚动,绿=有声、暗=静音。 + 小巧、不占空间,让用户一眼看出有没有拾到声音。""" + + _BARS = 28 + _STRIDE = 3 # 每 3 个音频块(~300ms)才滚一格:滚动慢 3 倍、更顺眼 + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setFixedHeight(20) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._levels = deque([0.0] * self._BARS, maxlen=self._BARS) + self._pending = 0.0 + self._tick = 0 + + def push(self, level: float) -> None: + # 攒够 _STRIDE 块才滚一格、取这期间峰值 → 滚动慢而平稳,又不漏掉音量起伏 + self._pending = max(self._pending, max(0.0, min(1.0, float(level)))) + self._tick += 1 + if self._tick >= self._STRIDE: + self._levels.append(self._pending) + self._pending = 0.0 + self._tick = 0 + self.update() + + def reset(self) -> None: + self._levels = deque([0.0] * self._BARS, maxlen=self._BARS) + self._pending = 0.0 + self._tick = 0 + self.update() + + def paintEvent(self, event) -> None: # noqa: ARG002 + n = len(self._levels) + if n == 0 or self.width() <= 0: + return + p = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + live = QColor(p.accent) + idle = QColor(p.muted) + idle.setAlpha(64) + gap = 3.0 + bw = max(2.0, (self.width() - (n - 1) * gap) / n) + cy = self.height() / 2.0 + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + for i, lv in enumerate(self._levels): + bh = max(2.0, lv * (self.height() - 2)) + x = i * (bw + gap) + painter.setBrush(live if lv > 0.04 else idle) + painter.drawRoundedRect(QRectF(x, cy - bh / 2.0, bw, bh), bw / 2.0, bw / 2.0) + + +class SessionView(QWidget): + """会话页:ready / live / paused / ended / error 五态。""" + + startClicked = pyqtSignal() + pauseClicked = pyqtSignal() + resumeClicked = pyqtSignal() + stopClicked = pyqtSignal() + historyClicked = pyqtSignal() + recordOpened = pyqtSignal(object) + configClicked = pyqtSignal() + exportClicked = pyqtSignal() + deviceChanged = pyqtSignal(object) + translateToggled = pyqtSignal(bool) + targetLanguageChanged = pyqtSignal(object) # 发 TargetLanguage 成员 + sourceLanguageChanged = pyqtSignal(str) # 发识别语言 code(auto 或 provider 支持的语言码) + overlayToggled = pyqtSignal(bool) + newSessionClicked = pyqtSignal() + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._mode = MODE_READY + self.transcript = TranscriptList(selectable=True) # 转录文字可选中复制 + self._build() + self.set_mode(MODE_READY) + + # ----- 构建 ----- + + def _build(self) -> None: + p = app_palette() + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(16) + root.addWidget(_h2(tr("liveview.title.session"), self)) + + workspace = QHBoxLayout() + workspace.setContentsMargins(0, 0, 0, 0) + workspace.setSpacing(16) + + # 主面板 + self._main = _Panel(self) + ml = QVBoxLayout(self._main) + ml.setContentsMargins(20, 18, 20, 18) + ml.setSpacing(14) + # 记录头(mic + 标题 + 导出)只在「有录制内容」时显示(录制中/暂停/已结束);就绪态左侧 + # 只放历史记录。包进 self._head 容器整体显隐。 + self._head = QWidget(self._main) + head = QHBoxLayout(self._head) + head.setContentsMargins(0, 0, 0, 0) + head.setSpacing(12) + self._mark = IconBox(AppIcon.MICROPHONE, self._head, size=40, tone="accent") + head.addWidget(self._mark, 0, Qt.AlignVCenter) # type: ignore[arg-type] + tcol = QVBoxLayout() + tcol.setSpacing(2) + self._title = QLabel(self._head) + apply_font(self._title, 16, 850) + self._title.setStyleSheet(f"color:{p.text};background:transparent;") + self._sub = QLabel(self._head) + apply_font(self._sub, 13, 600) + self._sub.setStyleSheet(f"color:{p.subtle};background:transparent;") + tcol.addWidget(self._title) + tcol.addWidget(self._sub) + head.addLayout(tcol, 1) + self._btn_export = CompactButton(tr("liveview.action.export"), AppIcon.DOWNLOAD, self._head) + self._btn_export.clicked.connect(self.exportClicked.emit) + head.addWidget(self._btn_export, 0, Qt.AlignVCenter) # type: ignore[arg-type] + ml.addWidget(self._head) + + # 错误横幅:错误态不接管整页/藏侧栏,只在头部下方叠一条紧凑提示(侧栏保留设备选择+重试, + # 便于当场修复)。 + self._error_banner = ErrorCard("", parent=self._main) + self._error_banner.setVisible(False) + ml.addWidget(self._error_banner) + + # 内容堆叠:最近记录 / 转录流 / 错误 + from PyQt5.QtWidgets import QStackedWidget + + self._stack = QStackedWidget(self._main) + # 0 最近记录 + recent = QWidget() + rl = QVBoxLayout(recent) + rl.setContentsMargins(0, 0, 0, 0) + rl.setSpacing(10) + rhead = QHBoxLayout() + rt = QLabel(tr("liveview.recent.title"), recent) + apply_font(rt, 14, 820) + rt.setStyleSheet(f"color:{p.muted};background:transparent;") + self._view_all = CompactButton(tr("liveview.recent.view_all"), AppIcon.HISTORY, recent) + self._view_all.clicked.connect(self.historyClicked.emit) + rhead.addWidget(rt) + rhead.addStretch(1) + rhead.addWidget(self._view_all) + rl.addLayout(rhead) + self._recent = _ScrollList(recent) + rl.addWidget(self._recent, 1) + self._recent_empty = _EmptyState(AppIcon.MICROPHONE, tr("liveview.recent.empty.title"), + tr("liveview.recent.empty.detail"), recent) + rl.addWidget(self._recent_empty) + self._recent_empty.setVisible(False) + self._stack.addWidget(recent) + # 1 转录流 + self._stack.addWidget(self.transcript) + ml.addWidget(self._stack, 1) + workspace.addWidget(self._main, 1) + + # 侧栏 + from PyQt5.QtWidgets import QScrollArea + self._side = QWidget(self) + self._side.setFixedWidth(300) + # 侧栏放进滚动区:内容超高时侧栏自身滚动,不把主窗口最小高度顶出屏幕。 + _side_outer = QVBoxLayout(self._side) + _side_outer.setContentsMargins(0, 0, 0, 0) + _side_scroll = QScrollArea(self._side) + _side_scroll.setWidgetResizable(True) + _side_scroll.setFrameShape(QFrame.NoFrame) + _side_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + _side_scroll.setStyleSheet( + "QScrollArea{background:transparent;border:0;}" + "QScrollBar:vertical{width:6px;background:transparent;margin:2px;}" + "QScrollBar::handle:vertical{background:rgba(170,183,178,0.28);border-radius:3px;}" + "QScrollBar::add-line,QScrollBar::sub-line{height:0;}" + ) + _side_outer.addWidget(_side_scroll) + _side_inner = QWidget() + _side_inner.setStyleSheet("background:transparent;") + _side_scroll.setWidget(_side_inner) + sl = QVBoxLayout(_side_inner) + sl.setContentsMargins(0, 0, 0, 0) + sl.setSpacing(16) + # 控制卡 + self._control = _Panel(self._side) + # 6 个控制按钮常驻、按态显隐;构造态会把全部按钮高度计入、把最小高度顶到超屏,而同时最多 + # 只显 2 个,故给控制卡封顶。 + self._control.setMaximumHeight(290) + cl = QVBoxLayout(self._control) + cl.setContentsMargins(22, 22, 22, 22) + cl.setSpacing(18) + self._timer = QLabel(self._control) + apply_font(self._timer, 52, 900) + self._timer.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self._timer.setStyleSheet(f"color:{p.text};background:transparent;") + self._timer_label = QLabel(self._control) + apply_font(self._timer_label, 13, 760) + self._timer_label.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self._timer_label.setStyleSheet(f"color:{p.subtle};background:transparent;") + tbox = QVBoxLayout() + tbox.setSpacing(4) + tbox.addWidget(self._timer) + tbox.addWidget(self._timer_label) + self._meter = _LevelMeter(self._control) # 拾音电平波形:录制中显示有没有听到声音 + tbox.addSpacing(8) + tbox.addWidget(self._meter) + cl.addLayout(tbox) + self._controls = QVBoxLayout() + self._controls.setSpacing(10) + cl.addLayout(self._controls) + # 控制按钮(建一次,按态显隐)——统一用项目 WorkbenchButton(primary/warn/danger/default) + self._b_start = WorkbenchButton(tr("liveview.btn.start"), AppIcon.PLAY, primary=True) + self._b_start.clicked.connect(self.startClicked.emit) + self._b_pause = WorkbenchButton(tr("liveview.btn.pause"), tone="warn") + self._b_pause.clicked.connect(self.pauseClicked.emit) + self._b_resume = WorkbenchButton(tr("liveview.btn.resume"), AppIcon.PLAY, primary=True) + self._b_resume.clicked.connect(self.resumeClicked.emit) + self._b_stop = WorkbenchButton(tr("liveview.btn.stop"), tone="danger") + self._b_stop.clicked.connect(self.stopClicked.emit) + self._b_view = WorkbenchButton(tr("liveview.btn.view_record"), AppIcon.DOCUMENT, primary=True) + self._b_view.clicked.connect(self._open_current) + self._b_new = WorkbenchButton(tr("liveview.btn.new_session"), AppIcon.SYNC) + self._b_new.clicked.connect(self.newSessionClicked.emit) + for b in (self._b_start, self._b_pause, self._b_resume, self._b_stop, + self._b_view, self._b_new): + self._controls.addWidget(b) + sl.addWidget(self._control) + + # 设置卡 + self._settings = _Panel(self._side) + stl = QVBoxLayout(self._settings) + stl.setContentsMargins(20, 18, 20, 18) + stl.setSpacing(14) + sthead = QHBoxLayout() + stt = QLabel(tr("liveview.settings.title"), self._settings) + apply_font(stt, 15, 850) + stt.setStyleSheet(f"color:{p.text};background:transparent;") + self._btn_config = CompactButton(tr("liveview.settings.config"), AppIcon.SETTING, self._settings) + self._btn_config.clicked.connect(self.configClicked.emit) + sthead.addWidget(stt) + sthead.addStretch(1) + sthead.addWidget(self._btn_config) + stl.addLayout(sthead) + self._device_items: List[tuple] = [] + self._device_pill = PillSelect(self._settings) + self._device_pill.currentTextChanged.connect(self._on_device_text) + self._src_lang_items: List[tuple] = [] + self._src_lang_pill = PillSelect(self._settings) + self._src_lang_pill.currentTextChanged.connect(self._on_src_lang_text) + self._lang_items: List[tuple] = [] + self._lang_pill = PillSelect(self._settings) + self._lang_pill.currentTextChanged.connect(self._on_lang_text) + self._translate_sw = ToggleSwitch(True, self._settings) + self._translate_sw.toggled.connect(self.translateToggled.emit) + self._overlay_sw = ToggleSwitch(True, self._settings) + self._overlay_sw.toggled.connect(self.overlayToggled.emit) + for card in ( + OptionCard(tr("liveview.option.audio_source"), self._device_pill, self._settings), + OptionCard(tr("liveview.option.source_language"), self._src_lang_pill, self._settings), + OptionCard(tr("liveview.option.live_translate"), self._translate_sw, self._settings), + OptionCard(tr("liveview.option.target_language"), self._lang_pill, self._settings), + OptionCard(tr("liveview.option.overlay"), self._overlay_sw, self._settings), + ): + stl.addWidget(card) + sl.addWidget(self._settings) + sl.addStretch(1) + workspace.addWidget(self._side) + root.addLayout(workspace, 1) + self._current_record: Optional[LiveCaptionRecord] = None + + # ----- 对外 API ----- + + def set_devices(self, items: List[tuple], current=None) -> None: + self._device_items = list(items) + labels = [label for _v, label in items] + cur = next((label for v, label in items if v == current), + labels[0] if labels else "") + self._device_pill.blockSignals(True) + self._device_pill.setItems(labels, cur) + self._device_pill.blockSignals(False) + + def _on_device_text(self, text: str) -> None: + for v, label in self._device_items: + if label == text: + self.deviceChanged.emit(v) + return + + def set_target_languages(self, items: List[tuple], current=None) -> None: + # items: [(TargetLanguage, 中文标签)]; current: TargetLanguage 成员 + self._lang_items = list(items) + labels = [label for _v, label in items] + cur = next((label for v, label in items if v == current), + labels[0] if labels else "") + self._lang_pill.blockSignals(True) + self._lang_pill.setItems(labels, cur) + self._lang_pill.blockSignals(False) + + def _on_lang_text(self, text: str) -> None: + for v, label in self._lang_items: + if label == text: + self.targetLanguageChanged.emit(v) + return + + def set_source_languages(self, items: List[tuple], current=None) -> None: + # items: [(code, 中文标签)],如 ("auto","自动识别") + self._src_lang_items = list(items) + labels = [label for _v, label in items] + cur = next((label for v, label in items if v == current), labels[0] if labels else "") + self._src_lang_pill.blockSignals(True) + self._src_lang_pill.setItems(labels, cur) + self._src_lang_pill.blockSignals(False) + + def _on_src_lang_text(self, text: str) -> None: + for v, label in self._src_lang_items: + if label == text: + self.sourceLanguageChanged.emit(v) + return + + def set_translate(self, on: bool) -> None: + self._translate_sw.blockSignals(True) + self._translate_sw.setChecked(on) + self._translate_sw.blockSignals(False) + + def set_overlay(self, on: bool) -> None: + self._overlay_sw.blockSignals(True) + self._overlay_sw.setChecked(on) + self._overlay_sw.blockSignals(False) + + def set_level(self, level: float) -> None: + """实时拾音电平(0~1)→ 波形。""" + self._meter.push(level) + + def reset_level(self) -> None: + self._meter.reset() + + def set_recent(self, records: List[LiveCaptionRecord]) -> None: + self._recent.clear() + for rec in records[:8]: + card = _RecentCard(rec, self._recent) + card.opened.connect(self.recordOpened.emit) + self._recent.add(card) + empty = self._recent.count() == 0 + self._recent_empty.setVisible(empty) + self._recent.setVisible(not empty) + + def set_timer(self, value: str, label: str) -> None: + self._timer.setText(value) + self._timer_label.setText(label) + + def set_record_title(self, title: str, sub: str) -> None: + self._title.setText(title) + self._sub.setText(sub) + + def set_error(self, title: str, detail: str) -> None: + # 标题走记录头(宿主 set_record_title),可操作的详情走顶部错误横幅。 + self._error_banner.setText(detail) + + def set_current_record(self, record: Optional[LiveCaptionRecord]) -> None: + self._current_record = record + + def _open_current(self) -> None: + if self._current_record is not None: + self.recordOpened.emit(self._current_record) + + def set_saving(self) -> None: + """「结束保存」后、记录就绪前的过渡态:禁用控制按钮 + 提示「正在保存…」,避免后台收尾 + (冲刷末句 + 写盘)期间用户以为没反应而反复点。""" + self._b_stop.setEnabled(False) + self._b_stop.setText(tr("liveview.btn.saving")) + self._b_pause.setEnabled(False) + self.set_timer(self._timer.text() or "00:00", tr("liveview.timer.saving")) + + def set_mode(self, mode: str) -> None: + self._mode = mode + # 退出「保存中…」过渡态:任何态切换都复位结束/暂停按钮的可用与文案。 + self._b_stop.setEnabled(True) + self._b_stop.setText(tr("liveview.btn.stop")) + self._b_pause.setEnabled(True) + live = mode in (MODE_LIVE, MODE_PAUSED) + error = mode == MODE_ERROR + # 内容区:录制/结束=转录流;就绪/错误=最近记录(错误态复用就绪布局 + 顶部错误横幅)。 + self._stack.setCurrentIndex(1 if mode in (MODE_LIVE, MODE_PAUSED, MODE_ENDED) else 0) + self._error_banner.setVisible(error) + # 侧栏始终保留:错误态也能就地换音频来源 + 重试。 + self._side.setVisible(True) + # 记录头只在「有录制内容」时显示(录制中/暂停/已结束)。 + self._head.setVisible(mode in (MODE_LIVE, MODE_PAUSED, MODE_ENDED)) + self._btn_export.setVisible(mode == MODE_ENDED) + # 控制按钮:就绪=开始,错误=重试(同一按钮,复用 startClicked) + self._b_start.setVisible(mode in (MODE_READY, MODE_ERROR)) + if mode in (MODE_READY, MODE_ERROR): + self._b_start.setText(tr("common.retry") if error else tr("liveview.btn.start")) + self._b_start.setIcon(AppIcon.SYNC if error else AppIcon.PLAY) + self._b_pause.setVisible(mode == MODE_LIVE) + self._b_resume.setVisible(mode == MODE_PAUSED) + self._b_stop.setVisible(live) + self._b_view.setVisible(mode == MODE_ENDED) + self._b_new.setVisible(mode == MODE_ENDED) # 结束态:明确的「返回就绪/新建」路径 + # 结束态:当前段落落定成普通历史条(去掉「当前句」绿高亮) + if mode == MODE_ENDED: + self.transcript.finalize_all() + # 设置可改性:录制中锁定来源/翻译/语言 + self._device_pill.setEnabled(not live) + self._translate_sw.setEnabled(not live) + self._lang_pill.setEnabled(not live) + self._src_lang_pill.setEnabled(not live) # 识别语言录制中锁定(改动需重开会话生效) + if not live: + self._meter.reset() # 非录制态:波形归静音暗条 + # 头部文案默认(宿主可覆盖) + defaults = { + MODE_READY: (tr("liveview.ready.title"), tr("liveview.ready.detail")), + MODE_ERROR: (tr("liveview.error.title"), tr("liveview.error.detail")), + } + if mode in defaults: + self.set_record_title(*defaults[mode]) + labels = { + MODE_READY: ("00:00", tr("liveview.timer.waiting")), + MODE_ERROR: ("--:--", tr("liveview.error.title")), + } + if mode in labels: + self.set_timer(*labels[mode]) + + +class HistoryView(QWidget): + """历史页:搜索 + 刷新 + 目录 + 卡片式记录列表。""" + + backClicked = pyqtSignal() + refreshClicked = pyqtSignal() + openDirClicked = pyqtSignal() + recordOpened = pyqtSignal(object) + renameRequested = pyqtSignal(object) + exportRequested = pyqtSignal(object) + deleteRequested = pyqtSignal(object) + searchChanged = pyqtSignal(str) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._build() + + def _build(self) -> None: + from videocaptioner.ui.components.workbench import AppLineEdit + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(16) + head = QHBoxLayout() + head.addWidget(_h2(tr("liveview.title.history"), self)) + head.addStretch(1) + # 「返回」即回会话页(=实时字幕首页),不再放重复的「主页」 + self._back = CompactButton(tr("liveview.action.back"), AppIcon.ARROW_LEFT, self) + self._back.clicked.connect(self.backClicked.emit) + head.addWidget(self._back, 0, Qt.AlignVCenter) # type: ignore[arg-type] + root.addLayout(head) + + toolbar = _Panel(self) + tl = QHBoxLayout(toolbar) + tl.setContentsMargins(16, 12, 16, 12) + tl.setSpacing(12) + self._search = AppLineEdit("", toolbar) + self._search.setPlaceholderText(tr("liveview.history.search_placeholder")) + self._search.textChanged.connect(self.searchChanged.emit) + self._refresh = CompactButton(tr("liveview.action.refresh"), AppIcon.SYNC, toolbar) + self._refresh.clicked.connect(self.refreshClicked.emit) + self._dir = CompactButton(tr("liveview.action.folder"), AppIcon.FOLDER, toolbar) + self._dir.clicked.connect(self.openDirClicked.emit) + tl.addWidget(self._search, 1) + tl.addWidget(self._refresh) + tl.addWidget(self._dir) + root.addWidget(toolbar) + + body = _Panel(self) + bl = QVBoxLayout(body) + bl.setContentsMargins(14, 14, 14, 14) + bl.setSpacing(0) + self._list = _ScrollList(body) + bl.addWidget(self._list, 1) + self._empty = _EmptyState(AppIcon.HISTORY, tr("liveview.history.empty.title"), + tr("liveview.history.empty.detail"), body) + bl.addWidget(self._empty) + self._empty.setVisible(False) + root.addWidget(body, 1) + + def set_records(self, records: List[LiveCaptionRecord]) -> None: + self._list.clear() + for rec in records: + row = _HistoryRow(rec, self._list) + row.opened.connect(self.recordOpened.emit) + row.renameRequested.connect(self.renameRequested.emit) + row.exportRequested.connect(self.exportRequested.emit) + row.deleteRequested.connect(self.deleteRequested.emit) + self._list.add(row) + empty = self._list.count() == 0 + self._empty.setVisible(empty) + self._list.setVisible(not empty) + + +class DetailView(QWidget): + """详情页:元信息 + 转录时间线 + 显示切换 + 导出 + 音频播放器。""" + + backClicked = pyqtSignal() + homeClicked = pyqtSignal() + exportRequested = pyqtSignal(str) # "srt" / "txt" + openFolderRequested = pyqtSignal() + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._record: Optional[LiveCaptionRecord] = None + self._build() + + def _build(self) -> None: + p = app_palette() + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(16) + head = QHBoxLayout() + head.addWidget(_h2(tr("liveview.title.detail"), self)) + head.addStretch(1) + self._home = CompactButton(tr("liveview.action.home"), AppIcon.HOME, self) + self._home.clicked.connect(self.homeClicked.emit) + self._back = CompactButton(tr("liveview.action.back"), AppIcon.ARROW_LEFT, self) + self._back.clicked.connect(self.backClicked.emit) + head.addWidget(self._home, 0, Qt.AlignVCenter) # type: ignore[arg-type] + head.addWidget(self._back, 0, Qt.AlignVCenter) # type: ignore[arg-type] + root.addLayout(head) + + top = _Panel(self) + topl = QHBoxLayout(top) + topl.setContentsMargins(20, 14, 20, 14) + topl.setSpacing(12) + self._mark = IconBox(AppIcon.MICROPHONE, top, size=40, tone="accent") + tcol = QVBoxLayout() + tcol.setSpacing(2) + self._title = QLabel(top) + apply_font(self._title, 16, 850) + self._title.setStyleSheet(f"color:{p.text};background:transparent;") + self._sub = QLabel(top) + apply_font(self._sub, 13, 600) + self._sub.setStyleSheet(f"color:{p.subtle};background:transparent;") + tcol.addWidget(self._title) + tcol.addWidget(self._sub) + topl.addWidget(self._mark, 0, Qt.AlignVCenter) # type: ignore[arg-type] + topl.addLayout(tcol, 1) + root.addWidget(top) + + body = QHBoxLayout() + body.setSpacing(16) + self._tpanel = _Panel(self) + tpl = QVBoxLayout(self._tpanel) + tpl.setContentsMargins(8, 12, 8, 12) + self.transcript = TranscriptList(self._tpanel, selectable=True) # 文字可选中复制 + self.transcript.entryActivated.connect(self._on_entry) + tpl.addWidget(self.transcript) + body.addWidget(self._tpanel, 1) + + side = QWidget(self) + side.setFixedWidth(240) + sl = QVBoxLayout(side) + sl.setContentsMargins(0, 0, 0, 0) + sl.setSpacing(16) + disp = _Panel(side) + dl = QVBoxLayout(disp) + dl.setContentsMargins(18, 16, 18, 16) + dl.setSpacing(12) + dt = QLabel(tr("liveview.detail.display"), disp) + apply_font(dt, 15, 850) + dt.setStyleSheet(f"color:{p.text};background:transparent;") + dl.addWidget(dt) + self._display_tabs = FilterTabs( + [ + (DISPLAY_BILINGUAL, tr("liveview.display.bilingual")), + (DISPLAY_SOURCE, tr("liveview.display.source")), + (DISPLAY_TARGET, tr("liveview.display.target")), + ], + disp, + ) + self._display_tabs.changed.connect(self.transcript.set_display) + dl.addWidget(self._display_tabs) + sl.addWidget(disp) + exp = _Panel(side) + el = QVBoxLayout(exp) + el.setContentsMargins(18, 16, 18, 16) + el.setSpacing(10) + et = QLabel(tr("liveview.detail.export"), exp) + apply_font(et, 15, 850) + et.setStyleSheet(f"color:{p.text};background:transparent;") + el.addWidget(et) + self._exp_srt = WorkbenchButton(tr("liveview.export.srt"), AppIcon.DOWNLOAD) + self._exp_srt.clicked.connect(lambda: self.exportRequested.emit("srt")) + self._exp_txt = WorkbenchButton(tr("liveview.export.txt"), AppIcon.DOCUMENT) + self._exp_txt.clicked.connect(lambda: self.exportRequested.emit("txt")) + self._open_dir = WorkbenchButton(tr("common.open_folder"), AppIcon.FOLDER) + self._open_dir.clicked.connect(self.openFolderRequested.emit) + el.addWidget(self._exp_srt) + el.addWidget(self._exp_txt) + el.addWidget(self._open_dir) + sl.addWidget(exp) + sl.addStretch(1) + body.addWidget(side) + root.addLayout(body, 1) + + self.player = AudioPlayerBar(self) + self.player.sentenceChanged.connect(self.transcript.set_playing) + root.addWidget(self.player) + + # 播放器快捷键(焦点在详情页内即生效):空格 播放/暂停,←→ ±5s,↑↓ 上/下一句 + from PyQt5.QtGui import QKeySequence + from PyQt5.QtWidgets import QShortcut + + self.setFocusPolicy(Qt.StrongFocus) # type: ignore[arg-type] + + def _sc(seq, fn): + s = QShortcut(QKeySequence(seq), self) + s.setContext(Qt.WidgetWithChildrenShortcut) # type: ignore[arg-type] + s.activated.connect(fn) + + _sc(Qt.Key_Space, self.player.toggle) # type: ignore[arg-type] + _sc(Qt.Key_Left, lambda: self.player.seek_relative(-5000)) # type: ignore[arg-type] + _sc(Qt.Key_Right, lambda: self.player.seek_relative(5000)) # type: ignore[arg-type] + _sc(Qt.Key_Up, lambda: self.player.step_sentence(-1)) # type: ignore[arg-type] + _sc(Qt.Key_Down, lambda: self.player.step_sentence(1)) # type: ignore[arg-type] + + def load(self, record: LiveCaptionRecord) -> None: + self._record = record + self._title.setText(record.name) + self._sub.setText(record.summary) + self.transcript.set_segments(record.segments, _fmt_pos) + starts = [s.start for s in record.segments] + self.player.load(record.audio_path, starts, record.duration) + self.player.setVisible(record.audio_path is not None) + self.setFocus() # 进入详情即可用播放快捷键 + + def _on_entry(self, index: int) -> None: + # 点击某句 → 跳到该句对应时间并从那里开始播放 + self.player.seek_sentence(index) + self.player.play() + self.transcript.set_playing(index) + self.transcript.scroll_to_index(index) + + def stop_playback(self) -> None: + self.player.stop() diff --git a/videocaptioner/ui/components/model_manager_dialog.py b/videocaptioner/ui/components/model_manager_dialog.py new file mode 100644 index 00000000..7cfe4bd2 --- /dev/null +++ b/videocaptioner/ui/components/model_manager_dialog.py @@ -0,0 +1,857 @@ +"""本地模型管理弹窗。 + +结构:标题栏 → 引擎页签(单引擎平台隐藏)→ 运行程序区(按平台给出 +变体行:检测 / 直接下载 / 复制命令 / 打开页面)→ 模型表(文件名 + +用途 + 大小 + 状态点 + 下载/继续/删除/取消)→ 底栏(本地模型目录 + +打开目录 + 关闭)。 + +状态约定: +- 同一时刻只跑一个下载任务(模型或程序),其余操作按钮禁用; +- 当前配置选中的已下载模型显示「当前」且不可删除; +- 有 .part 残留的模型显示「继续」(断点续传); +- 关闭弹窗即取消进行中的下载(.part 保留)。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PyQt5.QtCore import Qt, QUrl, pyqtSignal +from PyQt5.QtGui import QDesktopServices +from PyQt5.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from videocaptioner.config import BIN_PATH, MODEL_PATH +from videocaptioner.core.constant import ( + INFOBAR_DURATION_ERROR, + INFOBAR_DURATION_SUCCESS, + INFOBAR_DURATION_WARNING, +) +from videocaptioner.core.download import ( + ModelSpec, + ProgramVariant, + has_partial_download, + iter_models, + model_install_state, + program_variants, + remove_model, +) +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.app_dialog import AppDialog, ConfirmDialog +from videocaptioner.ui.components.workbench import ( + AccentButton, + CompactButton, + DangerButton, + IconBox, + ProgressBarLine, + SectionLabel, + apply_font, + draw_rounded_surface, + icon_pixmap, +) +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.artifact_download_thread import ( + ArtifactDownloadThread, + model_download_thread, + program_download_thread, +) + +KIND_TITLES = {"whisper-cpp": "Whisper CPP", "faster-whisper": "Faster Whisper"} +SIZE_COLUMN = 86 +STATUS_COLUMN = 92 +ACTION_COLUMN = 104 + + +def available_model_kinds(platform: str | None = None) -> list[str]: + """当前平台可用的本地引擎;macOS 不出现 Faster Whisper。""" + plat = platform or sys.platform + kinds = ["whisper-cpp"] + if plat.startswith("win"): + kinds.append("faster-whisper") + return kinds + + +# --------------------------------------------------------------------------- +# 基础小件 +# --------------------------------------------------------------------------- + + +class _StatusDot(QWidget): + """状态点:小圆点 + 文字。""" + + def __init__(self, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(7) + self.dot = QLabel(self) + self.dot.setFixedSize(7, 7) + self.textLabel = QLabel(self) + self.textLabel.setObjectName("statusDotText") + apply_font(self.textLabel, 12, 720) + layout.addWidget(self.dot) + layout.addWidget(self.textLabel) + layout.addStretch(1) + self._level = "neutral" + self.setState("", "neutral") + + def setState(self, text: str, level: str): + self._level = level + self.textLabel.setText(text) + self.syncStyle() + + def syncStyle(self): + palette = app_palette() + color = { + "ok": palette.accent_text, + "missing": palette.danger_fg, + "neutral": palette.subtle, + }.get(self._level, palette.subtle) + dot_color = { + "ok": palette.accent, + "missing": palette.danger, + "neutral": palette.subtle, + }.get(self._level, palette.subtle) + self.dot.setStyleSheet( + f"background: {dot_color}; border-radius: 3px; border: none;" + ) + self.textLabel.setStyleSheet(f"color: {color}; background: transparent; border: none;") + + +class _EngineTabs(QFrame): + """引擎页签:均分两块,单引擎时整体隐藏。""" + + changed = pyqtSignal(str) + + def __init__(self, kinds: list[str], current: str, parent=None): + super().__init__(parent) + self._tabs: dict[str, QLabel] = {} + self._current = current + self.setFixedHeight(46) + layout = QHBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(6) + for kind in kinds: + tab = QLabel(KIND_TITLES[kind], self) + tab.setObjectName("engineTab") + tab.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + tab.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + apply_font(tab, 14, 800) + tab.mousePressEvent = lambda _e, k=kind: self._on_tab(k) # type: ignore[method-assign] + layout.addWidget(tab, 1) + self._tabs[kind] = tab + self.syncStyle() + + def _on_tab(self, kind: str): + if kind != self._current: + self._current = kind + self.syncStyle() + self.changed.emit(kind) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + for kind, tab in self._tabs.items(): + active = kind == self._current + bg = palette.field if active else "transparent" + border = palette.line if active else "transparent" + color = palette.text if active else palette.muted + tab.setStyleSheet( + f""" + QLabel#engineTab {{ + background: {bg}; + border: 1px solid {border}; + border-radius: 9px; + color: {color}; + }} + """ + ) + self.setStyleSheet("QFrame { background: transparent; border: none; }") + + +# 小节标题用第一方 SectionLabel(本页历史别名,保留调用点不变) +_SectionLabel = SectionLabel + + +# --------------------------------------------------------------------------- +# 运行程序行 / 模型行 +# --------------------------------------------------------------------------- + + +class _ProgramRow(QFrame): + """运行程序行:图标盒 + 名称/说明 + 状态点 + 操作。""" + + actionRequested = pyqtSignal(object) # ProgramVariant + recheckRequested = pyqtSignal() + + def __init__(self, variant: ProgramVariant, parent=None): + super().__init__(parent) + self.variant = variant + self.setFixedHeight(64) + layout = QHBoxLayout(self) + layout.setContentsMargins(13, 0, 13, 0) + layout.setSpacing(12) + layout.addWidget(IconBox(AppIcon.TERMINAL, self)) + + column = QVBoxLayout() + column.setSpacing(3) + self.nameLabel = QLabel(variant.title, self) + self.nameLabel.setObjectName("rowName") + apply_font(self.nameLabel, 14, 820) + column.addWidget(self.nameLabel) + self.descLabel = QLabel("", self) + self.descLabel.setObjectName("rowDesc") + apply_font(self.descLabel, 12, 650) + column.addWidget(self.descLabel) + layout.addLayout(column, 1) + layout.setAlignment(column, Qt.AlignVCenter) # type: ignore[arg-type] + + self.status = _StatusDot(self) + self.status.setFixedWidth(STATUS_COLUMN) + layout.addWidget(self.status) + + self.actionButton = AccentButton("", None, self) + self.actionButton.clicked.connect(lambda: self.actionRequested.emit(self.variant)) + layout.addWidget(self.actionButton) + self.recheckButton = CompactButton(tr("modelmgr.program.recheck"), AppIcon.SYNC, self) + self.recheckButton.clicked.connect(self.recheckRequested) + layout.addWidget(self.recheckButton) + self.syncStyle() + + def refresh(self, busy: bool): + status = self.variant.detect() + if status.installed: + self.nameLabel.setText(self.variant.title) + self.descLabel.setText(tr("modelmgr.program.found", name=status.name or "")) + self.descLabel.setToolTip(status.path or "") + self.status.setState(tr("modelmgr.program.available"), "ok") + self.actionButton.hide() + else: + self.nameLabel.setText(self.variant.title) + self.descLabel.setText(self.variant.description_missing) + self.descLabel.setToolTip("") + self.status.setState(tr("modelmgr.program.missing"), "missing") + if self.variant.download is not None: + self.actionButton.setText(tr("modelmgr.action.download")) + self.actionButton.setIcon(AppIcon.DOWNLOAD) + self.actionButton.show() + elif self.variant.command: + self.actionButton.hide() # 命令行在下方 _CommandRow 展示 + elif self.variant.link: + self.actionButton.setText(tr("modelmgr.program.open_page")) + self.actionButton.setIcon(AppIcon.LINK) + self.actionButton.show() + else: + self.actionButton.hide() + self.actionButton.setEnabled(not busy) + self.recheckButton.setEnabled(not busy) + + def showDownloading(self): + self.status.setState(tr("modelmgr.status.downloading"), "neutral") + self.actionButton.setText(tr("common.cancel")) + self.actionButton.setIcon(AppIcon.CANCEL) + self.actionButton.setEnabled(True) + self.actionButton.show() + self.recheckButton.setEnabled(False) + + def setProgressText(self, message: str): + self.descLabel.setText(message) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f""" + QFrame {{ background: transparent; border: none; }} + QLabel#rowName {{ color: {palette.text}; background: transparent; }} + QLabel#rowDesc {{ color: {palette.subtle}; background: transparent; }} + """ + ) + + +class _CommandRow(QFrame): + """安装命令行:命令文本 + 复制按钮。""" + + def __init__(self, command: str, parent=None): + super().__init__(parent) + self.command = command + self.setFixedHeight(48) + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 0, 9, 0) + layout.setSpacing(10) + self.commandLabel = QLabel(command, self) + self.commandLabel.setObjectName("commandText") + self.commandLabel.setTextInteractionFlags(Qt.TextSelectableByMouse) # type: ignore[arg-type] + apply_font(self.commandLabel, 13, 700) + layout.addWidget(self.commandLabel, 1) + self.copyButton = AccentButton(tr("modelmgr.command.copy"), AppIcon.COPY, self) + self.copyButton.clicked.connect(self._copy) + layout.addWidget(self.copyButton) + self.syncStyle() + + def _copy(self): + QApplication.clipboard().setText(self.command) + from qfluentwidgets import InfoBar + + InfoBar.success( + tr("modelmgr.command.copied_title"), + tr("modelmgr.command.copied_body"), + duration=INFOBAR_DURATION_WARNING, + parent=self.window(), + ) + + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.field, palette.line, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f""" + QFrame {{ background: transparent; border: none; }} + QLabel#commandText {{ color: {palette.text}; background: transparent; }} + """ + ) + + +class _ModelRow(QFrame): + """模型行:图标盒 + 文件名/用途 + 大小 + 状态点 + 操作(或下载进度)。""" + + downloadRequested = pyqtSignal(object) + removeRequested = pyqtSignal(object) + cancelRequested = pyqtSignal() + + def __init__(self, spec: ModelSpec, parent=None): + super().__init__(parent) + self.spec = spec + self._last = False + self.setObjectName("modelRow") + self.setFixedHeight(64) + layout = QHBoxLayout(self) + layout.setContentsMargins(13, 0, 13, 0) + layout.setSpacing(12) + icon = AppIcon.FILE if spec.kind == "whisper-cpp" else AppIcon.DOCUMENT + layout.addWidget(IconBox(icon, self)) + + column = QVBoxLayout() + column.setSpacing(3) + self.nameLabel = QLabel(spec.display_name, self) + self.nameLabel.setObjectName("rowName") + apply_font(self.nameLabel, 14, 820) + column.addWidget(self.nameLabel) + self.descLabel = QLabel(spec.description, self) + self.descLabel.setObjectName("rowDesc") + apply_font(self.descLabel, 12, 650) + column.addWidget(self.descLabel) + layout.addLayout(column, 1) + layout.setAlignment(column, Qt.AlignVCenter) # type: ignore[arg-type] + + self.sizeLabel = QLabel(spec.size_text, self) + self.sizeLabel.setObjectName("rowSize") + self.sizeLabel.setFixedWidth(SIZE_COLUMN) + apply_font(self.sizeLabel, 13, 720) + layout.addWidget(self.sizeLabel) + + self.status = _StatusDot(self) + self.status.setFixedWidth(STATUS_COLUMN) + layout.addWidget(self.status) + + # 进度区(下载中替代 大小+状态 列) + self.progressLine = ProgressBarLine(self) + self.progressLine.setFixedWidth(132) + self.progressLine.hide() + layout.addWidget(self.progressLine) + self.percentLabel = QLabel("", self) + self.percentLabel.setObjectName("rowSize") + self.percentLabel.setMinimumWidth(40) + self.percentLabel.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + apply_font(self.percentLabel, 12, 750) + self.percentLabel.hide() + layout.addWidget(self.percentLabel) + + # 操作列:固定宽度容器,三种按钮切换,行宽不抖 + action_host = QWidget(self) + action_host.setFixedWidth(ACTION_COLUMN) + action_layout = QHBoxLayout(action_host) + action_layout.setContentsMargins(0, 0, 0, 0) + action_layout.addStretch(1) + self.downloadButton = AccentButton(tr("modelmgr.action.download"), AppIcon.DOWNLOAD, action_host) + self.downloadButton.clicked.connect(lambda: self.downloadRequested.emit(self.spec)) + action_layout.addWidget(self.downloadButton) + self.removeButton = DangerButton(tr("common.delete"), None, action_host) + self.removeButton.clicked.connect(lambda: self.removeRequested.emit(self.spec)) + action_layout.addWidget(self.removeButton) + self.currentButton = CompactButton(tr("modelmgr.action.current"), None, action_host) + self.currentButton.setEnabled(False) + action_layout.addWidget(self.currentButton) + self.cancelButton = CompactButton(tr("common.cancel"), None, action_host) + self.cancelButton.clicked.connect(self.cancelRequested) + action_layout.addWidget(self.cancelButton) + layout.addWidget(action_host) + self.syncStyle() + + def _show_action(self, widget: QWidget | None): + for button in (self.downloadButton, self.removeButton, self.currentButton, self.cancelButton): + button.setVisible(button is widget) + + def _show_progress(self, downloading: bool): + self.progressLine.setVisible(downloading) + self.percentLabel.setVisible(downloading) + self.sizeLabel.setVisible(not downloading) + self.status.setVisible(not downloading) + + def showState(self, state: str, *, busy: bool, is_current: bool): + """state: installed / absent / partial。busy=有任务在跑。""" + self._show_progress(False) + self.descLabel.setText(self.spec.description) + if state == "installed": + self.status.setState(tr("modelmgr.status.installed"), "ok") + if is_current: + self._show_action(self.currentButton) + else: + self._show_action(self.removeButton) + self.removeButton.setEnabled(not busy) + elif state == "partial": + self.status.setState(tr("modelmgr.status.paused"), "neutral") + self.downloadButton.setText(tr("modelmgr.action.resume")) + self.downloadButton.setIcon(AppIcon.DOWNLOAD) + self._show_action(self.downloadButton) + self.downloadButton.setEnabled(not busy) + else: + self.status.setState(tr("modelmgr.status.pending"), "neutral") + self.downloadButton.setText(tr("modelmgr.action.download")) + self.downloadButton.setIcon(AppIcon.DOWNLOAD) + self._show_action(self.downloadButton) + self.downloadButton.setEnabled(not busy) + + def showDownloading(self): + self._show_progress(True) + self.progressLine.setValue(0) + self.percentLabel.setText("0%") + self.descLabel.setText(tr("modelmgr.progress.connecting")) + self._show_action(self.cancelButton) + self.cancelButton.setEnabled(True) + + def setProgress(self, percent: int, message: str): + if percent >= 0: + self.progressLine.setValue(percent) + self.percentLabel.setText(f"{percent}%") + self.descLabel.setText(message) + + def setLast(self, last: bool): + self._last = last + self.syncStyle() + + def syncStyle(self): + palette = app_palette() + border = "none" if self._last else f"1px solid {palette.line_soft}" + self.setStyleSheet( + f""" + QFrame#modelRow {{ background: transparent; border: none; border-bottom: {border}; }} + QFrame {{ background: transparent; border: none; }} + QLabel#rowName {{ color: {palette.text}; background: transparent; }} + QLabel#rowDesc {{ color: {palette.subtle}; background: transparent; }} + QLabel#rowSize {{ color: {palette.muted}; background: transparent; }} + """ + ) + + +# --------------------------------------------------------------------------- +# 弹窗 +# --------------------------------------------------------------------------- + + +class ModelManagerDialog(AppDialog): + """本地模型管理:运行程序 + 模型下载/删除,单任务串行。""" + + modelsChanged = pyqtSignal() + + def __init__(self, kind: str = "whisper-cpp", parent: QWidget | None = None): + kinds = available_model_kinds() + self._kinds = kinds + self._kind = kind if kind in kinds else kinds[0] + self._thread: ArtifactDownloadThread | None = None + self._active_model: _ModelRow | None = None + self._active_program: _ProgramRow | None = None + self._model_rows: dict[str, list[_ModelRow]] = {} + self._program_rows: dict[str, list[_ProgramRow]] = {} + self._command_rows: dict[str, _CommandRow] = {} + self._containers: dict[str, QWidget] = {} + + super().__init__(tr("modelmgr.title"), icon=AppIcon.FOLDER_ADD, parent=parent, width=720) + self._build_ui() + self._switch_kind(self._kind) + + # ------------------------------------------------------------------ UI + + def _build_ui(self): + card = self.widget + layout = self.bodyLayout + + # 引擎页签 + self.engineTabs = _EngineTabs(self._kinds, self._kind, card) + self.engineTabs.changed.connect(self._switch_kind) + self.engineTabs.setVisible(len(self._kinds) > 1) + layout.addWidget(self.engineTabs) + + # 每个引擎一个内容容器 + for kind in self._kinds: + container = QWidget(card) + column = QVBoxLayout(container) + column.setContentsMargins(0, 0, 0, 0) + column.setSpacing(10) + + column.addWidget(_SectionLabel(tr("modelmgr.section.programs"), container)) + program_rows = [] + for variant in program_variants(kind): + row = _ProgramRow(variant, container) + row.actionRequested.connect(self._on_program_action) + row.recheckRequested.connect(lambda r=row: self._on_recheck(r)) + column.addWidget(row) + program_rows.append(row) + self._program_rows[kind] = program_rows + command = next( + (v.command for v in program_variants(kind) if v.command), None + ) + if command: + command_row = _CommandRow(command, container) + column.addWidget(command_row) + self._command_rows[kind] = command_row + + column.addSpacing(2) + column.addWidget(_SectionLabel(tr("modelmgr.section.models"), container)) + table = QFrame(container) + table.setObjectName("modelTable") + table_layout = QVBoxLayout(table) + table_layout.setContentsMargins(0, 0, 0, 0) + table_layout.setSpacing(0) + table_layout.addWidget(self._build_table_head(table)) + + list_host = QWidget(table) + list_layout = QVBoxLayout(list_host) + list_layout.setContentsMargins(0, 0, 0, 0) + list_layout.setSpacing(0) + rows = [] + for spec in iter_models(kind): + row = _ModelRow(spec, list_host) + row.downloadRequested.connect(self._on_model_download) + row.removeRequested.connect(self._on_model_remove) + row.cancelRequested.connect(self._cancel_active) + list_layout.addWidget(row) + rows.append(row) + if rows: + rows[-1].setLast(True) + self._model_rows[kind] = rows + + scroll = QScrollArea(table) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + scroll.setWidget(list_host) + scroll.setFixedHeight(min(len(rows), 5) * 64 + 6) + self._style_scroll(scroll) + table_layout.addWidget(scroll) + column.addWidget(table) + container.hide() + layout.addWidget(container) + self._containers[kind] = container + + # 底栏 + self.footIcon = QLabel(card) + self.footerLayout.addWidget(self.footIcon) + self.footLabel = QLabel(tr("modelmgr.footer.model_dir"), card) + self.footLabel.setObjectName("modelFootLabel") + self.footLabel.setToolTip(str(MODEL_PATH)) + apply_font(self.footLabel, 12, 700) + self.footerLayout.addWidget(self.footLabel) + self.addFooterStretch() + self.openDirButton = self.addFooterButton(tr("modelmgr.footer.open_dir"), icon=AppIcon.FOLDER) + self.openDirButton.clicked.connect(self._open_models_dir) + self.dismissButton = self.addFooterButton(tr("common.close")) + self.dismissButton.clicked.connect(lambda: self.done(0)) + self.syncStyle() + + def _build_table_head(self, parent: QWidget) -> QFrame: + head = QFrame(parent) + head.setObjectName("modelTableHead") + head.setFixedHeight(36) + layout = QHBoxLayout(head) + layout.setContentsMargins(13, 0, 13, 0) + layout.setSpacing(12) + for text, width in ( + (tr("modelmgr.col.model"), None), + (tr("modelmgr.col.size"), SIZE_COLUMN), + (tr("modelmgr.col.status"), STATUS_COLUMN), + (tr("modelmgr.col.action"), ACTION_COLUMN), + ): + label = QLabel(text, head) + label.setObjectName("modelTableHeadText") + apply_font(label, 12, 780) + if width is None: + layout.addWidget(label, 1) + else: + label.setFixedWidth(width) + layout.addWidget(label) + return head + + def _style_scroll(self, scroll: QScrollArea): + palette = app_palette() + scrollbar_rules = f""" + QScrollBar:vertical {{ background: transparent; width: 9px; margin: 2px 3px; }} + QScrollBar::handle:vertical {{ + background: {rgba(palette.muted, 0.32)}; + border-radius: 2px; min-height: 24px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; width: 0; background: transparent; border: none; + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }} + """ + # 规则同时设到 area(关掉 macOS transient 浮层)与 scrollbar 本体 + # (qfluent 主题样式会覆盖仅写在容器里的滚动条规则) + scroll.setStyleSheet( + "QScrollArea { background: transparent; border: none; }" + scrollbar_rules + ) + scroll.verticalScrollBar().setStyleSheet(scrollbar_rules) + scroll.widget().setStyleSheet("background: transparent;") + + def extraStyleRules(self, palette) -> str: + return f""" + QLabel#modelFootLabel {{ color: {palette.muted}; background: transparent; }} + QFrame#modelTable {{ + background: transparent; + border: 1px solid {palette.line_soft}; + border-radius: 12px; + }} + QFrame#modelTableHead {{ + background: transparent; + border: none; + border-bottom: 1px solid {palette.line_soft}; + }} + QLabel#modelTableHeadText {{ color: {palette.subtle}; background: transparent; }} + """ + + def syncStyle(self): + super().syncStyle() + if hasattr(self, "footIcon"): + self.footIcon.setPixmap(icon_pixmap(AppIcon.FOLDER, app_palette().muted, 15)) + + # ------------------------------------------------------------- 状态刷新 + + @property + def _busy(self) -> bool: + return self._thread is not None + + def _switch_kind(self, kind: str): + self._kind = kind + for name in self._kinds: + self._containers[name].setVisible(name == kind) + self._refresh_current() + self.widget.adjustSize() + + def _refresh_current(self): + models_dir = self._models_dir(self._kind) + current_name = self._current_model_name(self._kind) + for row in self._model_rows[self._kind]: + if row is self._active_model: + continue + if model_install_state(row.spec, models_dir): + state = "installed" + elif has_partial_download(row.spec, models_dir): + state = "partial" + else: + state = "absent" + row.showState( + state, + busy=self._busy, + is_current=state == "installed" and row.spec.name == current_name, + ) + missing_command = False + for row in self._program_rows[self._kind]: + if row is self._active_program: + continue + row.refresh(busy=self._busy) + if not row.variant.detect().installed and row.variant.command: + missing_command = True + command_row = self._command_rows.get(self._kind) + if command_row is not None: + command_row.setVisible(missing_command) + command_row.copyButton.setEnabled(not self._busy) + self.widget.adjustSize() + + @staticmethod + def _models_dir(kind: str) -> Path: + if kind == "faster-whisper": + from videocaptioner.ui.common.config import cfg + + return Path(cfg.faster_whisper_model_dir.value or MODEL_PATH) + return Path(MODEL_PATH) + + @staticmethod + def _current_model_name(kind: str) -> str: + from videocaptioner.ui.common.config import cfg + + field = cfg.whisper_model if kind == "whisper-cpp" else cfg.faster_whisper_model + return str(getattr(field.value, "value", field.value)) + + # ------------------------------------------------------------- 模型操作 + + def _on_model_download(self, spec: ModelSpec): + if self._busy: + return + row = next((r for r in self._model_rows[spec.kind] if r.spec.key == spec.key), None) + if row is None: + return + thread = model_download_thread(spec, self._models_dir(spec.kind), self) + self._thread = thread + self._active_model = row + row.showDownloading() + thread.progress.connect(row.setProgress) + thread.completed.connect(lambda _path, s=spec: self._on_download_done(s)) + thread.error.connect(self._on_download_error) + thread.finished.connect(self._on_thread_finished) + self._refresh_current() + thread.start() + + def _on_model_remove(self, spec: ModelSpec): + if self._busy: + return + box = ConfirmDialog( + tr("modelmgr.remove.title"), + tr("modelmgr.remove.body", name=spec.display_name, size=spec.size_text), + self, + confirm_text=tr("common.delete"), + danger=True, + icon=AppIcon.DELETE, + ) + if not box.exec(): + return + try: + remove_model(spec, self._models_dir(spec.kind)) + except OSError as exc: + self._error(tr("modelmgr.remove.failed"), str(exc)) + return + self._info(tr("modelmgr.remove.done"), spec.display_name) + self.modelsChanged.emit() + self._refresh_current() + + # ------------------------------------------------------------- 程序操作 + + def _on_program_action(self, variant: ProgramVariant): + row = next( + (r for r in self._program_rows[self._kind] if r.variant is variant), None + ) + if row is not None and row is self._active_program: + self._cancel_active() + return + if self._busy: + return + if variant.download is not None: + self._start_program_download(variant, row) + elif variant.link: + QDesktopServices.openUrl(QUrl(variant.link)) + self._info(tr("modelmgr.program.opened_title"), tr("modelmgr.program.opened_body")) + + def _on_recheck(self, row: _ProgramRow): + self._refresh_current() + status = row.variant.detect() + if status.installed: + self._info( + tr("modelmgr.recheck.available_title"), + tr("modelmgr.recheck.available_body", name=status.name or row.variant.title), + ) + else: + self._warn( + tr("modelmgr.recheck.missing_title"), + row.variant.description_missing, + ) + + def _start_program_download(self, variant: ProgramVariant, row: _ProgramRow | None): + if variant.download is None or row is None: + return + thread = program_download_thread(variant.download, Path(BIN_PATH), self) + self._thread = thread + self._active_program = row + row.showDownloading() + thread.progress.connect(lambda _p, message, r=row: r.setProgressText(message)) + thread.completed.connect(self._on_program_downloaded) + thread.error.connect(self._on_download_error) + thread.finished.connect(self._on_thread_finished) + self._refresh_current() + thread.start() + + def _on_program_downloaded(self, path: str): + if self._kind == "faster-whisper": + from videocaptioner.ui.common.config import cfg + + cfg.set(cfg.faster_whisper_program, Path(path).name) + self._info(tr("modelmgr.program.ready_title"), tr("modelmgr.program.ready_body")) + + # ------------------------------------------------------------- 任务收尾 + + def _on_download_done(self, spec: ModelSpec): + self._info(tr("modelmgr.download.done_title"), tr("modelmgr.download.done_body", name=spec.display_name)) + self.modelsChanged.emit() + + def _on_download_error(self, message: str): + self._error(tr("modelmgr.download.failed"), message) + + def _on_thread_finished(self): + thread = self._thread + self._thread = None + self._active_model = None + self._active_program = None + if thread is not None: + thread.deleteLater() + self._refresh_current() + + def _cancel_active(self): + if self._thread is not None: + self._thread.stop() + + def done(self, code: int): # noqa: A003 + # 关闭即取消进行中的下载(.part 保留,下次显示「继续」) + self._cancel_active() + super().done(code) + + # ------------------------------------------------------------- 工具 + + def _open_models_dir(self): + models_dir = self._models_dir(self._kind) + models_dir.mkdir(parents=True, exist_ok=True) + QDesktopServices.openUrl(QUrl.fromLocalFile(str(models_dir))) + + def _info(self, title: str, message: str): + from qfluentwidgets import InfoBar + + InfoBar.success(title, message, duration=INFOBAR_DURATION_SUCCESS, parent=self.window()) + + def _warn(self, title: str, message: str): + from qfluentwidgets import InfoBar + + InfoBar.warning(title, message, duration=INFOBAR_DURATION_WARNING, parent=self.window()) + + def _error(self, title: str, message: str): + from qfluentwidgets import InfoBar + + InfoBar.error(title, message, duration=INFOBAR_DURATION_ERROR, parent=self.window()) diff --git a/videocaptioner/ui/components/roi_selector.py b/videocaptioner/ui/components/roi_selector.py new file mode 100644 index 00000000..7515a57c --- /dev/null +++ b/videocaptioner/ui/components/roi_selector.py @@ -0,0 +1,462 @@ +"""视频字幕区域框选控件:静止帧背景上自绘可拖拽/缩放的矩形选框 + 底部换帧 scrubber。 + +为什么用静止帧而不在 QVideoWidget 上叠层:QVideoWidget 在 mac/Win 是原生子窗口,叠的选框会 +被视频画面盖住 / 透明失效。框选字幕区根本不需要播放——用 ffmpeg 抽某一时刻的帧当背景,在普通 +QWidget 上自绘选框,坐标系最干净。拖 scrubber 换看不同时刻的帧,便于把框对准稳定的字幕带。 + +坐标:选框对外一律用「视频原始分辨率像素」(roi_src),控件像素经 letterbox(居中黑边)反算。 +""" + +from __future__ import annotations + +from typing import Callable, Optional + +import numpy as np +from PyQt5.QtCore import QPoint, QRect, QRectF, QSize, Qt, pyqtSignal +from PyQt5.QtGui import QColor, QImage, QPainter, QPainterPath, QPen, QPixmap +from PyQt5.QtWidgets import QWidget + +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.workbench import apply_font, icon_pixmap, to_qcolor +from videocaptioner.ui.i18n import tr + +# 边缘掩码(位运算,与 caption_overlay 的拖拽/缩放同风格) +_L, _R, _T, _B = 1, 2, 4, 8 +_HANDLE = 9 # 手柄命中半径(px) +_BAR_H = 56 # 底部控制条高度 + + +def ndarray_to_qpixmap(arr: np.ndarray) -> QPixmap: + """RGB ndarray (H,W,3) → QPixmap。必须 ascontiguous + 传 strides + copy(), + 否则 QImage 零拷贝引用 numpy buffer,buffer 被 GC 后会花屏/崩溃。""" + arr = np.ascontiguousarray(arr) + h, w, _ = arr.shape + qimg = QImage(arr.data, w, h, arr.strides[0], QImage.Format_RGB888) + return QPixmap.fromImage(qimg.copy()) + + +class _ScrubBar(QWidget): + """底部换帧条:进度条 + 时间码 + 文件名 + 提示。点击/拖动发出 seek(比例)。""" + + seeked = pyqtSignal(float) # 0.0 - 1.0 + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setFixedHeight(_BAR_H) + self._fraction = 0.0 + self._duration = 0.0 + self._clip_name = "" + self._dragging = False + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + def set_clip(self, name: str, duration: float) -> None: + self._clip_name = name + self._duration = duration + self.update() + + def set_fraction(self, fraction: float) -> None: + self._fraction = max(0.0, min(1.0, fraction)) + self.update() + + def _track_rect(self) -> QRect: + return QRect(14, 12, self.width() - 28, 6) + + def _seek_to(self, x: int) -> None: + track = self._track_rect() + frac = (x - track.left()) / max(1, track.width()) + self.set_fraction(frac) + self.seeked.emit(self._fraction) + + def mousePressEvent(self, event): + # 只在轨道/滑块区起拖;点到下面的时间/提示文字不跳帧(否则像"突然播放")。 + if ( + event.button() == Qt.LeftButton # type: ignore[attr-defined] + and event.pos().y() <= self._track_rect().bottom() + 8 + ): + self._dragging = True + self._seek_to(event.pos().x()) + + def mouseMoveEvent(self, event): + if self._dragging: + self._seek_to(event.pos().x()) + + def mouseReleaseEvent(self, event): + self._dragging = False + + @staticmethod + def _fmt(seconds: float) -> str: + seconds = max(0, int(seconds)) + return f"{seconds // 60:02d}:{seconds % 60:02d}" + + def paintEvent(self, _event): + palette = app_palette() + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + # 半透明深色底(叠在帧上) + p.setPen(Qt.NoPen) # type: ignore[arg-type] + p.setBrush(QColor(10, 17, 15, 184)) + p.drawRoundedRect(self.rect(), 12, 12) + # 轨道 + 已播部分 + 滑块 + track = self._track_rect() + p.setBrush(QColor(197, 215, 209, 56)) + p.drawRoundedRect(track, 3, 3) + fill_w = int(track.width() * self._fraction) + if fill_w > 0: + p.setBrush(QColor(palette.accent)) + p.drawRoundedRect(QRect(track.left(), track.top(), fill_w, track.height()), 3, 3) + thumb_x = track.left() + fill_w + p.setBrush(QColor(palette.accent)) + p.drawEllipse(QPoint(thumb_x, track.center().y()), 7, 7) + # 时间码 + 文件名(左)/ 提示(右) + cur = self._fraction * self._duration + p.setPen(QColor("#f5fbf8")) + apply_font(self, 12, 800) + p.setFont(self.font()) + # 左:时间码;右:提示。不再画文件名——它与面板头标题重复。 + text_y = track.bottom() + 6 + p.drawText(QRect(14, text_y, 200, 22), Qt.AlignVCenter | Qt.AlignLeft, # type: ignore[arg-type] + f"{self._fmt(cur)} / {self._fmt(self._duration)}") + p.setPen(QColor(palette.subtle)) + p.drawText(QRect(self.width() - 180, text_y, 166, 22), + Qt.AlignVCenter | Qt.AlignRight, tr("roi.scrub.hint")) # type: ignore[arg-type] + + +class RoiSelector(QWidget): + """静止帧 + 可编辑字幕区选框。``frame_provider(t)`` 由页面提供,按秒抽帧返回 RGB ndarray。""" + + roi_changed = pyqtSignal(object) # (x, y, w, h) 原始分辨率,或 None + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setObjectName("roiSelector") + self.setMinimumHeight(320) + self.setMouseTracking(True) + self._pix: Optional[QPixmap] = None + self._src_size = QSize(1, 1) + self._duration = 0.0 + self._frame_provider: Optional[Callable[[float], Optional[np.ndarray]]] = None + # ROI 以「原始分辨率坐标」为唯一真相;_roi 是它在当前布局下派生的控件矩形(绘制/命中用)。 + # 这样窗口 resize / 换帧时框始终贴住同一画面区域,不漂移。 + self._roi_src: Optional[tuple] = None + self._roi = QRect() # 控件坐标(从 _roi_src 派生) + self._mode: Optional[str] = None + self._edge = 0 + self._press = QPoint() + self._roi_at_press = QRect() + self._scale = 1.0 + self._off = QPoint(0, 0) + self._new_dragging = False # 新建框是否已拖动(区分单击空白 vs 拖框) + self._busy_text: Optional[str] = None + + self._bar = _ScrubBar(self) + self._bar.seeked.connect(self._on_seek) + self._bar.hide() + + # ----- 对外 ----- + + def set_video( + self, + frame_provider: Callable[[float], Optional[np.ndarray]], + src_size: QSize, + duration: float, + clip_name: str, + first_frame: Optional[np.ndarray] = None, + ) -> None: + """绑定抽帧回调与视频尺寸/时长并显示首帧。``first_frame`` 由上层后台预抽好直接用, + 避免在 GUI 线程再抽一次(拖入卡顿)。坐标系恒为 ``src_size`` 原始分辨率。""" + self._frame_provider = frame_provider + self._src_size = src_size + self._duration = duration + self._busy_text = None + self._bar.set_clip(clip_name, duration) + self._bar.set_fraction(0.1) + self._bar.show() + if first_frame is not None: + self._pix = ndarray_to_qpixmap(first_frame) + self._recompute_layout() + self.update() + else: + self._show_frame_at(max(0.5, duration * 0.1)) + + def set_roi_src(self, roi: Optional[tuple]) -> None: + """从原始分辨率 ROI 设置选框(自动检测结果回填)。None 清空;越界先 clamp 到画面内。""" + if roi is None: + self._roi_src = None + else: + sw, sh = self._src_size.width(), self._src_size.height() + x = max(0, min(int(roi[0]), sw - 1)) + y = max(0, min(int(roi[1]), sh - 1)) + w = max(1, min(int(roi[2]), sw - x)) + h = max(1, min(int(roi[3]), sh - y)) + self._roi_src = (x, y, w, h) + self._sync_roi_from_src() + self.update() + + def set_busy(self, text: Optional[str]) -> None: + """显示/清除居中「正在进行」遮罩(如「识别字幕区域中…」)。""" + self._busy_text = text + self.update() + + def roi_src(self) -> Optional[tuple]: + return self._roi_src + + def seek_to(self, seconds: float) -> None: + """跳到某一时刻并显示该帧(结果表点行「定位到此画面」用:让用户核对该条字幕的真实画面)。""" + if self._frame_provider is None: + return + seconds = max(0.0, min(seconds, self._duration)) + if self._duration > 0: + self._bar.set_fraction(seconds / self._duration) + self._show_frame_at(seconds) + + def _sync_roi_from_src(self) -> None: + """按当前布局把 _roi_src(原始坐标)派生成 _roi(控件坐标),并 clamp 到画面显示区。""" + if self._roi_src is None: + self._roi = QRect() + return + x, y, w, h = self._roi_src + tl = self._src_to_widget(x, y) + br = self._src_to_widget(x + w, y + h) + self._roi = QRect(tl, br).normalized() + self._clamp_to_image() + + def _commit_roi_to_src(self) -> None: + """编辑(拖/拉/新建)结束后把控件坐标的 _roi 写回 _roi_src。""" + if not self._roi.isValid() or self._roi.width() < 4 or self._roi.height() < 4: + self._roi_src = None + return + tl = self._widget_to_src(self._roi.left(), self._roi.top()) + br = self._widget_to_src(self._roi.right(), self._roi.bottom()) + # round 而非 int:_src_to_widget 与这里都向下取整会让每次微调框左上角往原点漂 3-4px、 + # 多次编辑后累积偏移,round 让往返映射近似无损。 + self._roi_src = (round(tl[0]), round(tl[1]), round(br[0] - tl[0]), round(br[1] - tl[1])) + + def clear(self) -> None: + self._pix = None + self._roi_src = None + self._roi = QRect() + self._bar.hide() + self.update() + + # ----- 帧 / 布局 ----- + + def _show_frame_at(self, t: float) -> None: + if self._frame_provider is None: + return + arr = self._frame_provider(t) + if arr is None: + return + # 抽帧可能为提速被缩小,但坐标系始终用原始分辨率(_src_size 由 set_video 设定,不被覆盖): + # 否则 ROI 的原始坐标会被当成缩放坐标,框就错位/超界。pixmap 画进 image_rect 时自然缩放。 + self._pix = ndarray_to_qpixmap(arr) + self._recompute_layout() + self.update() + + def _on_seek(self, fraction: float) -> None: + # 换帧只换背景图;ROI 是原始坐标,_sync_roi_from_src 会重画,位置不变。 + self._show_frame_at(fraction * self._duration) + + def _recompute_layout(self) -> None: + sw, sh = self._src_size.width(), self._src_size.height() + # 底部 scrubber 覆盖在帧上:帧避开它,避免字幕区被遮且坐标更直观。 + avail_h = max(1, self.height() - _BAR_H - 14) + scale = min(self.width() / max(1, sw), avail_h / max(1, sh)) + self._scale = scale + dw, dh = sw * scale, sh * scale + self._off = QPoint(int((self.width() - dw) / 2), int((avail_h - dh) / 2)) + self._sync_roi_from_src() + + def resizeEvent(self, event): + self._recompute_layout() + self._bar.setGeometry(16, self.height() - _BAR_H - 14, self.width() - 32, _BAR_H) + super().resizeEvent(event) + + # ----- 坐标映射 ----- + + def _widget_to_src(self, px: float, py: float) -> tuple: + s = self._scale or 1.0 + return ((px - self._off.x()) / s, (py - self._off.y()) / s) + + def _src_to_widget(self, sx: float, sy: float) -> QPoint: + return QPoint( + round(sx * self._scale + self._off.x()), round(sy * self._scale + self._off.y()) + ) + + def _image_rect(self) -> QRect: + return QRect( + self._off.x(), self._off.y(), + int(self._src_size.width() * self._scale), + int(self._src_size.height() * self._scale), + ) + + # ----- 选框手柄 ----- + + def _handles(self) -> dict: + r = self._roi + cx, cy = r.center().x(), r.center().y() + pts = { + _T | _L: r.topLeft(), _T: QPoint(cx, r.top()), _T | _R: r.topRight(), + _L: QPoint(r.left(), cy), _R: QPoint(r.right(), cy), + _B | _L: r.bottomLeft(), _B: QPoint(cx, r.bottom()), _B | _R: r.bottomRight(), + } + return {edge: QRect(p.x() - _HANDLE, p.y() - _HANDLE, 2 * _HANDLE, 2 * _HANDLE) + for edge, p in pts.items()} + + def _hit(self, pos: QPoint) -> tuple: + if self._roi.isValid(): + for edge, rect in self._handles().items(): + if rect.contains(pos): + return "resize", edge + if self._roi.contains(pos): + return "move", 0 + return "new", 0 + + @staticmethod + def _cursor_for(edge: int): + if edge in (_T | _L, _B | _R): + return Qt.SizeFDiagCursor + if edge in (_T | _R, _B | _L): + return Qt.SizeBDiagCursor + if edge in (_L, _R): + return Qt.SizeHorCursor + if edge in (_T, _B): + return Qt.SizeVerCursor + return Qt.SizeAllCursor + + # ----- 鼠标 ----- + + def mousePressEvent(self, event): + if event.button() != Qt.LeftButton or self._pix is None: # type: ignore[attr-defined] + return + if not self._image_rect().contains(event.pos()): + return + self._press = event.pos() + self._roi_at_press = QRect(self._roi) + self._mode, self._edge = self._hit(event.pos()) + # 新建框先不动旧框,等真正拖动了再开画——单击空白不应把已有框清成一个点。 + self._new_dragging = False + self.update() + + def mouseMoveEvent(self, event): + if self._mode is None: + if self._pix is not None and self._image_rect().contains(event.pos()): + mode, edge = self._hit(event.pos()) + self.setCursor( + self._cursor_for(edge) if mode == "resize" + else (Qt.SizeAllCursor if mode == "move" else Qt.CrossCursor) + ) + else: + self.unsetCursor() + return + delta = event.pos() - self._press + if self._mode == "new": + # 拖动超过阈值才开始画新框;否则保持旧框(避免单击空白误清)。 + if not self._new_dragging and delta.manhattanLength() < 6: + return + self._new_dragging = True + self._roi = QRect(self._press, event.pos()).normalized() + elif self._mode == "move": + self._roi = self._roi_at_press.translated(delta) + elif self._mode == "resize": + r = QRect(self._roi_at_press) + if self._edge & _L: + r.setLeft(r.left() + delta.x()) + if self._edge & _R: + r.setRight(r.right() + delta.x()) + if self._edge & _T: + r.setTop(r.top() + delta.y()) + if self._edge & _B: + r.setBottom(r.bottom() + delta.y()) + self._roi = r.normalized() + self._clamp_to_image() + self.update() + + def mouseReleaseEvent(self, event): + if self._mode is None: + return + was_new = self._mode == "new" + self._mode = None + self._edge = 0 + if was_new and not self._new_dragging: + # 单击空白没拖动:保持原框不变,不提交(不清掉已有框)。 + self._roi = QRect(self._roi_at_press) + self.update() + return + self._commit_roi_to_src() + self.update() + self.roi_changed.emit(self.roi_src()) + + def _clamp_to_image(self) -> None: + img = self._image_rect() + r = self._roi + r.setLeft(max(img.left(), r.left())) + r.setTop(max(img.top(), r.top())) + r.setRight(min(img.right(), r.right())) + r.setBottom(min(img.bottom(), r.bottom())) + self._roi = r + + # ----- 绘制 ----- + + def paintEvent(self, _event): + palette = app_palette() + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.fillRect(self.rect(), QColor("#10100f")) # 帧外黑边 + if self._pix is None: + if self._busy_text: + self._paint_busy(p) + return + img = self._image_rect() + # 圆角裁剪帧,和面板/卡片的圆润风格一致(直角硬边显得突兀)。 + frame_path = QPainterPath() + frame_path.addRoundedRect(QRectF(img), 12, 12) + p.save() + p.setClipPath(frame_path) + p.drawPixmap(img, self._pix) + p.restore() + roi = self._roi.intersected(img) + if roi.width() > 2 and roi.height() > 2: + # 只压暗 ROI 之外的画面(even-odd:整幅画面矩形「减去」ROI 矩形),ROI 内透出原画面。 + # 不能用 CompositionMode_Clear——那会把已画的视频帧一起擦成透明、露出底色(之前的白块)。 + mask = QPainterPath() + mask.addRect(QRectF(img)) + mask.addRect(QRectF(roi)) + mask.setFillRule(Qt.OddEvenFill) # type: ignore[arg-type] + p.fillPath(mask, QColor(0, 0, 0, 96)) + # 选框 + 8 手柄(主题色边、白色实心手柄) + accent = to_qcolor(palette.accent) + p.setPen(QPen(accent, 2)) + p.setBrush(Qt.NoBrush) # type: ignore[arg-type] + p.drawRoundedRect(roi, 6, 6) + p.setPen(Qt.NoPen) # type: ignore[arg-type] + p.setBrush(QColor("#ffffff")) + for hr in self._handles().values(): + p.drawRoundedRect(hr.adjusted(4, 4, -4, -4), 2, 2) + # 右上角「字幕区域」标签(不越出画面顶部) + label = tr("roi.tag.caption_area") + apply_font(self, 11, 850) + p.setFont(self.font()) + lw = self.fontMetrics().horizontalAdvance(label) + 20 + tag_y = max(img.top() + 2, roi.top() - 28) + tag = QRect(min(roi.right() - lw, img.right() - lw - 2), tag_y, lw, 22) + p.setBrush(QColor(11, 23, 19, 235)) + p.setPen(QPen(to_qcolor(palette.accent_border), 1)) + p.drawRoundedRect(tag, 11, 11) + p.setPen(to_qcolor(palette.accent_text)) + p.drawText(tag, Qt.AlignCenter, label) # type: ignore[arg-type] + if self._busy_text: + self._paint_busy(p) + + def _paint_busy(self, p: QPainter) -> None: + """识别/载入时的居中遮罩提示,让用户知道「正在进行」。""" + palette = app_palette() + p.fillRect(self.rect(), QColor(8, 14, 12, 150)) + apply_font(self, 15, 850) + p.setFont(self.font()) + p.setPen(to_qcolor(palette.accent_text)) + p.drawText(self.rect(), Qt.AlignCenter, self._busy_text) # type: ignore[arg-type] + + +# 让 icon 引用不被 lint 当未使用(页面层用同一套 AppIcon;此处保留以备扩展工具按钮) +_ = (AppIcon, icon_pixmap) diff --git a/videocaptioner/ui/components/settings_controls.py b/videocaptioner/ui/components/settings_controls.py new file mode 100644 index 00000000..4a0be4fb --- /dev/null +++ b/videocaptioner/ui/components/settings_controls.py @@ -0,0 +1,955 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Any + +from PyQt5.QtCore import QEvent, Qt, pyqtSignal +from PyQt5.QtGui import QColor +from PyQt5.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QSlider, + QStackedWidget, + QVBoxLayout, + QWidget, +) +from qfluentwidgets import ( + ComboBox, + EditableComboBox, + ScrollArea, + SwitchButton, +) + +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.config import cfg +from videocaptioner.ui.common.enum_labels import enum_label +from videocaptioner.ui.common.settings_state import SettingField +from videocaptioner.ui.common.theme_tokens import app_palette, is_dark_theme, rgba +from videocaptioner.ui.components.workbench import ( + AppLineEdit, + RoundIconButton, + WorkbenchButton, + apply_font, +) +from videocaptioner.ui.i18n import tr + +CONTROL_WIDTH = 246 +CONTROL_HEIGHT = 42 +SHORT_CONTROL_WIDTH = 112 +SLIDER_WIDTH = 190 +ROW_MIN_HEIGHT = 68 +ROW_VERTICAL_PADDING = 11 + + +def _bind_config_value(widget: QWidget, config_item: SettingField, handler: Callable[[Any], None]) -> None: + config_item.valueChanged.connect(handler) + + def disconnect_handler(*_args) -> None: + try: + config_item.valueChanged.disconnect(handler) + except (RuntimeError, TypeError): + pass + + widget.destroyed.connect(disconnect_handler) + + +@dataclass(frozen=True) +class Option: + value: Any + text: str + + +def option_text(value: Any) -> str: + # 已注册枚举 → i18n 标签(key=enum.<类名>.<成员名>);其余取 .value 并去装饰 ✨。 + label = enum_label(value) + if label is not None: + return label + text = str(getattr(value, "value", value)) + return text.replace(" ✨", "").strip() + + +def options_from(values: Iterable[Any], text: Callable[[Any], str] | None = None) -> list[Option]: + display = text or option_text + return [Option(value, display(value)) for value in values] + + +class SettingsShell(QWidget): + pageChanged = pyqtSignal(str) + backRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("settingsShell") + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self._pages: dict[str, SettingsPage] = {} + self._nav_buttons: dict[str, QPushButton] = {} + + self.rootLayout = QHBoxLayout(self) + self.rootLayout.setContentsMargins(0, 0, 0, 0) + self.rootLayout.setSpacing(0) + + self.sidebar = QFrame(self) + self.sidebar.setObjectName("settingsSidebar") + self.sidebar.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.sidebarLayout = QVBoxLayout(self.sidebar) + self.sidebarLayout.setContentsMargins(12, 18, 12, 18) + self.sidebarLayout.setSpacing(12) + + # 弹窗里不再用左侧「返回应用」——关闭统一走右上角 X / Esc / 点遮罩 + self.navTitle = QLabel(tr("ctrl.settings_title"), self.sidebar) + self.navTitle.setObjectName("settingsNavTitle") + apply_font(self.navTitle, 13, 820) + + self.navLayout = QVBoxLayout() + self.navLayout.setContentsMargins(0, 0, 0, 0) + self.navLayout.setSpacing(5) + + self.sidebarLayout.addWidget(self.navTitle) + self.sidebarLayout.addLayout(self.navLayout) + self.sidebarLayout.addStretch(1) + + self.stack = QStackedWidget(self) + self.stack.setObjectName("settingsStack") + + self.rootLayout.addWidget(self.sidebar) + self.rootLayout.addWidget(self.stack, 1) + self.syncStyle() + + def addPage(self, key: str, title: str) -> "SettingsPage": # noqa: N802 + page = SettingsPage(title, self.stack) + self._pages[key] = page + self.stack.addWidget(page) + + button = QPushButton(title, self.sidebar) + button.setObjectName("settingsNavButton") + button.setCheckable(True) + button.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + button.clicked.connect(lambda _checked=False, page_key=key: self.setCurrentPage(page_key)) + self._nav_buttons[key] = button + self.navLayout.addWidget(button) + return page + + def setCurrentPage(self, key: str) -> bool: + page = self._pages.get(key) + if page is None: + return False + self.stack.setCurrentWidget(page) + for page_key, button in self._nav_buttons.items(): + button.setChecked(page_key == key) + self.pageChanged.emit(key) + return True + + def currentPageKey(self) -> str: + current = self.stack.currentWidget() + for key, page in self._pages.items(): + if page is current: + return key + return "" + + def resizeEvent(self, event): # noqa: N802 + super().resizeEvent(event) + width = max(188, min(210, int(self.width() * 0.16))) + self.sidebar.setFixedWidth(width) + + def syncStyle(self) -> None: + palette = app_palette() + # 侧栏与全局面板同色,不再用独立硬编码色 + sidebar_bg = palette.panel + sidebar_hover = rgba(palette.accent, 0.13 if is_dark_theme() else 0.10) + sidebar_checked = rgba(palette.accent, 0.18 if is_dark_theme() else 0.14) + self.setStyleSheet( + f""" + QWidget#settingsShell {{ + background: {palette.bg}; + color: {palette.text}; + }} + QFrame#settingsSidebar {{ + background: {sidebar_bg}; + border: none; + }} + QLabel#settingsNavTitle {{ + color: {palette.subtle}; + background: transparent; + margin-left: 10px; + margin-top: 2px; + margin-bottom: 2px; + }} + QPushButton#settingsNavButton {{ + min-height: 42px; + border: none; + border-radius: 10px; + padding: 0 14px; + text-align: left; + color: {palette.muted}; + background: transparent; + font-size: 15px; + font-weight: bold; + }} + QPushButton#settingsNavButton:hover {{ + color: {palette.text}; + background: {sidebar_hover}; + }} + QPushButton#settingsNavButton:checked {{ + color: {palette.text}; + background: {sidebar_checked}; + }} + QStackedWidget#settingsStack {{ + background: {palette.bg}; + border: none; + }} + """ + ) + for page in self._pages.values(): + page.syncStyle() + + +class SettingsPage(ScrollArea): + def __init__(self, title: str, parent=None): + super().__init__(parent) + self.setObjectName("settingsPage") + self._groups: list[SettingsGroup] = [] + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + self.setWidgetResizable(True) + + self.container = QWidget(self) + self.container.setObjectName("settingsPageContainer") + self.container.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.layout = QVBoxLayout(self.container) + self.layout.setContentsMargins(52, 42, 52, 40) + self.layout.setSpacing(0) + + self.titleLabel = QLabel(title, self.container) + self.titleLabel.setObjectName("settingsPageTitle") + self.titleLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.titleLabel, 30, 860) + self.layout.addWidget(self.titleLabel, 0, Qt.AlignHCenter) # type: ignore[arg-type] + self.layout.addSpacing(28) + self.layout.addStretch(1) + self.setWidget(self.container) + self.syncStyle() + + def resizeEvent(self, event): # noqa: N802 + super().resizeEvent(event) + self._sync_group_widths() + + def addGroup(self, group: "SettingsGroup") -> None: # noqa: N802 + stretch = self.layout.takeAt(self.layout.count() - 1) + self._groups.append(group) + self._sync_group_width(group) + self.layout.addWidget(group, 0, Qt.AlignHCenter) # type: ignore[arg-type] + self.layout.addSpacing(28) + if stretch is not None: + self.layout.addItem(stretch) + + def _sync_group_width(self, group: "SettingsGroup") -> None: + # 关键:组宽绝不能超过可视宽度,否则内容横向溢出、整页能被左右拖动。 + # 理想 640–940,但窄视口下以「可视宽-边距」为准(宁可窄也不溢出)。 + avail = self.viewport().width() - 104 + width = min(940, avail) if avail > 0 else self.viewport().width() + group.setFixedWidth(max(1, width)) + + def _sync_group_widths(self) -> None: + for group in self._groups: + self._sync_group_width(group) + + def syncStyle(self) -> None: + palette = app_palette() + self.setAutoFillBackground(True) + self.viewport().setAutoFillBackground(True) + self.viewport().setStyleSheet(f"background: {palette.bg}; border: none;") + self.container.setStyleSheet(f"background: {palette.bg};") + self.titleLabel.setStyleSheet(f"color: {palette.text}; background: transparent;") + self.setStyleSheet( + f""" + QScrollArea#settingsPage {{ + background: {palette.bg}; + border: none; + }} + QWidget#settingsPageContainer {{ + background: {palette.bg}; + }} + QLabel#settingsPageTitle {{ + color: {palette.text}; + background: transparent; + }} + QScrollBar:vertical {{ + width: 6px; + background: transparent; + }} + QScrollBar::handle:vertical {{ + background: {palette.line}; + border-radius: 3px; + }} + QScrollBar::add-line:vertical, + QScrollBar::sub-line:vertical {{ + height: 0; + }} + """ + ) + for group in self._groups: + group.syncStyle() + + +class SettingsGroup(QFrame): + def __init__(self, title: str = "", parent=None): + super().__init__(parent) + self.setObjectName("settingsGroup") + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) + self._rows: list[SettingRow] = [] + self.rootLayout = QVBoxLayout(self) + self.rootLayout.setContentsMargins(0, 0, 0, 0) + self.rootLayout.setSpacing(0) + if title: + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("settingsSectionTitle") + apply_font(self.titleLabel, 17, 840) + self.rootLayout.addWidget(self.titleLabel) + self.rootLayout.addSpacing(14) + else: + self.titleLabel = None + self.box = QFrame(self) + self.box.setObjectName("settingsBox") + self.boxLayout = QVBoxLayout(self.box) + self.boxLayout.setContentsMargins(0, 0, 0, 0) + self.boxLayout.setSpacing(0) + self.rootLayout.addWidget(self.box) + self.syncStyle() + + def addRow(self, row: "SettingRow") -> "SettingRow": # noqa: N802 + if self._rows: + self._rows[-1].setLast(False) + self._rows.append(row) + row.setLast(True) + row.installEventFilter(self) + self.boxLayout.addWidget(row) + self._refresh_row_edges() + return row + + def eventFilter(self, watched, event): # noqa: N802 + if event.type() in {QEvent.Show, QEvent.Hide}: + self._refresh_row_edges() + return super().eventFilter(watched, event) + + def _refresh_row_edges(self) -> None: + visible_rows = [row for row in self._rows if row.isVisible()] + last_visible = visible_rows[-1] if visible_rows else None + for row in self._rows: + row.setLast(row is last_visible) + + def syncStyle(self) -> None: + palette = app_palette() + self.setStyleSheet( + f""" + QLabel#settingsSectionTitle {{ + color: {palette.text}; + background: transparent; + }} + QFrame#settingsBox {{ + background: {palette.panel}; + border: 1px solid {palette.line}; + border-radius: 14px; + }} + """ + ) + for row in self._rows: + row.syncStyle() + + +class SettingRow(QFrame): + def __init__(self, title: str, description: str = "", control: QWidget | None = None, parent=None): + super().__init__(parent) + self.setObjectName("settingRow") + self.setMinimumHeight(ROW_MIN_HEIGHT) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) # 高度随描述换行增长,不裁字 + self._is_last = False + self.rootLayout = QHBoxLayout(self) + self.rootLayout.setContentsMargins(18, ROW_VERTICAL_PADDING, 18, ROW_VERTICAL_PADDING) + self.rootLayout.setSpacing(24) + + textBox = QWidget(self) + textBox.setObjectName("settingRowTextBox") + textBox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + textLayout = QVBoxLayout(textBox) + textLayout.setContentsMargins(0, 0, 0, 0) + textLayout.setSpacing(6) + self.titleLabel = QLabel(title, textBox) + self.titleLabel.setObjectName("settingRowTitle") + self.descLabel = QLabel(description, textBox) + self.descLabel.setObjectName("settingRowDescription") + self.descLabel.setWordWrap(True) # 描述自动换行,不再被右侧控件挤出容器/截断 + self.titleLabel.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + self.descLabel.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + apply_font(self.titleLabel, 15, 820) + apply_font(self.descLabel, 13, 500) + textLayout.addWidget(self.titleLabel) + if description: + textLayout.addWidget(self.descLabel) + else: + self.descLabel.hide() + + self.controlSlot = QWidget(self) + self.controlSlot.setObjectName("settingRowControlSlot") + self.controlSlot.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.controlLayout = QHBoxLayout(self.controlSlot) + self.controlLayout.setContentsMargins(0, 0, 0, 0) + self.controlLayout.setSpacing(8) + self.controlLayout.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + + self.rootLayout.addWidget(textBox, 1) + self.rootLayout.addWidget(self.controlSlot, 0) + if control is not None: + self.setControl(control) + self.syncStyle() + + def setControl(self, control: QWidget) -> None: # noqa: N802 + while self.controlLayout.count(): + item = self.controlLayout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + self.controlLayout.addWidget(control) + self._sync_control_styles() + + def setLast(self, is_last: bool) -> None: # noqa: N802 + self._is_last = is_last + self.syncStyle() + + def syncStyle(self) -> None: + palette = app_palette() + border = "none" if self._is_last else f"1px solid {palette.line_soft}" + self.setStyleSheet( + f""" + QFrame#settingRow {{ + min-height: {ROW_MIN_HEIGHT}px; + background: transparent; + border: none; + border-bottom: {border}; + }} + QLabel#settingRowTitle {{ + color: {palette.text}; + background: transparent; + }} + QLabel#settingRowDescription {{ + color: {palette.muted}; + background: transparent; + }} + QWidget#settingRowControlSlot, + QWidget#settingRowTextBox {{ + background: transparent; + }} + """ + ) + self._sync_control_styles() + + def _sync_control_styles(self) -> None: + for widget in self.controlSlot.findChildren(QWidget): + sync_style = getattr(widget, "syncStyle", None) + if callable(sync_style): + sync_style() + if widget.objectName() == "settingsButton": + _apply_button_style(widget, bool(widget.property("settingsPrimary"))) + elif widget.objectName() == "settingsValueLabel" and not widget.property("settingsCustomStyle"): + _apply_value_label_style(widget) + + +class BoundComboBox(ComboBox): + currentValueChanged = pyqtSignal(object) + + def __init__(self, config_item: SettingField, options: Iterable[Option], parent=None): + super().__init__(parent) + self.config_item = config_item + self.options: list[Option] = list(options) + self.setObjectName("settingsControl") + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + self.setFixedWidth(CONTROL_WIDTH) + self.setFixedHeight(CONTROL_HEIGHT) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self._populate() + self.currentTextChanged.connect(self._on_text_changed) + _bind_config_value(self, config_item, self.setValue) + self.syncStyle() + + def _populate(self) -> None: + self.blockSignals(True) + self.clear() + self.addItems([option.text for option in self.options]) + self.setValue(self.config_item.value) + self.blockSignals(False) + + def setOptions(self, options: Iterable[Option], keep_value: Any | None = None) -> None: # noqa: N802 + self.options = list(options) + if keep_value is not None: + cfg.set(self.config_item, keep_value) + self._populate() + + def value(self) -> Any: + text = self.currentText() + for option in self.options: + if option.text == text: + return option.value + return text + + def setValue(self, value: Any) -> None: # noqa: N802 + text = option_text(value) + for option in self.options: + if option.value == value: + text = option.text + break + self.blockSignals(True) + if text and self.findText(text) < 0: + self.addItem(text) + self.setCurrentText(text) + self.blockSignals(False) + + def _on_text_changed(self, _text: str) -> None: + value = self.value() + cfg.set(self.config_item, value) + self.currentValueChanged.emit(value) + + def syncStyle(self) -> None: + _apply_control_style(self) + + +class BoundEditableComboBox(EditableComboBox): + currentValueChanged = pyqtSignal(str) + + def paintEvent(self, e): # noqa: N802 + # 跳过 qfluent 聚焦底部下划线:聚焦态已有边框,双重指示很乱 + QLineEdit.paintEvent(self, e) + + def __init__(self, config_item: SettingField, items: Iterable[str] = (), parent=None): + super().__init__(parent) + self.config_item = config_item + self.setObjectName("settingsControl") + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + self.setFixedWidth(CONTROL_WIDTH) + self.setFixedHeight(CONTROL_HEIGHT) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.setItems(list(items)) + self.setValue(str(config_item.value or "")) + self.currentTextChanged.connect(self._on_text_changed) + _bind_config_value(self, config_item, self._set_config_value) + self.syncStyle() + + def setItems(self, items: Iterable[str]) -> None: # noqa: N802 + current = self.currentText() + self.blockSignals(True) + self.clear() + self.addItems(list(items)) + if current: + if self.findText(current) < 0: + self.addItem(current) + self.setText(current) + self.blockSignals(False) + + def setValue(self, value: str) -> None: # noqa: N802 + self.blockSignals(True) + if value and self.findText(value) < 0: + self.addItem(value) + self.setText(value) + self.blockSignals(False) + + def _set_config_value(self, value: Any) -> None: + self.setValue(str(value or "")) + + def _on_text_changed(self, text: str) -> None: + if "key" in self.config_item.name.lower(): + text = text.strip() + cfg.set(self.config_item, text) + self.currentValueChanged.emit(text) + + def syncStyle(self) -> None: + _apply_control_style(self) + + +class BoundLineEdit(AppLineEdit): + """设置页文本输入:复用第一方 AppLineEdit(自绘圆角、聚焦主题色描边、 + + 失焦自动清除、无 qfluent 下划线),并绑定到配置项。""" + + def __init__(self, config_item: SettingField, placeholder: str = "", parent=None, password: bool = False): + super().__init__(parent=parent, height=CONTROL_HEIGHT) + self.config_item = config_item + self.setFixedWidth(CONTROL_WIDTH) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.setPlaceholderText(placeholder) + if password: + self.setEchoMode(QLineEdit.Password) + self.setText(str(config_item.value or "")) + self.textChanged.connect(self._on_text_changed) + _bind_config_value(self, config_item, self.setValue) + + def setValue(self, value: Any) -> None: # noqa: N802 + self.blockSignals(True) + self.setText(str(value or "")) + self.blockSignals(False) + + def _on_text_changed(self, text: str) -> None: + if "key" in self.config_item.name.lower(): + text = text.strip() + if text != self.text(): + self.blockSignals(True) + self.setText(text) + self.blockSignals(False) + cfg.set(self.config_item, text) + + +class BoundSwitch(SwitchButton): + def __init__(self, config_item: SettingField, parent=None): + super().__init__(parent) + self.config_item = config_item + self.setOnText("") + self.setOffText("") + self.setChecked(bool(config_item.value)) + self.checkedChanged.connect(self._on_checked_changed) + _bind_config_value(self, config_item, self.setChecked) + + def _on_checked_changed(self, checked: bool) -> None: + cfg.set(self.config_item, checked) + + +class BoundSlider(QWidget): + valueChanged = pyqtSignal(int) + + def __init__(self, config_item: SettingField, parent=None): + super().__init__(parent) + self.config_item = config_item + self.setObjectName("settingsSliderControl") + self.setFixedHeight(CONTROL_HEIGHT) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + minimum, maximum = getattr(config_item, "range", (0, 100)) + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(12) + self.label = QLabel(str(config_item.value), self) + self.label.setObjectName("settingsSliderLabel") + self.label.setFixedWidth(38) + self.label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + self.slider = QSlider(Qt.Horizontal, self) + self.slider.setObjectName("settingsSlider") + self.slider.setRange(int(minimum), int(maximum)) + self.slider.setFixedWidth(SLIDER_WIDTH) + self.slider.setValue(int(config_item.value)) + self.layout.addWidget(self.label) + self.layout.addWidget(self.slider) + self.slider.valueChanged.connect(self._on_value_changed) + _bind_config_value(self, config_item, self.setValue) + self.syncStyle() + + def setValue(self, value: Any) -> None: # noqa: N802 + self.slider.blockSignals(True) + self.slider.setValue(int(value)) + self.slider.blockSignals(False) + self.label.setText(str(value)) + + def _on_value_changed(self, value: int) -> None: + cfg.set(self.config_item, value) + self.label.setText(str(value)) + self.valueChanged.emit(value) + + def syncStyle(self) -> None: + palette = app_palette() + self.setStyleSheet( + f""" + QWidget#settingsSliderControl, + QSlider#settingsSlider {{ + background: transparent; + }} + QLabel#settingsSliderLabel {{ + color: {palette.text}; + background: transparent; + min-width: 34px; + }} + QSlider#settingsSlider::groove:horizontal {{ + height: 4px; + border-radius: 2px; + background: {palette.line_soft}; + }} + QSlider#settingsSlider::sub-page:horizontal {{ + height: 4px; + border-radius: 2px; + background: {palette.accent}; + }} + QSlider#settingsSlider::add-page:horizontal {{ + height: 4px; + border-radius: 2px; + background: {palette.line_soft}; + }} + QSlider#settingsSlider::handle:horizontal {{ + width: 18px; + height: 18px; + margin: -7px 0; + border-radius: 9px; + background: {palette.accent}; + border: 1px solid {palette.control_border}; + }} + """ + ) + + +class BoundFloatSlider(QWidget): + valueChanged = pyqtSignal(float) + + def __init__(self, config_item: SettingField, decimals: int = 2, parent=None): + super().__init__(parent) + self.config_item = config_item + self.setFixedHeight(CONTROL_HEIGHT) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.decimals = decimals + self.scale = 10 ** decimals + minimum, maximum = getattr(config_item, "range", (0, 1)) + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(12) + self.label = QLabel(self._format(float(config_item.value)), self) + self.label.setObjectName("settingsSliderLabel") + self.label.setFixedWidth(48) + self.label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + self.slider = QSlider(Qt.Horizontal, self) + self.slider.setRange(int(float(minimum) * self.scale), int(float(maximum) * self.scale)) + self.slider.setFixedWidth(SLIDER_WIDTH) + self.slider.setValue(int(float(config_item.value) * self.scale)) + self.layout.addWidget(self.label) + self.layout.addWidget(self.slider) + self.slider.valueChanged.connect(self._on_value_changed) + _bind_config_value(self, config_item, self.setValue) + self.syncStyle() + + def _format(self, value: float) -> str: + return f"{value:.{self.decimals}f}" + + def setValue(self, value: Any) -> None: # noqa: N802 + numeric = float(value) + self.slider.blockSignals(True) + self.slider.setValue(int(numeric * self.scale)) + self.slider.blockSignals(False) + self.label.setText(self._format(numeric)) + + def _on_value_changed(self, slider_value: int) -> None: + value = slider_value / self.scale + cfg.set(self.config_item, value) + self.label.setText(self._format(value)) + self.valueChanged.emit(value) + + def syncStyle(self) -> None: + BoundSlider.syncStyle(self) # type: ignore[misc] + + +def make_button(text: str, primary: bool = False, parent=None) -> WorkbenchButton: + """设置页操作按钮:统一用第一方 WorkbenchButton(弃 qfluent PushButton),与全站按钮一致。""" + button = WorkbenchButton(text, None, primary=primary, height=CONTROL_HEIGHT, parent=parent) + button.setMinimumWidth(SHORT_CONTROL_WIDTH) + return button + + +class _ElidedPathLabel(QLabel): + """只读路径展示:家目录缩写成 ~,超宽时中段省略,完整路径走 tooltip。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("settingsValueLabel") + self.setProperty("settingsCustomStyle", False) + self.setFixedWidth(CONTROL_WIDTH) + self.setFixedHeight(CONTROL_HEIGHT) + self.setAlignment(Qt.AlignVCenter | Qt.AlignLeft) # type: ignore[arg-type] + _apply_value_label_style(self) + self._display = "" + + def setPath(self, path: str, placeholder: str) -> None: + if path: + from pathlib import Path + + home = str(Path.home()) + self._display = "~" + path[len(home):] if path.startswith(home) else path + self.setToolTip(path) + else: + self._display = placeholder + self.setToolTip("") + self._refresh_elide() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._refresh_elide() + + def _refresh_elide(self) -> None: + # 路径首尾都有信息量(盘符/盘点 + 末级目录名),中段省略最可读。 + width = self.width() - 28 # 与 value label 的左右内边距对齐 + self.setText(self.fontMetrics().elidedText(self._display, Qt.ElideMiddle, width)) # type: ignore[arg-type] + + +class FolderPickerControl(QWidget): + """目录设置控件:省略路径 + 紧凑图标按钮(打开 / 更改)。 + + 目录路径是配置值,不该被当文本手敲——只读展示防误改,完整路径走 tooltip。 + 打开/更改用 34px 图标按钮而非宽文字按钮,控件区收窄,给左侧标题/描述让位 + (选目录统一走系统对话框,changeRequested 由所属页面接管,定制标题与落库)。 + """ + + changeRequested = pyqtSignal() + + def __init__(self, parent=None, *, placeholder: str = ""): + super().__init__(parent) + self._path = "" + self._placeholder = placeholder + self.setObjectName("settingsControlPair") + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.setStyleSheet("QWidget#settingsControlPair { background: transparent; }") + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + self.pathLabel = _ElidedPathLabel(self) + self.pathLabel.setFixedWidth(210) # 比 CONTROL_WIDTH 窄,腾出空间给标题/描述 + self.openButton = RoundIconButton(AppIcon.FOLDER, diameter=34, parent=self) + self.openButton.setToolTip(tr("ctrl.open")) + self.changeButton = RoundIconButton(AppIcon.EDIT, diameter=34, parent=self) + self.changeButton.setToolTip(tr("ctrl.change")) + layout.addWidget(self.pathLabel) + layout.addWidget(self.openButton) + layout.addWidget(self.changeButton) + + self.openButton.clicked.connect(self._open) + self.changeButton.clicked.connect(self.changeRequested) + + def path(self) -> str: + return self._path + + def setPath(self, path: str) -> None: + self._path = str(path or "") + self.pathLabel.setPath(self._path, self._placeholder) + self.openButton.setEnabled(bool(self._path)) + + def _open(self) -> None: + from videocaptioner.core.utils.platform_utils import open_folder + + if self._path: + open_folder(self._path) + + +class ColorSwatchButton(QPushButton): + colorChanged = pyqtSignal(QColor) + + def __init__(self, color: QColor, parent=None): + super().__init__(parent) + self.setObjectName("settingsColorSwatch") + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + # 124 + 间距 10 + 按钮 112 = CONTROL_WIDTH,与其他行左缘对齐 + self.setFixedSize(124, CONTROL_HEIGHT) + self._color = QColor(color) + self.syncStyle() + + def color(self) -> QColor: + return QColor(self._color) + + def setColor(self, color: QColor) -> None: # noqa: N802 + if not color.isValid(): + return + self._color = QColor(color) + self.syncStyle() + self.colorChanged.emit(QColor(color)) + + def syncStyle(self) -> None: + palette = app_palette() + color = self._color if self._color.isValid() else QColor(palette.accent) + self.setToolTip(color.name(QColor.HexRgb)) + self.setStyleSheet( + f""" + QPushButton#settingsColorSwatch {{ + background: {color.name(QColor.HexRgb)}; + border: 1px solid {palette.control_border_strong}; + border-radius: 9px; + }} + QPushButton#settingsColorSwatch:hover {{ + border: 2px solid {palette.text}; + }} + """ + ) + + +def _apply_value_label_style(label: QWidget) -> None: + palette = app_palette() + label.setStyleSheet( + f""" + QLabel#settingsValueLabel {{ + color: {palette.text}; + background: {palette.field}; + border: 1px solid {palette.line_soft}; + border-radius: 9px; + padding: 0 12px; + font-weight: normal; + }} + """ + ) + + +def _apply_control_style(widget: QWidget) -> None: + palette = app_palette() + widget.setStyleSheet( + f""" + QWidget#settingsControl, + ComboBox#settingsControl, + EditableComboBox#settingsControl, + QPushButton#settingsControl, + QToolButton#settingsControl, + LineEdit#settingsControl {{ + min-height: {CONTROL_HEIGHT}px; + max-height: {CONTROL_HEIGHT}px; + color: {palette.text}; + background: {palette.field}; + border: 1px solid {palette.line_soft}; + border-radius: 9px; + padding: 0 12px; + font-weight: bold; + }} + QWidget#settingsControl:hover, + ComboBox#settingsControl:hover, + EditableComboBox#settingsControl:hover, + QPushButton#settingsControl:hover, + QToolButton#settingsControl:hover, + LineEdit#settingsControl:hover {{ + border-color: {palette.accent_border}; + }} + EditableComboBox#settingsControl:focus, + LineEdit#settingsControl:focus {{ + border: 1px solid {palette.accent}; + }} + """ + ) + + +def _apply_button_style(button: QWidget, primary: bool) -> None: + palette = app_palette() + if primary: + bg = palette.accent + fg = palette.accent_fg + border = palette.accent + else: + bg = palette.panel + fg = palette.text + border = palette.line + button.setStyleSheet( + f""" + QPushButton#settingsButton {{ + color: {fg}; + background: {bg}; + border: 1px solid {border}; + border-radius: 9px; + padding: 0 14px; + font-weight: bold; + }} + QPushButton#settingsButton:hover {{ + background: {palette.accent_soft if not primary else palette.accent}; + }} + QPushButton#settingsButton:disabled {{ + color: {palette.subtle}; + background: {palette.disabled}; + border-color: {palette.line_soft}; + }} + """ + ) diff --git a/videocaptioner/ui/components/sidebar.py b/videocaptioner/ui/components/sidebar.py new file mode 100644 index 00000000..24e820e2 --- /dev/null +++ b/videocaptioner/ui/components/sidebar.py @@ -0,0 +1,277 @@ +"""第一方侧边栏:可展开/收纳的应用导航(替代 qfluent NavigationInterface)。 + +设计要点(对齐产品参考稿): +- 两态:展开(图标+文字+active 胶囊)/ 收纳(纯图标 rail);宽度动画过渡。 +- 图标列的水平位置在两态间完全不变——只有文字区伸缩,切换不跳动。 +- 文字随宽度动画按比例淡出/淡入并被裁切,不换行不挤压。 +- 颜色全部取自 app_palette(),亮/暗主题自适应;收纳态悬停显示 tooltip。 + +用法: + sidebar = Sidebar() + sidebar.add_page("home", AppIcon.HOME, tr("app.nav.home")) + sidebar.add_action("github", AppIcon.GITHUB, "GitHub", on_click) # 底部动作,不参与选中 + sidebar.currentChanged.connect(...) # 参数为页面 key + sidebar.set_current("home") +""" + +from __future__ import annotations + +from typing import Callable, Optional + +from PyQt5.QtCore import ( + QEasingCurve, + QPropertyAnimation, + QRectF, + Qt, + pyqtProperty, + pyqtSignal, +) +from PyQt5.QtGui import QColor, QFont, QFontMetrics, QPainter +from PyQt5.QtWidgets import QAbstractButton, QFrame, QVBoxLayout + +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.workbench import icon_pixmap, qt_font_weight +from videocaptioner.ui.i18n import tr + +# 几何常量:图标中心在两态下都固定在 x=COLLAPSED_WIDTH/2,保证切换不跳动 +COLLAPSED_WIDTH = 64 +EXPANDED_WIDTH = 224 +ITEM_HEIGHT = 40 +ITEM_MARGIN_X = 10 # 胶囊距侧栏左右边缘 +ICON_SIZE = 20 +LABEL_X = COLLAPSED_WIDTH - ITEM_MARGIN_X + 2 # 文字起点:图标区右侧 +ANIM_MS = 180 + + +class SidebarItem(QAbstractButton): + """一行导航项:自绘 胶囊底 + 固定位图标 + 随展开度淡入的文字。""" + + def __init__( + self, + key: str, + icon: AppIcon, + label: str, + parent: "Sidebar", + *, + selectable: bool = True, + ) -> None: + super().__init__(parent) + self.key = key + self._icon = icon + self._label = label + self._selectable = selectable + self._active = False + self._hover = False + self._sidebar = parent + self.setFixedHeight(ITEM_HEIGHT) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + font = QFont(self.font()) + font.setPixelSize(14) + font.setWeight(qt_font_weight(600)) + self.setFont(font) + + # ---- 状态 ---- + + def set_active(self, active: bool) -> None: + if self._active != active: + self._active = active + self.update() + + def label(self) -> str: + return self._label + + def set_label(self, label: str) -> None: + self._label = label + self.update() + + # ---- 事件 ---- + + def enterEvent(self, event): + self._hover = True + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self._hover = False + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # 胶囊底:active 实底、hover 弱底;宽度跟随当前侧栏宽度 + pill = QRectF( + ITEM_MARGIN_X, + 2, + self._sidebar.width() - ITEM_MARGIN_X * 2, + ITEM_HEIGHT - 4, + ) + if self._active: + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + painter.setBrush(QColor(palette.field)) + painter.drawRoundedRect(pill, 10, 10) + elif self._hover: + hover = QColor(palette.field) + hover.setAlphaF(0.55) + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + painter.setBrush(hover) + painter.drawRoundedRect(pill, 10, 10) + + # 图标:中心固定在收纳态 rail 的中线,两态不移动 + color = palette.accent if self._active else palette.muted + pixmap = icon_pixmap(self._icon, color, ICON_SIZE) + icon_x = COLLAPSED_WIDTH // 2 - ICON_SIZE // 2 + icon_y = (ITEM_HEIGHT - ICON_SIZE) // 2 + painter.drawPixmap(icon_x, icon_y, pixmap) + + # 文字:透明度随展开进度(宽度比例)淡入淡出;裁切在胶囊内,不换行 + progress = self._sidebar.expand_progress() + if progress > 0.05: + painter.setOpacity(progress) + painter.setPen(QColor(palette.text if self._active else palette.subtle)) + painter.setFont(self.font()) + avail = int(pill.right()) - LABEL_X - 6 + if avail > 12: + text = QFontMetrics(self.font()).elidedText( + self._label, Qt.ElideRight, avail # type: ignore[arg-type] + ) + painter.drawText( + LABEL_X, + 0, + avail, + ITEM_HEIGHT, + Qt.AlignVCenter | Qt.AlignLeft, # type: ignore[arg-type] + text, + ) + + +class Sidebar(QFrame): + """应用侧边导航栏。页面项互斥选中;动作项只触发回调(如 GitHub / 设置弹窗)。""" + + currentChanged = pyqtSignal(str) + expandedChanged = pyqtSignal(bool) + + def __init__(self, parent=None, *, expanded: bool = True, top_inset: int = 0) -> None: + """``top_inset``:顶部让位高度(如宿主的自绘标题栏区),内容从其下方开始。""" + super().__init__(parent) + self.setObjectName("appSidebar") + self._expanded = expanded + self._current: Optional[str] = None + self._items: dict[str, SidebarItem] = {} + self._width_anim = QPropertyAnimation(self, b"paneWidth", self) + self._width_anim.setDuration(ANIM_MS) + self._width_anim.setEasingCurve(QEasingCurve.OutCubic) + self.setFixedWidth(EXPANDED_WIDTH if expanded else COLLAPSED_WIDTH) + + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 8 + top_inset, 0, 10) + self._layout.setSpacing(2) + + # 顶部:展开/收纳开关(图标位置与普通项一致),文字随状态切换 + self._toggle = SidebarItem( + "__toggle__", AppIcon.MENU, self._toggle_label(), self, selectable=False + ) + self._toggle.clicked.connect(self.toggle) + self._layout.addWidget(self._toggle) + self._layout.addSpacing(6) + + self._main_index = self._layout.count() # 页面项插入点 + self._layout.addStretch(1) # 主区与底部区之间的弹性空隙 + + # ---- 组装 ---- + + def add_page(self, key: str, icon: AppIcon, label: str) -> None: + """主导航页面项(互斥选中,点击发 currentChanged)。""" + item = self._make_item(key, icon, label, selectable=True) + self._layout.insertWidget(self._main_index, item) + self._main_index += 1 + + def add_action( + self, key: str, icon: AppIcon, label: str, on_click: Callable[[], None] + ) -> None: + """底部动作项(不参与选中,点击执行回调)。""" + item = self._make_item(key, icon, label, selectable=False) + item.clicked.connect(on_click) + self._layout.addWidget(item) + + def _make_item(self, key: str, icon: AppIcon, label: str, *, selectable: bool) -> SidebarItem: + item = SidebarItem(key, icon, label, self, selectable=selectable) + if selectable: + item.clicked.connect(lambda _=False, k=key: self.set_current(k)) + self._items[key] = item + self._sync_tooltips() + return item + + # ---- 选中 ---- + + def set_current(self, key: str) -> None: + if key == self._current or key not in self._items: + return + self._current = key + for item_key, item in self._items.items(): + item.set_active(item_key == key) + self.currentChanged.emit(key) + + def current(self) -> Optional[str]: + return self._current + + # ---- 展开/收纳 ---- + + def is_expanded(self) -> bool: + return self._expanded + + def expand_progress(self) -> float: + """0=完全收纳,1=完全展开(供子项按进度渲染文字透明度)。""" + span = EXPANDED_WIDTH - COLLAPSED_WIDTH + return max(0.0, min(1.0, (self.width() - COLLAPSED_WIDTH) / span)) + + def set_expanded(self, expanded: bool, *, animate: bool = True) -> None: + if expanded == self._expanded: + return + self._expanded = expanded + target = EXPANDED_WIDTH if expanded else COLLAPSED_WIDTH + self._width_anim.stop() + if animate: + self._width_anim.setStartValue(self.width()) + self._width_anim.setEndValue(target) + self._width_anim.start() + else: + self.setFixedWidth(target) + self._toggle.set_label(self._toggle_label()) + self._sync_tooltips() + self.expandedChanged.emit(expanded) + + def _toggle_label(self) -> str: + return tr("app.sidebar.collapse") if self._expanded else tr("app.sidebar.expand") + + def toggle(self) -> None: + self.set_expanded(not self._expanded) + + def _sync_tooltips(self) -> None: + # 收纳态看不到文字,用 tooltip 兜底;展开态不打扰 + for item in self._items.values(): + item.setToolTip("" if self._expanded else item.label()) + self._toggle.setToolTip("" if self._expanded else self._toggle.label()) + + # 动画属性:QFrame 没有 paneWidth,这里落到 fixedWidth 上驱动布局与子项重绘 + def _get_pane_width(self) -> int: + return self.width() + + def _set_pane_width(self, value: int) -> None: + self.setFixedWidth(int(value)) + for item in self._items.values(): + item.update() + self._toggle.update() + + paneWidth = pyqtProperty(int, fget=_get_pane_width, fset=_set_pane_width) + + def paintEvent(self, event): + palette = app_palette() + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(palette.bg)) + # 右缘 1px 分隔线,与内容区形成轻边界 + painter.setPen(QColor(palette.line_soft)) + painter.drawLine(self.width() - 1, 0, self.width() - 1, self.height()) diff --git a/videocaptioner/ui/components/transcription_setting_card.py b/videocaptioner/ui/components/transcription_setting_card.py deleted file mode 100644 index b7fb1ad8..00000000 --- a/videocaptioner/ui/components/transcription_setting_card.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Optional - -from PyQt5.QtWidgets import ( - QStackedWidget, - QVBoxLayout, - QWidget, -) - -from videocaptioner.core.entities import ( - TranscribeModelEnum, -) -from videocaptioner.core.utils.platform_utils import is_macos - -from .FasterWhisperSettingWidget import FasterWhisperSettingWidget -from .WhisperAPISettingWidget import WhisperAPISettingWidget -from .WhisperCppSettingWidget import WhisperCppSettingWidget - - -class TranscriptionSettingCard(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 0, 0, 0) - - # 设置界面堆叠 - self.stacked_widget = QStackedWidget(self) - - # 添加各个设置界面 - self.empty_widget = QWidget(self) # 添加空白页面作为默认显示 - self.whisper_cpp_widget = WhisperCppSettingWidget(self) - self.whisper_api_widget = WhisperAPISettingWidget(self) - - # FasterWhisper 在 macOS 上不可用 - self.faster_whisper_widget: Optional[FasterWhisperSettingWidget] = None - if not is_macos(): - self.faster_whisper_widget = FasterWhisperSettingWidget(self) - - self.stacked_widget.addWidget(self.empty_widget) # 添加空白页面 - self.stacked_widget.addWidget(self.whisper_cpp_widget) - self.stacked_widget.addWidget(self.whisper_api_widget) - if self.faster_whisper_widget is not None: - self.stacked_widget.addWidget(self.faster_whisper_widget) - - self.main_layout.addWidget(self.stacked_widget) - - def on_model_changed(self, value): - # 切换对应的设置界面 - if value == TranscribeModelEnum.WHISPER_CPP.value: - self.stacked_widget.setCurrentWidget(self.whisper_cpp_widget) - elif value == TranscribeModelEnum.WHISPER_API.value: - self.stacked_widget.setCurrentWidget(self.whisper_api_widget) - elif value == TranscribeModelEnum.FASTER_WHISPER.value: - self.stacked_widget.setCurrentWidget(self.faster_whisper_widget) - else: - self.stacked_widget.setCurrentWidget(self.empty_widget) diff --git a/videocaptioner/ui/components/update_banner.py b/videocaptioner/ui/components/update_banner.py new file mode 100644 index 00000000..9191ab7d --- /dev/null +++ b/videocaptioner/ui/components/update_banner.py @@ -0,0 +1,160 @@ +"""更新提示条:可用 → 下载中 NN% → 重启并安装;失败可重试,开发态/不可写则退化为「前往下载」。 + +持有一个常驻 InfoBar + 一个按钮(按钮动作随状态切换),挂在主窗口上。下载走 +UpdateDownloadThread(含 sha256 校验、可取消)。安装由调用方注入的回调完成 +(apply_update + 退出应用)——本组件不直接退出进程,也不碰业务配置。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Callable + +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QDesktopServices +from qfluentwidgets import InfoBar, InfoBarIcon, InfoBarPosition, PrimaryPushButton + +from videocaptioner.config import RELEASE_URL +from videocaptioner.core.update import UpdateInfo, can_self_update +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.update_thread import UpdateDownloadThread + + +class UpdateBanner: + """更新提示条的状态机。``on_install(zip_path)`` 在用户点「重启并安装」时被调用。""" + + def __init__( + self, + window, + info: UpdateInfo | None, + dest_dir: Path, + on_install: Callable[[str], None], + *, + blocked: str | None = None, + ): + self._window = window + self._info = info + self._dest_dir = Path(dest_dir) + self._on_install = on_install + self._blocked = blocked # 非空=当前版本被后端封禁,文案直接展示 + self._self_update = can_self_update() + self._state = "available" + self._zip_path: str | None = None + self._dl = None + self._bar: InfoBar | None = None + self._button: PrimaryPushButton | None = None + + def show(self) -> None: + # 普通更新标题带版本号;版本被封禁时用「需更新才能继续使用」标题,原因放正文 + title = ( + tr("app.update.title", version=self._info.version) + if (self._info and not self._blocked) + else tr("app.update.mandatory") + ) + self._bar = InfoBar( + InfoBarIcon.INFORMATION, + title, + "", + # 封禁且能自更新时才不可关(逼用户在应用内更新);不能自更新时仍可关, + # 否则用户被卡在一个只能「前往下载」的常驻条上、无应用内出路。 + isClosable=not (self._blocked and self._self_update), + duration=-1, + # 右下角:TOP 会压住自绘标题栏和页头文字;常驻条放通知惯例位、不遮内容 + position=InfoBarPosition.BOTTOM_RIGHT, + parent=self._window, + ) + self._button = PrimaryPushButton(self._bar) + self._button.clicked.connect(self._on_button) + self._bar.addWidget(self._button) + self._bar.closedSignal.connect(self.stop) + self._set_state("available") + self._bar.show() + + def stop(self) -> None: + """取消在途下载并等待线程退出(供窗口关闭/提示条关闭时调用)。""" + self._teardown_dl() + + def _teardown_dl(self) -> None: + """取消 + 等待 + 断信号 + 清空当前下载线程,维持「至多一个在跑」的单例不变量。 + + 否则 取消→再下载 会让旧线程成为仍在跑、信号仍连着 banner 的孤儿:迟到的 progress + 会盖掉新线程进度,退出时 stop() 也够不到它 → 销毁运行中 QThread 触发 abort。 + """ + if self._dl is None: + return + dl, self._dl = self._dl, None + dl.cancel() + dl.wait(3000) + try: + dl.progress.disconnect(self._on_progress) + dl.downloaded.disconnect(self._on_downloaded) + dl.downloadFailed.disconnect(self._on_failed) + except (TypeError, RuntimeError): + pass + + # ---- 状态机 ---- + def _set_state(self, state: str, *, percent: int = 0, error: str = "") -> None: + self._state = state + if self._bar is None or self._button is None: + return + if state == "available": + content = self._blocked or tr("app.update.available") + # 能自更新且有资产才给「下载更新」,否则退化「前往下载」(pip / 无本平台资产) + button = ( + tr("app.update.download") + if (self._self_update and self._info) + else tr("app.update.go_download") + ) + elif state == "downloading": + content = tr("app.update.downloading", percent=percent) + button = tr("app.update.cancel") + elif state == "ready": + content = tr("app.update.ready") + button = tr("app.update.install") + else: # failed + content = tr("app.update.failed", error=error) + button = tr("app.update.retry") if self._self_update else tr("app.update.go_download") + self._bar.contentLabel.setText(content) + self._button.setText(button) + + def _on_button(self) -> None: + if self._state == "downloading": + self._cancel() + elif self._state == "ready": + self._install() + elif self._self_update and self._info is not None: # available / failed + self._start_download() + else: + QDesktopServices.openUrl(QUrl(RELEASE_URL)) + + def _start_download(self) -> None: + if self._info is None: # 无资产不可下载(理论上按钮已退化为前往下载) + return + self._teardown_dl() # 重试/再下载前先清掉上一个线程,保证单例 + self._dl = UpdateDownloadThread(self._info, self._dest_dir, self._window) + self._dl.progress.connect(self._on_progress) + self._dl.downloaded.connect(self._on_downloaded) + self._dl.downloadFailed.connect(self._on_failed) + self._set_state("downloading", percent=0) + self._dl.start() + + def _cancel(self) -> None: + self._teardown_dl() + self._set_state("available") + + def _install(self) -> None: + if self._zip_path: + self._on_install(self._zip_path) + + def _on_progress(self, received: int, total: int) -> None: + if self._bar is None: + return + percent = int(received * 100 / total) if total else 0 + self._bar.contentLabel.setText(tr("app.update.downloading", percent=percent)) + + def _on_downloaded(self, path: str) -> None: + self._zip_path = path + self._set_state("ready") + + def _on_failed(self, message: str) -> None: + self._set_state("failed", error=message) diff --git a/videocaptioner/ui/components/workbench.py b/videocaptioner/ui/components/workbench.py new file mode 100644 index 00000000..cd68a576 --- /dev/null +++ b/videocaptioner/ui/components/workbench.py @@ -0,0 +1,2283 @@ +# -*- coding: utf-8 -*- +"""设计语言基础控件(workbench design language)。 + +页面通用的原子样式控件;颜色一律取自 theme_tokens.app_palette(),不允许 +页面私造色值。 + +控件清单: + +- StatusPill 状态胶囊(neutral/ok/warn/fail 四级) +- InfoChip 信息胶囊(只读元数据标签) +- HeaderLinkButton 面板头部的胶囊链接 +- RoundIconButton 圆形图标按钮(面板头部设置入口) +- WorkbenchButton 按钮(默认 44 高 9 圆角,可带图标) +- CompactButton 紧凑按钮(32 高) +- ToggleSwitch 开关(48x28 自绘) +- PillSelect 胶囊下拉(取值胶囊,点击弹菜单换值) +- OptionCard 选项卡片(左标签右控件) +- DropZone 拖放导入空态(framed 虚线框 / 无框两种形态) +- FilePickLink “点击选择文件”链接 +- ProgressBarLine 8px 进度条 +- AdaptiveTitleLabel / ElidedLabel 自适应标题 / 单行省略标签 +- WorkbenchPanel 面板容器(14 圆角) +- PanelHeader 面板标题行(inline / bar,可加 underline) + +所有圆角背景边框统一走 draw_rounded_surface 自绘(QSS 圆角无抗锯齿)。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from PyQt5.QtCore import ( + QEasingCurve, + QParallelAnimationGroup, + QPropertyAnimation, + QRectF, + Qt, + QTimer, + pyqtSignal, +) +from PyQt5.QtGui import ( + QColor, + QFont, + QLinearGradient, + QPainter, + QPainterPath, + QPalette, + QPen, + QPixmap, + QRadialGradient, +) +from PyQt5.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPlainTextEdit, + QVBoxLayout, + QWidget, +) +from qfluentwidgets import Action, RoundMenu + +from videocaptioner.core.entities import ( + SupportedAudioFormats, + SupportedSubtitleFormats, + SupportedVideoFormats, +) +from videocaptioner.ui.common.app_icons import AppIcon, render_svg_pixmap +from videocaptioner.ui.common.theme_tokens import app_palette, is_dark_theme, rgba +from videocaptioner.ui.i18n import tr + +_VIDEO_EXTENSIONS = {fmt.value for fmt in SupportedVideoFormats} +_AUDIO_EXTENSIONS = {fmt.value for fmt in SupportedAudioFormats} +_SUBTITLE_EXTENSIONS = {fmt.value for fmt in SupportedSubtitleFormats} +_TEXT_EXTENSIONS = {"txt", "md", "json", "log"} + + +def file_type_icon(path: str | Path) -> AppIcon: + """按扩展名归类文件图标:视频 / 音频 / 字幕 / 文本 / 通用文件。""" + suffix = Path(str(path)).suffix.lower().lstrip(".") + if suffix in _VIDEO_EXTENSIONS: + return AppIcon.VIDEO + if suffix in _AUDIO_EXTENSIONS: + return AppIcon.MUSIC + if suffix in _SUBTITLE_EXTENSIONS: + return AppIcon.SUBTITLE + if suffix in _TEXT_EXTENSIONS: + return AppIcon.DOCUMENT + return AppIcon.FILE + + +def _hover_colors() -> tuple[str, str]: + """按主题返回控件 hover 的背景 / 边框色。""" + if is_dark_theme(): + return "#303735", "#74807b" + return "#e8ecea", "#9aa6a1" + + +def to_qcolor(value: str | QColor) -> QColor: + """解析调色板里的颜色串:支持 #hex 与 rgba(r, g, b, a)。""" + if isinstance(value, QColor): + return value + text = str(value).strip() + if text.startswith("rgba"): + parts = text[text.index("(") + 1 : text.rindex(")")].split(",") + red, green, blue = (int(float(p)) for p in parts[:3]) + alpha = float(parts[3]) + return QColor(red, green, blue, round(alpha * 255)) + return QColor(text) + + +def draw_rounded_surface( + widget, + bg: str | QColor, + border: str | QColor, + radius: float, +) -> None: + """抗锯齿地画控件的圆角背景和 1px 边框。 + + QSS 的 border-radius 渲染不做抗锯齿,胶囊形控件边缘会有明显锯齿; + 统一改为 QPainter 自绘,QSS 只负责文字颜色。 + """ + painter = QPainter(widget) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(widget.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + effective_radius = min(radius, rect.height() / 2) + path = QPainterPath() + path.addRoundedRect(rect, effective_radius, effective_radius) + painter.fillPath(path, to_qcolor(bg)) + painter.setPen(QPen(to_qcolor(border), 1)) + painter.drawPath(path) + + +def qt_font_weight(css_weight: int) -> int: + """把 CSS font-weight(400-900)映射到 Qt5 字重(50-87)。""" + return max(1, min(99, round(50 + (css_weight - 400) * 37 / 500))) + + +def apply_font( + widget, pixel_size: int, css_weight: int = 400, families: Optional[list[str]] = None +) -> None: + font = QFont(widget.font()) + font.setPixelSize(pixel_size) + font.setWeight(qt_font_weight(css_weight)) + if families: + font.setFamilies(families) + widget.setFont(font) + + +def icon_pixmap(icon: AppIcon, color: str, size: int): + """DPR 感知的图标位图;直接返回物理分辨率渲染结果,Retina 上不发糊。""" + return render_svg_pixmap(icon, color, size) + + +class ClickableFrame(QFrame): + """可点击容器:左键发 clicked 信号,卡片/行类组件的通用基座。""" + + clicked = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + super().mousePressEvent(event) + + +class AdaptiveTitleLabel(QLabel): + """自适应标题:一行放得下用大字号;放不下降字号排两行;仍超出则省略。 + + 避免长文件名被单行省略掉大半,同时不留大片空白。 + """ + + def __init__( + self, + base_size: int = 24, + base_weight: int = 860, + compact_size: int = 19, + parent=None, + ): + super().__init__(parent) + self._full = "" + self._base_size = base_size + self._base_weight = base_weight + self._compact_size = compact_size + self.setWordWrap(False) + apply_font(self, base_size, base_weight) + + def setFullText(self, text: str): + self._full = text + self.setToolTip(text) + self._relayout() + + def fullText(self) -> str: + return self._full + + def resizeEvent(self, event): + super().resizeEvent(event) + self._relayout() + + def _relayout(self): + width = max(40, self.width()) + apply_font(self, self._base_size, self._base_weight) + if self.fontMetrics().horizontalAdvance(self._full) <= width: + self.setText(self._full) + return + # 一行放不下:降字号排两行(按字符贪心断行,中文文件名为主)。 + apply_font(self, self._compact_size, self._base_weight) + metrics = self.fontMetrics() + line1_end = len(self._full) + for index in range(1, len(self._full) + 1): + if metrics.horizontalAdvance(self._full[:index]) > width: + line1_end = index - 1 + break + line1 = self._full[:line1_end] + rest = self._full[line1_end:] + line2 = metrics.elidedText(rest, Qt.ElideRight, width) # type: ignore[arg-type] + self.setText(f"{line1}\n{line2}") + + +class ElidedLabel(QLabel): + """单行省略标签:超宽时尾部省略号,悬浮提示完整文本。""" + + def __init__(self, text: str = "", parent=None): + super().__init__(parent) + self._full = text + self.setMinimumWidth(60) + super().setText(text) + + def setText(self, text: str): + self._full = text + self.setToolTip(text) + self._elide() + + def fullText(self) -> str: + return self._full + + def resizeEvent(self, event): + super().resizeEvent(event) + self._elide() + + def _elide(self): + metrics = self.fontMetrics() + super().setText( + metrics.elidedText(self._full, Qt.ElideRight, max(40, self.width())) # type: ignore[arg-type] + ) + + +class StatusPill(QLabel): + """状态胶囊:等待文件 / 转录中 / 转录失败 / 完成 等。""" + + LEVELS = ("neutral", "ok", "warn", "fail") + + def __init__(self, text: str = "", level: str = "neutral", parent=None): + super().__init__(text, parent) + self.setObjectName("wbStatusPill") + self._level = level + self.setFixedHeight(31) + self.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self, 13, 760) + self.syncStyle() + + def level(self) -> str: + return self._level + + def setLevel(self, level: str): + assert level in self.LEVELS, level + self._level = level + self.syncStyle() + + def setState(self, text: str, level: str): + self.setText(text) + self.setLevel(level) + + def syncStyle(self): + palette = app_palette() + styles = { + "neutral": (palette.control, palette.line, palette.muted), + "ok": (palette.accent_soft, rgba(palette.accent, 0.72), palette.accent_text), + "warn": (palette.warn_soft, rgba(palette.warn, 0.70), palette.warn_fg), + "fail": (palette.danger_soft, rgba(palette.danger, 0.75), palette.danger_fg), + } + self._bg, self._border, fg = styles[self._level] + self.setStyleSheet( + f""" + QLabel#wbStatusPill {{ + color: {fg}; + background: transparent; + border: none; + padding: 0 12px; + }} + """ + ) + self.update() + + def paintEvent(self, event): + draw_rounded_surface(self, self._bg, self._border, 15) + super().paintEvent(event) + + +class InfoChip(QLabel): + """只读信息胶囊:分辨率、时长、文件大小、格式等元数据。""" + + def __init__(self, text: str = "", parent=None): + super().__init__(text, parent) + self.setObjectName("wbInfoChip") + self.setFixedHeight(31) + self.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self, 13, 760) + self.syncStyle() + + def syncStyle(self): + palette = app_palette() + self._bg, self._border = palette.control, palette.line + self.setStyleSheet( + f""" + QLabel#wbInfoChip {{ + color: {palette.muted}; + background: transparent; + border: none; + padding: 0 12px; + }} + """ + ) + self.update() + + def paintEvent(self, event): + draw_rounded_surface(self, self._bg, self._border, 15) + super().paintEvent(event) + + +class HeaderLinkButton(QFrame): + """面板头部的胶囊链接:更换文件、转录配置。""" + + clicked = pyqtSignal() + + def __init__(self, text: str, icon: AppIcon | None = None, parent=None): + super().__init__(parent) + self.setObjectName("wbHeaderLink") + self._icon = icon + self.setFixedHeight(31) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + layout = QHBoxLayout(self) + layout.setContentsMargins(11, 0, 11, 0) + layout.setSpacing(7) + self.iconLabel = QLabel(self) + self.iconLabel.setVisible(icon is not None) + layout.addWidget(self.iconLabel) + self.textLabel = QLabel(text, self) + self.textLabel.setObjectName("wbHeaderLinkText") + apply_font(self.textLabel, 13, 820) + layout.addWidget(self.textLabel) + self.syncStyle() + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def syncStyle(self): + palette = app_palette() + if self._icon is not None: + self.iconLabel.setPixmap(icon_pixmap(self._icon, palette.accent_text, 17)) + self._bg = rgba(palette.accent, 0.08) + self._border = rgba(palette.accent, 0.46) + self._hover_bg = rgba(palette.accent, 0.14) + self._hover_border = rgba(palette.accent, 0.82) + self.setStyleSheet( + f""" + QFrame#wbHeaderLink {{ background: transparent; border: none; }} + QLabel#wbHeaderLinkText {{ + color: {palette.accent_text}; + background: transparent; + border: none; + }} + """ + ) + self.update() + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + hovered = self.underMouse() + draw_rounded_surface( + self, + self._hover_bg if hovered else self._bg, + self._hover_border if hovered else self._border, + 15, + ) + super().paintEvent(event) + + +class WorkbenchButton(QFrame): + """按钮:44 高、9 圆角、可带 17px 图标。""" + + clicked = pyqtSignal() + + def __init__( + self, + text: str, + icon: AppIcon | None = None, + primary: bool = False, + height: int = 44, + parent=None, + tone: str | None = None, + ): + super().__init__(parent) + self.setObjectName("wbButton") + self._icon = icon + self._height = height + # tone: primary / default / danger / warn。不传时由 primary 推断,向后兼容。 + self._tone = tone or ("primary" if primary else "default") + self._primary = self._tone == "primary" + self.setFixedHeight(height) + self.setMinimumWidth(126) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + layout = QHBoxLayout(self) + layout.setContentsMargins(18, 0, 18, 0) + layout.setSpacing(10) + layout.addStretch(1) + self.iconLabel = QLabel(self) + # 自带透明底:父级 setStyleSheet 后,未声明背景的子 QLabel 会回退到调色板底色, + # 在按钮上糊出一块方块(与 CompactButton 同款修复)。 + self.iconLabel.setStyleSheet("background: transparent; border: none;") + self.iconLabel.setVisible(icon is not None) + layout.addWidget(self.iconLabel) + self.textLabel = QLabel(text, self) + self.textLabel.setObjectName("wbButtonText") + layout.addWidget(self.textLabel) + layout.addStretch(1) + self.syncStyle() + + def text(self) -> str: + return self.textLabel.text() + + def setText(self, text: str): + self.textLabel.setText(text) + + def setIcon(self, icon: AppIcon | None): + self._icon = icon + self.iconLabel.setVisible(icon is not None) + self.syncStyle() + + def setPrimary(self, primary: bool): + self._tone = "primary" if primary else "default" + self._primary = primary + self.syncStyle() + + def setTone(self, tone: str): + self._tone = tone + self._primary = tone == "primary" + self.syncStyle() + + def setEnabled(self, enabled: bool): + super().setEnabled(enabled) + self.setCursor( + Qt.PointingHandCursor if enabled else Qt.ArrowCursor # type: ignore[arg-type] + ) + self.syncStyle() + + def click(self): + """QPushButton 兼容:供 .click() 直接触发(测试/脚本/程序化调用)。""" + if self.isEnabled(): + self.clicked.emit() + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def syncStyle(self): + palette = app_palette() + tone = self._tone + weight = 860 if tone == "primary" else 780 + if not self.isEnabled(): + # disabled 态是整体 45% 透明度,QSS 做不到,按等效色近似。 + if tone == "primary": + bg = border = rgba(palette.accent, 0.30) + fg = rgba(palette.accent_fg, 0.55) + else: + bg, border, fg = palette.control, palette.line, palette.subtle + hover_bg, hover_border = bg, border + icon_color = palette.subtle + elif tone == "primary": + bg = border = palette.accent + fg = palette.accent_fg + # hover 提亮基于主题色派生,自定义主题色时跟随变化 + hover_bg = hover_border = to_qcolor(palette.accent).lighter(112).name() + icon_color = palette.accent_fg + elif tone == "danger": + bg, border = rgba(palette.danger, 0.10), rgba(palette.danger, 0.55) + fg = icon_color = palette.danger_fg + hover_bg, hover_border = rgba(palette.danger, 0.16), rgba(palette.danger, 0.70) + elif tone == "warn": + bg, border = rgba(palette.warn, 0.12), rgba(palette.warn, 0.50) + fg = icon_color = palette.warn + hover_bg, hover_border = rgba(palette.warn, 0.18), rgba(palette.warn, 0.66) + else: + bg, border, fg = palette.control, palette.line, palette.text + hover_bg, hover_border = _hover_colors() + icon_color = palette.text + apply_font(self.textLabel, 16, weight) + if self._icon is not None: + self.iconLabel.setPixmap(icon_pixmap(self._icon, icon_color, 17)) + self._bg, self._border = bg, border + self._hover_bg, self._hover_border = hover_bg, hover_border + self.setStyleSheet( + f""" + QFrame#wbButton {{ background: transparent; border: none; }} + QLabel#wbButtonText {{ + color: {fg}; + background: transparent; + border: none; + }} + """ + ) + self.update() + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + hovered = self.underMouse() and self.isEnabled() + draw_rounded_surface( + self, + self._hover_bg if hovered else self._bg, + self._hover_border if hovered else self._border, + 9, + ) + super().paintEvent(event) + + +class ToggleSwitch(QFrame): + """开关:48x28 自绘胶囊 + 20px 圆形滑块。""" + + toggled = pyqtSignal(bool) + + def __init__(self, checked: bool = False, parent=None): + super().__init__(parent) + self._checked = checked + self.setFixedSize(48, 28) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + def isChecked(self) -> bool: + return self._checked + + def setChecked(self, checked: bool): + if checked == self._checked: + return + self._checked = checked + self.update() + self.toggled.emit(checked) + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.setChecked(not self._checked) + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + palette = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + if self._checked: + track_bg = QColor(palette.accent) + track_bg.setAlphaF(0.14) + track_border = QColor(palette.accent) + track_border.setAlphaF(0.78) + knob = QColor(palette.accent) + knob_x = self.width() - 3 - 20 + else: + track_bg = QColor("#222927" if is_dark_theme() else "#e2e7e5") + track_border = QColor(palette.line) + knob = QColor("#b8c6c0" if is_dark_theme() else "#7d8a85") + knob_x = 3 + track = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + painter.setPen(QPen(track_border, 1)) + painter.setBrush(track_bg) + painter.drawRoundedRect(track, track.height() / 2, track.height() / 2) + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + painter.setBrush(knob) + painter.drawEllipse(QRectF(knob_x, 4, 20, 20)) + + +class FilePickLink(QFrame): + """“点击选择文件”链接:图标 + 文案 + 渐变下划线。""" + + clicked = pyqtSignal() + + def __init__(self, text: str, icon: AppIcon = AppIcon.FOLDER, parent=None): + super().__init__(parent) + self._icon = icon + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + layout = QHBoxLayout(self) + layout.setContentsMargins(2, 0, 2, 6) + layout.setSpacing(7) + self.iconLabel = QLabel(self) + layout.addWidget(self.iconLabel) + self.textLabel = QLabel(text, self) + apply_font(self.textLabel, 16, 850) + layout.addWidget(self.textLabel) + self.syncStyle() + + def _hover_accent(self) -> str: + """hover 高亮:深色主题提亮一档,浅色主题加深一档(提亮会糊在浅底上)。""" + palette = app_palette() + if is_dark_theme(): + return to_qcolor(palette.accent).lighter(125).name() + return to_qcolor(palette.accent_text).darker(115).name() + + def _rest_accent(self) -> str: + """静默态降一档存在感,hover 跳到高亮才有明确反馈。 + + 深色主题用半透明压暗;浅色主题用可读的加深主题色 + (原色亮绿在浅底上对比不足)。 + """ + palette = app_palette() + if is_dark_theme(): + accent = to_qcolor(palette.accent) + accent.setAlphaF(0.72) + return accent.name(QColor.HexArgb) + return palette.accent_text + + def syncStyle(self): + color = self._hover_accent() if self.underMouse() else self._rest_accent() + self.iconLabel.setPixmap(icon_pixmap(self._icon, color, 17)) + self.textLabel.setStyleSheet( + f"color: {color}; background: transparent; border: none;" + ) + + def enterEvent(self, event): + self.syncStyle() + super().enterEvent(event) + + def leaveEvent(self, event): + self.syncStyle() + super().leaveEvent(event) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + super().paintEvent(event) + palette = app_palette() + hovered = self.underMouse() + painter = QPainter(self) + gradient = QLinearGradient(0, 0, self.width(), 0) + accent = to_qcolor(self._hover_accent() if hovered else palette.accent) + accent.setAlphaF(1.0 if hovered else 0.30) + gradient.setColorAt(0, QColor(0, 0, 0, 0)) + gradient.setColorAt(0.5, accent) + gradient.setColorAt(1, QColor(0, 0, 0, 0)) + painter.fillRect(QRectF(0, self.height() - 2, self.width(), 2), gradient) + + +class DropZone(QFrame): + """拖放导入空态。 + + framed=True:虚线边框 + 顶部辉光(转录页样式); + framed=False:无边框,仅居中引导内容(字幕页表格空态样式)。 + 两种形态拖拽悬停时边框都会点亮为主题色;整个区域可点击选择文件。 + """ + + browseRequested = pyqtSignal() + + def __init__( + self, + *, + icon: AppIcon, + title: str, + pick_text: str, + pick_icon: AppIcon = AppIcon.FOLDER, + formats_line: str = "", + framed: bool = True, + accent_icon: bool = False, + icon_box: int = 92, + icon_size: int = 48, + title_size: int = 34, + parent=None, + ): + super().__init__(parent) + self._framed = framed + self._drag_active = False + self._icon = icon + self._icon_size = icon_size + self._accent_icon = accent_icon + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + layout = QVBoxLayout(self) + layout.setContentsMargins(34, 34, 34, 34) + layout.setSpacing(0) + layout.addStretch(1) + + self.iconBox = QFrame(self) + self.iconBox.setObjectName("dropIcon") + self.iconBox.setFixedSize(icon_box, icon_box) + icon_layout = QVBoxLayout(self.iconBox) + icon_layout.setContentsMargins(0, 0, 0, 0) + self.iconLabel = QLabel(self.iconBox) + self.iconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + icon_layout.addWidget(self.iconLabel) + layout.addWidget(self.iconBox, 0, Qt.AlignHCenter) # type: ignore[arg-type] + layout.addSpacing(20) + + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("dropTitle") + self.titleLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.titleLabel, title_size, 880) + layout.addWidget(self.titleLabel) + layout.addSpacing(14) + + pick_row = QHBoxLayout() + pick_row.setSpacing(8) + pick_row.addStretch(1) + self.orLabel = QLabel(tr("workbench.drop.or"), self) + self.orLabel.setObjectName("dropOr") + apply_font(self.orLabel, 16, 400) + pick_row.addWidget(self.orLabel) + self.pickLink = FilePickLink(pick_text, pick_icon, self) + self.pickLink.clicked.connect(self.browseRequested) + pick_row.addWidget(self.pickLink) + pick_row.addStretch(1) + layout.addLayout(pick_row) + + self.formatLabel = QLabel(formats_line, self) + self.formatLabel.setObjectName("dropFormats") + self.formatLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.formatLabel, 14, 720) + self.formatLabel.setVisible(bool(formats_line)) + if formats_line: + layout.addSpacing(22) + layout.addWidget(self.formatLabel) + layout.addStretch(1) + self.syncStyle() + + def setDragActive(self, active: bool): + if active != self._drag_active: + self._drag_active = active + self.update() + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.browseRequested.emit() + event.accept() + return + super().mousePressEvent(event) + + def syncStyle(self): + palette = app_palette() + icon_color = palette.accent if self._accent_icon else palette.muted + self.iconLabel.setPixmap(icon_pixmap(self._icon, icon_color, self._icon_size)) + self.setStyleSheet( + f""" + QFrame#dropIcon {{ + background: {palette.control}; + border: 1px solid {rgba("#ffffff", 0.04) if is_dark_theme() else palette.line_soft}; + border-radius: 22px; + }} + QLabel#dropTitle {{ color: {palette.text}; background: transparent; }} + QLabel#dropOr {{ color: {palette.subtle}; background: transparent; }} + QLabel#dropFormats {{ color: {palette.faint}; background: transparent; }} + """ + ) + + def paintEvent(self, event): + palette = app_palette() + dark = is_dark_theme() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(0.5, 0.5, self.width() - 1, self.height() - 1) + + if self._framed: + clip = QPainterPath() + clip.addRoundedRect(rect, 16, 16) + painter.setClipPath(clip) + # 下沉式内嵌:内部比所在面板更深一档,拖放区与周围明确区分。 + painter.fillRect( + self.rect(), QColor(0, 0, 0, 64) if dark else QColor(0, 0, 0, 12) + ) + glow = QRadialGradient( + self.width() * 0.5, self.height() * 0.18, self.width() * 0.34 + ) + accent = QColor(palette.accent) + accent.setAlphaF(0.16) + glow.setColorAt(0, accent) + glow.setColorAt(1, QColor(0, 0, 0, 0)) + painter.fillRect(self.rect(), glow) + painter.setClipping(False) + + hovered = self.underMouse() + if self._drag_active: + border, width = QColor(palette.accent), 2 + elif self._framed: + if hovered: + border = to_qcolor(rgba(palette.accent, 0.55)) + else: + border = QColor("#53615c") if dark else QColor(palette.line) + width = 1 + elif hovered: + border, width = to_qcolor(rgba(palette.accent, 0.45)), 1 + else: + super().paintEvent(event) + return + pen = QPen(border, width, Qt.DashLine) # type: ignore[arg-type] + painter.setPen(pen) + painter.setBrush(Qt.NoBrush) # type: ignore[arg-type] + painter.drawRoundedRect(rect, 16, 16) + super().paintEvent(event) + + +class ProgressBarLine(QFrame): + """8px 进度条。 + + tone 控制填充色:accent(默认)/ warn(处理中黄)/ fail(失败红)。 + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._value = 0 + self._tone = "accent" + self.setFixedHeight(8) + + def value(self) -> int: + return self._value + + def setValue(self, value: int): + self._value = max(0, min(100, value)) + self.update() + + def setTone(self, tone: str): + assert tone in ("accent", "warn", "fail"), tone + if tone != self._tone: + self._tone = tone + self.update() + + def paintEvent(self, event): + palette = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + painter.setPen(Qt.NoPen) # type: ignore[arg-type] + painter.setBrush(QColor(palette.disabled)) + painter.drawRoundedRect(self.rect(), 4, 4) + if self._value > 0: + fill = QRectF(0, 0, self.width() * self._value / 100, self.height()) + # 浅色主题下黄/红原色与浅底对比不足,用加深的前景色 + dark = is_dark_theme() + color = { + "accent": palette.accent, + "warn": palette.warn if dark else palette.warn_fg, + "fail": palette.danger if dark else palette.danger_fg, + }[self._tone] + painter.setBrush(QColor(color)) + painter.drawRoundedRect(fill, 4, 4) + + +class RoundIconButton(QFrame): + """圆形图标按钮:面板头部的设置入口等,与胶囊 tag 形成视觉区分。""" + + clicked = pyqtSignal() + + def __init__(self, icon: AppIcon, diameter: int = 34, parent=None): + super().__init__(parent) + self._icon = icon + self._diameter = diameter + self.setFixedSize(diameter, diameter) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.iconLabel = QLabel(self) + self.iconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + layout.addWidget(self.iconLabel) + self.syncStyle() + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + hovered = self.underMouse() and self.isEnabled() + draw_rounded_surface( + self, + rgba(palette.accent, 0.14) if hovered else rgba(palette.accent, 0.08), + rgba(palette.accent, 0.82) if hovered else rgba(palette.accent, 0.46), + self._diameter / 2, + ) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.iconLabel.setPixmap( + icon_pixmap(self._icon, palette.accent_text, int(self._diameter * 0.5)) + ) + self.setStyleSheet("background: transparent; border: none;") + self.update() + + +class _FilterTab(QLabel): + clicked = pyqtSignal(str) + + def __init__(self, key: str, text: str, parent=None): + super().__init__(text, parent) + self.key = key + self._active = False + self.setFixedHeight(30) + self.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + apply_font(self, 13, 800) + self.syncStyle() + + def setActive(self, active: bool): + if active != self._active: + self._active = active + self.syncStyle() + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit(self.key) + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + if self._active: + palette = app_palette() + draw_rounded_surface(self, palette.accent, palette.accent, 7) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + color = palette.accent_fg if self._active else palette.muted + self.setStyleSheet( + f"color: {color}; background: transparent; border: none; padding: 0 12px;" + ) + self.update() + + +class FilterTabs(QFrame): + """分段筛选:批量队列过滤、配音声线筛选等通用。""" + + changed = pyqtSignal(str) + + def __init__(self, items: list[tuple[str, str]], parent=None): + super().__init__(parent) + self.setObjectName("batchFilterTabs") + # 固定高度:否则在头部行里被拉伸贴边,圆角容器糊到面板边缘 + self.setFixedHeight(38) + self._current = items[0][0] + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(6) + self._tabs: list[_FilterTab] = [] + for key, text in items: + tab = _FilterTab(key, text, self) + tab.clicked.connect(self.setCurrent) + layout.addWidget(tab) + self._tabs.append(tab) + self.setStyleSheet("QFrame#batchFilterTabs { background: transparent; border: none; }") + self._sync() + + def current(self) -> str: + return self._current + + def setCurrent(self, key: str): + if key == self._current: + return + self._current = key + self._sync() + self.changed.emit(key) + + def _sync(self): + for tab in self._tabs: + tab.setActive(tab.key == self._current) + + def syncStyle(self): + for tab in self._tabs: + tab.syncStyle() + self.update() + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 10) + super().paintEvent(event) + + +class SelectableCard(QFrame): + """可选卡:左侧可选图标盒 + 标题/说明, + 选中态主题色描边+底色,hover 轻描边。批量处理模式卡、配音提供商卡共用, + 全自绘圆角(QSS 圆角无抗锯齿)。点击发 clicked(key)。""" + + clicked = pyqtSignal(str) + + def __init__( + self, + key: str, + title: str, + desc: str, + icon: Optional[AppIcon] = None, + parent=None, + min_height: int = 84, + ): + super().__init__(parent) + self.setObjectName("selectableCard") + self.key = key + self._icon = icon + self._active = False + self.setMinimumHeight(min_height) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 12, 16, 12) + layout.setSpacing(13) + self.iconBox: Optional[QFrame] = None + self.iconLabel: Optional[QLabel] = None + if icon is not None: + self.iconBox = QFrame(self) + self.iconBox.setObjectName("selectableCardIcon") + self.iconBox.setFixedSize(40, 40) + icon_layout = QVBoxLayout(self.iconBox) + icon_layout.setContentsMargins(0, 0, 0, 0) + self.iconLabel = QLabel(self.iconBox) + self.iconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + icon_layout.addWidget(self.iconLabel) + layout.addWidget(self.iconBox) + + text_box = QVBoxLayout() + text_box.setSpacing(3) + text_box.addStretch(1) + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("selectableCardTitle") + apply_font(self.titleLabel, 15, 870) + text_box.addWidget(self.titleLabel) + self.descLabel = QLabel(desc, self) + self.descLabel.setObjectName("selectableCardDesc") + self.descLabel.setWordWrap(True) + apply_font(self.descLabel, 12, 700) + text_box.addWidget(self.descLabel) + text_box.addStretch(1) + layout.addLayout(text_box, 1) + self.syncStyle() + + def setActive(self, active: bool): + if active != self._active: + self._active = active + self.syncStyle() + + def isActive(self) -> bool: + return self._active + + def setBadge(self, text: str, level: str = "neutral"): + """卡片右侧状态胶囊(如配音提供商的「免 Key / 需 Key / 可克隆」)。""" + if getattr(self, "_badge", None) is None: + self._badge = StatusPill(text, level, self) + self.layout().addWidget(self._badge, 0, Qt.AlignVCenter) # type: ignore[arg-type] + else: + self._badge.setState(text, level) + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit(self.key) + event.accept() + return + super().mousePressEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + if self._active: + bg, border = rgba(palette.accent, 0.075), rgba(palette.accent, 0.72) + elif self.underMouse() and self.isEnabled(): + bg, border = palette.panel, rgba(palette.accent, 0.4) + else: + bg, border = palette.panel, palette.line_soft + draw_rounded_surface(self, bg, border, 13) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + icon_css = "" + if self.iconLabel is not None and self._icon is not None: + icon_color = palette.accent if self._active else palette.muted + icon_bg = rgba(palette.accent, 0.12) if self._active else palette.control + self.iconLabel.setPixmap(icon_pixmap(self._icon, icon_color, 20)) + icon_css = ( + f"QFrame#selectableCardIcon {{ background: {icon_bg};" + f" border: none; border-radius: 11px; }}" + ) + self.setStyleSheet( + f""" + QFrame#selectableCard {{ background: transparent; border: none; }} + {icon_css} + QLabel#selectableCardTitle {{ color: {palette.text}; background: transparent; }} + QLabel#selectableCardDesc {{ color: {palette.subtle}; background: transparent; }} + """ + ) + self.update() + + +class ErrorCard(QFrame): + """错误卡:danger_soft 底 + danger 描边 + 可选标题 + 可选中正文。 + + 转录/字幕/合成/任务创建四页统一用它(过去各页一份 QSS 复制,边框透明度/ + 内边距/字号各自漂移)。全自绘圆角;正文可选中复制,方便用户反馈。 + """ + + def __init__(self, text: str = "", title: Optional[str] = None, parent=None): + super().__init__(parent) + self.setObjectName("wbErrorCard") + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 14, 16, 14) + layout.setSpacing(7) + self.titleLabel: Optional[QLabel] = None + if title is not None: + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("wbErrorTitle") + apply_font(self.titleLabel, 15, 820) + layout.addWidget(self.titleLabel) + self.messageLabel = QLabel(text, self) + self.messageLabel.setObjectName("wbErrorMessage") + self.messageLabel.setWordWrap(True) + self.messageLabel.setTextInteractionFlags(Qt.TextSelectableByMouse) # type: ignore[arg-type] + apply_font(self.messageLabel, 14, 720) + layout.addWidget(self.messageLabel) + self.syncStyle() + + def setText(self, text: str): + self.messageLabel.setText(text) + + def text(self) -> str: + return self.messageLabel.text() + + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.danger_soft, rgba(palette.danger, 0.70), 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + css = "QFrame#wbErrorCard { background: transparent; border: none; }" + if self.titleLabel is not None: + css += f" QLabel#wbErrorTitle {{ color: {palette.text}; background: transparent; }}" + css += f" QLabel#wbErrorMessage {{ color: {palette.danger_fg}; background: transparent; }}" + self.setStyleSheet(css) + self.update() + + +class SectionLabel(QLabel): + """小节标题:弱化色 + 12px/800,面板内分组小标题统一用它。""" + + def __init__(self, text: str = "", parent=None): + super().__init__(text, parent) + self.setObjectName("wbSectionLabel") + apply_font(self, 12, 800) + self.syncStyle() + + def syncStyle(self): + self.setStyleSheet( + f"color: {app_palette().subtle}; background: transparent; border: none;" + ) + + +class PrimaryIconButton(QFrame): + """圆形实心主操作图标按钮:主题绿底 + 深色图标,靠图标与悬浮提示表意。""" + + clicked = pyqtSignal() + + def __init__(self, icon: AppIcon, diameter: int = 52, parent=None): + super().__init__(parent) + self._icon = icon + self._diameter = diameter + self.setFixedSize(diameter, diameter) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.iconLabel = QLabel(self) + self.iconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + layout.addWidget(self.iconLabel) + self.syncStyle() + + def setIcon(self, icon: AppIcon): + if icon != self._icon: + self._icon = icon + self.syncStyle() + + def setEnabled(self, enabled: bool): + super().setEnabled(enabled) + self.setCursor( + Qt.PointingHandCursor if enabled else Qt.ArrowCursor # type: ignore[arg-type] + ) + self.syncStyle() + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + if not self.isEnabled(): + bg = rgba(palette.accent, 0.30) + elif self.underMouse(): + bg = to_qcolor(palette.accent).lighter(112).name() + else: + bg = palette.accent + draw_rounded_surface(self, bg, bg, self._diameter / 2) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + color = ( + palette.accent_fg + if self.isEnabled() + else rgba(palette.accent_fg, 0.55) + ) + self.iconLabel.setPixmap( + icon_pixmap(self._icon, color, int(self._diameter * 0.42)) + ) + self.setStyleSheet("background: transparent; border: none;") + self.update() + + +class AppLineEdit(QLineEdit): + """第一方单行输入框:自绘抗锯齿圆角底 + 聚焦主题色描边,去掉 qfluent 的底部下划线, + + 与 workbench 其余控件同一套设计语言。用于弹窗 / 取色器等所有单行文本输入。""" + + def __init__(self, text: str = "", parent=None, height: int = 36): + super().__init__(text, parent) + self.setObjectName("appLineEdit") + self.setFrame(False) + self.setFixedHeight(height) + self.setCursor(Qt.IBeamCursor) # type: ignore[arg-type] + self.syncStyle() + + def paintEvent(self, event): + palette = app_palette() + if self.hasFocus(): + border = palette.accent + elif self.underMouse() and self.isEnabled(): + border = palette.accent_border + else: + border = palette.line_soft + draw_rounded_surface(self, palette.field, border, 9) + super().paintEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def focusInEvent(self, event): + super().focusInEvent(event) + self.update() + + def focusOutEvent(self, event): + super().focusOutEvent(event) + self.update() + + def syncStyle(self): + palette = app_palette() + apply_font(self, 14, 720) + self.setStyleSheet( + f""" + QLineEdit#appLineEdit {{ + background: transparent; + border: none; + padding: 0 12px; + color: {palette.text}; + selection-background-color: {rgba(palette.accent, 0.35)}; + selection-color: {palette.text}; + }} + """ + ) + self.update() + + +class AppTextEdit(QPlainTextEdit): + """第一方多行输入框:与 AppLineEdit 同一套圆角 + 聚焦/悬浮主题色描边,去掉 qfluent 下划线。 + + 文稿提示、配音文案、克隆参考文本等多行输入统一用它。""" + + def __init__(self, text: str = "", parent=None, min_height: int = 96, radius: int = 12): + super().__init__(parent) + self.setObjectName("appTextEdit") + self._radius = radius + self.setFrameShape(QFrame.NoFrame) # type: ignore[attr-defined] + self.setMinimumHeight(min_height) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # type: ignore[attr-defined] + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[attr-defined] + # 文本内边距走 documentMargin:QSS 的 padding 到不了 QPlainTextEdit 文本区 + # (文字会贴边),documentMargin 才能稳定把文字从边缘推开。 + self.document().setDocumentMargin(12) + apply_font(self, 14, 720) # 只设一次;调用方可在构造后用 apply_font 覆盖字号 + if text: + self.setPlainText(text) + self.syncStyle() + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def focusInEvent(self, event): + super().focusInEvent(event) + self.syncStyle() + + def focusOutEvent(self, event): + super().focusOutEvent(event) + self.syncStyle() + + def syncStyle(self): + palette = app_palette() + if self.hasFocus(): + border = palette.accent + elif self.underMouse() and self.isEnabled(): + border = palette.accent_border + else: + border = palette.line_soft + # 文本区背景/文字色走 QPalette:QSS 的 background 在某些父级样式表下到不了 + # QPlainTextEdit 的 viewport(会露出 Qt 默认白底),调色板直控更可靠。 + # 用 panel_deep(比 panel 更暗)做下沉式输入框的深色底, + # 避免用 field(比面板更亮)时输入框显得「浮起/和面板分不清」。 + pal = self.palette() + pal.setColor(QPalette.Base, QColor(palette.panel_deep)) + pal.setColor(QPalette.Text, QColor(palette.text)) + self.setPalette(pal) + self.setStyleSheet( + f""" + QPlainTextEdit#appTextEdit {{ + background: {palette.panel_deep}; + border: 1px solid {border}; + border-radius: {self._radius}px; + color: {palette.text}; + selection-background-color: {rgba(palette.accent, 0.35)}; + selection-color: {palette.text}; + }} + QScrollBar:vertical {{ + background: transparent; width: 6px; margin: 4px 2px; border: none; + }} + QScrollBar::handle:vertical {{ + background: {palette.line}; border-radius: 3px; min-height: 28px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} + """ + ) + self.update() + + +class CompactButton(QFrame): + """紧凑按钮:32 高、图标 + 文案,表格头部操作用。""" + + clicked = pyqtSignal() + + def __init__( + self, + text: str, + icon: AppIcon | None = None, + parent=None, + icon_only: bool = False, + pad_h: int = 11, + ): + super().__init__(parent) + self.setObjectName("wbCompactButton") + self._icon = icon + self.setFixedHeight(32) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + + layout = QHBoxLayout(self) + layout.setContentsMargins(pad_h, 0, pad_h, 0) + layout.setSpacing(6) + self.iconLabel = QLabel(self) + # 自带透明底:父级一旦 setStyleSheet,未显式声明背景的子 QLabel 会回退到调色板 + # 底色,在按钮上糊出一块深色方块(图标背后的黑块就是这么来的)。 + self.iconLabel.setStyleSheet("background: transparent; border: none;") + self.iconLabel.setVisible(icon is not None) + layout.addWidget(self.iconLabel) + self.textLabel = QLabel(text, self) + self.textLabel.setObjectName("wbCompactText") + apply_font(self.textLabel, 13, 850) + layout.addWidget(self.textLabel) + if icon_only: + # 仅图标按钮:收起文字与间距并居中图标,适合窄列内的卡片动作(配 tooltip)。 + self.textLabel.hide() + layout.setSpacing(0) + layout.insertStretch(0, 1) + layout.addStretch(1) + self.syncStyle() + + def text(self) -> str: + return self.textLabel.text() + + def setText(self, text: str): + self.textLabel.setText(text) + + def setIcon(self, icon: AppIcon | None): + self._icon = icon + self.iconLabel.setVisible(icon is not None) + self.syncStyle() + + def setEnabled(self, enabled: bool): + super().setEnabled(enabled) + self.setCursor( + Qt.PointingHandCursor if enabled else Qt.ArrowCursor # type: ignore[arg-type] + ) + self.syncStyle() + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + hover_bg, hover_border = _hover_colors() + hovered = self.underMouse() and self.isEnabled() + draw_rounded_surface( + self, + hover_bg if hovered else palette.field, + hover_border if hovered else palette.line, + 9, + ) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + fg = palette.text if self.isEnabled() else palette.subtle + if self._icon is not None: + self.iconLabel.setPixmap( + icon_pixmap(self._icon, fg if self.isEnabled() else palette.subtle, 16) + ) + self.update() + self.setStyleSheet( + f""" + QFrame#wbCompactButton {{ background: transparent; border: none; }} + QLabel#wbCompactText {{ color: {fg}; background: transparent; border: none; }} + """ + ) + + +class AccentButton(CompactButton): + """主操作迷你按钮:主题色描边与文字。""" + + def paintEvent(self, event): + palette = app_palette() + hovered = self.underMouse() and self.isEnabled() + if self.isEnabled(): + bg = rgba(palette.accent, 0.16 if hovered else 0.10) + border = rgba(palette.accent, 0.72) + else: + bg, border = palette.disabled, palette.line + draw_rounded_surface(self, bg, border, 9) + + def syncStyle(self): + palette = app_palette() + fg = palette.accent_text if self.isEnabled() else palette.subtle + if self._icon is not None: + self.iconLabel.setPixmap(icon_pixmap(self._icon, fg, 16)) + self.update() + self.setStyleSheet( + f""" + QFrame#wbCompactButton {{ background: transparent; border: none; }} + QLabel#wbCompactText {{ color: {fg}; background: transparent; border: none; }} + """ + ) + + +class DangerButton(CompactButton): + """危险操作迷你按钮:删除等不可逆操作。""" + + def paintEvent(self, event): + palette = app_palette() + hovered = self.underMouse() and self.isEnabled() + bg = rgba(palette.danger, 0.16 if hovered else 0.08) + draw_rounded_surface(self, bg, rgba(palette.danger, 0.55), 9) + + def syncStyle(self): + palette = app_palette() + fg = palette.danger_fg if self.isEnabled() else palette.subtle + if self._icon is not None: + self.iconLabel.setPixmap(icon_pixmap(self._icon, fg, 16)) + self.update() + self.setStyleSheet( + f""" + QFrame#wbCompactButton {{ background: transparent; border: none; }} + QLabel#wbCompactText {{ color: {fg}; background: transparent; border: none; }} + """ + ) + + +class IconBox(QFrame): + """图标盒:半透明圆角块内嵌图标。 + + tone 决定底色与图标色: + - ``surface``(默认):淡叠色底 + line_soft 边 + muted 图标; + - ``accent``:主题色 0.08 底 + 无边 + accent_text 图标(流水线阶段 / 生成计划步骤)。 + """ + + def __init__(self, icon: AppIcon, parent=None, size: int = 38, tone: str = "surface"): + super().__init__(parent) + self._icon = icon + self._tone = tone + self.setFixedSize(size, size) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.iconLabel = QLabel(self) + self.iconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + layout.addWidget(self.iconLabel) + self.syncStyle() + + def setIcon(self, icon: AppIcon): + self._icon = icon + self.syncStyle() + + def paintEvent(self, event): + palette = app_palette() + if self._tone == "accent": + tint = rgba(palette.accent, 0.08) + draw_rounded_surface(self, tint, tint, 10) + else: + draw_rounded_surface(self, palette.card_surface_hover, palette.line_soft, 10) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + color = palette.accent_text if self._tone == "accent" else palette.muted + self.iconLabel.setPixmap(icon_pixmap(self._icon, color, 17)) + self.setStyleSheet("background: transparent; border: none;") + + +class PillSelect(QFrame): + """胶囊下拉取值胶囊:点击弹 RoundMenu 换值。""" + + currentTextChanged = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("wbPillSelect") + self._items: list[str] = [] + self._current = "" + self.setFixedHeight(30) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + layout = QHBoxLayout(self) + layout.setContentsMargins(13, 0, 10, 0) + layout.setSpacing(6) + self.textLabel = QLabel(self) + self.textLabel.setObjectName("wbPillSelectText") + apply_font(self.textLabel, 13, 780) + layout.addWidget(self.textLabel) + # 常驻下拉箭头:让取值胶囊一眼可辨「可点选」,区别于只读 InfoChip 元数据胶囊 + self.chevronLabel = QLabel(self) + # 自带透明底:父级 setStyleSheet 后未声明背景的子 QLabel 会回退调色板底色 → 箭头后糊一块方块 + self.chevronLabel.setStyleSheet("background: transparent; border: none;") + self.chevronLabel.setFixedSize(14, 14) + layout.addWidget(self.chevronLabel, 0, Qt.AlignVCenter) # type: ignore[arg-type] + self.syncStyle() + + def setItems(self, items: list[str], current: str | None = None): + self._items = list(items) + if current is not None: + self.setCurrentText(current) + elif self._items and self._current not in self._items: + self.setCurrentText(self._items[0]) + + def items(self) -> list[str]: + return list(self._items) + + def currentText(self) -> str: + return self._current + + def setCurrentText(self, text: str): + if text == self._current: + return + self._current = text + self.textLabel.setText(text) + self.currentTextChanged.emit(text) + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton and self._items: # type: ignore[attr-defined] + menu = RoundMenu(parent=self) + for item in self._items: + action = Action(item) + action.triggered.connect( + lambda _=False, text=item: self.setCurrentText(text) + ) + menu.addAction(action) + # 候选很多时(如字体)限制可见行数并滚动,避免菜单顶到屏幕高度。 + menu.setMaxVisibleItems(14) + menu.exec(self.mapToGlobal(self.rect().bottomLeft())) + event.accept() + return + super().mousePressEvent(event) + + def setEnabled(self, enabled: bool): + super().setEnabled(enabled) + self.syncStyle() + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + hovered = self.underMouse() and self.isEnabled() + # 静息态就用主题色描边(而非 line),与只读 InfoChip 拉开差异 → 用户知道这是可点控件 + border = rgba(palette.accent, 0.75 if hovered else 0.42) if self.isEnabled() else palette.line + draw_rounded_surface(self, palette.control, border, 15) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + fg = palette.text if self.isEnabled() else palette.subtle + self.chevronLabel.setPixmap( + icon_pixmap(AppIcon.CHEVRON_DOWN, fg if self.isEnabled() else palette.subtle, 14) + ) + self.update() + self.setStyleSheet( + f""" + QFrame#wbPillSelect {{ background: transparent; border: none; }} + QLabel#wbPillSelectText {{ color: {fg}; background: transparent; border: none; }} + """ + ) + + +class _StepButton(QFrame): + """步进器左右的加减格子(自绘圆角)。按住可连续触发并随按住时间加速。""" + + clicked = pyqtSignal() + + # 首次延迟后开始连发,连发间隔随按住时长逐级加快(毫秒)。 + _HOLD_DELAY = 380 + _REPEAT_STEPS = ((6, 110), (16, 60), (10**9, 28)) + + def __init__(self, symbol: str, parent=None): + super().__init__(parent) + self.setFixedSize(24, 24) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.label = QLabel(symbol, self) + self.label.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.label, 16, 760) + layout.addWidget(self.label) + self._repeat_timer = QTimer(self) + self._repeat_timer.setSingleShot(True) + self._repeat_timer.timeout.connect(self._on_repeat) + self._repeat_count = 0 + self.syncStyle() + + def _on_repeat(self): + if not self.isEnabled(): + return + self._repeat_count += 1 + self.clicked.emit() + interval = next(ms for limit, ms in self._REPEAT_STEPS if self._repeat_count < limit) + self._repeat_timer.start(interval) + + def _stop_repeat(self): + self._repeat_timer.stop() + self._repeat_count = 0 + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + self._repeat_count = 0 + self._repeat_timer.start(self._HOLD_DELAY) # 按住超过延迟才开始连发 + event.accept() + return + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + self._stop_repeat() + super().mouseReleaseEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self._stop_repeat() # 移出按钮即停止连发,避免“跑飞” + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + hovered = self.underMouse() and self.isEnabled() + border = rgba(palette.accent, 0.6) if hovered else palette.line_soft + draw_rounded_surface(self, palette.card_surface_hover, border, 7) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + fg = palette.muted if self.isEnabled() else palette.subtle + self.setStyleSheet("QFrame { background: transparent; border: none; }") + self.label.setStyleSheet(f"color: {fg}; background: transparent; border: none;") + self.update() + + +class StepperControl(QFrame): + """数值步进器:[−] 值 单位 [+]。 + + 参数面板里所有数值项统一用它(字号、描边、边距、圆角半径…), + 支持整数/小数(``decimals``)、范围与步长,全自绘圆角。 + """ + + valueChanged = pyqtSignal(float) + + def __init__( + self, + value: float = 0, + minimum: float = 0, + maximum: float = 100, + step: float = 1, + decimals: int = 0, + suffix: str = "", + parent=None, + width: int = 150, + ): + super().__init__(parent) + self.setObjectName("wbStepper") + self._min = minimum + self._max = maximum + self._step = step + self._decimals = decimals + self._suffix = suffix + self._value = self._clamp(value) + self.setFixedHeight(34) + self.setFixedWidth(width) + + layout = QHBoxLayout(self) + layout.setContentsMargins(7, 0, 7, 0) + layout.setSpacing(6) + self.minusButton = _StepButton("−", self) + self.minusButton.clicked.connect(lambda: self._nudge(-self._step)) + layout.addWidget(self.minusButton) + + self.valueLabel = QLabel(self) + self.valueLabel.setObjectName("wbStepperValue") + self.valueLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.valueLabel, 13, 850) + layout.addWidget(self.valueLabel, 1) + + self.plusButton = _StepButton("+", self) + self.plusButton.clicked.connect(lambda: self._nudge(self._step)) + layout.addWidget(self.plusButton) + self._refreshText() + self.syncStyle() + + def _clamp(self, value: float) -> float: + return max(self._min, min(self._max, round(float(value), self._decimals))) + + def value(self) -> float: + return self._value + + def setValue(self, value: float, *, emit: bool = False): + clamped = self._clamp(value) + changed = clamped != self._value + self._value = clamped + self._refreshText() + if changed and emit: + self.valueChanged.emit(self._value) + + def _nudge(self, delta: float): + before = self._value + self._value = self._clamp(self._value + delta) + if self._value != before: + self._refreshText() + self.valueChanged.emit(self._value) + + def _refreshText(self): + if self._decimals > 0: + text = f"{self._value:.{self._decimals}f}" + else: + text = str(int(self._value)) + if self._suffix: + text = f"{text} {self._suffix}" + self.valueLabel.setText(text) + + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.field, palette.line, 9) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet("QFrame#wbStepper { background: transparent; border: none; }") + self.valueLabel.setStyleSheet( + f"color: {palette.text}; background: transparent; border: none;" + ) + self.minusButton.syncStyle() + self.plusButton.syncStyle() + self.update() + + +class ToggleCard(QFrame): + """输出内容卡:标题 + 说明 + 开关,开启时主题色描边。""" + + toggled = pyqtSignal(bool) + + def __init__(self, title: str, desc: str, checked: bool = False, parent=None): + super().__init__(parent) + self.setObjectName("wbToggleCard") + self.setMinimumHeight(64) + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 11, 14, 11) + layout.setSpacing(12) + column = QVBoxLayout() + column.setSpacing(3) + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("wbToggleCardTitle") + apply_font(self.titleLabel, 14, 850) + column.addWidget(self.titleLabel) + self.descLabel = QLabel(desc, self) + self.descLabel.setObjectName("wbToggleCardDesc") + apply_font(self.descLabel, 12, 700) + column.addWidget(self.descLabel) + layout.addLayout(column, 1) + self.switch = ToggleSwitch(checked, self) + self.switch.toggled.connect(self._on_toggled) + layout.addWidget(self.switch) + self.syncStyle() + + def isChecked(self) -> bool: + return self.switch.isChecked() + + def setChecked(self, checked: bool): + self.switch.setChecked(checked) + + def _on_toggled(self, checked: bool): + self.update() + self.toggled.emit(checked) + + def mousePressEvent(self, event): + # 整卡可点切换,符合开关卡的常见交互。 + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.switch.setChecked(not self.switch.isChecked()) + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + palette = app_palette() + active = self.switch.isChecked() + surface = ( + rgba(palette.accent, 0.07) + if active + else (palette.card_surface) + ) + border = rgba(palette.accent, 0.62) if active else palette.line_soft + draw_rounded_surface(self, surface, border, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.update() + self.setStyleSheet( + f""" + QFrame#wbToggleCard {{ background: transparent; border: none; }} + QLabel#wbToggleCardTitle {{ color: {palette.text}; background: transparent; border: none; }} + QLabel#wbToggleCardDesc {{ color: {palette.subtle}; background: transparent; border: none; }} + """ + ) + + +class OptionCard(QFrame): + """选项卡片:左标签 + 右控件(开关 / 取值胶囊等)。""" + + def __init__(self, label: str, control: QWidget, parent=None): + super().__init__(parent) + self.setObjectName("wbOptionCard") + layout = QHBoxLayout(self) + layout.setContentsMargins(13, 9, 13, 9) + layout.setSpacing(12) + self.label = QLabel(label, self) + self.label.setObjectName("wbOptionCardLabel") + apply_font(self.label, 14, 850) + layout.addWidget(self.label, 1, Qt.AlignVCenter) # type: ignore[arg-type] + self.control = control + layout.addWidget(control, 0, Qt.AlignVCenter) # type: ignore[arg-type] + # 高度由内容驱动:复合控件(如 音色胶囊+音色库按钮)超过 30px 时 + # 卡片随之长高,绝不压缩边距把控件底边裁掉。 + self.setMinimumHeight(max(48, control.sizeHint().height() + 18)) + self.syncStyle() + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.update() + self.setStyleSheet( + f""" + QFrame#wbOptionCard {{ background: transparent; border: none; }} + QLabel#wbOptionCardLabel {{ color: {palette.text}; background: transparent; border: none; }} + """ + ) + + +class MediaThumb(QFrame): + """媒体缩略图:有封面画封面,没有就画音/视频占位图。 + + 转录页媒体卡与合成页结果预览共用。``fit``: + - "cover"(默认)铺满并裁切,适合固定高度的小卡片; + - "contain" 完整显示留边,适合结果预览这种「必须看全画面」的大区域。 + """ + + # 音频波形折线的折点(180x28 坐标系),绘制时按尺寸缩放。 + _WAVE_POINTS = [ + (2, 15), (24, 6), (44, 22), (64, 9), (84, 18), + (104, 4), (124, 23), (144, 10), (178, 16), + ] + + def __init__(self, parent=None, *, fit: str = "cover"): + super().__init__(parent) + self._pixmap: Optional[QPixmap] = None + self._audio = False + self._fit = fit + + def setMedia(self, thumbnail_path: str | None, is_audio: bool): + self._audio = is_audio + self._pixmap = None + if thumbnail_path and Path(thumbnail_path).exists(): + pixmap = QPixmap(thumbnail_path) + if not pixmap.isNull(): + self._pixmap = pixmap + self.update() + + def clear(self): + self._pixmap = None + self._audio = False + self.update() + + def paintEvent(self, event): + palette = app_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(0.5, 0.5, self.width() - 1, self.height() - 1) + clip = QPainterPath() + clip.addRoundedRect(rect, 10, 10) + painter.setClipPath(clip) + + if self._pixmap is not None: + if self._fit == "contain": + # 完整显示:先铺主题底色再等比缩放居中,留边不裁画面 + painter.fillRect(self.rect(), QColor(palette.panel_deep)) + aspect_mode = Qt.KeepAspectRatio + else: + aspect_mode = Qt.KeepAspectRatioByExpanding + scaled = self._pixmap.scaled( + self.size(), + aspect_mode, # type: ignore[arg-type] + Qt.SmoothTransformation, # type: ignore[arg-type] + ) + painter.drawPixmap( + (self.width() - scaled.width()) // 2, + (self.height() - scaled.height()) // 2, + scaled, + ) + else: + gradient = QLinearGradient(0, 0, self.width(), self.height()) + gradient.setColorAt(0, QColor("#23302c")) + gradient.setColorAt(1, QColor("#18201d")) + painter.fillRect(self.rect(), gradient) + + glow = QRadialGradient(self.width() * 0.5, self.height() * 0.42, self.width() * 0.31) + accent_glow = QColor(palette.accent) + accent_glow.setAlphaF(0.18) + glow.setColorAt(0, accent_glow) + glow.setColorAt(1, QColor(0, 0, 0, 0)) + painter.fillRect(self.rect(), glow) + + box = min(68, int(self.height() * 0.58)) + box_rect = QRectF( + (self.width() - box) / 2, (self.height() - box) / 2 - (6 if self._audio else 0), + box, box, + ) + painter.setPen(QPen(QColor(203, 212, 208, 46), 1)) + painter.setBrush(QColor(8, 17, 14, 107)) + painter.drawRoundedRect(box_rect, 16, 16) + icon = AppIcon.MUSIC if self._audio else AppIcon.VIDEO + icon_size = int(box * 0.5) + pixmap = icon_pixmap(icon, palette.accent, icon_size) + painter.drawPixmap( + int(box_rect.x() + (box - icon_size) / 2), + int(box_rect.y() + (box - icon_size) / 2), + pixmap, + ) + if self._audio: + self._draw_waveform(painter, palette.accent) + + painter.setClipping(False) + painter.setPen(QPen(QColor(palette.line_soft), 1)) + painter.setBrush(Qt.NoBrush) # type: ignore[arg-type] + painter.drawRoundedRect(rect, 10, 10) + + def _draw_waveform(self, painter: QPainter, accent: str): + inset, height = 18, 28 + width = self.width() - inset * 2 + top = self.height() - inset - height + pen = QPen(QColor(accent), 3) + pen.setCapStyle(Qt.RoundCap) # type: ignore[arg-type] + painter.setPen(pen) + path = QPainterPath() + points = [ + (inset + x / 180 * width, top + y / 28 * height) + for x, y in self._WAVE_POINTS + ] + path.moveTo(*points[0]) + for index in range(1, len(points)): + prev_x, prev_y = points[index - 1] + x, y = points[index] + mid = (prev_x + x) / 2 + path.cubicTo(mid, prev_y, mid, y, x, y) + painter.drawPath(path) + + +class WorkbenchPanel(QFrame): + """面板容器:14 圆角、1px 边框。布局由调用方自己放。""" + + def __init__(self, parent=None, padded: bool = True): + super().__init__(parent) + self.setObjectName("wbPanel") + self.bodyLayout = QVBoxLayout(self) + if padded: + self.bodyLayout.setContentsMargins(22, 22, 22, 22) + else: + self.bodyLayout.setContentsMargins(0, 0, 0, 0) + self.bodyLayout.setSpacing(0) + # 子类通常覆写 syncStyle 并引用自己的成员,构造期只应用基类样式。 + WorkbenchPanel.syncStyle(self) + + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.panel, palette.line, 14) + super().paintEvent(event) + + def syncStyle(self): + self.update() + self.setStyleSheet( + """ + QFrame#wbPanel { background: transparent; border: none; } + QFrame#wbPanel QLabel { + background: transparent; + border: none; + } + """ + ) + + +class CollapsibleSideHost(QFrame): + """可折叠右栏宿主:展开显示内容面板,折叠完全收起。 + + - 折叠/展开带宽度动画(220ms InOutCubic),收起后整体隐藏, + 不留占位竖条;展开入口由页面放在左侧面板头部 + - 折叠状态由页面负责持久化(监听 collapsedChanged 写配置) + """ + + collapsedChanged = pyqtSignal(bool) + + def __init__( + self, + content: QWidget, + expanded_min: int = 280, + expanded_max: int = 330, + parent=None, + ): + super().__init__(parent) + self._content = content + self._expanded_min = expanded_min + self._expanded_max = expanded_max + self._collapsed = False + self._animation: QParallelAnimationGroup | None = None + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + content.setParent(self) + layout.addWidget(content) + + self.setMinimumWidth(expanded_min) + self.setMaximumWidth(expanded_max) + + def isCollapsed(self) -> bool: + return self._collapsed + + def setCollapsed(self, collapsed: bool, animate: bool = True): + if collapsed == self._collapsed: + return + self._collapsed = collapsed + + if self._animation is not None: + self._animation.stop() + self._animation = None + + target_min = 0 if collapsed else self._expanded_min + target_max = 0 if collapsed else self._expanded_max + if collapsed: + self._content.hide() + else: + self.show() + + def finish(): + if self._collapsed: + self.hide() + else: + self._content.show() + + if animate: + group = QParallelAnimationGroup(self) + for prop, end in ((b"minimumWidth", target_min), (b"maximumWidth", target_max)): + animation = QPropertyAnimation(self, prop, self) + animation.setDuration(220) + animation.setEasingCurve(QEasingCurve.InOutCubic) + animation.setEndValue(end) + group.addAnimation(animation) + group.finished.connect(finish) + self._animation = group # 持有引用避免被回收 + group.start() + else: + self.setMinimumWidth(target_min) + self.setMaximumWidth(target_max) + finish() + self.collapsedChanged.emit(collapsed) + + +class PanelHeader(QFrame): + """面板标题行。 + + inline=True:放在带内边距面板顶部(默认无分隔线,underline=True 加线); + inline=False:独立标题栏(56 高、左右 22 内边距、底部分隔线)。 + """ + + def __init__( + self, + title: str, + inline: bool = True, + bar_height: int = 56, + underline: bool = False, + parent=None, + ): + super().__init__(parent) + self.setObjectName("wbPanelHeader") + self._inline = inline + self._underline = underline + + layout = QHBoxLayout(self) + if inline: + layout.setContentsMargins(0, 0, 0, 14 if underline else 18) + else: + self.setFixedHeight(bar_height) + layout.setContentsMargins(22, 0, 22, 0) + layout.setSpacing(9) + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("wbPanelTitle") + apply_font(self.titleLabel, 20, 860) + layout.addWidget(self.titleLabel) + layout.addStretch(1) + self._actions = layout + self.syncStyle() + + def addRight(self, widget: QWidget): + self._actions.addWidget(widget) + + def setTitle(self, title: str): + self.titleLabel.setText(title) + + def setInline(self, inline: bool): + """运行时切换形态:bar(56 高 + 底部分隔线)<-> inline(无分隔线)。""" + if inline == self._inline: + return + self._inline = inline + if inline: + self.setMinimumHeight(0) + self.setMaximumHeight(16777215) + self.layout().setContentsMargins(22, 22, 22, 0) + else: + self.setMaximumHeight(56) + self.setFixedHeight(56) + self.layout().setContentsMargins(22, 0, 22, 0) + self.syncStyle() + + def syncStyle(self): + palette = app_palette() + show_line = (not self._inline) or self._underline + border = f"1px solid {palette.line_soft}" if show_line else "none" + self.setStyleSheet( + f""" + QFrame#wbPanelHeader {{ + background: transparent; + border: none; + border-bottom: {border}; + }} + QLabel#wbPanelTitle {{ + color: {palette.text}; + background: transparent; + border: none; + }} + """ + ) diff --git a/videocaptioner/ui/config_adapter.py b/videocaptioner/ui/config_adapter.py new file mode 100644 index 00000000..a87ceed4 --- /dev/null +++ b/videocaptioner/ui/config_adapter.py @@ -0,0 +1,147 @@ +"""Convert desktop UI settings state into AppConfig. + +Execution code consumes AppConfig, not UI widgets or UI setting fields. +""" + +from __future__ import annotations + +from videocaptioner.config import MODEL_PATH +from videocaptioner.core.application.app_config import ( + AppConfig, + DubbingSettings, + LLMSettings, + SubtitleSettings, + SynthesisSettings, + TranscribeSettings, +) +from videocaptioner.core.entities import LANGUAGES, LLMServiceEnum +from videocaptioner.core.llm import free_model + + +def app_config_from_ui(cfg) -> AppConfig: + llm = _llm_from_ui(cfg) + language_label = cfg.transcribe_language.value.value + faster_model_dir = str(cfg.faster_whisper_model_dir.value or MODEL_PATH) + return AppConfig( + work_dir=str(cfg.work_dir.value), + cache_enabled=bool(cfg.cache_enabled.value), + llm=llm, + transcribe=TranscribeSettings( + model=cfg.transcribe_model.value, + language=LANGUAGES[language_label], + language_label=language_label, + output_format=cfg.transcribe_output_format.value, + whisper_model=cfg.whisper_model.value, + whisper_api_key=str(cfg.whisper_api_key.value or ""), + whisper_api_base=str(cfg.whisper_api_base.value or ""), + whisper_api_model=str(cfg.whisper_api_model.value or ""), + whisper_api_prompt=str(cfg.whisper_api_prompt.value or ""), + fun_asr_api_key=str(cfg.fun_asr_api_key.value or ""), + fun_asr_api_base=str(cfg.fun_asr_api_base.value or ""), + fun_asr_model=str(cfg.fun_asr_model.value or ""), + faster_whisper_program=str(cfg.faster_whisper_program.value or ""), + faster_whisper_model=cfg.faster_whisper_model.value, + faster_whisper_model_dir=faster_model_dir, + faster_whisper_device=str(cfg.faster_whisper_device.value or "cuda"), + faster_whisper_vad_filter=bool(cfg.faster_whisper_vad_filter.value), + faster_whisper_vad_threshold=float(cfg.faster_whisper_vad_threshold.value), + faster_whisper_vad_method=cfg.faster_whisper_vad_method.value, + faster_whisper_ff_mdx_kim2=bool(cfg.faster_whisper_ff_mdx_kim2.value), + faster_whisper_one_word=bool(cfg.faster_whisper_one_word.value), + faster_whisper_prompt=str(cfg.faster_whisper_prompt.value or ""), + ), + subtitle=SubtitleSettings( + translator_service=cfg.translator_service.value, + need_reflect=bool(cfg.need_reflect_translate.value), + deeplx_endpoint=str(cfg.deeplx_endpoint.value or ""), + thread_num=int(cfg.thread_num.value), + batch_size=int(cfg.batch_size.value), + need_optimize=bool(cfg.need_optimize.value), + need_translate=bool(cfg.need_translate.value), + need_split=bool(cfg.need_split.value), + target_language=cfg.target_language.value, + max_word_count_cjk=int(cfg.max_word_count_cjk.value), + max_word_count_english=int(cfg.max_word_count_english.value), + custom_prompt_text=str(cfg.custom_prompt_text.value or ""), + layout=cfg.subtitle_layout.value, + style_name=str(cfg.subtitle_style_name.value or "default"), + ), + synthesis=SynthesisSettings( + need_video=bool(cfg.need_video.value), + soft_subtitle=bool(cfg.soft_subtitle.value), + video_quality=cfg.video_quality.value, + render_mode=cfg.subtitle_render_mode.value, + style_id=str(cfg.subtitle_style_name.value or "rounded/default"), + ), + dubbing=DubbingSettings( + enabled=bool(cfg.dubbing_enabled.value), + provider=str(cfg.dubbing_provider.value or "edge"), + preset=str(cfg.dubbing_preset.value or ""), + voice=str(cfg.dubbing_voice.value or ""), + text_track=str(cfg.dubbing_text_track.value or "auto"), + timing=str(cfg.dubbing_timing.value or "balanced"), + audio_mode=str(cfg.dubbing_audio_mode.value or "replace"), + api_key=str(cfg.dubbing_api_key.value or "").strip(), + api_base=str(cfg.dubbing_api_base.value or "").strip(), + model=str(cfg.dubbing_model.value or "").strip(), + tts_workers=int(cfg.dubbing_tts_workers.value), + clone_audio_path=str(cfg.dubbing_clone_audio.value or "").strip(), + clone_audio_text=str(cfg.dubbing_clone_text.value or "").strip(), + ), + ) + + +def _llm_from_ui(cfg) -> LLMSettings: + service = cfg.llm_service.value + if service == LLMServiceEnum.IMMERSIVE: + # 公益大模型:无需用户配置,base/model 固定,真实令牌在 LLM client 内实时取 + return LLMSettings( + service=service, + api_key=free_model.PLACEHOLDER_KEY, + api_base=free_model.BASE_URL, + model=free_model.MODEL, + ) + items = { + LLMServiceEnum.OPENAI: ( + cfg.openai_api_key, + cfg.openai_api_base, + cfg.openai_model, + ), + LLMServiceEnum.SILICON_CLOUD: ( + cfg.silicon_cloud_api_key, + cfg.silicon_cloud_api_base, + cfg.silicon_cloud_model, + ), + LLMServiceEnum.DEEPSEEK: ( + cfg.deepseek_api_key, + cfg.deepseek_api_base, + cfg.deepseek_model, + ), + LLMServiceEnum.OLLAMA: ( + cfg.ollama_api_key, + cfg.ollama_api_base, + cfg.ollama_model, + ), + LLMServiceEnum.LM_STUDIO: ( + cfg.lm_studio_api_key, + cfg.lm_studio_api_base, + cfg.lm_studio_model, + ), + LLMServiceEnum.GEMINI: ( + cfg.gemini_api_key, + cfg.gemini_api_base, + cfg.gemini_model, + ), + LLMServiceEnum.CHATGLM: ( + cfg.chatglm_api_key, + cfg.chatglm_api_base, + cfg.chatglm_model, + ), + } + api_key, api_base, model = items.get(service, items[LLMServiceEnum.OPENAI]) + return LLMSettings( + service=service, + api_key=str(api_key.value or ""), + api_base=str(api_base.value or ""), + model=str(model.value or ""), + ) diff --git a/videocaptioner/ui/i18n/__init__.py b/videocaptioner/ui/i18n/__init__.py new file mode 100644 index 00000000..77e04b50 --- /dev/null +++ b/videocaptioner/ui/i18n/__init__.py @@ -0,0 +1,47 @@ +"""UI 国际化入口(只在 UI 层使用;core/CLI 不翻译)。 + + from videocaptioner.ui.i18n import tr + label.setText(tr("home.drop.hint")) + label.setText(tr("hardsub.toast.done", count=n)) # 命名占位插值 + +key 命名规范:``<域>.<组件>.<语义>``,小写点分(见 docs/dev/i18n-plan.md)。 +""" + +from __future__ import annotations + +from videocaptioner.ui.i18n.catalog import ( + BASE_LANG, + SUPPORTED, + current_language, + init, + set_language, +) +from videocaptioner.ui.i18n.catalog import ( + translate as _translate, +) + +__all__ = [ + "tr", + "N_", + "init", + "set_language", + "current_language", + "BASE_LANG", + "SUPPORTED", +] + + +def tr(key: str, /, **params: object) -> str: + """按当前语言翻译 key。有 ``**params`` 时做命名占位插值;无则不 format(避免误伤含 ``{}`` 的文案)。""" + text = _translate(key) + if params: + try: + return text.format(**params) + except (KeyError, IndexError, ValueError): + return text + return text + + +def N_(key: str) -> str: + """标记 key 供 pybabel 抽取,运行时原样返回。用于非 tr() 调用点的字面量(如枚举标签映射表)。""" + return key diff --git a/videocaptioner/ui/i18n/catalog.py b/videocaptioner/ui/i18n/catalog.py new file mode 100644 index 00000000..852969ea --- /dev/null +++ b/videocaptioner/ui/i18n/catalog.py @@ -0,0 +1,62 @@ +"""UI 翻译目录:key-based gettext,运行时只读 .mo(标准库 gettext,无第三方依赖)。 + +msgid 是稳定 key(如 ``dubbing.btn.start``),msgstr 是各语言文案;基准语言 zh_Hans 存中文。 +翻译边界只在 UI——core/CLI 不使用本模块。 +""" + +from __future__ import annotations + +import gettext as _gettext +from pathlib import Path +from typing import Optional + +_DOMAIN = "videocaptioner" +BASE_LANG = "zh_Hans" +SUPPORTED = ("zh_Hans", "zh_Hant", "en") + +_lang = BASE_LANG +_trans: _gettext.NullTranslations = _gettext.NullTranslations() +_dir: Optional[Path] = None + + +def _normalize(name: str) -> str: + """任意 locale 名(QLocale.name() / 'auto' 等)→ 受支持的 catalog 目录名;未知归基准。""" + n = (name or "").replace("-", "_").lower() + if n.startswith("en"): + return "en" + if "hant" in n or n in ("zh_tw", "zh_hk", "zh_mo"): + return "zh_Hant" + return "zh_Hans" + + +def init(i18n_dir: Path, locale_name: str) -> None: + """启动时调用一次:记住资源目录并按 locale 装载当前语言。""" + global _dir + _dir = Path(i18n_dir) + set_language(locale_name) + + +def set_language(locale_name: str) -> None: + """切换当前语言。非基准语言缺译时回退到基准中文(而非显示 key),实现优雅降级。""" + global _lang, _trans + _lang = _normalize(locale_name) + if _dir is None: + _trans = _gettext.NullTranslations() + return + trans = _gettext.translation( + _DOMAIN, localedir=str(_dir), languages=[_lang], fallback=True + ) + if _lang != BASE_LANG: + base = _gettext.translation( + _DOMAIN, localedir=str(_dir), languages=[BASE_LANG], fallback=True + ) + trans.add_fallback(base) + _trans = trans + + +def current_language() -> str: + return _lang + + +def translate(key: str) -> str: + return _trans.gettext(key) diff --git a/videocaptioner/ui/main.py b/videocaptioner/ui/main.py index f6854b5d..8872cf01 100644 --- a/videocaptioner/ui/main.py +++ b/videocaptioner/ui/main.py @@ -5,24 +5,72 @@ import sys +def _install_app_font(app): + """加载内置霞鹜文楷并设为全局 UI 字体(Windows 缺省字体没有中文字形,会回退宋体)。 + + qfluent 控件走自己的 getFont 字族表,需一并替换。 + """ + import qfluentwidgets + from PyQt5.QtGui import QFont, QFontDatabase + from qfluentwidgets.common import font as fluent_font + + from videocaptioner.config import FONTS_PATH + + families = [] + font_id = QFontDatabase.addApplicationFont(str(FONTS_PATH / "LXGWWenKai-Regular.ttf")) + if font_id != -1: + families = list(QFontDatabase.applicationFontFamilies(font_id)) + families += [ + "Segoe UI", + "Microsoft YaHei UI", + "Microsoft YaHei", + "PingFang SC", + "Helvetica Neue", + "Arial", + ] + + font = app.font() + font.setFamilies(families) + app.setFont(font) + + def get_font(fontSize=14, weight=QFont.Normal): + font = QFont() + font.setFamilies(families) + font.setPixelSize(fontSize) + font.setWeight(weight) + return font + + fluent_font.getFont = get_font + qfluentwidgets.getFont = get_font + + def main(): import traceback - from PyQt5.QtCore import Qt, QTranslator + _suppress_qt_font_alias_warning() + + from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication - from videocaptioner.config import TRANSLATIONS_PATH + from videocaptioner.config import I18N_PATH from videocaptioner.core.utils.cache import disable_cache, enable_cache from videocaptioner.core.utils.logger import setup_logger + from videocaptioner.ui.i18n import init as init_i18n # Suppress qfluentwidgets ad with open(os.devnull, "w") as _devnull: sys.stdout, _stdout = _devnull, sys.stdout - from qfluentwidgets import FluentTranslator + from qfluentwidgets import FluentTranslator, Theme, setTheme, setThemeColor sys.stdout = _stdout - from videocaptioner.ui.common.config import cfg - from videocaptioner.ui.view.main_window import MainWindow + from videocaptioner.ui.common.config import ThemeMode, cfg + + def _to_qfluent_theme(theme: ThemeMode) -> Theme: + if theme == ThemeMode.LIGHT: + return Theme.LIGHT + if theme == ThemeMode.AUTO: + return Theme.AUTO + return Theme.DARK # Qt platform plugin path lib_folder = "Lib" if platform.system() == "Windows" else "lib" @@ -46,6 +94,11 @@ def exception_hook(exctype, value, tb): else: disable_cache() + # 一次性把旧 APPDATA 的实时字幕历史迁入工作目录(仅真实启动时,避免 widget 构造/测试误触发)。 + from videocaptioner.core.realtime.recording.history import default_root, migrate_legacy_root + + migrate_legacy_root(default_root(cfg.get(cfg.work_dir))) + # DPI scaling if cfg.get(cfg.dpiScale) == "Auto": QApplication.setHighDpiScaleFactorRoundingPolicy( @@ -59,18 +112,32 @@ def exception_hook(exctype, value, tb): app = QApplication(sys.argv) app.setAttribute(Qt.AA_DontCreateNativeWidgetSiblings, True) # type: ignore + _install_app_font(app) + setTheme(_to_qfluent_theme(cfg.themeMode.value)) + setThemeColor(cfg.themeColor.value) - # i18n + # i18n:UI 走 key-based gettext(core/CLI 不翻译);FluentTranslator 负责 qfluent 自带控件。 + # 必须在 import 页面模块之前装载语言——模块级常量若含 tr()/N_() key 求值发生在 import 时。 locale = cfg.get(cfg.language).value app.installTranslator(FluentTranslator(locale)) - my_translator = QTranslator() - my_translator.load(str(TRANSLATIONS_PATH / f"VideoCaptioner_{locale.name()}.qm")) - app.installTranslator(my_translator) + init_i18n(I18N_PATH, locale.name()) + + from videocaptioner.ui.view.main_window import MainWindow w = MainWindow() w.show() sys.exit(app.exec_()) +def _suppress_qt_font_alias_warning(): + rule = "qt.qpa.fonts=false;qt.qpa.fonts.warning=false" + current = os.environ.get("QT_LOGGING_RULES", "").strip() + if not current: + os.environ["QT_LOGGING_RULES"] = rule + elif rule not in current: + separator = "\n" if "\n" in current else ";" + os.environ["QT_LOGGING_RULES"] = f"{current}{separator}{rule}" + + if __name__ == "__main__": main() diff --git a/videocaptioner/ui/task_factory.py b/videocaptioner/ui/task_factory.py index 8b68e42b..feb59c7d 100644 --- a/videocaptioner/ui/task_factory.py +++ b/videocaptioner/ui/task_factory.py @@ -1,110 +1,52 @@ -import datetime -from pathlib import Path from typing import Optional -from videocaptioner.config import MODEL_PATH -from videocaptioner.core.entities import ( - LANGUAGES, - FullProcessTask, - LLMServiceEnum, - SubtitleConfig, - SubtitleTask, - SynthesisConfig, - SynthesisTask, - TranscribeConfig, - TranscribeTask, - TranscriptAndSubtitleTask, -) +from videocaptioner.core.application import TaskBuilder +from videocaptioner.core.entities import DubbingUIConfig from videocaptioner.ui.common.config import cfg +from videocaptioner.ui.config_adapter import app_config_from_ui class TaskFactory: - """任务工厂类,用于创建各种类型的任务""" + """Build UI tasks from the shared application config. + + Task-construction logic lives in core.application.TaskBuilder. This class + keeps UI call sites small and removes UI settings internals from the executable task layer. + """ @staticmethod - def get_ass_style(style_name: str) -> str: - """获取 ASS 字幕样式内容 (via style_manager, JSON-first with .txt fallback)""" - from videocaptioner.core.subtitle.style_manager import load_style + def _builder() -> TaskBuilder: + return TaskBuilder(app_config_from_ui(cfg)) - style = load_style(style_name) - if style is not None: - return style.to_ass_string() - return "" + @staticmethod + def get_ass_style(style_name: str) -> str: + return TaskFactory._builder().get_ass_style(style_name) @staticmethod def get_rounded_style() -> dict: - """获取圆角背景样式配置 (from UI cfg overrides)""" - return { - "font_name": cfg.rounded_bg_font_name.value, - "font_size": cfg.rounded_bg_font_size.value, - "bg_color": cfg.rounded_bg_color.value, - "text_color": cfg.rounded_bg_text_color.value, - "corner_radius": cfg.rounded_bg_corner_radius.value, - "padding_h": cfg.rounded_bg_padding_h.value, - "padding_v": cfg.rounded_bg_padding_v.value, - "margin_bottom": cfg.rounded_bg_margin_bottom.value, - "line_spacing": cfg.rounded_bg_line_spacing.value, - "letter_spacing": cfg.rounded_bg_letter_spacing.value, - } + return TaskFactory._builder().get_rounded_style() + + @staticmethod + def new_task_dir(source: str, task_type: str) -> str: + """流水线开始时创建一次任务目录,跨阶段复用,由流程所有者清理。 + + task_type 按功能归类(output_paths.TASK_*:transcribe/synthesis/batch/dubbing), + 决定落到 ``{work_dir}/{task_type}/`` 下哪个子目录。 + """ + return TaskFactory._builder().new_task_dir(source, task_type) @staticmethod def create_transcribe_task( file_path: str, need_next_task: bool = False, task_id: Optional[str] = None, - ) -> TranscribeTask: - """创建转录任务""" - # 获取文件名 - file_name = Path(file_path).stem - - # 构建输出路径 - if need_next_task: - need_word_time_stamp = cfg.need_split.value - output_path = str( - Path(cfg.work_dir.value) - / file_name - / "subtitle" - / f"【原始字幕】{file_name}-{cfg.transcribe_model.value.value}-{cfg.transcribe_language.value.value}.srt" - ) - else: - need_word_time_stamp = False - output_path = str(Path(file_path).parent / f"{file_name}.srt") - - config = TranscribeConfig( - transcribe_model=cfg.transcribe_model.value, - transcribe_language=LANGUAGES[cfg.transcribe_language.value.value], - need_word_time_stamp=need_word_time_stamp, - output_format=cfg.transcribe_output_format.value, - # Whisper Cpp 配置 - whisper_model=cfg.whisper_model.value, - # Whisper API 配置 - whisper_api_key=cfg.whisper_api_key.value, - whisper_api_base=cfg.whisper_api_base.value, - whisper_api_model=cfg.whisper_api_model.value, - whisper_api_prompt=cfg.whisper_api_prompt.value, - # Faster Whisper 配置 - faster_whisper_program=cfg.faster_whisper_program.value, - faster_whisper_model=cfg.faster_whisper_model.value, - faster_whisper_model_dir=str(MODEL_PATH), - faster_whisper_device=cfg.faster_whisper_device.value, - faster_whisper_vad_filter=cfg.faster_whisper_vad_filter.value, - faster_whisper_vad_threshold=cfg.faster_whisper_vad_threshold.value, - faster_whisper_vad_method=cfg.faster_whisper_vad_method.value, - faster_whisper_ff_mdx_kim2=cfg.faster_whisper_ff_mdx_kim2.value, - faster_whisper_one_word=cfg.faster_whisper_one_word.value, - faster_whisper_prompt=cfg.faster_whisper_prompt.value, - ) - - task = TranscribeTask( - queued_at=datetime.datetime.now(), - file_path=file_path, - output_path=output_path, - transcribe_config=config, + task_dir: Optional[str] = None, + ): + return TaskFactory._builder().create_transcribe_task( + file_path, need_next_task=need_next_task, + task_id=task_id, + task_dir=task_dir, ) - if task_id: - task.task_id = task_id - return task @staticmethod def create_subtitle_task( @@ -112,98 +54,15 @@ def create_subtitle_task( video_path: Optional[str] = None, need_next_task: bool = False, task_id: Optional[str] = None, - ) -> SubtitleTask: - """创建字幕任务""" - output_name = ( - Path(file_path).stem.replace("【原始字幕】", "").replace("【下载字幕】", "") - ) - # 只在需要翻译时添加翻译服务后缀 - suffix = ( - f"-{cfg.translator_service.value.value}" if cfg.need_translate.value else "" - ) - - if need_next_task: - output_path = str( - Path(file_path).parent / f"【样式字幕】{output_name}{suffix}.ass" - ) - else: - output_path = str( - Path(file_path).parent / f"【字幕】{output_name}{suffix}.srt" - ) - - # 根据当前选择的LLM服务获取对应的配置 - current_service = cfg.llm_service.value - if current_service == LLMServiceEnum.OPENAI: - base_url = cfg.openai_api_base.value - api_key = cfg.openai_api_key.value - llm_model = cfg.openai_model.value - elif current_service == LLMServiceEnum.SILICON_CLOUD: - base_url = cfg.silicon_cloud_api_base.value - api_key = cfg.silicon_cloud_api_key.value - llm_model = cfg.silicon_cloud_model.value - elif current_service == LLMServiceEnum.DEEPSEEK: - base_url = cfg.deepseek_api_base.value - api_key = cfg.deepseek_api_key.value - llm_model = cfg.deepseek_model.value - elif current_service == LLMServiceEnum.OLLAMA: - base_url = cfg.ollama_api_base.value - api_key = cfg.ollama_api_key.value - llm_model = cfg.ollama_model.value - elif current_service == LLMServiceEnum.LM_STUDIO: - base_url = cfg.lm_studio_api_base.value - api_key = cfg.lm_studio_api_key.value - llm_model = cfg.lm_studio_model.value - elif current_service == LLMServiceEnum.GEMINI: - base_url = cfg.gemini_api_base.value - api_key = cfg.gemini_api_key.value - llm_model = cfg.gemini_model.value - elif current_service == LLMServiceEnum.CHATGLM: - base_url = cfg.chatglm_api_base.value - api_key = cfg.chatglm_api_key.value - llm_model = cfg.chatglm_model.value - else: - base_url = "" - api_key = "" - llm_model = "" - - config = SubtitleConfig( - # 翻译配置 - base_url=base_url, - api_key=api_key, - llm_model=llm_model, - deeplx_endpoint=cfg.deeplx_endpoint.value, - # 翻译服务 - translator_service=cfg.translator_service.value, - # 字幕处理 - need_reflect=cfg.need_reflect_translate.value, - need_translate=cfg.need_translate.value, - need_optimize=cfg.need_optimize.value, - thread_num=cfg.thread_num.value, - batch_size=cfg.batch_size.value, - # 字幕布局、样式 - subtitle_layout=cfg.subtitle_layout.value, # Now returns SubtitleLayoutEnum - subtitle_style=TaskFactory.get_ass_style(cfg.subtitle_style_name.value), - # 字幕分割 - max_word_count_cjk=cfg.max_word_count_cjk.value, - max_word_count_english=cfg.max_word_count_english.value, - need_split=cfg.need_split.value, - # 字幕翻译 - target_language=cfg.target_language.value, - # 字幕提示 - custom_prompt_text=cfg.custom_prompt_text.value, - ) - - task = SubtitleTask( - queued_at=datetime.datetime.now(), - subtitle_path=file_path, + task_dir: Optional[str] = None, + ): + return TaskFactory._builder().create_subtitle_task( + file_path, video_path=video_path, - output_path=output_path, - subtitle_config=config, need_next_task=need_next_task, + task_id=task_id, + task_dir=task_dir, ) - if task_id: - task.task_id = task_id - return task @staticmethod def create_synthesis_task( @@ -211,72 +70,36 @@ def create_synthesis_task( subtitle_path: str, need_next_task: bool = False, task_id: Optional[str] = None, - ) -> SynthesisTask: - """创建视频合成任务""" - output_path = str( - Path(video_path).parent / f"【卡卡】{Path(video_path).stem}.mp4" - ) - - # 只有启用样式时才传入样式配置 - use_style = cfg.use_subtitle_style.value - config = SynthesisConfig( - need_video=cfg.need_video.value, - soft_subtitle=cfg.soft_subtitle.value, - render_mode=cfg.subtitle_render_mode.value, - video_quality=cfg.video_quality.value, - subtitle_layout=cfg.subtitle_layout.value, - ass_style=TaskFactory.get_ass_style(cfg.subtitle_style_name.value) if use_style else "", - rounded_style=TaskFactory.get_rounded_style() if use_style else None, - ) - - task = SynthesisTask( - queued_at=datetime.datetime.now(), - video_path=video_path, - subtitle_path=subtitle_path, - output_path=output_path, - synthesis_config=config, + task_dir: Optional[str] = None, + dubbed: bool = False, + ): + return TaskFactory._builder().create_synthesis_task( + video_path, + subtitle_path, need_next_task=need_next_task, + task_id=task_id, + task_dir=task_dir, + dubbed=dubbed, ) - if task_id: - task.task_id = task_id - return task @staticmethod - def create_transcript_and_subtitle_task( - file_path: str, - output_path: Optional[str] = None, - transcribe_config: Optional[TranscribeConfig] = None, - subtitle_config: Optional[SubtitleConfig] = None, - ) -> TranscriptAndSubtitleTask: - """创建转录和字幕任务""" - if output_path is None: - output_path = str( - Path(file_path).parent / f"{Path(file_path).stem}_processed.srt" - ) - - return TranscriptAndSubtitleTask( - queued_at=datetime.datetime.now(), - file_path=file_path, - output_path=output_path, + def create_dubbing_task( + video_path: str, + subtitle_path: str, + output_video_path: Optional[str] = None, + output_audio_path: Optional[str] = None, + task_id: Optional[str] = None, + task_dir: Optional[str] = None, + ): + return TaskFactory._builder().create_dubbing_task( + video_path, + subtitle_path, + output_video_path=output_video_path, + output_audio_path=output_audio_path, + task_id=task_id, + task_dir=task_dir, ) @staticmethod - def create_full_process_task( - file_path: str, - output_path: Optional[str] = None, - transcribe_config: Optional[TranscribeConfig] = None, - subtitle_config: Optional[SubtitleConfig] = None, - synthesis_config: Optional[SynthesisConfig] = None, - ) -> FullProcessTask: - """创建完整处理任务(转录+字幕+合成)""" - if output_path is None: - output_path = str( - Path(file_path).parent - / f"{Path(file_path).stem}_final{Path(file_path).suffix}" - ) - - return FullProcessTask( - queued_at=datetime.datetime.now(), - file_path=file_path, - output_path=output_path, - ) + def create_dubbing_ui_config() -> DubbingUIConfig: + return TaskFactory._builder().create_dubbing_ui_config() diff --git a/videocaptioner/ui/thread/artifact_download_thread.py b/videocaptioner/ui/thread/artifact_download_thread.py new file mode 100644 index 00000000..5e028ef3 --- /dev/null +++ b/videocaptioner/ui/thread/artifact_download_thread.py @@ -0,0 +1,133 @@ +"""工件下载线程:模型(多文件)与运行程序(单文件)共用一个线程类。 + +线程只负责把 core 下载器跑在后台并翻译进度为 +``progress(percent, message)``;message 形如 ``model.bin · 760.4 MB / 1.6 GB``, +与模型管理弹窗下载行的副文案约定一致。 +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from PyQt5.QtCore import pyqtSignal + +from videocaptioner.core.download import ( + DownloadCancelled, + ModelFile, + ModelSpec, + download_file, + download_model, +) +from videocaptioner.core.download.dependencies import DependencySpec, install_dependency +from videocaptioner.ui.thread.worker import WorkerCancelled, WorkerThread + +# job(progress_cb, cancel_cb) -> 落盘路径 +_Job = Callable[[Callable[[int, str], None], Callable[[], bool]], str] + + +class ArtifactDownloadThread(WorkerThread): + """completed(str) 为落盘路径;取消静默结束(.part 保留续传)。""" + + completed = pyqtSignal(str) + + def __init__(self, job: _Job, parent=None): + super().__init__(parent) + self._job = job + + def _work(self) -> None: + try: + result = self._job(self.progress.emit, self.is_cancel_requested) + except DownloadCancelled as exc: + raise WorkerCancelled from exc + self.checkpoint() + self.completed.emit(str(result)) + + +def model_download_thread( + spec: ModelSpec, models_dir: Path, parent=None +) -> ArtifactDownloadThread: + def job(report, should_cancel) -> str: + def on_progress(event): + if event.total_bytes: + percent = int(event.total_received * 100 / event.total_bytes) + position = f"{_size_text(event.total_received)} / {_size_text(event.total_bytes)}" + elif event.file.total: + percent = int(event.file.received * 100 / event.file.total) + position = f"{_size_text(event.file.received)} / {_size_text(event.file.total)}" + else: + percent = -1 + position = _size_text(event.file.received) + report(percent, f"{event.file.file_name} · {position}") + + return str( + download_model( + spec, models_dir, on_progress=on_progress, should_cancel=should_cancel + ) + ) + + return ArtifactDownloadThread(job, parent) + + +def program_download_thread( + file: ModelFile, bin_dir: Path, parent=None +) -> ArtifactDownloadThread: + def job(report, should_cancel) -> str: + dest = Path(bin_dir) / file.name + + def on_progress(progress): + total = progress.total or file.size_bytes + if total: + percent = int(progress.received * 100 / total) + position = f"{_size_text(progress.received)} / {_size_text(total)}" + else: + percent = -1 + position = _size_text(progress.received) + report(percent, f"{progress.file_name} · {position}") + + download_file( + file.urls, + dest, + sha1=file.sha1, + on_progress=on_progress, + should_cancel=should_cancel, + ) + return str(dest) + + return ArtifactDownloadThread(job, parent) + + +def dependency_download_thread( + spec: DependencySpec, parent=None +) -> ArtifactDownloadThread: + """下载并安装一个运行依赖(ffmpeg / voxgate):下载→解压→落 BIN_PATH。""" + + def job(report, should_cancel) -> str: + def on_progress(progress): + total = progress.total + if total: + percent = int(progress.received * 100 / total) + position = f"{_size_text(progress.received)} / {_size_text(total)}" + else: + percent = -1 + position = _size_text(progress.received) + report(percent, f"{progress.file_name} · {position}") + + def on_phase(message: str): + report(-1, message) # 解压等无字节进度的阶段,只刷文案 + + path = install_dependency( + spec, + on_progress=on_progress, + on_phase=on_phase, + should_cancel=should_cancel, + ) + return str(path) + + return ArtifactDownloadThread(job, parent) + + +def _size_text(num_bytes: int) -> str: + if num_bytes >= 1_000_000_000: + return f"{num_bytes / 1_000_000_000:.1f} GB" + return f"{num_bytes / 1_000_000:.1f} MB" diff --git a/videocaptioner/ui/thread/batch_process_thread.py b/videocaptioner/ui/thread/batch_process_thread.py deleted file mode 100644 index 7ed118d8..00000000 --- a/videocaptioner/ui/thread/batch_process_thread.py +++ /dev/null @@ -1,334 +0,0 @@ -import queue -import time -from functools import partial -from typing import Dict, Optional - -from PyQt5.QtCore import QThread, pyqtSignal - -from videocaptioner.core.entities import ( - BatchTaskStatus, - BatchTaskType, - TranscribeTask, -) -from videocaptioner.core.utils.logger import setup_logger -from videocaptioner.ui.task_factory import TaskFactory -from videocaptioner.ui.thread.subtitle_thread import SubtitleThread -from videocaptioner.ui.thread.transcript_thread import TranscriptThread -from videocaptioner.ui.thread.video_synthesis_thread import VideoSynthesisThread - -logger = setup_logger("batch_process_thread") - - -class BatchTask: - def __init__(self, file_path: str, task_type: BatchTaskType): - self.file_path = file_path - self.task_type = task_type - self.status = BatchTaskStatus.WAITING - self.progress = 0 - self.error_message = "" - self.current_thread: Optional[QThread] = None - - -class BatchProcessThread(QThread): - # 信号定义 - task_progress = pyqtSignal(str, int, str) # file_path, progress, status - task_error = pyqtSignal(str, str) # file_path, error_message - task_completed = pyqtSignal(str) # file_path - - def __init__(self): - super().__init__() - self.task_queue = queue.Queue() - self.current_tasks: Dict[str, BatchTask] = {} - self.max_concurrent_tasks = 1 - self.is_running = False - self.factory = TaskFactory() - self.threads = [] # 保存所有创建的线程 - - def add_task(self, task: BatchTask): - self.task_queue.put(task) - self.current_tasks[task.file_path] = task - if not self.isRunning(): - self.is_running = True - self.start() - - def run(self): - while self.is_running: - # 检查是否有正在运行的任务数量是否达到上限 - running_tasks = sum( - 1 - for task in self.current_tasks.values() - if task.status == BatchTaskStatus.RUNNING - ) - - if running_tasks < self.max_concurrent_tasks: - try: - # 非阻塞方式获取任务 - task = self.task_queue.get_nowait() - self._process_task(task) - except queue.Empty: - time.sleep(0.1) # 避免CPU过度使用 - else: - time.sleep(0.1) - - def _process_task(self, batch_task: BatchTask): - try: - batch_task.status = BatchTaskStatus.RUNNING - self.task_progress.emit( - batch_task.file_path, 0, str(BatchTaskStatus.RUNNING) - ) - - if batch_task.task_type == BatchTaskType.TRANSCRIBE: - self._handle_transcribe_task(batch_task) - elif batch_task.task_type == BatchTaskType.SUBTITLE: - self._handle_subtitle_task(batch_task) - elif batch_task.task_type == BatchTaskType.TRANS_SUB: - self._handle_trans_sub_task(batch_task) - elif batch_task.task_type == BatchTaskType.FULL_PROCESS: - self._handle_full_process_task(batch_task) - - except Exception as e: - logger.exception(f"处理任务失败: {str(e)}") - batch_task.status = BatchTaskStatus.FAILED - batch_task.error_message = str(e) - self.task_error.emit(batch_task.file_path, str(e)) - - def _on_progress_wrapper(self, batch_task: BatchTask, progress: int, message: str): - """进度信号包装器""" - self.task_progress.emit(batch_task.file_path, progress, message) - - def _on_error_wrapper(self, batch_task: BatchTask, error: str): - """错误信号包装器""" - batch_task.status = BatchTaskStatus.FAILED - batch_task.error_message = error - self.task_error.emit(batch_task.file_path, error) - - def _on_finished_wrapper(self, batch_task: BatchTask, task=None): - """完成信号包装器""" - batch_task.status = BatchTaskStatus.COMPLETED - batch_task.progress = 100 - self.task_completed.emit(batch_task.file_path) - if batch_task.current_thread in self.threads: - self.threads.remove(batch_task.current_thread) - - def _handle_transcribe_task(self, batch_task: BatchTask): - # self.max_concurrent_tasks = 3 - task = self.factory.create_transcribe_task(batch_task.file_path) - thread = TranscriptThread(task) - batch_task.current_thread = thread - - # 保存线程引用 - self.threads.append(thread) - - thread.progress.connect( # type: ignore - partial(self._on_progress_wrapper, batch_task) # type: ignore - ) - thread.error.connect( # type: ignore - partial(self._on_error_wrapper, batch_task) # type: ignore - ) - thread.finished.connect( # type: ignore - partial(self._on_finished_wrapper, batch_task) # type: ignore - ) - - thread.start() - - def _handle_subtitle_task(self, batch_task: BatchTask): - logger.info(f"开始处理字幕任务: {batch_task.file_path}") - - task = self.factory.create_subtitle_task(batch_task.file_path) - thread = SubtitleThread(task) - batch_task.current_thread = thread - - # 保存线程引用 - self.threads.append(thread) - - thread.progress.connect( # type: ignore - partial(self._on_progress_wrapper, batch_task) # type: ignore - ) - thread.error.connect( # type: ignore - partial(self._on_error_wrapper, batch_task) # type: ignore - ) - thread.finished.connect( # type: ignore - partial(self._on_finished_wrapper, batch_task) # type: ignore - ) - - thread.start() - - def _handle_trans_sub_task(self, batch_task: BatchTask): - trans_task = self.factory.create_transcribe_task( - batch_task.file_path, need_next_task=True - ) - thread = TranscriptThread(trans_task) - batch_task.current_thread = thread - self.current_tasks[batch_task.file_path] = batch_task - - # 保存线程引用 - self.threads.append(thread) - - thread.progress.connect( - partial(self._on_trans_sub_progress_wrapper, batch_task) - ) - thread.error.connect(partial(self._on_error_wrapper, batch_task)) - thread.finished.connect( - partial(self._on_trans_sub_finished_wrapper, batch_task) - ) - - thread.start() - - def _on_trans_sub_progress_wrapper( - self, batch_task: BatchTask, progress: int, message: str - ): - """转录+字幕任务进度包装器""" - progress = progress // 2 # 转录占50%进度 - self.task_progress.emit(batch_task.file_path, progress, message) - - def _on_trans_sub_finished_wrapper( - self, batch_task: BatchTask, task: TranscribeTask - ): - """转录+字幕任务转录完成包装器""" - if batch_task.current_thread in self.threads: - self.threads.remove(batch_task.current_thread) - - # 创建字幕任务 - if not task.output_path: - raise ValueError("Task output_path is None") - subtitle_task = self.factory.create_subtitle_task( - task.output_path, batch_task.file_path, need_next_task=True - ) - thread = SubtitleThread(subtitle_task) - batch_task.current_thread = thread - self.current_tasks[batch_task.file_path] = batch_task - - # 保存线程引用 - self.threads.append(thread) - - thread.progress.connect( - partial(self._on_trans_sub_subtitle_progress_wrapper, batch_task) - ) - thread.error.connect(partial(self._on_error_wrapper, batch_task)) - thread.finished.connect(partial(self._on_finished_wrapper, batch_task)) - - thread.start() - - def _on_trans_sub_subtitle_progress_wrapper( - self, batch_task: BatchTask, progress: int, message: str - ): - """转录+字幕任务字幕进度包装器""" - progress = 50 + progress // 2 # 字幕处理占后50%进度 - self.task_progress.emit(batch_task.file_path, progress, message) - - def _handle_full_process_task(self, batch_task: BatchTask): - # 首先创建转录任务 - trans_task = self.factory.create_transcribe_task( - batch_task.file_path, need_next_task=True - ) - thread = TranscriptThread(trans_task) - batch_task.current_thread = thread - - # 保存线程引用 - self.threads.append(thread) - - thread.progress.connect(partial(self.on_full_process_progress, batch_task)) - thread.error.connect(partial(self._on_error_wrapper, batch_task)) - thread.finished.connect(partial(self.on_full_process_finished, batch_task)) - - thread.start() - - def on_full_process_progress( - self, batch_task: BatchTask, progress: int, message: str - ): - """处理全流程任务的转录进度""" - if batch_task.status == BatchTaskStatus.RUNNING: - progress_value = progress // 3 # 转录占33%进度 - self.task_progress.emit(batch_task.file_path, progress_value, message) - - def on_full_process_finished(self, batch_task: BatchTask, task: TranscribeTask): - """处理转录完成后开始字幕任务""" - if batch_task.current_thread in self.threads: - self.threads.remove(batch_task.current_thread) - - # 转录完成后创建字幕任务 - if not task.output_path: - raise ValueError("Task output_path is None") - subtitle_task = self.factory.create_subtitle_task( - task.output_path, - batch_task.file_path, - need_next_task=True, - ) - thread = SubtitleThread(subtitle_task) - batch_task.current_thread = thread - - # 保存线程引用 - self.threads.append(thread) - - thread.progress.connect( - partial(self.on_full_process_subtitle_progress, batch_task) - ) - thread.error.connect(partial(self._on_error_wrapper, batch_task)) - thread.finished.connect( - partial(self.on_full_process_subtitle_finished, batch_task) - ) - - thread.start() - - def on_full_process_subtitle_progress( - self, batch_task: BatchTask, progress: int, message: str - ): - """处理全流程任务中字幕部分的进度""" - if batch_task.status == BatchTaskStatus.RUNNING: - progress_value = 33 + progress // 3 # 字幕处理占中间33%进度 - self.task_progress.emit(batch_task.file_path, progress_value, message) - - def on_full_process_subtitle_finished( - self, batch_task: BatchTask, video_path: str, subtitle_path: str - ): - """处理字幕完成后开始视频合成任务""" - if batch_task.current_thread in self.threads: - self.threads.remove(batch_task.current_thread) - - # 字幕完成后创建视频合成任务 - synthesis_task = self.factory.create_synthesis_task(video_path, subtitle_path) - thread = VideoSynthesisThread(synthesis_task) - batch_task.current_thread = thread - - # 保存线程引用 - self.threads.append(thread) - - thread.progress.connect( - partial(self.on_full_process_synthesis_progress, batch_task) - ) - thread.error.connect(partial(self._on_error_wrapper, batch_task)) - thread.finished.connect(partial(self._on_finished_wrapper, batch_task)) - - thread.start() - - def on_full_process_synthesis_progress( - self, batch_task: BatchTask, progress: int, message: str - ): - """处理全流程任务中视频合成部分的进度""" - if batch_task.status == BatchTaskStatus.RUNNING: - progress_value = 66 + progress // 3 # 视频合成占最后34%进度 - self.task_progress.emit(batch_task.file_path, progress_value, message) - - def stop_task(self, file_path: str): - if file_path in self.current_tasks: - task = self.current_tasks[file_path] - if task.current_thread: - if hasattr(task.current_thread, "stop"): - task.current_thread.stop() # type: ignore - del self.current_tasks[file_path] - # 从队列中移除任务 - with self.task_queue.mutex: - self.task_queue.queue.clear() - - def stop_all(self): - self.is_running = False - # 停止所有线程 - for thread in self.threads: - if hasattr(thread, "stop"): - thread.stop() # type: ignore - thread.wait() # 等待线程结束 - self.threads.clear() - self.current_tasks.clear() - # 清空任务队列 - with self.task_queue.mutex: - self.task_queue.queue.clear() diff --git a/videocaptioner/ui/thread/dubbing_thread.py b/videocaptioner/ui/thread/dubbing_thread.py new file mode 100644 index 00000000..5d51aac3 --- /dev/null +++ b/videocaptioner/ui/thread/dubbing_thread.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""配音线程:按字幕生成配音音轨(可选合入视频)。 + +基于 WorkerThread:管线进度回调里有取消检查点,stop() 协作中止。 +中间产物(拟合分段、report.json)写进任务目录的 dubbing/ 子目录, +由流程所有者(控制器/JobRunner)在收尾时统一清理。 +""" + +from __future__ import annotations + +import datetime +from pathlib import Path + +from PyQt5.QtCore import pyqtSignal + +from videocaptioner.core.application import output_paths +from videocaptioner.core.dubbing import DubbingPipeline, SpeakerProfile, build_dubbing_config +from videocaptioner.core.entities import DubbingTask +from videocaptioner.core.utils.logger import setup_logger +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.worker import WorkerThread + +logger = setup_logger("dubbing_thread") + + +class DubbingThread(WorkerThread): + finished = pyqtSignal(DubbingTask) + + def __init__(self, task: DubbingTask): + super().__init__() + self.task = task + + def _work(self): + self.task.started_at = datetime.datetime.now() + config = self.task.dubbing_config + if config is None: + raise ValueError(tr("t_dubbing.error.config_empty")) + if not self.task.subtitle_path: + raise ValueError(tr("t_dubbing.error.subtitle_path_empty")) + if not self.task.output_audio_path: + raise ValueError(tr("t_dubbing.error.output_audio_path_empty")) + if not self.task.task_dir: + raise ValueError(tr("t_dubbing.error.task_dir_empty")) + + logger.info("\n%s", config.print_config()) + self.progress.emit(2, tr("t_dubbing.status.preparing")) + + speaker_profiles = { + name: SpeakerProfile(name=name, voice=voice) + for name, voice in config.speaker_voices.items() + if voice + } + if config.clone_audio_path: + speaker_profiles["default"] = SpeakerProfile( + name="default", + clone_audio_path=config.clone_audio_path, + clone_audio_text=config.clone_audio_text, + ) + + core_config = build_dubbing_config( + provider=config.provider, + preset=config.preset, + api_key=config.api_key, + api_base=config.api_base, + model=config.model, + voice=config.voice, + timing=config.timing, + audio_mode=config.audio_mode, + tts_workers=config.tts_workers, + use_cache=config.use_cache, + speaker_profiles=speaker_profiles, + ) + + self.checkpoint() + + result = DubbingPipeline(core_config).run( + self.task.subtitle_path, + self.task.output_audio_path, + work_dir=str(Path(self.task.task_dir) / output_paths.DUBBING_DIR), + video_path=self.task.video_path or None, + output_video_path=self.task.output_video_path or None, + text_track=config.text_track, + callback=self._progress_callback, + ) + + self.task.output_audio_path = str(result.audio_path) + self.task.output_video_path = ( + str(result.video_path) if result.video_path else None + ) + self.task.completed_at = datetime.datetime.now() + self.progress.emit(100, tr("t_dubbing.status.done")) + self.finished.emit(self.task) + + def _progress_callback(self, value: int, message: str): + self.checkpoint() + self.progress.emit(value, message) diff --git a/videocaptioner/ui/thread/feedback_thread.py b/videocaptioner/ui/thread/feedback_thread.py new file mode 100644 index 00000000..d25cb7f7 --- /dev/null +++ b/videocaptioner/ui/thread/feedback_thread.py @@ -0,0 +1,33 @@ +"""反馈提交的 Qt 线程薄壳;业务在 core/feedback,UI 只消费信号。""" + +from __future__ import annotations + +from PyQt5.QtCore import QThread, pyqtSignal + +from videocaptioner.core.feedback import FeedbackClient, FeedbackReport +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("feedback_thread") + + +class FeedbackSubmitThread(QThread): + """后台 multipart 提交反馈。结果回 Qt 信号(队列到 GUI 线程)。""" + + succeeded = pyqtSignal(str) # feedback id + failed = pyqtSignal(str, str) # (code, error) + + def __init__(self, report: FeedbackReport, parent=None): + super().__init__(parent) + self._report = report + + def run(self) -> None: + try: + result = FeedbackClient().submit(self._report) + except Exception as exc: # noqa: BLE001 — 提交失败不该崩,回失败信号 + logger.warning("feedback submit error: %s", exc) + self.failed.emit("server_error", str(exc)) + return + if result.ok: + self.succeeded.emit(result.id) + else: + self.failed.emit(result.code, result.error) diff --git a/videocaptioner/ui/thread/file_download_thread.py b/videocaptioner/ui/thread/file_download_thread.py deleted file mode 100644 index dbbc6fab..00000000 --- a/videocaptioner/ui/thread/file_download_thread.py +++ /dev/null @@ -1,230 +0,0 @@ -import shutil -import subprocess -from abc import ABC, abstractmethod -from pathlib import Path - -import requests -from PyQt5.QtCore import QThread, pyqtSignal - -from videocaptioner.config import CACHE_PATH -from videocaptioner.core.utils.logger import setup_logger -from videocaptioner.core.utils.platform_utils import get_subprocess_kwargs - -logger = setup_logger("download_thread") - - -class BaseDownloader(ABC): - """下载器基类""" - - def __init__(self, url: str, save_path: Path, progress_callback): - self.url = url - self.save_path = save_path - self.progress_callback = progress_callback - self._cancelled = False - - @abstractmethod - def download(self) -> bool: - """执行下载,返回是否成功""" - pass - - def cancel(self): - """取消下载""" - self._cancelled = True - - -class Aria2Downloader(BaseDownloader): - """aria2c 多线程下载器""" - - def __init__(self, url: str, save_path: Path, progress_callback): - super().__init__(url, save_path, progress_callback) - self.process = None - - @staticmethod - def is_available() -> bool: - """检查 aria2c 是否可用""" - return shutil.which("aria2c") is not None - - def download(self) -> bool: - temp_dir = CACHE_PATH / "download_cache" - temp_dir.mkdir(parents=True, exist_ok=True) - temp_file = temp_dir / self.save_path.name - - cmd = [ - "aria2c", - "--no-conf", - "--show-console-readout=false", - "--summary-interval=1", - "--max-connection-per-server=2", - "--split=2", - "--connect-timeout=10", - "--timeout=10", - "--max-tries=2", - "--retry-wait=1", - "--continue=true", - "--auto-file-renaming=false", - "--allow-overwrite=true", - "--check-certificate=false", - f"--dir={temp_dir}", - f"--out={temp_file.name}", - self.url, - ] - - subprocess_args = { - "stdout": subprocess.PIPE, - "stderr": subprocess.PIPE, - "universal_newlines": True, - "encoding": "utf-8", - **get_subprocess_kwargs(), - } - - logger.info(f"使用 aria2c 下载: {self.url}") - self.process = subprocess.Popen(cmd, **subprocess_args) - - while True: - if self._cancelled: - self.process.terminate() - return False - - if self.process.poll() is not None: - break - - line = self.process.stdout.readline() - self._parse_progress(line) - - if self.process.returncode == 0: - self.save_path.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(temp_file), self.save_path) - return True - else: - error = self.process.stderr.read() - logger.error(f"aria2c 下载失败: {error}") - return False - - def _parse_progress(self, line: str): - """解析 aria2c 输出格式: [#40ca1b 2.4MiB/74MiB(3%) CN:2 DL:3.9MiB ETA:18s]""" - if "[#" not in line or "]" not in line: - return - - try: - progress_part = line.split("(")[1].split(")")[0] - percent = float(progress_part.strip("%")) - - speed = "0" - eta = "" - if "DL:" in line: - speed = line.split("DL:")[1].split()[0] - if "ETA:" in line: - eta = line.split("ETA:")[1].split("]")[0] - - status = f"速度: {speed}/s, 剩余: {eta}" - self.progress_callback(percent, status) - except Exception: - pass - - def cancel(self): - super().cancel() - if self.process: - self.process.terminate() - self.process.wait() - - -class RequestsDownloader(BaseDownloader): - """Python requests 下载器(回退方案)""" - - CHUNK_SIZE = 8192 - - def download(self) -> bool: - logger.info(f"使用 requests 下载: {self.url}") - self.progress_callback(0, "正在连接...") - - try: - response = requests.get(self.url, stream=True, timeout=30) - response.raise_for_status() - - total_size = int(response.headers.get("content-length", 0)) - downloaded = 0 - - self.save_path.parent.mkdir(parents=True, exist_ok=True) - temp_file = self.save_path.with_suffix(".tmp") - - with open(temp_file, "wb") as f: - for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE): - if self._cancelled: - temp_file.unlink(missing_ok=True) - return False - - f.write(chunk) - downloaded += len(chunk) - - if total_size > 0: - percent = (downloaded / total_size) * 100 - speed = self._format_size(downloaded) - status = f"已下载: {speed} / {self._format_size(total_size)}" - self.progress_callback(percent, status) - - # 下载完成后重命名 - shutil.move(str(temp_file), self.save_path) - return True - - except requests.RequestException as e: - logger.error(f"requests 下载失败: {e}") - return False - - @staticmethod - def _format_size(bytes_size: int) -> str: - """格式化文件大小""" - size = float(bytes_size) - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024: - return f"{size:.1f}{unit}" - size /= 1024 - return f"{size:.1f}TB" - - -class FileDownloadThread(QThread): - """文件下载线程""" - - progress = pyqtSignal(float, str) - finished = pyqtSignal() - error = pyqtSignal(str) - - def __init__(self, url: str, save_path: str): - super().__init__() - self.url = url - self.save_path = Path(save_path) - self.downloader: BaseDownloader | None = None - - def run(self): - try: - self.progress.emit(0, self.tr("正在连接...")) - - # 选择下载器:优先 aria2c,否则回退到 requests - if Aria2Downloader.is_available(): - self.downloader = Aria2Downloader( - self.url, self.save_path, self._on_progress - ) - else: - logger.info("aria2c 不可用,使用 requests 下载") - self.downloader = RequestsDownloader( - self.url, self.save_path, self._on_progress - ) - - success = self.downloader.download() - - if success: - self.finished.emit() - else: - self.error.emit(self.tr("下载失败")) - - except Exception as e: - logger.exception("下载异常") - self.error.emit(str(e)) - - def _on_progress(self, percent: float, status: str): - """进度回调""" - self.progress.emit(percent, status) - - def stop(self): - """停止下载""" - if self.downloader: - self.downloader.cancel() diff --git a/videocaptioner/ui/thread/hardsub_thread.py b/videocaptioner/ui/thread/hardsub_thread.py new file mode 100644 index 00000000..a8b5f393 --- /dev/null +++ b/videocaptioner/ui/thread/hardsub_thread.py @@ -0,0 +1,109 @@ +"""硬字幕提取的 QThread 薄壳:core 耗时操作搬离 GUI 线程,结果经 Qt 信号回传。 + +- :class:`RegionDetectThread` → ``detected(RegionResult|None)``; +- :class:`HardsubExtractThread` → ``cue_ready`` 流式 + ``finished(ASRData)``。 +core 回调在工作线程,只能经 Qt 信号(queued)回 GUI 线程碰控件;取消保留已识别结果。 +""" + +from __future__ import annotations + +from typing import Optional + +from PyQt5.QtCore import pyqtSignal + +from videocaptioner.core.hardsub.config import HardsubConfig +from videocaptioner.core.ocr.base import OcrEngine +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.worker import WorkerThread + + +class PrepareThread(WorkerThread): + """载入视频的耗时探测放后台:ffmpeg 取尺寸/时长 + 抽首帧,避免拖入时 GUI 卡死。""" + + ready = pyqtSignal(int, int, float, object) # width, height, duration, first_frame_rgb|None + + def __init__(self, video_path: str, parent=None) -> None: + super().__init__(parent) + self._video_path = video_path + + def _work(self) -> None: + from videocaptioner.core.hardsub.frames import grab_frame, probe_dimensions + + dims = probe_dimensions(self._video_path) + self.checkpoint() + if dims is None: + self.error.emit(tr("t_hardsub.error.read_video_failed")) + return + width, height, _, duration = dims + frame = grab_frame(self._video_path, max(0.5, duration * 0.1), max_width=1280) + self.checkpoint() + self.ready.emit(width, height, duration, frame) + + +class RegionDetectThread(WorkerThread): + """自动检测字幕区域(采样若干帧跑文本检测 + 时间稳定性投票)。""" + + detected = pyqtSignal(object) # RegionResult(roi, font_height) 或 None + + def __init__( + self, video_path: str, engine: OcrEngine, sample_count: int = 20, parent=None + ) -> None: + super().__init__(parent) + self._video_path = video_path + self._engine = engine + self._sample_count = sample_count + + def _work(self) -> None: + from videocaptioner.core.hardsub.region import detect_subtitle_region + + self.progress.emit(0, tr("t_hardsub.status.detecting_region")) + result = detect_subtitle_region( + self._video_path, self._engine, self._sample_count, + on_progress=lambda p: self.progress.emit(p, tr("t_hardsub.status.detecting_region")), + ) + self.checkpoint() + self.detected.emit(result) + + +class HardsubExtractThread(WorkerThread): + """提取硬字幕:流式产出每条字幕,结束发出完整 :class:`ASRData`。""" + + cue_ready = pyqtSignal(object) # HardsubCue + finished = pyqtSignal(object) # ASRData(取消时为已识别的部分结果) + + def __init__(self, config: HardsubConfig, engine: OcrEngine, parent=None) -> None: + super().__init__(parent) + self._config = config + self._engine = engine + + def _work(self) -> None: + from videocaptioner.core.hardsub.pipeline import extract_hardsub + + data = extract_hardsub( + self._config, + self._engine, + on_cue=lambda cue: self.cue_ready.emit(cue), + on_progress=lambda p: self.progress.emit( + p.percent, tr("t_hardsub.status.recognizing", count=p.cue_count) + ), + should_cancel=self.is_cancel_requested, + ) + # 取消也照常发 finished:已识别的部分保留给用户,不丢弃。 + self.finished.emit(data) + + +def make_engine(config: HardsubConfig, det_limit_side_len: int = 960) -> OcrEngine: + """按配置构造 OCR 引擎(廉价,模型在引擎首次推理时才加载——放到线程里)。""" + from videocaptioner.core.ocr.rapid import create_rapidocr + + return create_rapidocr( + lang=config.lang, ocr_version=config.ocr_version, model_type=config.model_type, + det_limit_side_len=det_limit_side_len, + ) + + +def ocr_ready() -> Optional[str]: + """OCR 依赖是否就绪;就绪返回 None,否则返回缺失原因。""" + from videocaptioner.core.ocr.rapid import ocr_dependency_ready + + return ocr_dependency_ready() diff --git a/videocaptioner/ui/thread/live_caption_thread.py b/videocaptioner/ui/thread/live_caption_thread.py new file mode 100644 index 00000000..a9d359e3 --- /dev/null +++ b/videocaptioner/ui/thread/live_caption_thread.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""实时字幕转录流线程:core 层 :class:`LiveCaptionSession` 的 Qt 薄壳。 + +core 无 Qt 依赖;本线程只把会话的回调桥接成 Qt 信号,并把 ``WorkerThread`` 的 +协作取消接到音频泵。识别/翻译事件可能来自后端接收线程或翻译线程,经 Qt 信号 +自动跨线程投递到 GUI 线程。 +""" + +from __future__ import annotations + +from typing import Optional + +from PyQt5.QtCore import pyqtSignal + +from videocaptioner.core.realtime.backends.base import LiveCaptionError +from videocaptioner.core.realtime.config import LiveCaptionConfig +from videocaptioner.core.realtime.events import CaptionEntry +from videocaptioner.core.realtime.recording.history import LiveCaptionStore +from videocaptioner.core.realtime.session import LiveCaptionSession +from videocaptioner.ui.thread.worker import WorkerThread + + +class LiveCaptionThread(WorkerThread): + """驱动一次实时字幕会话。""" + + # 一条字幕(新增/更新/定稿/译文回填),UI 按 entry.seg_id upsert + caption = pyqtSignal(object) # CaptionEntry + # 后端连接状态变化(TranscriberState 的字符串值) + stateChanged = pyqtSignal(str) + # 会话结束并存盘后发出已保存的 LiveCaptionRecord(无内容则不发) + recorded = pyqtSignal(object) + # 实时拾音电平(0~1),驱动会话页波形/「有没有听到声音」指示 + level = pyqtSignal(float) + + def __init__( + self, config: LiveCaptionConfig, store: Optional[LiveCaptionStore] = None, parent=None + ) -> None: + super().__init__(parent) + self._config = config + self._store = store # 录制存盘用,与宿主历史列表共用同一 root(工作目录下) + self._session: Optional[LiveCaptionSession] = None + + def _work(self) -> None: + session = LiveCaptionSession( + self._config, + on_caption=self._emit_caption, + on_state=lambda s: self.stateChanged.emit(s.value), + on_record=self.recorded.emit, + on_level=self.level.emit, + store=self._store, + ) + self._session = session + session.start() + try: + session.pump(self.checkpoint) # 阻塞直到 stop / 取消 / 致命失败 + finally: + session.stop() + # 用户主动停止(取消)时,收尾里产生的错误(如对空缓冲 commit 报「empty buffer」) + # 不是真失败,绝不上抛——否则 error 信号回到槽里抛异常会让 PyQt 直接 abort 整个程序。 + # 只有「非用户取消」的致命失败(如并发配额满)才作为终态上抛、弹一条提示。 + if session.fatal_error and not self.is_cancel_requested(): + raise LiveCaptionError(session.fatal_error) + + def stop(self, wait_ms: int = 22000) -> None: + # 退出时 closeEvent 走阻塞 stop()。会话收尾是串行的、预算大(音频 ~5s + voxgate EOF + # 冲刷末句 ~10s + 翻译 drain ~6s),基类默认 3s 会在收尾半途 terminate() 硬杀 + # (WAV 未关、记录未存)。给足等待,terminate 只作真卡死的最后兜底。 + super().stop(wait_ms=wait_ms) + + def _on_cancel(self) -> None: + # 非阻塞:只让 pump 尽快退出(request_stop 不做收尾);真正的链路收尾在 + # _work 的 finally 里、跑在本线程上,绝不阻塞调用方(GUI)线程。 + session = self._session + if session is not None: + session.request_stop() + + def set_paused(self, paused: bool) -> None: + """暂停/恢复喂音频(GUI 线程调用,线程安全)。""" + session = self._session + if session is not None: + session.set_paused(paused) + + def _emit_caption(self, entry: CaptionEntry) -> None: + self.caption.emit(entry) diff --git a/videocaptioner/ui/thread/media_download_thread.py b/videocaptioner/ui/thread/media_download_thread.py new file mode 100644 index 00000000..9d3ac5fb --- /dev/null +++ b/videocaptioner/ui/thread/media_download_thread.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""在线视频下载线程:core/download/media.py 引擎的 Qt 信号薄壳。 + +下载逻辑(站点回退、浏览器登录态兜底、字幕 sidecar)全部在引擎里, +与 CLI download 共用;这里只做信号映射与协作取消(checkpoint 在 +yt-dlp 数据块边界被引擎回调,取消请求让下载干净退出)。 +""" + +from __future__ import annotations + +from typing import Optional + +from PyQt5.QtCore import pyqtSignal + +from videocaptioner.core.application import output_paths +from videocaptioner.core.download.media import MediaDownloader +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.worker import WorkerThread + + +class MediaDownloadThread(WorkerThread): + """下载在线视频(含可用字幕)到 work_dir/downloads/。 + + finished(video_path, subtitle_path_or_None) + stats(speed_text, eta_text) 下载中实时速度与剩余时间 + """ + + finished = pyqtSignal(str, object) + stats = pyqtSignal(str, str) + media = pyqtSignal(dict) # 解析出的元数据:title/uploader/duration/site + probed = pyqtSignal(dict) # probe 模式的完整解析结果(含清晰度档位) + + def __init__( + self, + url: str, + work_dir: str, + *, + probe_only: bool = False, + max_height: Optional[int] = None, + ): + super().__init__() + self.url = url + self.work_dir = work_dir + self.probe_only = probe_only + self.max_height = max_height + + def _work(self): + downloader = MediaDownloader( + self.url, + None if self.probe_only else str(output_paths.downloads_dir(self.work_dir)), + probe_only=self.probe_only, + max_height=self.max_height, + on_progress=self.progress.emit, + on_stats=self.stats.emit, + on_media=self.media.emit, + on_probed=self.probed.emit, + cancel_check=self.checkpoint, + ) + video_path, subtitle_path = downloader.run() + if self.probe_only: + return + if not video_path: + raise RuntimeError(tr("t_media.error.no_video_file")) + self.progress.emit(100, tr("t_media.status.completed")) + self.finished.emit(video_path, subtitle_path) diff --git a/videocaptioner/ui/thread/modelscope_download_thread.py b/videocaptioner/ui/thread/modelscope_download_thread.py deleted file mode 100644 index bfb3c4d8..00000000 --- a/videocaptioner/ui/thread/modelscope_download_thread.py +++ /dev/null @@ -1,111 +0,0 @@ -import io -import logging -import sys -from typing import Callable - -from modelscope.hub.callback import ProgressCallback -from modelscope.hub.snapshot_download import snapshot_download -from PyQt5.QtCore import QThread, pyqtSignal - - -class SuppressOutput: - """上下文管理器:抑制 stdout/stderr 和 modelscope 日志""" - - def __enter__(self): - self._stdout = sys.stdout - self._stderr = sys.stderr - sys.stdout = io.StringIO() - sys.stderr = io.StringIO() - self._loggers: dict[str, int] = {} - for name in ["modelscope", "tqdm"]: - logger = logging.getLogger(name) - self._loggers[name] = logger.level - logger.setLevel(logging.CRITICAL) - return self - - def __exit__(self, *args): - sys.stdout = self._stdout - sys.stderr = self._stderr - for name, level in self._loggers.items(): - logging.getLogger(name).setLevel(level) - - -def create_progress_callback_class( - progress_callback: Callable[[int, str], None], -) -> type[ProgressCallback]: - """创建一个自定义的 ProgressCallback 类,用于接收下载进度""" - - class CustomProgressCallback(ProgressCallback): - def __init__(self, filename: str, file_size: int): - super().__init__(filename, file_size) - self.downloaded = 0 - - def update(self, size: int): - self.downloaded += size - if self.file_size > 0: - percentage = min(int(self.downloaded * 100 / self.file_size), 99) - progress_callback(percentage, f"{self.filename}: {percentage}%") - - def end(self): - pass - - return CustomProgressCallback - - -class ModelscopeDownloadThread(QThread): - progress = pyqtSignal(int, str) - error = pyqtSignal(str) - - def __init__(self, model_id: str, save_path: str): - super().__init__() - self.model_id = model_id - self.save_path = save_path - - def run(self): - try: - self.progress.emit(0, self.tr("开始下载...")) - - callback_class = create_progress_callback_class(self.progress.emit) - - with SuppressOutput(): - snapshot_download( - self.model_id, - local_dir=self.save_path, - progress_callbacks=[callback_class], - ) - - self.progress.emit(100, self.tr("下载完成")) - - except Exception as e: - self.error.emit(str(e)) - - -if __name__ == "__main__": - import sys - - from PyQt5.QtCore import QCoreApplication - - app = QCoreApplication(sys.argv) - model_id = "pengzhendong/faster-whisper-tiny" - save_path = r"models/faster-whisper-tiny" - downloader = ModelscopeDownloadThread(model_id, save_path) - - def on_progress(percentage, message): - print(f"进度: {message}") - - def on_error(error_msg): - print(f"错误: {error_msg}") - app.quit() - - def on_finished(): - print("下载完成!") - app.quit() - - downloader.progress.connect(on_progress) - downloader.error.connect(on_error) - downloader.finished.connect(on_finished) - - print(f"开始下载模型 {model_id}") - downloader.start() - - sys.exit(app.exec_()) diff --git a/videocaptioner/ui/thread/subtitle_pipeline_thread.py b/videocaptioner/ui/thread/subtitle_pipeline_thread.py deleted file mode 100644 index 45fddf92..00000000 --- a/videocaptioner/ui/thread/subtitle_pipeline_thread.py +++ /dev/null @@ -1,131 +0,0 @@ -import datetime - -from PyQt5.QtCore import QThread, pyqtSignal - -from videocaptioner.core.entities import ( - FullProcessTask, - SubtitleTask, - SynthesisTask, - TranscribeTask, -) -from videocaptioner.core.utils.logger import setup_logger - -from .subtitle_thread import SubtitleThread -from .transcript_thread import TranscriptThread -from .video_synthesis_thread import VideoSynthesisThread - -logger = setup_logger("subtitle_pipeline_thread") - - -class SubtitlePipelineThread(QThread): - """字幕处理全流程线程,包含: - 1. 转录生成字幕 - 2. 字幕优化/翻译 - 3. 视频合成 - """ - - progress = pyqtSignal(int, str) # 进度值, 进度描述 - finished = pyqtSignal(FullProcessTask) - error = pyqtSignal(str) - - def __init__(self, task: FullProcessTask): - super().__init__() - self.task = task - self.has_error = False - - def run(self): - try: - - def handle_error(error_msg): - logger.error("pipeline 发生错误: %s", error_msg) - self.has_error = True - self.error.emit(error_msg) - - # 1. 转录生成字幕 - self.task.started_at = datetime.datetime.now() - logger.info(f"\n{self.task.transcribe_config.print_config()}") - logger.info(f"\n{self.task.subtitle_config.print_config()}") - if self.task.synthesis_config: - logger.info(f"\n{self.task.synthesis_config.print_config()}") - self.progress.emit(0, self.tr("开始转录")) - - # 创建转录任务 - transcribe_task = TranscribeTask( - file_path=self.task.file_path, - transcribe_config=self.task.transcribe_config, - need_next_task=True, - queued_at=self.task.queued_at, - started_at=self.task.started_at, - completed_at=self.task.completed_at, - ) - transcript_thread = TranscriptThread(transcribe_task) - transcript_thread.progress.connect( - lambda value, msg: self.progress.emit(int(value * 0.4), msg) - ) - transcript_thread.error.connect(handle_error) - transcript_thread.run() - - if self.has_error: - logger.info("转录过程中发生错误,终止流程") - return - - # 2. 字幕优化/翻译 - # self.task.status = Task.Status.OPTIMIZING - self.progress.emit(40, self.tr("开始优化字幕")) - - # 创建字幕任务 - subtitle_task = SubtitleTask( - subtitle_path=transcribe_task.output_path or "", - video_path=self.task.file_path, - output_path=self.task.output_path, - subtitle_config=self.task.subtitle_config, - need_next_task=True, - queued_at=self.task.queued_at, - started_at=self.task.started_at, - completed_at=self.task.completed_at, - ) - optimization_thread = SubtitleThread(subtitle_task) - optimization_thread.progress.connect( - lambda value, msg: self.progress.emit(int(40 + value * 0.2), msg) - ) - optimization_thread.error.connect(handle_error) - optimization_thread.run() - - if self.has_error: - logger.info("字幕优化过程中发生错误,终止流程") - return - - # 3. 视频合成 - # self.task.status = Task.Status.GENERATING - self.progress.emit(80, self.tr("开始合成视频")) - - # 创建合成任务 - synthesis_task = SynthesisTask( - video_path=self.task.file_path, - subtitle_path=subtitle_task.output_path, - output_path=self.task.output_path, - synthesis_config=self.task.synthesis_config, - queued_at=self.task.queued_at, - started_at=self.task.started_at, - completed_at=self.task.completed_at, - ) - synthesis_thread = VideoSynthesisThread(synthesis_task) - synthesis_thread.progress.connect( - lambda value, msg: self.progress.emit(int(70 + value * 0.3), msg) - ) - synthesis_thread.error.connect(handle_error) - synthesis_thread.run() - - if self.has_error: - logger.info("视频合成过程中发生错误,终止流程") - return - - # self.task.status = FullProcessTask.Status.COMPLETED # type: ignore - logger.info("处理完成") - self.progress.emit(100, self.tr("处理完成")) - self.finished.emit(self.task) - - except Exception as e: - # self.task.status = FullProcessTask.Status.FAILED # type: ignore - logger.exception("处理失败: %s", str(e)) - self.error.emit(str(e)) diff --git a/videocaptioner/ui/thread/subtitle_thread.py b/videocaptioner/ui/thread/subtitle_thread.py index 30db882f..0878e445 100644 --- a/videocaptioner/ui/thread/subtitle_thread.py +++ b/videocaptioner/ui/thread/subtitle_thread.py @@ -1,9 +1,20 @@ +# -*- coding: utf-8 -*- +"""字幕处理线程:断句 -> 校正 -> 翻译 -> 导出。 + +SubtitleThread 跑完整流水线,RetranslateThread 只翻译选中的行。 +两者都基于 WorkerThread:协作取消(stop() 会停掉正在执行的 +splitter / optimizer / translator),取消的运行静默退出。 +""" + +from __future__ import annotations + import os from pathlib import Path -from typing import List +from typing import List, Optional -from PyQt5.QtCore import QThread, pyqtSignal +from PyQt5.QtCore import pyqtSignal +from videocaptioner.core.application import output_paths from videocaptioner.core.asr.asr_data import ASRData from videocaptioner.core.entities import ( SubtitleConfig, @@ -12,6 +23,7 @@ SubtitleTask, TranslatorServiceEnum, ) +from videocaptioner.core.llm import free_model from videocaptioner.core.llm.check_llm import check_llm_connection from videocaptioner.core.llm.context import ( clear_task_context, @@ -24,15 +36,24 @@ from videocaptioner.core.translate.factory import TranslatorFactory from videocaptioner.core.translate.types import TranslatorType from videocaptioner.core.utils.logger import setup_logger +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.worker import WorkerThread -SERVICE_TO_TYPE = { +logger = setup_logger("subtitle_thread") + +_SERVICE_TO_TYPE = { TranslatorServiceEnum.OPENAI: TranslatorType.OPENAI, TranslatorServiceEnum.GOOGLE: TranslatorType.GOOGLE, TranslatorServiceEnum.BING: TranslatorType.BING, TranslatorServiceEnum.DEEPLX: TranslatorType.DEEPLX, } -logger = setup_logger("subtitle_optimization_thread") +# 不依赖 LLM 的翻译服务 +_NON_LLM_TRANSLATORS = ( + TranslatorServiceEnum.DEEPLX, + TranslatorServiceEnum.BING, + TranslatorServiceEnum.GOOGLE, +) def create_translator_from_config( @@ -40,16 +61,14 @@ def create_translator_from_config( custom_prompt: str = "", callback=None, ): - """根据 SubtitleConfig 创建翻译器""" - translator_service = config.translator_service - if translator_service not in SERVICE_TO_TYPE: - raise ValueError(f"不支持的翻译服务: {translator_service}") - - if translator_service == TranslatorServiceEnum.DEEPLX: + """根据 SubtitleConfig 创建翻译器。""" + service = config.translator_service + if service not in _SERVICE_TO_TYPE: + raise ValueError(tr("t_subtitle.error.unsupported_service", service=service)) + if service == TranslatorServiceEnum.DEEPLX: os.environ["DEEPLX_ENDPOINT"] = config.deeplx_endpoint or "" - return TranslatorFactory.create_translator( - translator_type=SERVICE_TO_TYPE[translator_service], + translator_type=_SERVICE_TO_TYPE[service], thread_num=config.thread_num, batch_num=config.batch_size, target_language=config.target_language, @@ -60,296 +79,299 @@ def create_translator_from_config( ) -class SubtitleThread(QThread): +def _setup_llm_environment(config: SubtitleConfig) -> None: + """验证 LLM 连通性并写入环境变量;失败抛异常。""" + if not (config.base_url and config.api_key and config.llm_model): + raise Exception(tr("t_subtitle.error.llm_not_configured")) + # 公益大模型令牌由 LLM client 实时获取,占位 key 无法预检,跳过 + if not free_model.is_free_base(config.base_url): + success, message = check_llm_connection( + config.base_url, config.api_key, config.llm_model + ) + if not success: + raise Exception(tr("t_subtitle.error.llm_test_failed", message=message or "")) + os.environ["OPENAI_BASE_URL"] = config.base_url + os.environ["OPENAI_API_KEY"] = config.api_key + + +class SubtitleThread(WorkerThread): + """字幕处理流水线线程。 + + 信号: + finished(video_path, output_path) 处理成功 + update(dict) 增量结果({行号: 文本}) + update_all(dict) 全量刷新(断句等结构性变化后) + """ + finished = pyqtSignal(str, str) - progress = pyqtSignal(int, str) update = pyqtSignal(dict) update_all = pyqtSignal(dict) - error = pyqtSignal(str) def __init__(self, task: SubtitleTask): super().__init__() - self.task: SubtitleTask = task - self.subtitle_length = 0 - self.finished_subtitle_length = 0 + self.task = task self.custom_prompt_text = "" - self.optimizer = None + self._total_segments = 0 + self._done_segments = 0 + # 当前正在执行的 core 执行器(splitter/optimizer/translator), + # 取消时调用它的 stop()。 + self._active_worker = None def set_custom_prompt_text(self, text: str): self.custom_prompt_text = text - def _setup_llm_config(self) -> SubtitleConfig: - """验证 LLM 配置并设置环境变量,返回 SubtitleConfig""" - config = self.task.subtitle_config - if not config: - raise Exception(self.tr("LLM API 未配置, 请检查LLM配置")) - if config.base_url and config.api_key and config.llm_model: - success, message = check_llm_connection( - config.base_url, - config.api_key, - config.llm_model, - ) - if not success: - raise Exception(f"{self.tr('LLM API 测试失败: ')}{message or ''}") - os.environ["OPENAI_BASE_URL"] = config.base_url - os.environ["OPENAI_API_KEY"] = config.api_key - return config - else: - raise Exception(self.tr("LLM API 未配置, 请检查LLM配置")) - - def run(self): - # 设置任务上下文 - task_file = ( - Path(self.task.video_path) if self.task.video_path else Path(self.task.subtitle_path) - ) + # ----- WorkerThread 接口 ----- + + def _on_cancel(self): + worker = self._active_worker + if worker is not None and hasattr(worker, "stop"): + worker.stop() + + def _work(self): + task_file = Path(self.task.video_path or self.task.subtitle_path) set_task_context( - task_id=self.task.task_id, - file_name=task_file.name, - stage="subtitle", + task_id=self.task.task_id, file_name=task_file.name, stage="subtitle" ) - try: - logger.info(f"\n{self.task.subtitle_config.print_config()}") - - # 字幕文件路径检查、对断句字幕路径进行定义 - subtitle_path = self.task.subtitle_path - assert subtitle_path is not None, self.tr("字幕文件路径为空") + config = self.task.subtitle_config + if self.task.subtitle_path is None: + raise Exception(tr("t_subtitle.error.no_subtitle_path")) + if config is None: + raise Exception(tr("t_subtitle.error.no_config")) + logger.info("\n%s", config.print_config()) - subtitle_config = self.task.subtitle_config - assert subtitle_config is not None, self.tr("字幕配置为空") + asr_data = ASRData.from_subtitle_file(self.task.subtitle_path) - asr_data = ASRData.from_subtitle_file(subtitle_path) - - # 1. 分割成字词级时间戳(对于非断句字幕且开启分割选项) - if subtitle_config.need_split and not asr_data.is_word_timestamp(): + # 1. 需要断句时先拆成字词级时间戳 + if config.need_split and not asr_data.is_word_timestamp(): asr_data.split_to_word_segments() self.update_all.emit(asr_data.to_json()) + self.checkpoint() - # 验证 LLM 配置 - if self.need_llm(subtitle_config, asr_data): - self.progress.emit(2, self.tr("开始验证 LLM 配置...")) - subtitle_config = self._setup_llm_config() + # 2. 验证 LLM(断句/校正/LLM 翻译任一需要) + if self._need_llm(config, asr_data): + self.progress.emit(2, tr("t_subtitle.status.verifying_llm")) + _setup_llm_environment(config) + self.checkpoint() - # 2. 重新断句(对于字词级字幕) + # 3. 字词级字幕重新断句 if asr_data.is_word_timestamp(): - update_stage("split") - self.progress.emit(5, self.tr("字幕断句...")) - logger.info("正在字幕断句...") - splitter = SubtitleSplitter( - thread_num=subtitle_config.thread_num, - model=subtitle_config.llm_model, - max_word_count_cjk=subtitle_config.max_word_count_cjk, - max_word_count_english=subtitle_config.max_word_count_english, - ) - asr_data = splitter.split_subtitle(asr_data) - self.update_all.emit(asr_data.to_json()) + asr_data = self._split_stage(asr_data, config) + self.checkpoint() - # 3. 优化字幕 - context_info = f'The subtitles below are from a file named "{task_file}". Use this context to improve accuracy if needed.\n' - custom_prompt = context_info + (subtitle_config.custom_prompt_text or "") + "\n" - self.subtitle_length = len(asr_data.segments) - - if subtitle_config.need_optimize: - update_stage("optimize") - self.progress.emit(0, self.tr("优化字幕...")) - logger.info("正在优化字幕...") - self.finished_subtitle_length = 0 - if not subtitle_config.llm_model: - raise Exception(self.tr("LLM 模型未配置")) - optimizer = SubtitleOptimizer( - thread_num=subtitle_config.thread_num, - batch_num=subtitle_config.batch_size, - model=subtitle_config.llm_model, - custom_prompt=custom_prompt or "", - update_callback=self.callback, - ) - asr_data = optimizer.optimize_subtitle(asr_data) - asr_data.remove_punctuation() - self.update_all.emit(asr_data.to_json()) - - # 4. 翻译字幕 - if subtitle_config.need_translate: - update_stage("translate") - self.progress.emit(0, self.tr("翻译字幕...")) - logger.info("正在翻译字幕...") - self.finished_subtitle_length = 0 - - if not subtitle_config.target_language: - raise Exception(self.tr("目标语言未配置")) - - translator = create_translator_from_config( - subtitle_config, custom_prompt, self.callback - ) - - asr_data = translator.translate_subtitle(asr_data) - - # 移除末尾标点符号 - asr_data.remove_punctuation() - self.update_all.emit(asr_data.to_json()) - - # 保存翻译结果(单语、双语) - if self.task.need_next_task and self.task.video_path: - for layout in SubtitleLayoutEnum: - save_path = str( - Path(self.task.subtitle_path).parent - / f"{Path(self.task.video_path).stem}-{layout.value}.srt" - ) - asr_data.save( - save_path=save_path, - ass_style=subtitle_config.subtitle_style or "", - layout=layout, - ) - logger.info(f"翻译字幕保存到:{save_path}") - - # 5. 保存字幕 - asr_data.save( - save_path=self.task.output_path or "", - ass_style=subtitle_config.subtitle_style or "", - layout=subtitle_config.subtitle_layout or SubtitleLayoutEnum.ONLY_TRANSLATE, + context_prompt = ( + f'The subtitles below are from a file named "{task_file}". ' + "Use this context to improve accuracy if needed.\n" + f"{config.custom_prompt_text or ''}\n" + ) + self._total_segments = len(asr_data.segments) + + # 4. 校正 + if config.need_optimize: + asr_data = self._optimize_stage(asr_data, config, context_prompt) + self.checkpoint() + + # 5. 翻译 + if config.need_translate: + asr_data = self._translate_stage(asr_data, config, context_prompt) + self.checkpoint() + + # 6. 导出 + self._export_stage(asr_data, config) + self.progress.emit(100, tr("t_subtitle.status.done")) + logger.info("字幕处理完成") + self.finished.emit( + self.task.video_path or "", self.task.output_path or "" ) - logger.info(f"字幕保存到 {self.task.output_path}") + finally: + clear_task_context() - # 6. 文件移动与清理 - if self.task.need_next_task and self.task.video_path: - # 保存srt/ass文件到视频目录(对于全流程任务) - save_srt_path = ( - Path(self.task.video_path).parent / f"{Path(self.task.video_path).stem}.srt" - ) - asr_data.to_srt( - save_path=str(save_srt_path), - layout=subtitle_config.subtitle_layout, - ) - save_ass_path = ( - Path(self.task.video_path).parent / f"{Path(self.task.video_path).stem}.ass" + # ----- 流水线阶段 ----- + + def _split_stage(self, asr_data: ASRData, config: SubtitleConfig) -> ASRData: + update_stage("split") + self.progress.emit(5, tr("t_subtitle.status.splitting")) + logger.info("正在字幕断句...") + splitter = SubtitleSplitter( + thread_num=config.thread_num, + model=config.llm_model, + max_word_count_cjk=config.max_word_count_cjk, + max_word_count_english=config.max_word_count_english, + ) + self._active_worker = splitter + try: + asr_data = splitter.split_subtitle(asr_data) + finally: + self._active_worker = None + self.update_all.emit(asr_data.to_json()) + return asr_data + + def _optimize_stage( + self, asr_data: ASRData, config: SubtitleConfig, prompt: str + ) -> ASRData: + update_stage("optimize") + self.progress.emit(0, tr("t_subtitle.status.optimizing")) + logger.info("正在优化字幕...") + if not config.llm_model: + raise Exception(tr("t_subtitle.error.llm_model_not_configured")) + self._done_segments = 0 + optimizer = SubtitleOptimizer( + thread_num=config.thread_num, + batch_num=config.batch_size, + model=config.llm_model, + custom_prompt=prompt, + update_callback=self._batch_callback, + ) + self._active_worker = optimizer + try: + asr_data = optimizer.optimize_subtitle(asr_data) + finally: + self._active_worker = None + asr_data.remove_punctuation() + self.update_all.emit(asr_data.to_json()) + return asr_data + + def _translate_stage( + self, asr_data: ASRData, config: SubtitleConfig, prompt: str + ) -> ASRData: + update_stage("translate") + self.progress.emit(0, tr("t_subtitle.status.translating")) + logger.info("正在翻译字幕...") + if not config.target_language: + raise Exception(tr("t_subtitle.error.no_target_language")) + self._done_segments = 0 + translator = create_translator_from_config(config, prompt, self._batch_callback) + self._active_worker = translator + try: + asr_data = translator.translate_subtitle(asr_data) + finally: + self._active_worker = None + asr_data.remove_punctuation() + self.update_all.emit(asr_data.to_json()) + + # 全流程任务把其余布局副本留在任务目录(保留中间文件时可取用) + if self.task.need_next_task and self.task.task_dir: + for layout in SubtitleLayoutEnum: + save_path = str( + Path(self.task.task_dir) / output_paths.layout_copy_name(layout) ) - asr_data.to_ass( - save_path=str(save_ass_path), - layout=subtitle_config.subtitle_layout, - style_str=subtitle_config.subtitle_style, + asr_data.save( + save_path=save_path, + ass_style=config.subtitle_style or "", + layout=layout, ) + logger.info("布局副本保存到:%s", save_path) + return asr_data + + def _export_stage(self, asr_data: ASRData, config: SubtitleConfig) -> None: + asr_data.save( + save_path=self.task.output_path or "", + ass_style=config.subtitle_style or "", + layout=config.subtitle_layout or SubtitleLayoutEnum.ONLY_TRANSLATE, + ) + logger.info("字幕保存到 %s", self.task.output_path) + + # 全流程任务的主输出在任务目录(subtitle.ass,供合成消费); + # 字幕成品本身也交付到视频旁,遵循播放器 sidecar 约定。 + if self.task.need_next_task and self.task.video_path: + tag = ( + output_paths.language_tag(config.target_language) + if config.need_translate + else output_paths.TAG_OPTIMIZED + ) + sidecar = output_paths.unique_path( + output_paths.product_path(self.task.video_path, tag, ext=".srt") + ) + asr_data.to_srt(save_path=str(sidecar), layout=config.subtitle_layout) + logger.info("字幕成品保存到 %s", sidecar) - self.progress.emit(100, self.tr("优化完成")) - logger.info("优化完成") - self.finished.emit(self.task.video_path, self.task.output_path) - - except Exception as e: - logger.exception(f"字幕处理失败: {str(e)}") - self.error.emit(str(e)) - self.progress.emit(100, self.tr("字幕处理失败")) - finally: - clear_task_context() + # ----- 工具 ----- - def need_llm(self, subtitle_config: SubtitleConfig, asr_data: ASRData): + @staticmethod + def _need_llm(config: SubtitleConfig, asr_data: ASRData) -> bool: return ( - subtitle_config.need_optimize + config.need_optimize or asr_data.is_word_timestamp() or ( - subtitle_config.need_translate - and subtitle_config.translator_service - not in [ - TranslatorServiceEnum.DEEPLX, - TranslatorServiceEnum.BING, - TranslatorServiceEnum.GOOGLE, - ] + config.need_translate + and config.translator_service not in _NON_LLM_TRANSLATORS ) ) - def callback(self, result: List[SubtitleProcessData]): - self.finished_subtitle_length += len(result) - # 简单计算当前进度(0-100%) - progress = min(int((self.finished_subtitle_length / max(self.subtitle_length, 1)) * 100), 100) - self.progress.emit(progress, self.tr("{0}% 处理字幕").format(progress)) - # 转换为字典格式供UI使用 - result_dict = { - str(data.index): data.translated_text or data.optimized_text or data.original_text - for data in result - } - self.update.emit(result_dict) - - def stop(self): - """停止所有处理""" - try: - # 先停止优化器 - if hasattr(self, "optimizer") and self.optimizer: - try: - self.optimizer.stop() # type: ignore - except Exception as e: - logger.error(f"停止优化器时出错:{str(e)}") - - # 终止线程 - self.terminate() - # 等待最多3秒 - if not self.wait(3000): - logger.warning("线程未能在3秒内正常停止") - - # 发送进度信号 - self.progress.emit(100, self.tr("已终止")) - - except Exception as e: - logger.error(f"停止线程时出错:{str(e)}") - self.progress.emit(100, self.tr("终止时发生错误")) - - -class RetranslateThread(QThread): - """重新翻译选中行的轻量线程""" - - finished = pyqtSignal(dict) # {key: translated_text} - progress = pyqtSignal(int, str) # (百分比, 状态描述) - error = pyqtSignal(str) - - def __init__(self, selected_data: dict, subtitle_config: SubtitleConfig, file_name: str = ""): - """ - selected_data: model._data 中选中的条目,键为行号字符串 - subtitle_config: 当前任务配置 - file_name: 用于日志上下文的文件名 - """ + def _batch_callback(self, result: List[SubtitleProcessData]): + """core 执行器的批量回调:上报进度 + 推送增量结果。""" + self.checkpoint() + self._done_segments += len(result) + percent = min( + int(self._done_segments / max(self._total_segments, 1) * 100), 100 + ) + self.progress.emit(percent, tr("t_subtitle.status.processing_percent", percent=percent)) + self.update.emit( + { + str(data.index): data.translated_text + or data.optimized_text + or data.original_text + for data in result + } + ) + + +class RetranslateThread(WorkerThread): + """重新翻译选中行的轻量线程。finished(dict) -> {行号: 译文}。""" + + finished = pyqtSignal(dict) + + def __init__( + self, + selected_data: dict, + subtitle_config: SubtitleConfig, + file_name: str = "", + ): super().__init__() self.selected_data = selected_data - self.subtitle_config = subtitle_config + self.config = subtitle_config self.file_name = file_name - self.total = len(selected_data) - self.done = 0 + self._done = 0 + self._translator: Optional[object] = None + + def _on_cancel(self): + translator = self._translator + if translator is not None and hasattr(translator, "stop"): + translator.stop() def _callback(self, result: List[SubtitleProcessData]): - self.done += len(result) - pct = min(int(self.done / self.total * 100), 100) - self.progress.emit(pct, self.tr("{0}% 翻译中").format(pct)) + self.checkpoint() + self._done += len(result) + percent = min(int(self._done / max(len(self.selected_data), 1) * 100), 100) + self.progress.emit(percent, tr("t_subtitle.status.translating_percent", percent=percent)) - def run(self): + def _work(self): set_task_context( - task_id=generate_task_id(), - file_name=self.file_name, - stage="translate", + task_id=generate_task_id(), file_name=self.file_name, stage="translate" ) try: - config = self.subtitle_config - if not config.target_language: - raise Exception("目标语言未配置") - - # 设置 LLM 环境变量(LLM 翻译需要) - if config.translator_service == TranslatorServiceEnum.OPENAI: - if not (config.base_url and config.api_key and config.llm_model): - raise Exception("LLM API 未配置,请检查 LLM 配置") - os.environ["OPENAI_BASE_URL"] = config.base_url - os.environ["OPENAI_API_KEY"] = config.api_key - - # 构建仅含选中行的 ASRData - asr_data = ASRData.from_json(self.selected_data) + if not self.config.target_language: + raise Exception(tr("t_subtitle.error.no_target_language")) + if self.config.translator_service == TranslatorServiceEnum.OPENAI: + _setup_llm_environment(self.config) - # 创建翻译器并翻译 - translator = create_translator_from_config(config, callback=self._callback) - asr_data = translator.translate_subtitle(asr_data) + asr_data = ASRData.from_json(self.selected_data) + translator = create_translator_from_config( + self.config, callback=self._callback + ) + self._translator = translator + try: + asr_data = translator.translate_subtitle(asr_data) + finally: + self._translator = None + self.checkpoint() - # 构建 {原始行号: translated_text} 映射 keys = list(self.selected_data.keys()) - result = { - keys[i]: seg.translated_text - for i, seg in enumerate(asr_data.segments) - } - self.finished.emit(result) - - except Exception as e: - logger.exception(f"重新翻译失败: {e}") - self.error.emit(str(e)) + self.finished.emit( + { + keys[index]: segment.translated_text + for index, segment in enumerate(asr_data.segments) + } + ) finally: clear_task_context() diff --git a/videocaptioner/ui/thread/transcript_thread.py b/videocaptioner/ui/thread/transcript_thread.py index ad914059..53a506c7 100644 --- a/videocaptioner/ui/thread/transcript_thread.py +++ b/videocaptioner/ui/thread/transcript_thread.py @@ -1,149 +1,112 @@ +# -*- coding: utf-8 -*- +"""转录线程:提取音频 -> ASR 转录 -> 按输出格式落盘。 + +基于 WorkerThread:进度回调里有取消检查点,stop() 可在阶段间 +协作中止;被取消的运行不发任何结果信号。 +""" + +from __future__ import annotations + import datetime import tempfile from pathlib import Path -from PyQt5.QtCore import QThread, pyqtSignal +from PyQt5.QtCore import pyqtSignal from videocaptioner.core.asr import transcribe -from videocaptioner.core.entities import TranscribeOutputFormatEnum, TranscribeTask +from videocaptioner.core.entities import ( + TranscribeOutputFormatEnum, + TranscribeTask, +) from videocaptioner.core.utils.logger import setup_logger from videocaptioner.core.utils.video_utils import video2audio +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.worker import WorkerThread logger = setup_logger("transcript_thread") -class TranscriptThread(QThread): +class TranscriptThread(WorkerThread): + """finished(task) 转录成功;progress/error 来自基类约定。""" + finished = pyqtSignal(TranscribeTask) - progress = pyqtSignal(int, str) - error = pyqtSignal(str) def __init__(self, task: TranscribeTask): super().__init__() self.task = task - def run(self): - try: - self.task.started_at = datetime.datetime.now() - logger.info(f"\n{self.task.transcribe_config.print_config()}") - - self._validate_task() - - # 检查是否已下载字幕文件 - if self._check_downloaded_subtitle(): - return - - self._perform_transcription() - - except Exception as e: - logger.exception("转录过程中发生错误: %s", str(e)) - self.error.emit(str(e)) - self.progress.emit(100, self.tr("转录失败")) - - def _validate_task(self): - """验证任务配置""" - if not self.task.file_path: - raise ValueError(self.tr("文件路径为空")) - - video_path = Path(self.task.file_path) - if not video_path.exists(): - logger.error(f"视频文件不存在:{video_path}") - raise ValueError(self.tr("视频文件不存在")) - - if not self.task.transcribe_config: - raise ValueError(self.tr("转录配置为空")) - - if not self.task.output_path: - raise ValueError(self.tr("输出路径为空")) - - def _check_downloaded_subtitle(self) -> bool: - """检查是否存在下载的字幕文件""" - if not (self.task.need_next_task and self.task.file_path): - return False - - subtitle_dir = Path(self.task.file_path).parent / "subtitle" - if not subtitle_dir.exists(): - return False - - downloaded_subtitles = list(subtitle_dir.glob("【下载字幕】*")) - if not downloaded_subtitles: - return False - - subtitle_file = downloaded_subtitles[0] - self.task.output_path = str(subtitle_file) - logger.info(f"字幕文件已下载,跳过转录。找到下载的字幕文件:{subtitle_file}") - self.progress.emit(100, self.tr("字幕已下载")) - self.finished.emit(self.task) - return True - - def _perform_transcription(self): - """执行转录流程""" - assert self.task.file_path is not None - assert self.task.transcribe_config is not None - assert self.task.output_path is not None - - video_path = Path(self.task.file_path) - - self.progress.emit(5, self.tr("转换音频中")) - logger.info("开始转换音频") - - # 创建临时音频文件(delete=False 避免 Windows 权限问题) - temp_audio_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - temp_audio_path = temp_audio_file.name - temp_audio_file.close() # 立即关闭文件句柄,让 ffmpeg 可以写入 + def _work(self): + self.task.started_at = datetime.datetime.now() + self._validate_task() + logger.info("\n%s", self.task.transcribe_config.print_config()) + audio_path = self._extract_audio() try: - # 转换音频文件 - # 获取选中的音轨索引(如果有) - audio_track_index = self.task.selected_audio_track_index - is_success = video2audio( - str(video_path), - output=temp_audio_path, - audio_track_index=audio_track_index, - ) - if not is_success: - logger.error("音频转换失败") - raise RuntimeError(self.tr("音频转换失败")) - - self.progress.emit(20, self.tr("语音转录中")) + self.checkpoint() + self.progress.emit(20, tr("t_transcript.status.transcribing")) logger.info("开始语音转录") - - # 进行转录 asr_data = transcribe( - temp_audio_path, + audio_path, self.task.transcribe_config, - callback=self.progress_callback, + callback=self._progress_callback, ) - - # 保存字幕文件(根据配置的输出格式) - output_path = Path(self.task.output_path) - output_format_enum = self.task.transcribe_config.output_format - base_path = output_path.with_suffix("") - - # 根据选择的格式导出 - if output_format_enum == TranscribeOutputFormatEnum.ALL: - formats_to_export = [ - fmt.value.lower() - for fmt in TranscribeOutputFormatEnum - if fmt != TranscribeOutputFormatEnum.ALL - ] - else: - formats_to_export = [output_format_enum.value.lower()] - - if self.task.need_next_task: - formats_to_export.append(TranscribeOutputFormatEnum.SRT.value.lower()) - formats_to_export = list(set(formats_to_export)) - - # 保存字幕文件 - for fmt in formats_to_export: - save_path = f"{base_path}.{fmt}" - asr_data.save(save_path) - logger.info("%s 字幕文件已保存到: %s", fmt.upper(), save_path) - - self.progress.emit(100, self.tr("转录完成")) + self.checkpoint() + self._save_outputs(asr_data) + self.progress.emit(100, tr("t_transcript.status.done")) self.finished.emit(self.task) finally: - Path(temp_audio_path).unlink(missing_ok=True) + Path(audio_path).unlink(missing_ok=True) + + # ----- 阶段 ----- - def progress_callback(self, value, message): - progress = min(20 + (value * 0.8), 100) - self.progress.emit(int(progress), message) + def _validate_task(self): + if not self.task.file_path: + raise ValueError(tr("t_transcript.error.no_file_path")) + if not Path(self.task.file_path).exists(): + raise ValueError(tr("t_transcript.error.file_not_found")) + if not self.task.transcribe_config: + raise ValueError(tr("t_transcript.error.no_config")) + if not self.task.output_path: + raise ValueError(tr("t_transcript.error.no_output_path")) + + def _extract_audio(self) -> str: + self.progress.emit(5, tr("t_transcript.status.extracting_audio")) + logger.info("开始转换音频") + # delete=False 避免 Windows 句柄占用,结束后统一清理 + temp_audio = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + temp_audio.close() + ok = video2audio( + str(self.task.file_path), + output=temp_audio.name, + audio_track_index=self.task.selected_audio_track_index, + ) + if not ok: + Path(temp_audio.name).unlink(missing_ok=True) + raise RuntimeError(tr("t_transcript.error.audio_extract_failed")) + return temp_audio.name + + def _save_outputs(self, asr_data): + """按配置导出目标格式;流水线模式额外保证 SRT 存在。""" + output_format = self.task.transcribe_config.output_format + base_path = Path(self.task.output_path).with_suffix("") + + if output_format == TranscribeOutputFormatEnum.ALL: + formats = { + fmt.value.lower() + for fmt in TranscribeOutputFormatEnum + if fmt != TranscribeOutputFormatEnum.ALL + } + else: + formats = {output_format.value.lower()} + if self.task.need_next_task: + formats.add(TranscribeOutputFormatEnum.SRT.value.lower()) + + for fmt in sorted(formats): + save_path = f"{base_path}.{fmt}" + asr_data.save(save_path) + logger.info("%s 字幕已保存到: %s", fmt.upper(), save_path) + + def _progress_callback(self, value, message): + self.checkpoint() + # ASR 阶段映射到整体进度的 20-100 区间 + self.progress.emit(int(min(20 + value * 0.8, 100)), message) diff --git a/videocaptioner/ui/thread/update_thread.py b/videocaptioner/ui/thread/update_thread.py new file mode 100644 index 00000000..faf3b634 --- /dev/null +++ b/videocaptioner/ui/thread/update_thread.py @@ -0,0 +1,70 @@ +"""更新检查 / 下载的 Qt 线程薄壳;业务在 core/update,UI 只消费信号。 + +- UpdateCheckThread:启动后台调后端 check → resultReady(CheckResult) / checkFailed。 + 开发版(channel=dev)也检查,方便调试公告与更新;pip 版后端不下发更新。 +- UpdateDownloadThread:用户点「下载更新」后后台下载(进度/取消)→ downloaded/downloadFailed。 +切语言/安装走 main_window;本模块不碰 UI 控件。 +""" + +from __future__ import annotations + +from pathlib import Path + +from PyQt5.QtCore import QThread, pyqtSignal + +from videocaptioner.config import UPDATE_CHECK_URL +from videocaptioner.core.download.downloader import DownloadCancelled, DownloadProgress +from videocaptioner.core.update import UpdateInfo, check_update, download_update +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("update_thread") + + +class UpdateCheckThread(QThread): + """后台调后端检查更新 + 公告(一次请求拿 block/update/announcement)。""" + + resultReady = pyqtSignal(object) # CheckResult + checkFailed = pyqtSignal(str) + + def run(self) -> None: + result = check_update(UPDATE_CHECK_URL) + if result is None: + self.checkFailed.emit("update check unreachable") + return + self.resultReady.emit(result) + + +class UpdateDownloadThread(QThread): + """后台下载更新包(含 sha256 校验);可取消。""" + + progress = pyqtSignal(int, int) # received, total(total 未知为 0) + downloaded = pyqtSignal(str) # zip 路径 + downloadFailed = pyqtSignal(str) + + def __init__(self, info: UpdateInfo, dest_dir: Path, parent=None): + super().__init__(parent) + self._info = info + self._dest_dir = dest_dir + self._cancelled = False + + def cancel(self) -> None: + self._cancelled = True + + def run(self) -> None: + def on_progress(p: DownloadProgress) -> None: + self.progress.emit(p.received, p.total or 0) + + try: + path = download_update( + self._info, + self._dest_dir, + on_progress=on_progress, + should_cancel=lambda: self._cancelled, + ) + except DownloadCancelled: + return + except Exception as exc: # noqa: BLE001 + logger.warning("update download failed: %s", exc) + self.downloadFailed.emit(str(exc)) + return + self.downloaded.emit(str(path)) diff --git a/videocaptioner/ui/thread/version_checker_thread.py b/videocaptioner/ui/thread/version_checker_thread.py deleted file mode 100644 index afc5b1e3..00000000 --- a/videocaptioner/ui/thread/version_checker_thread.py +++ /dev/null @@ -1,159 +0,0 @@ -# coding: utf-8 -import hashlib -from datetime import datetime - -import requests -from PyQt5.QtCore import QObject, QVersionNumber, pyqtSignal - -from videocaptioner.config import VERSION -from videocaptioner.core.utils.cache import get_version_state_cache -from videocaptioner.core.utils.logger import setup_logger - -logger = setup_logger("version_checker") - - -class VersionChecker(QObject): - """Version checker""" - - newVersionAvailable = pyqtSignal(str, bool, str, str) - announcementAvailable = pyqtSignal(str) - checkCompleted = pyqtSignal() - - def __init__(self): - super().__init__() - self.current_version = VERSION - self.latest_version = VERSION - self.update_info = "" - self.update_required = False - self.download_url = "" - self.announcement = {} - - self.cache = get_version_state_cache() - - def get_latest_version_info(self) -> dict: - """Get latest version information""" - url = "https://vc.bkfeng.top/api/version" - headers = {"app_version": VERSION} - - try: - response = requests.get(url, timeout=10, headers=headers) - response.raise_for_status() - data = response.json() - # data = { - # "latest_version": "v1.4.0", - # "update_required": True, - # "update_info": "更新内容", - # "download_url": "https://github.com/WEIFENG2333/VideoCaptioner/releases/latest", - # "announcement": { - # "enabled": True, - # "content": "公告内容211", - # "start_date": "2025-01-01", - # "end_date": "2025-12-30", - # }, - # } - - self.latest_version = data.get("latest_version", self.current_version) - self.update_required = data.get("update_required", False) - self.update_info = data.get("update_info", "") - self.download_url = data.get("download_url", "") - self.announcement = data.get("announcement", {}) - - logger.info("Successfully fetched version info: %s", self.latest_version) - return data - - except requests.RequestException: - return {} - - def has_new_version(self) -> bool: - """Check if new version is available""" - try: - latest_ver = self.latest_version.lstrip("v") - current_ver = self.current_version.lstrip("v") - - latest_ver_num = QVersionNumber.fromString(latest_ver) - current_ver_num = QVersionNumber.fromString(current_ver) - - if latest_ver_num > current_ver_num: - logger.info( - "New version found: %s (current: %s)", - self.latest_version, - self.current_version, - ) - self.newVersionAvailable.emit( - self.latest_version, - self.update_required, - self.update_info, - self.download_url, - ) - return True - - except Exception as e: - logger.error("Version comparison failed: %s", str(e)) - - return False - - def check_announcement(self) -> None: - """Check and show announcement""" - ann = self.announcement - if not ann.get("enabled", False): - return - - content = ann.get("content", "") - if not content: - return - - announcement_id = ( - hashlib.sha256(content.encode("utf-8")).hexdigest() - + "_" - + datetime.today().strftime("%Y-%m-%d") - ) - - settings_key = f"announcement/shown_{announcement_id}" - if self.cache.get(settings_key, default=False): - return - - start_date_str = ann.get("start_date") - end_date_str = ann.get("end_date") - if not start_date_str or not end_date_str: - return - - try: - start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() - end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() - today = datetime.today().date() - - if start_date <= today <= end_date: - self.cache.set(settings_key, True, expire=30 * 60 * 24) - self.announcementAvailable.emit(content) - - except ValueError as e: - logger.error("Announcement date format error: %s", str(e)) - - def check_new_version_announcement(self) -> None: - """Check new version announcement""" - if self.latest_version != self.current_version: - return - - version_key = f"version/shown_{self.latest_version}" - - if not self.cache.get(version_key, default=False): - self.cache.set(version_key, True) - - update_announcement = ( - f"Welcome to VideoCaptioner {self.current_version}\n\n" - f"What's new:\n{self.update_info}" - ) - self.announcementAvailable.emit(update_announcement) - - def perform_check(self) -> None: - """Perform version and announcement check""" - try: - version_data = self.get_latest_version_info() - if not version_data: - return - self.has_new_version() - self.check_new_version_announcement() - self.check_announcement() - self.checkCompleted.emit() - except Exception: - logger.exception("Version and announcement check failed") diff --git a/videocaptioner/ui/thread/video_download_thread.py b/videocaptioner/ui/thread/video_download_thread.py deleted file mode 100644 index 2943e3da..00000000 --- a/videocaptioner/ui/thread/video_download_thread.py +++ /dev/null @@ -1,219 +0,0 @@ -import os -import re -from pathlib import Path - -import requests -import yt_dlp -from PyQt5.QtCore import QThread, pyqtSignal - -from videocaptioner.config import APPDATA_PATH -from videocaptioner.core.utils.logger import setup_logger - -logger = setup_logger("video_download_thread") - - -class VideoDownloadThread(QThread): - """视频下载线程类""" - - finished = pyqtSignal( - str - ) # 发送下载完成的信号(视频路径, 字幕路径, 缩略图路径, 视频信息) - progress = pyqtSignal(int, str) # 发送下载进度的信号 - error = pyqtSignal(str) # 发送错误信息的信号 - - def __init__(self, url: str, work_dir: str): - super().__init__() - self.url = url - self.work_dir = work_dir - - def run(self): - try: - video_file_path, subtitle_file_path, thumbnail_file_path, info_dict = ( - self.download() - ) - self.finished.emit(video_file_path) - except Exception as e: - logger.exception("下载视频失败: %s", str(e)) - self.error.emit(str(e)) - - def progress_hook(self, d): - """下载进度回调函数""" - if d["status"] == "downloading": - percent = d["_percent_str"] - speed = d["_speed_str"] - - # 提取百分比和速度的纯文本 - clean_percent = ( - percent.replace("\x1b[0;94m", "") - .replace("\x1b[0m", "") - .strip() - .replace("%", "") - ) - clean_speed = speed.replace("\x1b[0;32m", "").replace("\x1b[0m", "").strip() - - self.progress.emit( - int(float(clean_percent)), - f"下载进度: {clean_percent}% 速度: {clean_speed}", - ) - - def sanitize_filename(self, name: str, replacement: str = "_") -> str: - """清理文件名中不允许的字符""" - # 定义不允许的字符 - forbidden_chars = r'<>:"/\\|?*' - - # 替换不允许的字符 - sanitized = re.sub(f"[{re.escape(forbidden_chars)}]", replacement, name) - - # 移除控制字符 - sanitized = re.sub(r"[\0-\31]", "", sanitized) - - # 去除文件名末尾的空格和点 - sanitized = sanitized.rstrip(" .") - - # 限制文件名长度 - max_length = 255 - if len(sanitized) > max_length: - base, ext = os.path.splitext(sanitized) - base_max_length = max_length - len(ext) - sanitized = base[:base_max_length] + ext - - # 处理Windows保留名称 - windows_reserved_names = { - "CON", - "PRN", - "AUX", - "NUL", - "COM1", - "COM2", - "COM3", - "COM4", - "COM5", - "COM6", - "COM7", - "COM8", - "COM9", - "LPT1", - "LPT2", - "LPT3", - "LPT4", - "LPT5", - "LPT6", - "LPT7", - "LPT8", - "LPT9", - } - name_without_ext = os.path.splitext(sanitized)[0].upper() - if name_without_ext in windows_reserved_names: - sanitized = f"{sanitized}_" - - # 如果文件名为空,返回默认名称 - if not sanitized: - sanitized = "default_filename" - - return sanitized - - def download(self, need_subtitle: bool = True, need_thumbnail: bool = False): - """下载视频""" - logger.info("开始下载视频: %s", self.url) - - # 初始化 ydl 选项 - initial_ydl_opts = { - "outtmpl": { - "default": "%(title).200s.%(ext)s", # 限制文件名最长200个字符 - "subtitle": "【下载字幕】.%(ext)s", - "thumbnail": "thumbnail", - }, - "format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best", # 优先下载mp4格式 - "progress_hooks": [self.progress_hook], # 下载进度钩子 - "quiet": True, # 禁用日志输出 - "no_warnings": True, # 禁用警告信息 - "noprogress": True, - "writeautomaticsub": need_subtitle, # 下载自动生成的字幕 - "writethumbnail": need_thumbnail, # 下载缩略图 - "thumbnail_format": "jpg", # 指定缩略图的格式 - } - - # 检查 cookies 文件 - cookiefile_path = APPDATA_PATH / "cookies.txt" - if cookiefile_path.exists(): - logger.info(f"使用cookiefile: {cookiefile_path}") - initial_ydl_opts["cookiefile"] = str(cookiefile_path) - - with yt_dlp.YoutubeDL(initial_ydl_opts) as ydl: - # 提取视频信息(不下载) - info_dict = ydl.extract_info(self.url, download=False) - - # 设置动态下载文件夹为视频标题 - video_title = self.sanitize_filename(info_dict.get("title", "MyVideo")) - video_work_dir = Path(self.work_dir) / self.sanitize_filename(video_title) - subtitle_language = info_dict.get("language", None) - if subtitle_language: - subtitle_language = subtitle_language.lower().split("-")[0] - - try: - subtitle_download_link = None - automatic_captions = info_dict.get("automatic_captions") - if automatic_captions and subtitle_language: - for lang_code in automatic_captions: - if lang_code.startswith(subtitle_language): - subtitle_download_link = automatic_captions[lang_code][-1][ - "url" - ] - break - except Exception: - subtitle_download_link = None - - # 设置 yt-dlp 下载选项 - ydl_opts = { - "paths": { - "home": str(video_work_dir), - "subtitle": str(video_work_dir / "subtitle"), - "thumbnail": str(video_work_dir), - }, - } - # 更新 yt-dlp 的配置 - ydl.params.update(ydl_opts) - - # 使用 process_info 进行下载 - ydl.process_info(info_dict) - - # 获取视频文件路径 - video_file_path = Path(ydl.prepare_filename(info_dict)) - if video_file_path.exists(): - video_file_path = str(video_file_path) - else: - video_file_path = None - - # 获取字幕文件路径 - subtitle_file_path = None - for file in video_work_dir.glob("**/【下载字幕】*"): - file_path = str(file) - if subtitle_language and subtitle_language not in file_path: - logger.info( - "字幕语言错误,重新下载字幕: %s", subtitle_download_link - ) - os.remove(file_path) - if subtitle_download_link: - response = requests.get(subtitle_download_link) - file_path = ( - video_work_dir - / "subtitle" - / f"【下载字幕】{subtitle_language}.vtt" - ) - if res := response.text: - with open(file_path, "w", encoding="utf-8") as f: - f.write(res) - subtitle_file_path = file_path - else: - subtitle_file_path = file_path - break - - # 获取缩略图文件路径 - thumbnail_file_path = None - for file in video_work_dir.glob("**/thumbnail*"): - thumbnail_file_path = str(file) - break - - logger.info(f"视频下载完成: {video_file_path}") - logger.info(f"字幕文件路径: {subtitle_file_path}") - return video_file_path, subtitle_file_path, thumbnail_file_path, info_dict diff --git a/videocaptioner/ui/thread/video_info_thread.py b/videocaptioner/ui/thread/video_info_thread.py index 9800587f..8ba7de70 100644 --- a/videocaptioner/ui/thread/video_info_thread.py +++ b/videocaptioner/ui/thread/video_info_thread.py @@ -1,38 +1,63 @@ +# -*- coding: utf-8 -*- +"""媒体信息线程:ffmpeg 探测媒体信息并生成缩略图。""" + +from __future__ import annotations + import tempfile from pathlib import Path -from PyQt5.QtCore import QThread, pyqtSignal +from PyQt5.QtCore import pyqtSignal -from videocaptioner.core.entities import VideoInfo +from videocaptioner.core.entities import AudioStreamInfo, VideoInfo from videocaptioner.core.utils.logger import setup_logger -from videocaptioner.core.utils.video_utils import get_video_info +from videocaptioner.core.utils.media_info import extract_thumbnail, probe_media +from videocaptioner.ui.thread.worker import WorkerThread logger = setup_logger("video_info_thread") -class VideoInfoThread(QThread): +class VideoInfoThread(WorkerThread): finished = pyqtSignal(VideoInfo) - error = pyqtSignal(str) - def __init__(self, file_path): + def __init__(self, file_path: str): super().__init__() self.file_path = file_path - def run(self): - try: - # 生成缩略图到临时文件 - temp_dir = tempfile.gettempdir() - file_name = Path(self.file_path).stem - thumbnail_path = f"{temp_dir}/{file_name}_thumbnail.jpg" - - # 使用统一的 get_video_info 函数 - video_info = get_video_info(self.file_path, thumbnail_path=thumbnail_path) - - if video_info: - self.finished.emit(video_info) - else: - self.error.emit("无法获取媒体文件信息,请确保文件格式正确") - - except Exception as e: - logger.exception("获取视频信息时出错") - self.error.emit(str(e)) + def _work(self): + info = probe_media(self.file_path) + self.checkpoint() + if info is None: + import shutil + + if shutil.which("ffmpeg") is None: + raise ValueError("FFmpeg 不可用,无法读取媒体信息,请先安装 FFmpeg") + raise ValueError("无法获取媒体文件信息,请确保文件格式正确") + + thumbnail_path = "" + if info.has_video and info.duration_seconds > 0: + candidate = ( + Path(tempfile.gettempdir()) / f"{Path(self.file_path).stem}_thumbnail.jpg" + ) + if extract_thumbnail(self.file_path, str(candidate), info.duration_seconds * 0.3): + thumbnail_path = str(candidate) + self.checkpoint() + + self.finished.emit( + VideoInfo( + file_name=Path(self.file_path).stem, + file_path=self.file_path, + width=info.width, + height=info.height, + fps=info.fps, + duration_seconds=info.duration_seconds, + bitrate_kbps=info.bitrate_kbps, + video_codec=info.video_codec, + audio_codec=info.audio_codec, + audio_sampling_rate=info.audio_sampling_rate, + thumbnail_path=thumbnail_path, + audio_streams=[ + AudioStreamInfo(index=s.index, codec=s.codec, language=s.language) + for s in info.audio_streams + ], + ) + ) diff --git a/videocaptioner/ui/thread/video_synthesis_thread.py b/videocaptioner/ui/thread/video_synthesis_thread.py index 55bdd34b..c47b9e1d 100644 --- a/videocaptioner/ui/thread/video_synthesis_thread.py +++ b/videocaptioner/ui/thread/video_synthesis_thread.py @@ -1,111 +1,105 @@ +# -*- coding: utf-8 -*- +"""视频合成线程:把字幕以软/硬方式合成进视频。 + +基于 WorkerThread:ffmpeg 进度回调里有取消检查点,stop() 协作中止。 +""" + +from __future__ import annotations + import datetime import tempfile from pathlib import Path -from PyQt5.QtCore import QThread, pyqtSignal +from PyQt5.QtCore import pyqtSignal from videocaptioner.core.asr.asr_data import ASRData from videocaptioner.core.entities import SynthesisTask from videocaptioner.core.utils.logger import setup_logger from videocaptioner.core.utils.video_utils import add_subtitles, add_subtitles_with_style +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.worker import WorkerThread logger = setup_logger("video_synthesis_thread") -class VideoSynthesisThread(QThread): +class VideoSynthesisThread(WorkerThread): finished = pyqtSignal(SynthesisTask) - progress = pyqtSignal(int, str) - error = pyqtSignal(str) def __init__(self, task: SynthesisTask): super().__init__() self.task = task - logger.debug(f"初始化 VideoSynthesisThread,任务: {self.task}") - - def run(self): - try: - self.task.started_at = datetime.datetime.now() - config = self.task.synthesis_config - logger.info(f"\n{config.print_config()}") - - video_file = self.task.video_path - subtitle_file = self.task.subtitle_path - output_path = self.task.output_path - - if not config.need_video: - logger.info("不需要合成视频,跳过") - self.progress.emit(100, self.tr("合成完成")) - self.finished.emit(self.task) - return - - logger.info(f"开始合成视频: {video_file}") - self.progress.emit(5, self.tr("正在合成")) - - if not video_file: - raise ValueError(self.tr("视频路径为空")) - if not subtitle_file: - raise ValueError(self.tr("字幕路径为空")) - if not output_path: - raise ValueError(self.tr("输出路径为空")) - - video_quality = config.video_quality - crf = video_quality.get_crf() - preset = video_quality.get_preset() - - # 读取字幕数据 - asr_data = ASRData.from_subtitle_file(subtitle_file) - - if config.soft_subtitle: - # 软字幕:转为 SRT 后内嵌 - with tempfile.NamedTemporaryFile( - mode="w", - suffix=".srt", - delete=False, - encoding="utf-8", - prefix="VideoCaptioner_soft_", - ) as f: - srt_content = asr_data.to_srt(layout=config.subtitle_layout) - f.write(srt_content) - temp_srt_path = f.name - - try: - add_subtitles( - video_file, - temp_srt_path, - output_path, - crf=crf, - preset=preset, - soft_subtitle=True, - progress_callback=self.progress_callback, - ) - finally: - Path(temp_srt_path).unlink(missing_ok=True) - - else: - # 硬字幕:使用样式配置渲染 - add_subtitles_with_style( - video_path=video_file, - asr_data=asr_data, - output_path=output_path, - render_mode=config.render_mode, - subtitle_layout=config.subtitle_layout, - ass_style=config.ass_style, - rounded_style=config.rounded_style, - crf=crf, - preset=preset, - progress_callback=self.progress_callback, - ) - - self.progress.emit(100, self.tr("合成完成")) - logger.info(f"视频合成完成,保存路径: {output_path}") - self.finished.emit(self.task) - except Exception as e: - logger.exception(f"视频合成失败: {e}") - self.error.emit(str(e)) - self.progress.emit(100, self.tr("视频合成失败")) + def _work(self): + self.task.started_at = datetime.datetime.now() + config = self.task.synthesis_config + logger.info("\n%s", config.print_config()) - def progress_callback(self, value, message): + if not config.need_video: + self.progress.emit(100, tr("t_synth.status.done")) + self.finished.emit(self.task) + return + + if not self.task.video_path: + raise ValueError(tr("t_synth.error.no_video_path")) + if not self.task.subtitle_path: + raise ValueError(tr("t_synth.error.no_subtitle_path")) + if not self.task.output_path: + raise ValueError(tr("t_synth.error.no_output_path")) + + self.progress.emit(5, tr("t_synth.status.synthesizing")) + logger.info("开始合成视频: %s", self.task.video_path) + asr_data = ASRData.from_subtitle_file(self.task.subtitle_path) + self.checkpoint() + + crf = config.video_quality.get_crf() + preset = config.video_quality.get_preset() + + if config.soft_subtitle: + self._synthesize_soft(asr_data, config, crf, preset) + else: + add_subtitles_with_style( + video_path=self.task.video_path, + asr_data=asr_data, + output_path=self.task.output_path, + render_mode=config.render_mode, + subtitle_layout=config.subtitle_layout, + ass_style=config.ass_style, + rounded_style=config.rounded_style, + ass_line_gap=config.ass_line_gap, + crf=crf, + preset=preset, + progress_callback=self._progress_callback, + ) + + self.progress.emit(100, tr("t_synth.status.done")) + logger.info("视频合成完成,保存路径: %s", self.task.output_path) + self.finished.emit(self.task) + + def _synthesize_soft(self, asr_data, config, crf: int, preset: str): + """软字幕:转为 SRT 临时文件后内嵌字幕轨。""" + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".srt", + delete=False, + encoding="utf-8", + prefix="VideoCaptioner_soft_", + ) as handle: + handle.write(asr_data.to_srt(layout=config.subtitle_layout)) + temp_srt = handle.name + try: + add_subtitles( + self.task.video_path, + temp_srt, + self.task.output_path, + crf=crf, + preset=preset, + soft_subtitle=True, + progress_callback=self._progress_callback, + ) + finally: + Path(temp_srt).unlink(missing_ok=True) + + def _progress_callback(self, value, message): + self.checkpoint() progress = int(5 + int(value) / 100 * 95) - logger.debug(f"合成进度: {progress}% - {message}") - self.progress.emit(progress, str(progress) + "% " + message) + self.progress.emit(progress, f"{progress}% {message}") diff --git a/videocaptioner/ui/thread/voice_preview_thread.py b/videocaptioner/ui/thread/voice_preview_thread.py new file mode 100644 index 00000000..f2171496 --- /dev/null +++ b/videocaptioner/ui/thread/voice_preview_thread.py @@ -0,0 +1,181 @@ +import subprocess +import tempfile +from pathlib import Path + +from PyQt5.QtCore import QThread, pyqtSignal + +from videocaptioner.config import ASSETS_PATH, CACHE_PATH, RESOURCE_PATH +from videocaptioner.core.dubbing import build_dubbing_config, get_dubbing_preset +from videocaptioner.core.speech import ( + SpeechProviderConfig, + SynthesisRequest, + create_speech_synthesizer, +) +from videocaptioner.core.utils.logger import setup_logger +from videocaptioner.ui.common.config import cfg +from videocaptioner.ui.i18n import tr + +logger = setup_logger("voice_preview_thread") + + +def _sample_text() -> str: + return tr("t_voice.sample_text") + + +class VoicePreviewThread(QThread): + finished = pyqtSignal(str) + error = pyqtSignal(str) + + def __init__( + self, + preset_name: str, + text: str = "", + clone_audio_path: str = "", + clone_audio_text: str = "", + ): + super().__init__() + self.preset_name = preset_name + self.text = text.strip() + self.clone_audio_path = clone_audio_path.strip() + self.clone_audio_text = clone_audio_text.strip() + + def run(self): + try: + use_bundled = not self.text and not self.clone_audio_path and not self.clone_audio_text + bundled = bundled_voice_preview(self.preset_name) if use_bundled else None + if bundled: + self.finished.emit(str(playable_voice_preview(bundled))) + return + + preset = get_dubbing_preset(self.preset_name) + api_key = cfg.dubbing_api_key.value.strip() + api_base = cfg.dubbing_api_base.value.strip() + model = cfg.dubbing_model.value.strip() or preset.model + if preset.provider == "edge": + api_key = "" + api_base = "" + elif not api_key: + raise ValueError(tr("t_voice.error.need_api_key", provider=preset.provider)) + + core_config = build_dubbing_config( + provider=preset.provider, + preset=self.preset_name, + api_key=api_key, + api_base=api_base, + model=model, + voice=preset.voice, + timing="balanced", + audio_mode="replace", + tts_workers=1, + use_cache=cfg.cache_enabled.value, + ) + work = Path(tempfile.mkdtemp(prefix="videocaptioner-voice-")) + response_format = core_config.response_format + if core_config.provider == "gemini": + response_format = "wav" + elif core_config.provider == "edge": + response_format = "mp3" + synthesizer = create_speech_synthesizer( + SpeechProviderConfig( + provider=core_config.provider, + api_key=core_config.api_key, + base_url=core_config.base_url, + model=core_config.model, + default_voice=core_config.voice, + response_format=response_format, + sample_rate=core_config.sample_rate, + speed=core_config.speed, + gain=core_config.gain, + timeout=core_config.timeout, + style_prompt=core_config.style_prompt, + ) + ) + output = work / f"{self.preset_name}.wav" + result = synthesizer.synthesize( + SynthesisRequest( + text=self.text or _sample_text(), + output_path=str(output), + voice=core_config.voice, + style_prompt=core_config.style_prompt or None, + clone_audio_path=self.clone_audio_path or None, + clone_audio_text=self.clone_audio_text or None, + ) + ) + self.finished.emit(str(playable_voice_preview(Path(result.output_path)))) + except Exception as exc: + if isinstance(exc, ValueError): + logger.warning("音色试听失败: %s", exc) + else: + logger.exception("音色试听失败: %s", exc) + self.error.emit(str(exc)) + + +def bundled_voice_preview(preset_name: str) -> Path | None: + preview_dirs = ( + ASSETS_PATH / "voice-previews", + RESOURCE_PATH / "assets" / "voice-previews", + Path(__file__).resolve().parents[2] / "resources" / "assets" / "voice-previews", + CACHE_PATH / "voice-previews", + ) + for preview_dir in preview_dirs: + for suffix in (".mp3", ".wav", ".flac"): + path = preview_dir / f"{preset_name}{suffix}" + if path.exists() and path.stat().st_size > 0: + return path + return None + + +def playable_voice_preview(path: Path) -> Path: + """Return a preview file that QMediaPlayer can play reliably. + + The bundled Edge/SiliconFlow samples are mp3, while Gemini samples are wav. + Some Qt Multimedia backends silently fail to play mp3 in the desktop app, + so normalize bundled non-wav samples to wav once and reuse the cached file. + """ + if path.suffix.lower() == ".wav": + return path + + target = _playable_preview_target(path) + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() and target.stat().st_size > 0 and target.stat().st_mtime >= path.stat().st_mtime: + return target + + try: + subprocess.run( + [ + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + str(path), + "-ac", + "1", + "-ar", + "24000", + str(target), + ], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except Exception as exc: + logger.warning("内置音色试听转码失败,回退原文件: %s", exc) + return path + + return target if target.exists() and target.stat().st_size > 0 else path + + +def _playable_preview_target(path: Path) -> Path: + preview_dir = path.parent.resolve() + bundled_dirs = { + (ASSETS_PATH / "voice-previews").resolve(), + (RESOURCE_PATH / "assets" / "voice-previews").resolve(), + (Path(__file__).resolve().parents[2] / "resources" / "assets" / "voice-previews").resolve(), + } + if preview_dir in bundled_dirs: + return CACHE_PATH / "voice-previews" / f"{path.stem}.wav" + return path.with_suffix(".wav") diff --git a/videocaptioner/ui/thread/worker.py b/videocaptioner/ui/thread/worker.py new file mode 100644 index 00000000..c3c4cf09 --- /dev/null +++ b/videocaptioner/ui/thread/worker.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +"""页面后台线程的统一基类。 + +约定: + +- 子类实现 ``_work()``,正常返回即成功;抛异常会被捕获并发 ``error`` 信号。 +- 取消是协作式的:``stop()`` 置取消标记并调用 ``_on_cancel()``(子类在这里 + 停掉 core 层的 optimizer/translator 等),``_work()`` 在阶段边界调用 + ``checkpoint()`` 主动退出;只有等待超时才退化为 ``terminate()``。 +- 被取消的运行静默结束:不发 ``error``,也不发结果信号。 +- 面向用户的错误/状态文案用模块级 ``tr("t_xxx.key")``(已 i18n),不要用 Qt 的 + ``self.tr()``(基类是 QThread,语境不对);纯日志/调试串不翻译。 +""" + +from __future__ import annotations + +from PyQt5.QtCore import QThread, pyqtSignal + +from videocaptioner.core.utils.logger import setup_logger + +logger = setup_logger("ui_worker_thread") + + +class WorkerCancelled(Exception): + """协作取消信号量:checkpoint() 在收到取消请求后抛出。""" + + +class WorkerThread(QThread): + """progress(int, str) / error(str) + 协作式取消的 QThread 基类。""" + + progress = pyqtSignal(int, str) + error = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._cancel_requested = False + + # ----- 子类接口 ----- + + def _work(self) -> None: + raise NotImplementedError + + def _on_cancel(self) -> None: + """收到取消请求时的钩子:停掉持有的 core 层执行器。""" + + # ----- 取消协议 ----- + + def is_cancel_requested(self) -> bool: + return self._cancel_requested + + def checkpoint(self) -> None: + """阶段边界调用;收到取消请求则中止本次运行。""" + if self._cancel_requested: + raise WorkerCancelled + + def request_cancel(self) -> None: + """只置取消标记并触发钩子,不等待。 + + 批量并发取消时先对所有线程 ``request_cancel()`` 广播,再逐个 ``stop()`` + 收割——这样 N 个线程并发退出,总等待 ≈ 单个最慢而非 N×wait_ms。 + """ + if not self.isRunning(): + return + self._cancel_requested = True + try: + self._on_cancel() + except Exception: + logger.exception("取消钩子执行失败") + + def stop(self, wait_ms: int = 3000) -> None: + """请求协作取消;超时未退出才强杀(最后手段)。""" + if not self.isRunning(): + return + self.request_cancel() + if not self.wait(wait_ms): + logger.warning("%s 未在 %dms 内退出,强制终止", type(self).__name__, wait_ms) + self.terminate() + self.wait(1000) + + # ----- 运行包装 ----- + + def run(self): + try: + self._work() + except WorkerCancelled: + logger.info("%s 已取消", type(self).__name__) + except Exception as exc: + logger.exception("%s 失败: %s", type(self).__name__, exc) + self.error.emit(str(exc)) diff --git a/videocaptioner/ui/view/batch_process_interface.py b/videocaptioner/ui/view/batch_process_interface.py index 121d6a83..3a331eef 100644 --- a/videocaptioner/ui/view/batch_process_interface.py +++ b/videocaptioner/ui/view/batch_process_interface.py @@ -1,471 +1,1803 @@ -import os +# -*- coding: utf-8 -*- +"""批量处理页:队列工作台。 + +布局与状态: +顶部是页头(标题 + 工具栏)与四张处理模式卡,中部左侧是任务队列 +(空态拖放区 / 任务行列表 + 过滤 tab),右侧是「本批任务」统计与 +流水线阶段卡,底部是提示条(含并发数选择)。 + +数据模型分三层,全部在 GUI 线程编排、无轮询: + + BatchJob 一行任务的纯数据(路径 / 状态 / 进度 / 输出) + JobRunner 把一个任务按阶段链(转录 -> 字幕 -> 配音 -> 合成) + 依次跑完,阶段进度映射为 0-100 总进度 + BatchController 持有队列与并发槽位,按并发数派发 JobRunner, + 支持暂停(停止派发、在跑任务继续)/ 重试 / 移除 + +页面生命周期状态(PageState)由队列与控制器派生(_page_state), +不单独维护状态字段,避免与队列事实脱节。 +""" + +from __future__ import annotations -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QColor, QFont +import os +import shutil +from dataclasses import dataclass, field +from enum import Enum, auto +from functools import partial +from pathlib import Path +from typing import Callable, Optional + +from PyQt5.QtCore import QObject, Qt, pyqtSignal from PyQt5.QtWidgets import ( QFileDialog, + QFrame, + QGridLayout, QHBoxLayout, - QHeaderView, - QSizePolicy, - QTableWidget, - QTableWidgetItem, + QLabel, + QScrollArea, + QStackedWidget, QVBoxLayout, QWidget, ) -from qfluentwidgets import ( - Action, - ComboBox, - InfoBar, - InfoBarPosition, - ProgressBar, - PushButton, - RoundMenu, - TableWidget, -) -from qfluentwidgets import ( - FluentIcon as FIF, -) +from qfluentwidgets import InfoBar, InfoBarPosition +from videocaptioner.core.application import output_paths from videocaptioner.core.constant import ( INFOBAR_DURATION_INFO, INFOBAR_DURATION_SUCCESS, INFOBAR_DURATION_WARNING, ) from videocaptioner.core.entities import ( - BatchTaskStatus, - BatchTaskType, SupportedAudioFormats, SupportedSubtitleFormats, SupportedVideoFormats, + TranscribeTask, + TranslatorServiceEnum, ) -from videocaptioner.core.utils.platform_utils import open_folder -from videocaptioner.ui.thread.batch_process_thread import ( - BatchProcessThread, - BatchTask, +from videocaptioner.core.utils.platform_utils import open_folder, reveal_in_explorer +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.config import cfg +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.app_dialog import ConfirmDialog +from videocaptioner.ui.components.workbench import ( + DropZone, + ElidedLabel, + FilterTabs, + IconBox, + PanelHeader, + PillSelect, + ProgressBarLine, + RoundIconButton, + SelectableCard, + StatusPill, + WorkbenchButton, + WorkbenchPanel, + apply_font, + draw_rounded_surface, + file_type_icon, + icon_pixmap, ) +from videocaptioner.ui.i18n import N_, tr +from videocaptioner.ui.task_factory import TaskFactory +from videocaptioner.ui.thread.dubbing_thread import DubbingThread +from videocaptioner.ui.thread.subtitle_thread import SubtitleThread +from videocaptioner.ui.thread.transcript_thread import TranscriptThread +from videocaptioner.ui.thread.video_synthesis_thread import VideoSynthesisThread + +_MEDIA_EXTENSIONS = {f".{fmt.value}" for fmt in SupportedAudioFormats} | { + f".{fmt.value}" for fmt in SupportedVideoFormats +} +_SUBTITLE_EXTENSIONS = {f".{fmt.value}" for fmt in SupportedSubtitleFormats} +_FOLDER_MAX_DEPTH = 3 + + +# 模块级常量在 init_i18n 之前求值,只能存 N_ 标记的 key,翻译放在使用点。 +@dataclass(frozen=True) +class BatchMode: + key: str + icon: AppIcon + title_key: str + desc_key: str + accepts_media: bool # True:音视频输入;False:字幕文件输入 + + +BATCH_MODES = [ + BatchMode( + "full", + AppIcon.VIDEO, + N_("batch.mode.full.title"), + N_("batch.mode.full.desc"), + True, + ), + BatchMode( + "trans_sub", + AppIcon.SUBTITLE, + N_("batch.mode.trans_sub.title"), + N_("batch.mode.trans_sub.desc"), + True, + ), + BatchMode( + "transcribe", + AppIcon.MICROPHONE, + N_("batch.mode.transcribe.title"), + N_("batch.mode.transcribe.desc"), + True, + ), + BatchMode( + "subtitle", + AppIcon.FILE, + N_("batch.mode.subtitle.title"), + N_("batch.mode.subtitle.desc"), + False, + ), +] + +# 阶段元数据:key -> (图标, 标题 key, 说明 key) +STAGE_SPECS = { + "transcribe": ( + AppIcon.MICROPHONE, + N_("batch.stage.transcribe.title"), + N_("batch.stage.transcribe.desc"), + ), + "subtitle": ( + AppIcon.SUBTITLE, + N_("batch.stage.subtitle.title"), + N_("batch.stage.subtitle.desc"), + ), + "dubbing": ( + AppIcon.VOLUME, + N_("batch.stage.dubbing.title"), + N_("batch.stage.dubbing.desc"), + ), + "synthesis": ( + AppIcon.VIDEO, + N_("batch.stage.synthesis.title"), + N_("batch.stage.synthesis.desc"), + ), +} + + +def stage_title(stage: str) -> str: + # 不在调用点写 tr(STAGE_SPECS["xx"][1]):babel 按 token 抽取,会把 "xx" 误收进 .pot + return tr(STAGE_SPECS[stage][1]) + + +def mode_by_key(key: str) -> BatchMode: + for mode in BATCH_MODES: + if mode.key == key: + return mode + return BATCH_MODES[0] + + +def stages_for_mode(mode_key: str, dubbing_enabled: bool) -> list[str]: + if mode_key == "transcribe": + return ["transcribe"] + if mode_key == "subtitle": + return ["subtitle"] + if mode_key == "trans_sub": + return ["transcribe", "subtitle"] + stages = ["transcribe", "subtitle"] + if dubbing_enabled: + stages.append("dubbing") + stages.append("synthesis") + return stages + + +def collect_files(paths: list[str], extensions: set[str]) -> tuple[list[str], int]: + """展开文件夹(最多 3 层)并按扩展名过滤,返回 (有效文件, 忽略数)。""" + expanded: list[str] = [] + for path in paths: + if os.path.isdir(path): + for root, dirs, files in os.walk(path): + depth = root[len(path) :].count(os.sep) + if depth >= _FOLDER_MAX_DEPTH: + dirs.clear() + continue + dirs.sort() + expanded.extend(os.path.join(root, name) for name in sorted(files)) + else: + expanded.append(path) + valid = [ + path + for path in expanded + if os.path.isfile(path) and Path(path).suffix.lower() in extensions + ] + return valid, len(expanded) - len(valid) + + +class JobStatus(Enum): + WAITING = auto() + RUNNING = auto() + COMPLETED = auto() + FAILED = auto() + + +@dataclass +class BatchJob: + path: str + status: JobStatus = JobStatus.WAITING + progress: int = 0 + note: str = field(default_factory=lambda: tr("batch.status.waiting")) + error: str = "" + stage: str = "" # 运行中的当前阶段 key + outputs: list[str] = field(default_factory=list) + + @property + def name(self) -> str: + return Path(self.path).name + + @property + def folder(self) -> str: + folder = str(Path(self.path).parent) + home = os.path.expanduser("~") + return "~" + folder[len(home) :] if folder.startswith(home) else folder + + +class PageState(Enum): + EMPTY = auto() + READY = auto() + RUNNING = auto() + DONE = auto() + + +# --------------------------------------------------------------------------- +# 线程编排 +# --------------------------------------------------------------------------- + + +class JobRunner(QObject): + """把一个文件按阶段链依次跑完;阶段 i/n 的进度映射到总进度。""" + + progressChanged = pyqtSignal(int, str, str) # 总进度 0-100 / 行内文案 / 阶段 key + completed = pyqtSignal(list) # 输出文件路径列表 + failed = pyqtSignal(str) + + def __init__(self, file_path: str, stages: list[str], parent: Optional[QObject] = None): + super().__init__(parent) + self._path = file_path + self._stages = stages + self._index = 0 + self._thread = None + self._subtitle_path = "" # 转录/字幕阶段产出,供后续阶段消费 + self._dub_video = "" # 配音阶段产出的中间配音视频(在任务目录内) + self._task_dir = "" # 本文件流水线的任务目录(中间产物落盘处) + + def start(self): + self._run_stage(0) + + def _ensure_task_dir(self) -> str: + if not self._task_dir: + self._task_dir = TaskFactory.new_task_dir(self._path, "batch") + return self._task_dir + + def request_cancel(self): + """广播取消请求(非阻塞):批量取消时先让所有线程并发开始退出,再逐个 cancel() 收割。""" + if self._thread is not None and self._thread.isRunning(): + self._thread.request_cancel() + + def cancel(self): + thread, self._thread = self._thread, None + if thread is not None and thread.isRunning(): + # 逐个断连:某个信号无连接抛 TypeError 不应放跑其余信号 + for signal in (thread.progress, thread.error, thread.finished): + try: + signal.disconnect() + except TypeError: + pass + thread.stop() + # 取消的运行是废弃物:thread.stop() 已等线程退出,任务目录可安全清掉 + output_paths.cleanup_task_dir(self._task_dir, keep=False) + self._task_dir = "" + self._dub_video = "" + + def release(self): + """正常完成路径的收尾:等线程真正退出,供控制器在 deleteLater 前调用。 + + 自定义 finished 信号在 run() 返回前发出,此刻线程可能还在 finally + (如 SubtitleThread clear_task_context);不等退出就销毁 running + QThread 会触发 "Destroyed while still running" qFatal。 + 取消路径 cancel() 已把 _thread 置 None 并 wait 过,这里自然跳过。 + """ + thread, self._thread = self._thread, None + if thread is not None and thread.isRunning(): + thread.wait(2000) + + # ----- 阶段链 ----- + + def _run_stage(self, index: int): + self._index = index + stage = self._stages[index] + try: + thread = self._build_thread(stage) + except Exception as exc: # 任务构建失败(配置/路径问题) + self.failed.emit(f"{stage_title(stage)}:{exc}") + return + thread.setParent(self) + thread.progress.connect(self._on_stage_progress) + thread.error.connect(self._on_stage_error) + self._thread = thread + self._emit_progress(0, tr("batch.status.preparing")) + thread.start() + + def _build_thread(self, stage: str): + if stage == "transcribe": + last = self._is_last(stage) + task = TaskFactory.create_transcribe_task( + self._path, + need_next_task=not last, + task_dir=None if last else self._ensure_task_dir(), + ) + thread = TranscriptThread(task) + thread.finished.connect(self._on_transcribed) + return thread + if stage == "subtitle": + # 字幕模式下输入文件本身就是字幕;链式模式消费转录产物。 + # 成品锚定到媒体文件,落在源文件旁(builder 内统一处理)。 + source = self._subtitle_path or self._path + video = self._path if self._subtitle_path else None + need_synthesis = "synthesis" in self._stages[self._index + 1 :] + task = TaskFactory.create_subtitle_task( + source, + video_path=video, + need_next_task=need_synthesis, + task_dir=self._ensure_task_dir() if need_synthesis else self._task_dir or None, + ) + thread = SubtitleThread(task) + thread.finished.connect(self._on_subtitled) + return thread + if stage == "dubbing": + # 配音视频与音频都是中间产物,进任务目录,随任务目录清理 + video = Path(self._path) + task_dir = Path(self._ensure_task_dir()) + dubbing_dir = task_dir / output_paths.DUBBING_DIR + task = TaskFactory.create_dubbing_task( + self._path, + self._subtitle_path, + output_video_path=str(dubbing_dir / f"dubbed{video.suffix}"), + output_audio_path=str(dubbing_dir / output_paths.DUBBING_AUDIO_FILE), + task_dir=str(task_dir), + ) + thread = DubbingThread(task) + thread.finished.connect(self._on_dubbed) + return thread + # synthesis:输出路径按原始视频命名,输入可换成配音视频 + task = TaskFactory.create_synthesis_task( + self._path, + self._subtitle_path, + task_dir=self._task_dir or None, + dubbed=bool(self._dub_video) or "dubbing" in self._stages, + ) + if self._dub_video: + task.video_path = self._dub_video + thread = VideoSynthesisThread(task) + thread.finished.connect(self._on_synthesized) + return thread + + def _finish(self, outputs: list[str]): + """流水线成功收尾:按设置清理任务目录后上报产物。""" + output_paths.cleanup_task_dir( + self._task_dir, keep=bool(cfg.keep_intermediates.value) + ) + self._task_dir = "" + self.completed.emit(outputs) + def _is_last(self, stage: str) -> bool: + return self._stages[-1] == stage -class BatchProcessInterface(QWidget): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setObjectName("batchProcessInterface") - self.setWindowTitle(self.tr("批量处理")) - self.setAcceptDrops(True) - self.batch_thread = BatchProcessThread() - - self.init_ui() - self.setup_connections() - - def init_ui(self): - # 创建主布局 - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(16, 16, 16, 16) - main_layout.setSpacing(8) - - # 顶部控制区域 - top_layout = QHBoxLayout() - top_layout.setSpacing(8) - - # 任务类型选择 - self.task_type_combo = ComboBox() - self.task_type_combo.addItems([str(task_type) for task_type in BatchTaskType]) - self.task_type_combo.setCurrentText(str(BatchTaskType.FULL_PROCESS)) - - # 任务类型说明 - self.task_type_descriptions = { - str(BatchTaskType.TRANSCRIBE): self.tr("仅进行语音识别,生成字幕文件"), - str(BatchTaskType.SUBTITLE): self.tr("对已有字幕进行分割、优化或翻译"), - str(BatchTaskType.TRANS_SUB): self.tr("先转录再处理字幕,不合成视频"), - str(BatchTaskType.FULL_PROCESS): self.tr("转录 → 字幕处理 → 合成视频"), - } + def _advance(self, outputs: Optional[list[str]] = None): + if outputs is not None: + self._finish(outputs) + return + self._run_stage(self._index + 1) - # 控制按钮 - self.add_file_btn = PushButton(self.tr("添加文件"), icon=FIF.ADD) - self.start_all_btn = PushButton(self.tr("开始处理"), icon=FIF.PLAY) - self.clear_btn = PushButton(self.tr("清空列表"), icon=FIF.DELETE) - - # 添加到顶部布局 - top_layout.addWidget(self.task_type_combo) - top_layout.addWidget(self.add_file_btn) - top_layout.addWidget(self.clear_btn) - - top_layout.addStretch() - top_layout.addWidget(self.start_all_btn) - - # 创建任务表格 - self.task_table = TableWidget() - self.task_table.setColumnCount(3) - self.task_table.setHorizontalHeaderLabels(["文件名", "进度", "状态"]) - - # 设置表格样式 - self.task_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - self.task_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Fixed) - self.task_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed) - self.task_table.setColumnWidth(1, 250) # 进度条列宽 - self.task_table.setColumnWidth(2, 160) # 状态列宽 - - # 设置行高 - self.task_table.verticalHeader().setDefaultSectionSize(40) # 设置默认行高 - - # 设置表格边框 - self.task_table.setBorderVisible(True) - self.task_table.setBorderRadius(12) - - # 设置表格不可编辑 - self.task_table.setEditTriggers(QTableWidget.NoEditTriggers) - - # 设置表格大小策略 - self.task_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.task_table.setMinimumHeight(300) # 设置最小高度 - - # 连接双击信号 - self.task_table.doubleClicked.connect(self.on_table_double_clicked) - - # 添加到主布局 - main_layout.addLayout(top_layout) - main_layout.addWidget(self.task_table) - - # 连接信号 - self.add_file_btn.clicked.connect(self.on_add_file_clicked) - self.start_all_btn.clicked.connect(self.start_all_tasks) - self.clear_btn.clicked.connect(self.clear_tasks) - self.task_type_combo.currentTextChanged.connect(self.on_task_type_changed) - - def setup_connections(self): - # 批处理线程信号连接 - self.batch_thread.task_progress.connect(self.update_task_progress) - self.batch_thread.task_error.connect(self.on_task_error) - self.batch_thread.task_completed.connect(self.on_task_completed) - - # 表格右键菜单 - self.task_table.setContextMenuPolicy(Qt.CustomContextMenu) # type: ignore - self.task_table.customContextMenuRequested.connect(self.show_context_menu) - - def on_add_file_clicked(self): - task_type = self.task_type_combo.currentText() - file_filter = "" - if task_type in [ - BatchTaskType.TRANSCRIBE, - BatchTaskType.TRANS_SUB, - BatchTaskType.FULL_PROCESS, - ]: - # 获取所有支持的音视频格式 - audio_formats = [f"*.{fmt.value}" for fmt in SupportedAudioFormats] - video_formats = [f"*.{fmt.value}" for fmt in SupportedVideoFormats] - formats = audio_formats + video_formats - file_filter = f"音视频文件 ({' '.join(formats)})" - elif task_type == BatchTaskType.SUBTITLE: - # 获取所有支持的字幕格式 - subtitle_formats = [f"*.{fmt.value}" for fmt in SupportedSubtitleFormats] - file_filter = f"字幕文件 ({' '.join(subtitle_formats)})" - - files, _ = QFileDialog.getOpenFileNames(self, "选择文件", "", file_filter) - if files: - self.add_files(files) + # ----- 阶段完成回调 ----- - def dragEnterEvent(self, event): - if event.mimeData().hasUrls(): - event.accept() - else: - event.ignore() + def _on_transcribed(self, task: TranscribeTask): + if not task.output_path: + self.failed.emit( + tr("batch.error.empty_output", stage=stage_title("transcribe")) + ) + return + self._subtitle_path = task.output_path + self._advance([task.output_path] if self._is_last("transcribe") else None) - def dropEvent(self, event): - files = [url.toLocalFile() for url in event.mimeData().urls()] - self.add_files(files) - - def add_files(self, file_paths): - task_type = BatchTaskType(self.task_type_combo.currentText()) - - # 展开文件夹为其中的文件(最多 3 层深度) - max_depth = 3 - expanded = [] - for path in file_paths: - if os.path.isdir(path): - for root, dirs, files in os.walk(path): - depth = root.replace(path, "").count(os.sep) - if depth >= max_depth: - dirs.clear() - continue - expanded.extend(os.path.join(root, f) for f in files) - else: - expanded.append(path) - file_paths = expanded - - # 检查文件是否存在并收集不存在的文件 - non_existent_files = [] - valid_files = [] - for file_path in file_paths: - if not os.path.exists(file_path): - non_existent_files.append(os.path.basename(file_path)) - else: - valid_files.append(file_path) + def _on_subtitled(self, _video_path: str, output_path: str): + if not output_path: + self.failed.emit( + tr("batch.error.empty_output", stage=stage_title("subtitle")) + ) + return + self._subtitle_path = output_path + self._advance([output_path] if self._is_last("subtitle") else None) - # 如果有不存在的文件,显示警告 - if non_existent_files: - InfoBar.warning( - title="文件不存在", - content=f"以下文件不存在:\n{', '.join(non_existent_files)}", - duration=INFOBAR_DURATION_WARNING, - position=InfoBarPosition.TOP, - parent=self, + def _on_dubbed(self, task): + if not task.output_video_path: + self.failed.emit( + tr("batch.error.empty_video_output", stage=stage_title("dubbing")) ) + return + self._dub_video = task.output_video_path + self._advance() - # 如果没有有效文件,直接返回 - if not valid_files: + def _on_synthesized(self, task): + if not task.output_path: + self.failed.emit( + tr("batch.error.empty_output", stage=stage_title("synthesis")) + ) + return + self._advance([task.output_path]) + + # ----- 进度 / 错误 ----- + + def _on_stage_progress(self, value: int, message: str): + self._emit_progress(value, message) + + def _emit_progress(self, value: int, message: str): + stage = self._stages[self._index] + title = stage_title(stage) + note = f"{title} · {message}" if message else title + overall = int((self._index * 100 + max(0, min(100, value))) / len(self._stages)) + self.progressChanged.emit(overall, note, stage) + + def _on_stage_error(self, message: str): + stage = self._stages[self._index] + self.failed.emit(f"{stage_title(stage)}:{message}") + + +class BatchController(QObject): + """批量队列:按并发数派发 JobRunner,暂停只停派发、在跑任务继续。""" + + queueChanged = pyqtSignal() # 队列成员变化(增删/清空),页面重建行 + jobChanged = pyqtSignal(int) # 单行状态/进度变化 + activityChanged = pyqtSignal() # 运行/暂停状态变化 + batchFinished = pyqtSignal() # 一轮批量跑完(暂停清空不算) + + def __init__( + self, concurrency: Callable[[], int], parent: Optional[QObject] = None + ): + super().__init__(parent) + self.jobs: list[BatchJob] = [] + self._concurrency = concurrency + self._runners: dict[JobRunner, BatchJob] = {} + self._stages: list[str] = [] + self._dispatch_enabled = False + self._started = False # 本批是否启动过(收尾判定用) + self._finish_announced = False # batchFinished 每批只发一次 + + # ----- 队列维护 ----- + + def add_paths(self, paths: list[str]) -> int: + existing = {os.path.normpath(job.path) for job in self.jobs} + added = 0 + for path in paths: + normalized = os.path.normpath(path) + if normalized in existing: + continue + existing.add(normalized) + self.jobs.append(BatchJob(path=path)) + added += 1 + if added: + self.queueChanged.emit() + if self._dispatch_enabled: + self._dispatch() + return added + + def keep_only(self, extensions: set[str]) -> int: + """切换模式后丢弃扩展名不匹配的任务,返回丢弃数。仅空闲时调用。""" + kept = [job for job in self.jobs if Path(job.path).suffix.lower() in extensions] + dropped = len(self.jobs) - len(kept) + if dropped: + self.jobs = kept + self.queueChanged.emit() + return dropped + + def remove(self, job: BatchJob): + runner = self._runner_of(job) + if runner is not None: + runner.cancel() + self._release_runner(runner) + if job in self.jobs: + self.jobs.remove(job) + self.queueChanged.emit() + self._dispatch() + + def retry(self, job: BatchJob, stages: Optional[list[str]] = None): + """失败任务重试即跑:批次已结束时带 stages 重新开闸(只复跑这一个)。 + + 暂停排空中(还有任务在跑但派发已关)不偷开闸,尊重暂停语义。 + """ + if job.status != JobStatus.FAILED: return + job.status = JobStatus.WAITING + job.progress = 0 + job.note = tr("batch.status.waiting") + job.error = "" + self.jobChanged.emit(self.jobs.index(job)) + if stages is not None and not self._dispatch_enabled and not self._runners: + self._stages = list(stages) + self._dispatch_enabled = True + self._started = True + self._finish_announced = False + if self._dispatch_enabled: + self._dispatch() + else: + self.activityChanged.emit() + + def clear(self): + self._dispatch_enabled = False + self._started = False + runners = list(self._runners) + for runner in runners: # 先并发广播取消,避免 N 个线程串行各等 3s 冻结 GUI + runner.request_cancel() + for runner in runners: + runner.cancel() + self._release_runner(runner) + self.jobs.clear() + self.queueChanged.emit() + self.activityChanged.emit() + + # ----- 运行控制 ----- + + def start(self, stages: list[str]): + self._stages = list(stages) + for job in self.jobs: + if job.status == JobStatus.FAILED: + job.status = JobStatus.WAITING + job.progress = 0 + job.note = tr("batch.status.waiting") + job.error = "" + self._dispatch_enabled = True + self._started = True + self._finish_announced = False + self.queueChanged.emit() + self._dispatch() + + def pause(self): + self._dispatch_enabled = False + self.activityChanged.emit() + + def resume(self): + self._dispatch_enabled = True + self._dispatch() + + def shutdown(self): + self._dispatch_enabled = False + runners = list(self._runners) + for runner in runners: # 先并发广播取消,再逐个收割 + runner.request_cancel() + for runner in runners: + runner.cancel() + self._release_runner(runner) + + # ----- 状态查询 ----- + + def is_active(self) -> bool: + return bool(self._runners) or ( + self._dispatch_enabled and self.count(JobStatus.WAITING) > 0 + ) - # 对有效文件按文件名排序 - valid_files.sort(key=lambda x: os.path.basename(x).lower()) + def is_paused(self) -> bool: + return bool(self._runners) and not self._dispatch_enabled - # 如果表格为空,自动检测文件类型并设置任务类型 - if self.task_table.rowCount() == 0 and self.task_type_combo.currentIndex() == 0: - first_file = valid_files[0].lower() - is_subtitle = any( - first_file.endswith(f".{fmt.value}") for fmt in SupportedSubtitleFormats - ) - if is_subtitle: - self.task_type_combo.setCurrentText(str(BatchTaskType.SUBTITLE)) - task_type = BatchTaskType.SUBTITLE - # elif is_media: - # self.task_type_combo.setCurrentText(str(BatchTaskType.FULL_PROCESS)) - # task_type = BatchTaskType.FULL_PROCESS + def count(self, status: JobStatus) -> int: + return sum(1 for job in self.jobs if job.status == status) - # 过滤文件类型 - valid_files = self.filter_files(valid_files, task_type) + def current_progress(self) -> int: + """当前进度:处理中任务的平均进度(与右栏「当前进度」对应)。""" + running = [job.progress for job in self.jobs if job.status == JobStatus.RUNNING] + return int(sum(running) / len(running)) if running else 0 - if not valid_files: - InfoBar.warning( - title="无效文件", - content="请选择正确的文件类型", - duration=INFOBAR_DURATION_WARNING, - position=InfoBarPosition.TOP, - parent=self, - ) - return + def active_stages(self) -> set[str]: + return {job.stage for job in self._runners.values() if job.stage} - for file_path in valid_files: - # 检查是否已存在相同任务 - exists = False - for row in range(self.task_table.rowCount()): - if self.task_table.item(row, 0).toolTip() == file_path: - exists = True - InfoBar.warning( - title="任务已存在", - content="任务已存在", - duration=INFOBAR_DURATION_WARNING, - position=InfoBarPosition.TOP_RIGHT, - parent=self, - ) - break - - if not exists: - self.add_task_to_table(file_path) - - def filter_files(self, file_paths, task_type: BatchTaskType): - valid_extensions = {} - - # 根据任务类型设置有效的扩展名 - if task_type in [ - BatchTaskType.TRANSCRIBE, - BatchTaskType.TRANS_SUB, - BatchTaskType.FULL_PROCESS, - ]: - valid_extensions = {f".{fmt.value}" for fmt in SupportedAudioFormats} | { - f".{fmt.value}" for fmt in SupportedVideoFormats - } - elif task_type == BatchTaskType.SUBTITLE: - valid_extensions = {f".{fmt.value}" for fmt in SupportedSubtitleFormats} - - return [ - f - for f in file_paths - if any(f.lower().endswith(ext) for ext in valid_extensions) - ] + # ----- 派发 ----- - def add_task_to_table(self, file_path): - row = self.task_table.rowCount() - self.task_table.insertRow(row) - - # 文件名 - file_name = QTableWidgetItem(os.path.basename(file_path)) - file_name.setToolTip(file_path) - self.task_table.setItem(row, 0, file_name) - - # 进度条 - progress_bar = ProgressBar() - progress_bar.setRange(0, 100) - progress_bar.setValue(0) - progress_bar.setFixedHeight(18) - self.task_table.setCellWidget(row, 1, progress_bar) - - # 状态 - status = QTableWidgetItem(str(BatchTaskStatus.WAITING)) - status.setTextAlignment(Qt.AlignCenter) # type: ignore - status.setForeground(Qt.gray) # type: ignore # 设置字体颜色为灰色 - font = QFont() - font.setBold(True) - status.setFont(font) - self.task_table.setItem(row, 2, status) - - def show_context_menu(self, pos): - row = self.task_table.rowAt(pos.y()) - if row < 0: - return + def _runner_of(self, job: BatchJob) -> Optional[JobRunner]: + for runner, owned in self._runners.items(): + if owned is job: + return runner + return None - menu = RoundMenu(parent=self) - file_path = self.task_table.item(row, 0).toolTip() - status = self.task_table.item(row, 2).text() + def _release_runner(self, runner: JobRunner): + self._runners.pop(runner, None) + runner.release() + runner.deleteLater() - start_action = Action(FIF.PLAY, "开始") - start_action.triggered.connect(lambda: self.start_task(file_path)) - menu.addAction(start_action) + def _dispatch(self): + while self._dispatch_enabled and len(self._runners) < max(1, self._concurrency()): + job = next((j for j in self.jobs if j.status == JobStatus.WAITING), None) + if job is None: + break + self._start_job(job) + self._maybe_finish() + self.activityChanged.emit() - cancel_action = Action(FIF.CLOSE, "取消") - cancel_action.triggered.connect(lambda: self.cancel_task(file_path)) - menu.addAction(cancel_action) + def _maybe_finish(self): + """统一收尾判定:没有在跑且没有可跑时宣布本批结束(每批一次)。 - menu.addSeparator() - open_folder_action = Action(FIF.FOLDER, "打开输出文件夹") - open_folder_action.triggered.connect(lambda: self.open_output_folder(file_path)) - menu.addAction(open_folder_action) + 覆盖正常跑完、暂停后排空到底、运行中移除最后一个等待任务等路径。 + 暂停且还有等待任务时不算结束(用户主动停在半程)。 + """ + if self._runners or not self._started or self._finish_announced: + return + if not self.jobs or self.count(JobStatus.WAITING) > 0: + return + self._dispatch_enabled = False + self._finish_announced = True + self.batchFinished.emit() + + def _start_job(self, job: BatchJob): + job.status = JobStatus.RUNNING + job.progress = 0 + job.note = tr("batch.status.preparing") + job.stage = self._stages[0] if self._stages else "" + runner = JobRunner(job.path, self._stages, self) + self._runners[runner] = job + runner.progressChanged.connect(partial(self._on_job_progress, job)) + runner.completed.connect(partial(self._on_job_completed, job, runner)) + runner.failed.connect(partial(self._on_job_failed, job, runner)) + self.jobChanged.emit(self.jobs.index(job)) + runner.start() + + def _on_job_progress(self, job: BatchJob, value: int, note: str, stage: str): + job.progress = value + job.note = note + job.stage = stage + if job in self.jobs: + self.jobChanged.emit(self.jobs.index(job)) + + def _on_job_completed(self, job: BatchJob, runner: JobRunner, outputs: list): + self._release_runner(runner) + job.status = JobStatus.COMPLETED + job.progress = 100 + job.stage = "" + job.outputs = list(outputs) + job.note = ( + tr("batch.note.output", name=Path(outputs[-1]).name) + if outputs + else tr("batch.note.completed") + ) + if job in self.jobs: + self.jobChanged.emit(self.jobs.index(job)) + self._dispatch() + + def _on_job_failed(self, job: BatchJob, runner: JobRunner, error: str): + self._release_runner(runner) + job.status = JobStatus.FAILED + job.stage = "" + job.error = error + job.note = error.splitlines()[0] if error else tr("batch.note.failed") + if job in self.jobs: + self.jobChanged.emit(self.jobs.index(job)) + self._dispatch() + + +# --------------------------------------------------------------------------- +# 页面组件 +# --------------------------------------------------------------------------- + + +class SquareIconButton(QFrame): + """38px 方形图标按钮:打开目录 / 重试 / 移除。""" + + clicked = pyqtSignal() + + def __init__(self, icon: AppIcon, tooltip: str = "", parent=None): + super().__init__(parent) + self._icon = icon + self.setFixedSize(38, 38) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + if tooltip: + self.setToolTip(tooltip) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.iconLabel = QLabel(self) + self.iconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + layout.addWidget(self.iconLabel) + self.syncStyle() + + def setIcon(self, icon: AppIcon): + if icon != self._icon: + self._icon = icon + self.syncStyle() + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def enterEvent(self, event): + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + palette = app_palette() + hovered = self.underMouse() and self.isEnabled() + surface = palette.card_surface + draw_rounded_surface( + self, + rgba(palette.accent, 0.10) if hovered else surface, + rgba(palette.accent, 0.62) if hovered else palette.line_soft, + 10, + ) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.iconLabel.setPixmap(icon_pixmap(self._icon, palette.muted, 17)) + self.setStyleSheet("background: transparent; border: none;") + self.update() + + +class TaskRow(QFrame): + """任务行:文件名/目录 + 进度 + 状态胶囊 + 操作。 + + 点击行空白区域弹出任务详情(完整错误 / 输出文件)。 + """ + + openRequested = pyqtSignal(object) + retryRequested = pyqtSignal(object) + removeRequested = pyqtSignal(object) + detailsRequested = pyqtSignal(object) + + def __init__(self, job: BatchJob, parent=None): + super().__init__(parent) + self.setObjectName("batchTaskRow") + self.job = job + self._running = False + self.setMinimumHeight(78) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + self.setToolTip(tr("batch.row.details_tip")) + + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 12, 14, 12) + layout.setSpacing(14) + + # 文件类型图标(视频/音频/字幕/文本) + self.typeIconBox = QFrame(self) + self.typeIconBox.setObjectName("taskTypeIconBox") + self.typeIconBox.setFixedSize(34, 34) + type_icon_layout = QVBoxLayout(self.typeIconBox) + type_icon_layout.setContentsMargins(0, 0, 0, 0) + self.typeIconLabel = QLabel(self.typeIconBox) + self.typeIconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + type_icon_layout.addWidget(self.typeIconLabel) + layout.addWidget(self.typeIconBox) + + file_box = QVBoxLayout() + file_box.setSpacing(5) + file_box.addStretch(1) + self.nameLabel = ElidedLabel(job.name, self) + self.nameLabel.setObjectName("taskName") + apply_font(self.nameLabel, 15, 820) + file_box.addWidget(self.nameLabel) + self.pathLabel = ElidedLabel(job.folder, self) + self.pathLabel.setObjectName("taskPath") + apply_font(self.pathLabel, 12, 720) + file_box.addWidget(self.pathLabel) + file_box.addStretch(1) + layout.addLayout(file_box, 2) + + progress_box = QVBoxLayout() + progress_box.setSpacing(7) + progress_box.addStretch(1) + self.progressLine = ProgressBarLine(self) + progress_box.addWidget(self.progressLine) + self.noteLabel = ElidedLabel(job.note, self) + self.noteLabel.setObjectName("taskNote") + apply_font(self.noteLabel, 12, 720) + progress_box.addWidget(self.noteLabel) + progress_box.addStretch(1) + # 进度列随窗口宽度自适应(窄窗口下让位给文件名列) + progress_host = QWidget(self) + progress_host.setLayout(progress_box) + progress_host.setMinimumWidth(110) + progress_host.setMaximumWidth(210) + layout.addWidget(progress_host, 1) + + self.pill = StatusPill(tr("batch.status.waiting"), "neutral", self) + self.pill.setMinimumWidth(86) + layout.addWidget(self.pill) + + self.primaryButton = SquareIconButton( + AppIcon.FOLDER, tr("batch.row.open_output"), self + ) + self.primaryButton.clicked.connect(lambda: self.openRequested.emit(self.job)) + layout.addWidget(self.primaryButton) + self.removeButton = SquareIconButton(AppIcon.CANCEL, tr("batch.row.remove"), self) + self.removeButton.clicked.connect(lambda: self.removeRequested.emit(self.job)) + layout.addWidget(self.removeButton) + self.syncStyle() + self.refresh() + + def mousePressEvent(self, event): + # 子按钮各自消费点击;落到行空白处的点击展示任务详情 + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.detailsRequested.emit(self.job) + event.accept() + return + super().mousePressEvent(event) + + def refresh(self): + job = self.job + self._running = job.status == JobStatus.RUNNING + self.nameLabel.setText(job.name) + self.pathLabel.setText(job.folder) + self.noteLabel.setText(job.note) + self.noteLabel.setToolTip(job.error or "") + self.progressLine.setValue(job.progress) + pill_specs = { + JobStatus.WAITING: (tr("batch.status.waiting"), "neutral", "accent"), + JobStatus.RUNNING: (tr("batch.status.running"), "warn", "warn"), + JobStatus.COMPLETED: (tr("batch.status.completed"), "ok", "accent"), + JobStatus.FAILED: (tr("batch.status.failed"), "fail", "fail"), + } + text, level, tone = pill_specs[job.status] + self.pill.setState(text, level) + self.progressLine.setTone(tone) + failed = job.status == JobStatus.FAILED + self.primaryButton.setIcon(AppIcon.SYNC if failed else AppIcon.FOLDER) + self.primaryButton.setToolTip( + tr("batch.row.retry") if failed else tr("batch.row.open_output") + ) + try: + self.primaryButton.clicked.disconnect() + except TypeError: + pass + if failed: + self.primaryButton.clicked.connect( + lambda: self.retryRequested.emit(self.job) + ) + else: + self.primaryButton.clicked.connect( + lambda: self.openRequested.emit(self.job) + ) + self.update() - if status != str(BatchTaskStatus.WAITING): - start_action.setEnabled(False) + def paintEvent(self, event): + palette = app_palette() + if self._running: + bg, border = rgba(palette.accent, 0.07), rgba(palette.accent, 0.7) + else: + bg = palette.card_surface + border = palette.line_soft + draw_rounded_surface(self, bg, border, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.typeIconLabel.setPixmap( + icon_pixmap(file_type_icon(self.job.path), palette.muted, 17) + ) + self.setStyleSheet( + f""" + QFrame#batchTaskRow {{ background: transparent; border: none; }} + QFrame#taskTypeIconBox {{ + background: {palette.control}; + border: none; + border-radius: 10px; + }} + QLabel#taskName {{ color: {palette.text}; background: transparent; }} + QLabel#taskPath, QLabel#taskNote {{ + color: {palette.muted}; background: transparent; + }} + """ + ) + self.pill.syncStyle() + self.primaryButton.syncStyle() + self.removeButton.syncStyle() - menu.exec_(self.task_table.viewport().mapToGlobal(pos)) - def open_output_folder(self, file_path: str): - # 根据任务类型和文件路径确定输出文件夹 - task_type = BatchTaskType(self.task_type_combo.currentText()) - file_dir = os.path.dirname(file_path) +class MetricCard(QFrame): + """统计卡:数值 + 标签。""" - if task_type == BatchTaskType.FULL_PROCESS: - # 对于全流程任务,输出在视频同目录下 - output_dir = file_dir + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("batchMetric") + self.setMinimumHeight(68) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(4) + layout.addStretch(1) + self.valueLabel = QLabel("0", self) + self.valueLabel.setObjectName("metricValue") + apply_font(self.valueLabel, 22, 860) + layout.addWidget(self.valueLabel) + self.nameLabel = QLabel("", self) + self.nameLabel.setObjectName("metricName") + apply_font(self.nameLabel, 13, 760) + layout.addWidget(self.nameLabel) + layout.addStretch(1) + self.syncStyle() + + def setData(self, value: str, name: str): + self.valueLabel.setText(value) + self.nameLabel.setText(name) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f""" + QFrame#batchMetric {{ background: transparent; border: none; }} + QLabel#metricValue {{ color: {palette.text}; background: transparent; }} + QLabel#metricName {{ color: {palette.muted}; background: transparent; }} + """ + ) + self.update() + + +class StageRow(QFrame): + """流水线阶段行:图标 + 标题/说明 + 设置入口。 + + 阶段状态(等待/当前/完成)用边框与底色表达;右侧是跳到对应 + 设置页的齿轮按钮,不放文字状态 tag。 + """ + + settingsRequested = pyqtSignal(str) # stage key + + def __init__(self, stage_key: str, parent=None): + super().__init__(parent) + self.setObjectName("batchStageRow") + icon, title, desc = STAGE_SPECS[stage_key] + title, desc = tr(title), tr(desc) + self.stage_key = stage_key + self._icon = icon + self._state = "wait" # wait / active / done + self.setMinimumHeight(56) + + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(11) + self.iconBox = IconBox(icon, self, size=32, tone="accent") + layout.addWidget(self.iconBox) + text_box = QVBoxLayout() + text_box.setSpacing(3) + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("stageTitle") + apply_font(self.titleLabel, 15, 820) + text_box.addWidget(self.titleLabel) + self.descLabel = ElidedLabel(desc, self) + self.descLabel.setObjectName("stageDesc") + apply_font(self.descLabel, 12, 720) + text_box.addWidget(self.descLabel) + layout.addLayout(text_box, 1) + self.settingsButton = RoundIconButton(AppIcon.SETTING, diameter=28, parent=self) + self.settingsButton.setToolTip(tr("batch.stage.settings_tip", title=title)) + self.settingsButton.clicked.connect( + lambda: self.settingsRequested.emit(self.stage_key) + ) + layout.addWidget(self.settingsButton) + self.syncStyle() + + def setState(self, state: str): + assert state in ("wait", "active", "done"), state + if state != self._state: + self._state = state + self.update() + + def paintEvent(self, event): + palette = app_palette() + if self._state in ("active", "done"): + bg, border = rgba(palette.accent, 0.07), rgba(palette.accent, 0.66) else: - # 其他任务输出在文件同目录下 - output_dir = file_dir - - open_folder(output_dir) - - def update_task_progress(self, file_path: str, progress: int, status: str): - for row in range(self.task_table.rowCount()): - if self.task_table.item(row, 0).toolTip() == file_path: - # 更新进度条 - progress_bar = self.task_table.cellWidget(row, 1) - progress_bar.setValue(progress) - # 更新状态 - self.task_table.item(row, 2).setText(status) - break + bg = palette.card_surface + border = palette.line_soft + draw_rounded_surface(self, bg, border, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.iconBox.syncStyle() + self.setStyleSheet( + f""" + QFrame#batchStageRow {{ background: transparent; border: none; }} + QLabel#stageTitle {{ color: {palette.text}; background: transparent; }} + QLabel#stageDesc {{ color: {palette.muted}; background: transparent; }} + """ + ) + self.settingsButton.syncStyle() + self.update() - def on_task_error(self, file_path: str, error: str): - for row in range(self.task_table.rowCount()): - if self.task_table.item(row, 0).toolTip() == file_path: - status_item = self.task_table.item(row, 2) - status_item.setText(str(BatchTaskStatus.FAILED)) - status_item.setToolTip(error) - break - def on_task_completed(self, file_path: str): - for row in range(self.task_table.rowCount()): - if self.task_table.item(row, 0).toolTip() == file_path: - self.task_table.item(row, 2).setText(str(BatchTaskStatus.COMPLETED)) - self.task_table.item(row, 2).setForeground(QColor("#13A10E")) - break +class BatchSidePanel(WorkbenchPanel): + """右栏「本批任务」:状态胶囊 + 2x2 统计 + 流水线阶段。""" + + stageSettingsRequested = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent, padded=True) + self.bodyLayout.setContentsMargins(20, 20, 20, 20) + self.bodyLayout.setSpacing(14) + + self.header = PanelHeader( + tr("batch.panel.title"), inline=True, underline=True, parent=self + ) + self.pill = StatusPill(tr("batch.panel.not_started"), "neutral", self) + self.header.addRight(self.pill) + self.bodyLayout.addWidget(self.header) + + grid = QGridLayout() + grid.setSpacing(10) + self.metrics = [MetricCard(self) for _ in range(4)] + for index, metric in enumerate(self.metrics): + grid.addWidget(metric, index // 2, index % 2) + self.bodyLayout.addLayout(grid) + self.bodyLayout.addSpacing(14) + + self.stageBox = QVBoxLayout() + self.stageBox.setSpacing(10) + self.bodyLayout.addLayout(self.stageBox) + self.stageRows: list[StageRow] = [] + self.bodyLayout.addStretch(1) + self.syncStyle() + + def rebuildStages(self, stage_keys: list[str]): + for row in self.stageRows: + row.hide() + row.setParent(None) + row.deleteLater() + self.stageRows = [] + for key in stage_keys: + row = StageRow(key, self) + row.settingsRequested.connect(self.stageSettingsRequested) + self.stageBox.addWidget(row) + self.stageRows.append(row) + + def setStageStates(self, states: dict[str, str]): + for row in self.stageRows: + row.setState(states.get(row.stage_key, "wait")) + + def setMetrics(self, data: list[tuple[str, str]]): + for metric, (value, name) in zip(self.metrics, data): + metric.setData(value, name) + + def syncStyle(self): + WorkbenchPanel.syncStyle(self) + self.header.syncStyle() + + +# --------------------------------------------------------------------------- +# 页面 +# --------------------------------------------------------------------------- + + +class BatchProcessInterface(QWidget): + """批量处理页。""" - def start_all_tasks(self): - # 检查是否有任务 - if self.task_table.rowCount() == 0: + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + self.setObjectName("BatchProcessInterface") + self.setWindowTitle(tr("batch.title")) + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.setAcceptDrops(True) + + self.mode = mode_by_key(str(cfg.batch_mode.value)) + self._batch_ran = False + self._filter = "all" + self._rows: list[TaskRow] = [] + + self.controller = BatchController( + concurrency=lambda: int(cfg.batch_concurrency.value), parent=self + ) + self._build_ui() + self._connect_signals() + self._rebuild_stage_rows() + self._refresh() + + # ------------------------------------------------------------------ UI + + def _build_ui(self): + palette = app_palette() + self.setStyleSheet( + f"QWidget#BatchProcessInterface {{ background: {palette.bg}; }}" + ) + root = QVBoxLayout(self) + root.setContentsMargins(26, 20, 26, 22) + root.setSpacing(14) + + # 页头:标题 + 工具栏 + head = QHBoxLayout() + head.setSpacing(14) + self.headIcon = QLabel(self) + self.headIcon.setFixedSize(24, 24) + head.addWidget(self.headIcon) + title_box = QVBoxLayout() + title_box.setSpacing(3) + self.titleLabel = QLabel(tr("batch.title"), self) + self.titleLabel.setObjectName("pageTitle") + apply_font(self.titleLabel, 26, 860) + title_box.addWidget(self.titleLabel) + self.subtitleLabel = QLabel("", self) + self.subtitleLabel.setObjectName("pageSubtitle") + apply_font(self.subtitleLabel, 13, 720) + title_box.addWidget(self.subtitleLabel) + head.addLayout(title_box) + head.addStretch(1) + self.addFolderButton = WorkbenchButton( + tr("batch.btn.add_folder"), AppIcon.FOLDER_ADD, parent=self + ) + head.addWidget(self.addFolderButton) + self.addFileButton = WorkbenchButton( + tr("batch.btn.add_file"), AppIcon.ADD, parent=self + ) + head.addWidget(self.addFileButton) + self.clearButton = WorkbenchButton( + tr("batch.btn.clear"), AppIcon.DELETE, parent=self + ) + head.addWidget(self.clearButton) + self.primaryButton = WorkbenchButton( + tr("batch.btn.start"), AppIcon.PLAY, primary=True, parent=self + ) + self.primaryButton.setMinimumWidth(150) + head.addWidget(self.primaryButton) + root.addLayout(head) + + # 模式卡 + mode_row = QHBoxLayout() + mode_row.setSpacing(12) + self.modeCards: list[SelectableCard] = [] + for mode in BATCH_MODES: + card = SelectableCard( + mode.key, tr(mode.title_key), tr(mode.desc_key), mode.icon, self + ) + card.clicked.connect(self._on_mode_clicked) + mode_row.addWidget(card, 1) + self.modeCards.append(card) + root.addLayout(mode_row) + + # 工作区:队列 + 右栏 + work = QHBoxLayout() + work.setSpacing(14) + self.queuePanel = WorkbenchPanel(self, padded=False) + queue_layout = self.queuePanel.bodyLayout + + queue_head = QFrame(self.queuePanel) + queue_head.setObjectName("queueHead") + queue_head.setFixedHeight(60) + head_layout = QHBoxLayout(queue_head) + head_layout.setContentsMargins(16, 0, 16, 0) + head_layout.setSpacing(12) + self.filterTabs = FilterTabs( + [ + ("all", tr("batch.filter.all")), + ("waiting", tr("batch.status.waiting")), + ("running", tr("batch.status.running")), + ("failed", tr("batch.status.failed")), + ], + queue_head, + ) + head_layout.addWidget(self.filterTabs) + head_layout.addStretch(1) + self.concurrencySelect = PillSelect(queue_head) + self.concurrencySelect.setItems( + [tr("batch.concurrency", n=value) for value in (1, 2, 3)], + tr("batch.concurrency", n=int(cfg.batch_concurrency.value)), + ) + head_layout.addWidget(self.concurrencySelect) + self.countPill = StatusPill(tr("batch.count", n=0), "neutral", queue_head) + self.countPill.setMinimumWidth(88) + head_layout.addWidget(self.countPill) + queue_layout.addWidget(queue_head) + + self.queueStack = QStackedWidget(self.queuePanel) + # 空态拖放区 + drop_host = QWidget(self.queuePanel) + drop_layout = QVBoxLayout(drop_host) + # 与转录/字幕/合成页拖放区统一 16 边距 + drop_layout.setContentsMargins(16, 16, 16, 16) + self.dropZone = DropZone( + icon=AppIcon.FOLDER_ADD, + title=tr("batch.drop.title"), + pick_text=tr("batch.btn.add_file"), + pick_icon=AppIcon.ADD, + formats_line=" ", # 占位保证间距,实际文案随模式在 _refresh 填充 + parent=drop_host, + ) + drop_layout.addWidget(self.dropZone) + self.queueStack.addWidget(drop_host) + # 任务列表 + self.listScroll = QScrollArea(self.queuePanel) + self.listScroll.setWidgetResizable(True) + self.listScroll.setFrameShape(QFrame.NoFrame) + self.rowsHost = QWidget(self.listScroll) + self.rowsLayout = QVBoxLayout(self.rowsHost) + self.rowsLayout.setContentsMargins(14, 14, 14, 14) + self.rowsLayout.setSpacing(10) + self.filterEmptyLabel = QLabel(tr("batch.filter.empty"), self.rowsHost) + self.filterEmptyLabel.setObjectName("filterEmpty") + self.filterEmptyLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.filterEmptyLabel, 14, 720) + self.filterEmptyLabel.hide() + self.rowsLayout.addWidget(self.filterEmptyLabel) + self.rowsLayout.addStretch(1) + self.listScroll.setWidget(self.rowsHost) + self.queueStack.addWidget(self.listScroll) + queue_layout.addWidget(self.queueStack, 1) + work.addWidget(self.queuePanel, 1) + + self.sidePanel = BatchSidePanel(self) + self.sidePanel.setFixedWidth(300) + work.addWidget(self.sidePanel) + root.addLayout(work, 1) + + self._sync_page_style() + + def _sync_page_style(self): + palette = app_palette() + self.headIcon.setPixmap(icon_pixmap(AppIcon.VIDEO, palette.muted, 24)) + # 滚动条规则并在 QScrollArea 样式表里:macOS 上只设到 QScrollBar + # 子控件会走 transient 浮层模式,滚动条盖住任务行右缘。 + self.listScroll.setStyleSheet( + f""" + QScrollArea {{ background: transparent; border: none; }} + QScrollBar:vertical {{ + background: transparent; width: 8px; margin: 4px 2px; + }} + QScrollBar::handle:vertical {{ + background: {rgba(palette.muted, 0.32)}; + border-radius: 3px; min-height: 32px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: transparent; + }} + """ + ) + self.rowsHost.setStyleSheet( + f""" + QWidget {{ background: transparent; }} + QLabel#filterEmpty {{ color: {palette.subtle}; }} + """ + ) + extra = f""" + QLabel#pageTitle {{ color: {palette.text}; background: transparent; }} + QLabel#pageSubtitle {{ color: {palette.muted}; background: transparent; }} + QFrame#queueHead {{ + background: transparent; border: none; + border-bottom: 1px solid {palette.line_soft}; + }} + """ + self.setStyleSheet( + f"QWidget#BatchProcessInterface {{ background: {palette.bg}; }}" + extra + ) + + # ------------------------------------------------------------- signals + + def _connect_signals(self): + self.controller.queueChanged.connect(self._rebuild_rows) + self.controller.jobChanged.connect(self._on_job_changed) + self.controller.activityChanged.connect(self._refresh) + self.controller.batchFinished.connect(self._on_batch_finished) + + self.dropZone.browseRequested.connect(self._browse_files) + self.addFileButton.clicked.connect(self._browse_files) + self.addFolderButton.clicked.connect(self._browse_folder) + self.clearButton.clicked.connect(self._on_clear_clicked) + self.primaryButton.clicked.connect(self._on_primary_clicked) + self.filterTabs.changed.connect(self._on_filter_changed) + self.concurrencySelect.currentTextChanged.connect(self._on_concurrency_selected) + self.sidePanel.stageSettingsRequested.connect(self._open_stage_settings) + + # ------------------------------------------------------------ 文件加入 + + def _allowed_extensions(self) -> set[str]: + return _MEDIA_EXTENSIONS if self.mode.accepts_media else _SUBTITLE_EXTENSIONS + + def _browse_files(self): + if self.mode.accepts_media: + patterns = " ".join(f"*{ext}" for ext in sorted(_MEDIA_EXTENSIONS)) + file_filter = tr("batch.filter.media", patterns=patterns) + else: + patterns = " ".join(f"*{ext}" for ext in sorted(_SUBTITLE_EXTENSIONS)) + file_filter = tr("batch.filter.subtitle", patterns=patterns) + files, _ = QFileDialog.getOpenFileNames( + self, tr("batch.dialog.pick_files"), "", file_filter + ) + if files: + self.add_paths(files) + + def _browse_folder(self): + folder = QFileDialog.getExistingDirectory( + self, tr("batch.dialog.pick_folder"), "" + ) + if folder: + self.add_paths([folder]) + + def add_paths(self, paths: list[str]): + """加入文件/文件夹:自动展开过滤、空队列时按文件类型自动切换模式。""" + self._maybe_auto_switch_mode(paths) + valid, ignored = collect_files(paths, self._allowed_extensions()) + added = self.controller.add_paths(valid) if valid else 0 + duplicated = len(valid) - added + if added: + parts = [tr("batch.added.count", n=added)] + if duplicated: + parts.append(tr("batch.added.duplicated", n=duplicated)) + if ignored: + parts.append(tr("batch.added.ignored", n=ignored)) + InfoBar.success( + tr("batch.added.title"), ",".join(parts), + duration=INFOBAR_DURATION_SUCCESS, + position=InfoBarPosition.TOP, parent=self, + ) + else: + reason = tr("batch.empty.duplicated") if duplicated else ( + tr("batch.empty.no_subtitle") + if not self.mode.accepts_media + else tr("batch.empty.no_media") + ) InfoBar.warning( - title="无任务", - content="请先添加需要处理的文件", + tr("batch.empty.title"), reason, duration=INFOBAR_DURATION_WARNING, - position=InfoBarPosition.TOP, - parent=self, + position=InfoBarPosition.TOP, parent=self, ) + self._refresh() + + def _maybe_auto_switch_mode(self, paths: list[str]): + """空队列时按拖入的文件类型自动切换输入类型不匹配的模式。""" + if self.controller.jobs or self.controller.is_active(): return + media, _ = collect_files(paths, _MEDIA_EXTENSIONS) + subtitles, _ = collect_files(paths, _SUBTITLE_EXTENSIONS) + if self.mode.accepts_media and subtitles and not media: + self._switch_mode("subtitle", announce=True) + elif not self.mode.accepts_media and media and not subtitles: + self._switch_mode("full", announce=True) - # 检查是否有等待处理的任务 - waiting_tasks = 0 - for row in range(self.task_table.rowCount()): - if self.task_table.item(row, 2).text() == str(BatchTaskStatus.WAITING): - waiting_tasks += 1 + # ----------------------------------------------------------- 模式切换 - if waiting_tasks == 0: + def _on_mode_clicked(self, key: str): + if key == self.mode.key: + return + if self.controller.is_active(): InfoBar.warning( - title="无待处理任务", - content="所有任务已经在处理或已完成", + tr("batch.switch.busy_title"), tr("batch.switch.busy_body"), duration=INFOBAR_DURATION_WARNING, - position=InfoBarPosition.TOP, - parent=self, + position=InfoBarPosition.TOP, parent=self, ) return + self._switch_mode(key) + + def _switch_mode(self, key: str, announce: bool = False): + self.mode = mode_by_key(key) + if cfg.batch_mode.value != key: + cfg.set(cfg.batch_mode, key) + dropped = self.controller.keep_only(self._allowed_extensions()) + if dropped: + InfoBar.info( + tr("batch.filtered.title"), + tr("batch.filtered.body", n=dropped, mode=tr(self.mode.title_key)), + duration=INFOBAR_DURATION_INFO, + position=InfoBarPosition.TOP, parent=self, + ) + if announce: + InfoBar.info( + tr("batch.switched.title"), + tr("batch.switched.body", mode=tr(self.mode.title_key)), + duration=INFOBAR_DURATION_INFO, + position=InfoBarPosition.TOP, parent=self, + ) + self._batch_ran = False + self._rebuild_stage_rows() + self._refresh() - # 显示开始处理的提示 - InfoBar.success( - title=self.tr("开始处理"), - content=f"开始处理 {waiting_tasks} 个任务", - duration=INFOBAR_DURATION_SUCCESS, - position=InfoBarPosition.TOP, - parent=self, - ) - # 开始处理任务 - for row in range(self.task_table.rowCount()): - file_path = self.task_table.item(row, 0).toolTip() - status = self.task_table.item(row, 2).text() - if status == str(BatchTaskStatus.WAITING): - task_type = BatchTaskType(self.task_type_combo.currentText()) - batch_task = BatchTask(file_path, task_type) - self.batch_thread.add_task(batch_task) - - def start_task(self, file_path: str): - # 显示开始处理的提示 - file_name = os.path.basename(file_path) - InfoBar.success( - title=self.tr("开始处理"), - content=f"开始处理文件:{file_name}", - duration=INFOBAR_DURATION_SUCCESS, - position=InfoBarPosition.TOP, - parent=self, - ) - - # 创建并添加单个任务 - task_type = BatchTaskType(self.task_type_combo.currentText()) - batch_task = BatchTask(file_path, task_type) - self.batch_thread.add_task(batch_task) - - def cancel_task(self, file_path: str): - self.batch_thread.stop_task(file_path) - # 从表格中移除任务 - for row in range(self.task_table.rowCount()): - if self.task_table.item(row, 0).toolTip() == file_path: - self.task_table.removeRow(row) - break + def _rebuild_stage_rows(self): + self.sidePanel.rebuildStages( + stages_for_mode(self.mode.key, bool(cfg.dubbing_enabled.value)) + ) + + # ----------------------------------------------------------- 运行控制 + + def _preflight_error(self) -> Optional[str]: + """开始前校验当前模式依赖的外部配置,返回错误文案。""" + stages = stages_for_mode(self.mode.key, bool(cfg.dubbing_enabled.value)) + if "subtitle" in stages: + needs_llm = ( + bool(cfg.need_optimize.value) + or bool(cfg.need_split.value) + or ( + bool(cfg.need_translate.value) + and cfg.translator_service.value == TranslatorServiceEnum.OPENAI + ) + ) + if needs_llm: + task = TaskFactory.create_subtitle_task(file_path="") + config = task.subtitle_config + if config is None or not ( + config.api_key and config.base_url and config.llm_model + ): + return tr("batch.preflight.llm") + if ("synthesis" in stages or "dubbing" in stages) and not shutil.which("ffmpeg"): + return tr("batch.preflight.ffmpeg") + if "dubbing" in stages: + # pydub 读 mp3 段(Edge 默认输出)经 ffprobe,缺了会在任务中途裸崩 + if not shutil.which("ffprobe"): + return tr("batch.preflight.ffprobe") + provider = cfg.dubbing_provider.value + if provider != "edge" and not cfg.dubbing_api_key.value.strip(): + return tr("batch.preflight.dubbing_key") + return None + + def _on_primary_clicked(self): + state = self._page_state() + if state == PageState.RUNNING: + if self.controller.is_paused(): + self.controller.resume() + else: + self.controller.pause() + return + if state in (PageState.READY, PageState.DONE): + error = self._preflight_error() + if error is not None: + InfoBar.error( + tr("batch.cannot_start"), error, + duration=INFOBAR_DURATION_WARNING, + position=InfoBarPosition.TOP, parent=self, + ) + return + self._batch_ran = False + self.controller.start( + stages_for_mode(self.mode.key, bool(cfg.dubbing_enabled.value)) + ) - def clear_tasks(self): - self.batch_thread.stop_all() - self.task_table.setRowCount(0) + def _on_clear_clicked(self): + self.controller.clear() + self._batch_ran = False + self._refresh() - def on_task_type_changed(self, task_type: str): - # 显示任务类型说明 - description = self.task_type_descriptions.get(task_type, "") - if description: - InfoBar.info( - title=task_type, - content=description, - duration=INFOBAR_DURATION_INFO, - position=InfoBarPosition.BOTTOM, - parent=self, + def _on_batch_finished(self): + self._batch_ran = True + failed = self.controller.count(JobStatus.FAILED) + completed = self.controller.count(JobStatus.COMPLETED) + if failed: + InfoBar.warning( + tr("batch.finished.title"), + tr("batch.finished.body", completed=completed, failed=failed), + duration=INFOBAR_DURATION_WARNING, + position=InfoBarPosition.TOP, parent=self, + ) + else: + InfoBar.success( + tr("batch.done.title"), + tr("batch.done.body", completed=completed), + duration=INFOBAR_DURATION_SUCCESS, + position=InfoBarPosition.TOP, parent=self, + ) + self._refresh() + + def _on_concurrency_selected(self, text: str): + digits = "".join(ch for ch in text if ch.isdigit()) + value = int(digits or 1) + if cfg.batch_concurrency.value != value: + cfg.set(cfg.batch_concurrency, value) + + # 阶段 key -> 设置页 key(SettingInterface.setCurrentPage) + _STAGE_SETTING_PAGES = { + "transcribe": "transcribe", + "subtitle": "translate", # 断句/优化/翻译在「翻译与优化」 + "dubbing": "dubbing", + "synthesis": "subtitle", # 「字幕合成配置」 + } + + def _open_stage_settings(self, stage_key: str): + window = self.window() + page_key = self._STAGE_SETTING_PAGES.get(stage_key) + if page_key and hasattr(window, "openSettingsPage"): + window.openSettingsPage(page_key) + + # ------------------------------------------------------------- 行操作 + + def _on_open_job(self, job: BatchJob): + # 在文件管理器中选中输出文件(无输出时选中源文件) + target = job.outputs[-1] if job.outputs else job.path + if Path(target).exists(): + reveal_in_explorer(str(target)) + else: + open_folder(str(Path(target).parent)) + + def _on_retry_job(self, job: BatchJob): + error = self._preflight_error() + if error is not None: + InfoBar.error( + tr("batch.cannot_retry"), error, + duration=INFOBAR_DURATION_WARNING, + position=InfoBarPosition.TOP, parent=self, + ) + return + self.controller.retry( + job, stages_for_mode(self.mode.key, bool(cfg.dubbing_enabled.value)) + ) + self._refresh() + + def _on_remove_job(self, job: BatchJob): + self.controller.remove(job) + self._refresh() + + def _show_job_details(self, job: BatchJob): + """任务详情:完整错误 / 输出文件清单,附打开目录或重试入口。""" + status_text = { + JobStatus.WAITING: tr("batch.status.waiting"), + JobStatus.RUNNING: tr("batch.status.running"), + JobStatus.COMPLETED: tr("batch.status.completed"), + JobStatus.FAILED: tr("batch.status.failed"), + }[job.status] + lines = [ + tr("batch.detail.file", path=job.path), + tr("batch.detail.status", status=status_text, progress=job.progress), + ] + if job.status == JobStatus.FAILED and job.error: + lines.append("") + lines.append(tr("batch.detail.error", error=job.error)) + elif job.outputs: + lines.append("") + lines.append(tr("batch.detail.outputs")) + lines.extend(f"· {path}" for path in job.outputs) + elif job.note: + lines.append(tr("batch.detail.progress", note=job.note)) + message = "\n".join(lines) + if job.status == JobStatus.FAILED: + dialog = ConfirmDialog( + tr("batch.detail.title"), + message, + self, + confirm_text=tr("batch.row.retry"), + cancel_text=tr("common.close"), + icon=AppIcon.FILE, + width=560, ) - # 清空当前任务列表 - self.clear_tasks() + if dialog.exec(): + self._on_retry_job(job) + elif job.status == JobStatus.COMPLETED: + dialog = ConfirmDialog( + tr("batch.detail.title"), + message, + self, + confirm_text=tr("batch.row.open_output"), + cancel_text=tr("common.close"), + icon=AppIcon.FILE, + width=560, + ) + if dialog.exec(): + self._on_open_job(job) + else: + ConfirmDialog( + tr("batch.detail.title"), + message, + self, + confirm_text=tr("batch.detail.got_it"), + cancel_text=None, + icon=AppIcon.FILE, + width=560, + ).exec() + + # ------------------------------------------------------------- 列表 + + def _rebuild_rows(self): + for row in self._rows: + row.hide() + row.setParent(None) + row.deleteLater() + self._rows = [] + insert_at = self.rowsLayout.count() - 1 # stretch 之前 + for job in self.controller.jobs: + row = TaskRow(job, self.rowsHost) + row.openRequested.connect(self._on_open_job) + row.retryRequested.connect(self._on_retry_job) + row.removeRequested.connect(self._on_remove_job) + row.detailsRequested.connect(self._show_job_details) + self.rowsLayout.insertWidget(insert_at, row) + insert_at += 1 + self._rows.append(row) + self._apply_filter() + self._refresh() + + def _on_job_changed(self, index: int): + if 0 <= index < len(self._rows): + self._rows[index].refresh() + self._apply_filter() + self._refresh() + + def _on_filter_changed(self, key: str): + self._filter = key + self._apply_filter() + + def _matches_filter(self, job: BatchJob) -> bool: + return { + "all": lambda j: True, + "waiting": lambda j: j.status == JobStatus.WAITING, + "running": lambda j: j.status == JobStatus.RUNNING, + "failed": lambda j: j.status == JobStatus.FAILED, + }[self._filter](job) + + def _apply_filter(self): + visible = 0 + for row in self._rows: + show = self._matches_filter(row.job) + row.setVisible(show) + visible += int(show) + self.filterEmptyLabel.setVisible(bool(self._rows) and visible == 0) + + # --------------------------------------------------------- 状态派生 + + def _page_state(self) -> PageState: + if not self.controller.jobs: + return PageState.EMPTY + if self.controller.is_active(): + return PageState.RUNNING + if self._batch_ran and self.controller.count(JobStatus.WAITING) == 0: + return PageState.DONE + return PageState.READY + + def _refresh(self): + state = self._page_state() + controller = self.controller + jobs = controller.jobs + total = len(jobs) + waiting = controller.count(JobStatus.WAITING) + running = controller.count(JobStatus.RUNNING) + failed = controller.count(JobStatus.FAILED) + completed = controller.count(JobStatus.COMPLETED) + concurrency = int(cfg.batch_concurrency.value) + paused = controller.is_paused() + + # 页头副标题 + 主按钮 + if state == PageState.EMPTY: + self.subtitleLabel.setText(tr("batch.subtitle.empty")) + self.primaryButton.setText(tr("batch.btn.start")) + self.primaryButton.setIcon(AppIcon.PLAY) + self.primaryButton.setEnabled(False) + elif state == PageState.READY: + self.subtitleLabel.setText(tr("batch.subtitle.ready", n=total)) + self.primaryButton.setText(tr("batch.btn.start")) + self.primaryButton.setIcon(AppIcon.PLAY) + self.primaryButton.setEnabled(waiting > 0 or failed > 0) + elif state == PageState.RUNNING: + if paused: + self.subtitleLabel.setText(tr("batch.subtitle.paused")) + self.primaryButton.setText(tr("batch.btn.resume")) + self.primaryButton.setIcon(AppIcon.PLAY) + else: + self.subtitleLabel.setText(tr("batch.subtitle.running")) + self.primaryButton.setText(tr("batch.btn.pause")) + self.primaryButton.setIcon(AppIcon.CANCEL) + self.primaryButton.setEnabled(True) + else: # DONE + if failed: + self.subtitleLabel.setText(tr("batch.subtitle.done_failed", n=failed)) + else: + self.subtitleLabel.setText(tr("batch.subtitle.done")) + self.primaryButton.setText(tr("batch.btn.start")) + self.primaryButton.setIcon(AppIcon.PLAY) + self.primaryButton.setEnabled(failed > 0) + self.clearButton.setEnabled(total > 0) + + # 模式卡:运行中锁定 + for card in self.modeCards: + card.setActive(card.key == self.mode.key) + card.setEnabled(state != PageState.RUNNING) + + # 队列区 + self.queueStack.setCurrentIndex(0 if state == PageState.EMPTY else 1) + accepts = ( + tr("batch.drop.media_kinds") + if self.mode.accepts_media + else tr("batch.drop.subtitle_kinds") + ) + self.dropZone.formatLabel.setText( + tr("batch.drop.format", mode=tr(self.mode.title_key), kinds=accepts) + ) + self.dropZone.formatLabel.setVisible(True) + count_level = {PageState.RUNNING: "warn", PageState.DONE: "ok"}.get( + state, "neutral" + ) + self.countPill.setState(tr("batch.count", n=total), count_level) + + # 右栏统计 + 阶段 + if state == PageState.EMPTY: + pill = (tr("batch.panel.not_started"), "neutral") + metrics = [ + ("0", tr("batch.metric.queue")), + (str(concurrency), tr("batch.metric.concurrency")), + ("0", tr("batch.status.running")), + ("0", tr("batch.status.failed")), + ] + elif state == PageState.READY: + pill = (tr("batch.panel.ready"), "neutral") + metrics = [ + (str(total), tr("batch.metric.queue")), + (str(concurrency), tr("batch.metric.concurrency")), + (str(waiting), tr("batch.status.waiting")), + (str(failed), tr("batch.status.failed")), + ] + elif state == PageState.RUNNING: + pill = ( + (tr("batch.panel.paused"), "neutral") + if paused + else (tr("batch.panel.running"), "warn") + ) + metrics = [ + (str(total), tr("batch.metric.queue")), + (str(running), tr("batch.status.running")), + (str(waiting), tr("batch.status.waiting")), + (f"{controller.current_progress()}%", tr("batch.metric.progress")), + ] + else: + pill = (tr("batch.panel.ended"), "ok") + rate = round(completed * 100 / total) if total else 0 + metrics = [ + (str(total), tr("batch.metric.queue")), + (str(completed), tr("batch.status.completed")), + (str(failed), tr("batch.status.failed")), + (f"{rate}%", tr("batch.metric.success_rate")), + ] + self.sidePanel.pill.setState(*pill) + self.sidePanel.setMetrics(metrics) + self.sidePanel.setStageStates(self._stage_states(state)) + + def _stage_states(self, state: PageState) -> dict[str, str]: + keys = [row.stage_key for row in self.sidePanel.stageRows] + if state == PageState.DONE: + # 有失败任务时不把流水线全标“完成”,避免误导 + if self.controller.count(JobStatus.FAILED): + return {} + return {key: "done" for key in keys} + if state != PageState.RUNNING: + return {} + active = self.controller.active_stages() + if not active: + return {} + indexes = [keys.index(key) for key in active if key in keys] + if not indexes: + return {} + first_active = min(indexes) + states: dict[str, str] = {} + for index, key in enumerate(keys): + if key in active: + states[key] = "active" + elif index < first_active: + states[key] = "done" + return states + + # ------------------------------------------------------------ 拖放 + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + self.dropZone.setDragActive(True) + else: + event.ignore() + + def dragLeaveEvent(self, event): + self.dropZone.setDragActive(False) + super().dragLeaveEvent(event) + + def dropEvent(self, event): + self.dropZone.setDragActive(False) + paths = [url.toLocalFile() for url in event.mimeData().urls() if url.toLocalFile()] + if paths: + self.add_paths(paths) + + # ------------------------------------------------------------ 生命周期 + + def showEvent(self, event): + # 配音开关可能在设置/配音页变化,回到本页时刷新阶段卡 + self._rebuild_stage_rows() + self._refresh() + super().showEvent(event) def closeEvent(self, event): - self.batch_thread.stop_all() + self.controller.shutdown() super().closeEvent(event) - - def on_table_double_clicked(self, index): - """处理表格双击事件""" - row = index.row() - file_path = self.task_table.item(row, 0).toolTip() - self.open_output_folder(file_path) diff --git a/videocaptioner/ui/view/doctor_interface.py b/videocaptioner/ui/view/doctor_interface.py new file mode 100644 index 00000000..1231fc64 --- /dev/null +++ b/videocaptioner/ui/view/doctor_interface.py @@ -0,0 +1,1080 @@ +from dataclasses import dataclass +from enum import Enum + +from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtGui import QColor, QPainter, QPen +from PyQt5.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from qfluentwidgets import ( + CaptionLabel, + InfoBar, + ScrollArea, + TitleLabel, +) + +from videocaptioner.cli.commands.doctor import Check, run_diagnostics +from videocaptioner.core.constant import INFOBAR_DURATION_ERROR, INFOBAR_DURATION_SUCCESS +from videocaptioner.core.entities import TranscribeModelEnum, TranslatorServiceEnum +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.config import cfg +from videocaptioner.ui.common.dubbing_options import get_provider_option +from videocaptioner.ui.common.theme_tokens import ( + AppPalette, + app_palette, + rgba, +) +from videocaptioner.ui.components.workbench import StatusPill as WbStatusPill +from videocaptioner.ui.components.workbench import ( + WorkbenchButton, + apply_font, + draw_rounded_surface, +) +from videocaptioner.ui.i18n import N_, tr + + +class ItemStatus(Enum): + PENDING = "pending" + CHECKING = "checking" + OK = "ok" + WARNING = "warning" # 可选项缺失/需注意:琥珀色,不等于硬错误(不计入"未通过") + ERROR = "error" + + +class ItemAction(Enum): + TOOL_HELP = "tool_help" + DOWNLOAD_HELP = "download_help" + TRANSCRIBE_SETTINGS = "transcribe_settings" + LLM_SETTINGS = "llm_settings" + TRANSLATE_SETTINGS = "translate_settings" + DUBBING_SETTINGS = "dubbing_settings" + LIVE_CAPTION_SETTINGS = "live_caption_settings" + DOWNLOAD_DEPENDENCIES = "download_dependencies" + + +@dataclass(frozen=True) +class DiagnosticItem: + key: str + title: str + description: str + action: ItemAction + button_text: str + status: ItemStatus = ItemStatus.PENDING + + +@dataclass(frozen=True) +class TaskChipData: + category: str + title: str + + +class DoctorThread(QThread): + finished = pyqtSignal(list) + error = pyqtSignal(str) + + def run(self): + try: + self.finished.emit( + run_diagnostics(_build_doctor_config(), check_api=False, check_download=True) + ) + except Exception as exc: + self.error.emit(str(exc)) + + +class TaskChip(QFrame): + def __init__(self, data: TaskChipData, parent=None): + super().__init__(parent) + self.setObjectName("taskChip") + self.setFixedHeight(62) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(3) + + category = QLabel(data.category, self) + category.setObjectName("taskChipCategory") + apply_font(category, 12, 500) + title = QLabel(data.title, self) + title.setObjectName("taskChipTitle") + apply_font(title, 14, 700) # 用 apply_font 走正确字重映射,避免 QSS font-weight 被压成黑体糊字 + title.setTextInteractionFlags(Qt.TextSelectableByMouse) + + layout.addWidget(category) + layout.addWidget(title) + + def paintEvent(self, event): + # 与 OptionCard 等内层卡片同一套表面:panel 上叠半透明卡 + 细边 + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 13) + super().paintEvent(event) + + +class StatusDot(QWidget): + def __init__(self, status: ItemStatus, parent=None): + super().__init__(parent) + self.setFixedSize(24, 24) + self._status = status + self.setStatus(status) + + def setStatus(self, status: ItemStatus): + self._status = status + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + palette = app_palette() + + if self._status == ItemStatus.OK: + fill = QColor(palette.accent) + border, mark = palette.accent, palette.accent_fg + elif self._status == ItemStatus.ERROR: + fill = QColor(palette.danger) + fill.setAlphaF(0.16) + border, mark = palette.danger, palette.danger_fg + elif self._status == ItemStatus.WARNING: + fill = QColor(palette.warn) + fill.setAlphaF(0.18) + border = mark = palette.warn + else: + fill = QColor(palette.field) + border, mark = palette.line, palette.muted + + painter.setPen(QPen(QColor(border), 1.4)) + painter.setBrush(fill) + painter.drawEllipse(1, 1, 22, 22) + + pen = QPen(QColor(mark), 1.7) + pen.setCapStyle(Qt.RoundCap) # type: ignore + pen.setJoinStyle(Qt.RoundJoin) # type: ignore + painter.setPen(pen) + if self._status == ItemStatus.OK: + painter.drawLine(7, 12, 10, 15) + painter.drawLine(10, 15, 17, 8) + elif self._status in (ItemStatus.ERROR, ItemStatus.WARNING): + # 同一个"!"标记:错误红、警告琥珀(颜色已在上面区分) + painter.drawLine(12, 7, 12, 13) + painter.drawPoint(12, 17) + else: + painter.drawLine(8, 12, 16, 12) + + +_WB_LEVELS = {"success": "ok", "danger": "fail", "warning": "warn", "neutral": "neutral"} + + +class StatusPill(WbStatusPill): + # 诊断状态胶囊:workbench 胶囊 + ItemStatus 映射 + def __init__(self, status: ItemStatus, parent=None): + super().__init__("", _WB_LEVELS[_status_level(status)], parent) + self.setMinimumWidth(82) + self.setStatus(status) + + def setStatus(self, status: ItemStatus): + self.setState(tr(_status_text(status)), _WB_LEVELS[_status_level(status)]) + + +class DiagnosticRow(QFrame): + actionRequested = pyqtSignal(object) + + def __init__(self, item: DiagnosticItem, actions_enabled: bool = True, parent=None): + super().__init__(parent) + self.item = item + self.setObjectName("diagnosticRow") + if item.status == ItemStatus.ERROR: + self.setProperty("status", "error") + elif item.status == ItemStatus.WARNING: + self.setProperty("status", "warning") + # 常规行 78 恰好容纳 标题+一行描述;描述换行时行高随内容长(定高会把长文案裁掉), + # 垂直 Maximum 挡住容器把多余空间平摊进行内(否则标题↔描述被撑开) + self.setMinimumHeight(78) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) + + layout = QGridLayout(self) + layout.setContentsMargins(16, 13, 16, 13) + layout.setHorizontalSpacing(14) + layout.setVerticalSpacing(0) + layout.setColumnStretch(1, 1) + + dot = StatusDot(item.status, self) + layout.addWidget(dot, 0, 0, 2, 1, Qt.AlignCenter) + + title = QLabel(item.title, self) + title.setObjectName("rowTitle") + apply_font(title, 16, 700) # 正确字重(~72);QSS font-weight 会被 Qt5 压成 ~98 黑体糊字 + description = QLabel(item.description, self) + description.setObjectName("rowDescription") + apply_font(description, 13, 450) + # 不可选中:wordWrap + TextSelectableByMouse 组合走 QTextDocument 渲染, + # 行高按字体 win-metrics 虚报(文楷下一行文字报两行高),标题描述被撑散。 + description.setWordWrap(True) + + text_layout = QVBoxLayout() + text_layout.setContentsMargins(0, 0, 0, 0) + text_layout.setSpacing(3) # 标题↔描述紧凑贴合(7 太松,标题副标题该靠近) + text_layout.addWidget(title) + text_layout.addWidget(description) + layout.addLayout(text_layout, 0, 1, 2, 1) + + pill = StatusPill(item.status, self) + pill.setFixedWidth(84) # 固定列宽:各行胶囊左右对齐(文字短的也占满) + layout.addWidget(pill, 0, 2, 2, 1, Qt.AlignVCenter) + + button = WorkbenchButton( + item.button_text, + primary=item.status == ItemStatus.ERROR, + height=36, + parent=self, + ) + button.setFixedWidth(132) # 固定按钮宽:右块(胶囊+按钮)逐行对齐,不随文字长短参差 + button.setEnabled(actions_enabled and item.status != ItemStatus.CHECKING) + button.clicked.connect(lambda: self.actionRequested.emit(item.action)) + layout.addWidget(button, 0, 3, 2, 1, Qt.AlignVCenter) + + +class DiagnosticPanel(QFrame): + actionRequested = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("diagnosticPanel") + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) + + # 表头:标题 + 汇总胶囊,底部一条分隔线把标题和清单分开 + self.headerFrame = QFrame(self) + self.headerFrame.setObjectName("diagnosticHeader") + header = QHBoxLayout(self.headerFrame) + header.setContentsMargins(18, 15, 16, 15) + header.setSpacing(12) + # 面板标题用第一方字体(不再用 qfluent SubtitleLabel),与全站面板头一致 + title = QLabel(tr("doctor.checklist"), self.headerFrame) + title.setObjectName("diagnosticPanelTitle") # 需显式着色,否则裸 QLabel 用默认色和深色面板不融合 + apply_font(title, 18, 760) + header.addWidget(title, 1, Qt.AlignVCenter) + self.summaryPill = StatusPill(ItemStatus.PENDING, self.headerFrame) + self.summaryPill.setMinimumWidth(104) + header.addWidget(self.summaryPill, 0, Qt.AlignVCenter) + self.layout.addWidget(self.headerFrame) + + self.rowsFrame = QFrame(self) + self.rowsFrame.setObjectName("rowsFrame") + self.rowsLayout = QVBoxLayout(self.rowsFrame) + self.rowsLayout.setContentsMargins(10, 4, 10, 8) + self.rowsLayout.setSpacing(0) + self.layout.addWidget(self.rowsFrame) + + def setItems( + self, + items: list[DiagnosticItem], + finished: bool = False, + actions_enabled: bool = True, + ): + _clear_layout(self.rowsLayout) + errors = sum(item.status == ItemStatus.ERROR for item in items) + warnings = sum(item.status == ItemStatus.WARNING for item in items) + checking = any(item.status == ItemStatus.CHECKING for item in items) + pending = sum(item.status == ItemStatus.PENDING for item in items) + if errors: + self.summaryPill.setState( + tr("doctor.summary.errors", count=errors), "fail" + ) + elif checking: + self.summaryPill.setState(tr("doctor.status.checking"), "neutral") + elif warnings: + self.summaryPill.setState( + tr("doctor.summary.warnings", count=warnings), "warn" + ) + elif finished: + self.summaryPill.setState(tr("doctor.summary.all_passed"), "ok") + else: + self.summaryPill.setState( + tr("doctor.summary.pending", count=pending), "neutral" + ) + + # 红错误置顶、琥珀警告其次、其余在后;行高与按钮列保持稳定 + ordered = sorted( + items, + key=lambda i: 0 + if i.status == ItemStatus.ERROR + else 1 + if i.status == ItemStatus.WARNING + else 2, + ) + for idx, item in enumerate(ordered): + row = DiagnosticRow(item, actions_enabled=actions_enabled, parent=self.rowsFrame) + if idx == len(ordered) - 1: + row.setProperty("last", "true") # 末行去掉底分隔线,避免悬空线 + row.actionRequested.connect(self.actionRequested) + self.rowsLayout.addWidget(row) + + +class DoctorInterface(ScrollArea): + """桌面端诊断页。""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setWindowTitle(tr("doctor.title")) + self._doctor_thread: DoctorThread | None = None + self.has_results = False + self.is_running = False + self.scrollWidget = QWidget() + self.pageLayout = QVBoxLayout(self.scrollWidget) + self.taskStrip = QFrame(self.scrollWidget) + self.taskGrid = QGridLayout(self.taskStrip) + self.panel = DiagnosticPanel(self.scrollWidget) + self.runButton = WorkbenchButton( + tr("doctor.btn.run"), AppIcon.SYNC, primary=True, parent=self.scrollWidget + ) + self._init_ui() + + def _init_ui(self): + self.resize(1000, 800) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore + self.setWidget(self.scrollWidget) + self.setWidgetResizable(True) + self.setObjectName("doctorInterface") + self.scrollWidget.setObjectName("scrollWidget") + self.enableTransparentBackground() + + self.pageLayout.setSpacing(18) + self.pageLayout.setContentsMargins(26, 20, 26, 22) + + toolbar = QWidget(self.scrollWidget) + toolbarLayout = QHBoxLayout(toolbar) + toolbarLayout.setContentsMargins(0, 0, 0, 0) + toolbarLayout.setSpacing(16) + heading = QVBoxLayout() + heading.setSpacing(0) + self.titleLabel = TitleLabel(tr("doctor.title"), toolbar) + self.subTitleLabel = CaptionLabel(tr("doctor.subtitle"), toolbar) + heading.addWidget(self.titleLabel) + self.subTitleLabel.hide() + toolbarLayout.addLayout(heading, 1) + toolbarLayout.addWidget(self.runButton, 0, Qt.AlignTop) + + self.taskStrip.setObjectName("taskStrip") + self.taskGrid.setContentsMargins(12, 12, 12, 12) + self.taskGrid.setHorizontalSpacing(10) + self.taskGrid.setVerticalSpacing(10) + + self.pageLayout.addWidget(toolbar) + self.pageLayout.addWidget(self.taskStrip) + self.pageLayout.addWidget(self.panel) + self.pageLayout.addStretch(1) + + self.runButton.clicked.connect(self._run) + self.panel.actionRequested.connect(self._handle_action) + self._sync_page_background() + self._refresh_pending() + + def showEvent(self, event): + super().showEvent(event) + self._sync_page_background() + if not self.has_results and not self.is_running: + self._refresh_pending() + + def _sync_page_background(self): + palette = app_palette() + self.setStyleSheet(f"QScrollArea {{ border: none; background: {palette.bg}; }}") + self.scrollWidget.setStyleSheet(_page_styles(palette)) + + def _refresh_task_strip(self, checks: list[Check] | None = None): + _clear_layout(self.taskGrid) + chips = _task_chips(checks) + columns = max(1, min(5, len(chips))) + for index, chip in enumerate(chips): + widget = TaskChip(chip, self.taskStrip) + widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.taskGrid.addWidget(widget, index // columns, index % columns) + + def _refresh_pending(self): + self.has_results = False + self.is_running = False + self.runButton.setEnabled(True) + self.runButton.setText(tr("doctor.btn.run")) + self._refresh_task_strip() + self.panel.setItems(_pending_items()) + + def _run(self): + if self.is_running or (self._doctor_thread and self._doctor_thread.isRunning()): + return + self.has_results = False + self.is_running = True + self.runButton.setEnabled(False) + self.runButton.setText(tr("doctor.btn.running")) + self.panel.setItems( + _with_status(_base_items(), ItemStatus.CHECKING), + actions_enabled=False, + ) + self._doctor_thread = DoctorThread() + self._doctor_thread.finished.connect(self._on_finished) + self._doctor_thread.error.connect(self._on_error) + self._doctor_thread.start() + + def closeEvent(self, event): + # 退出时停诊断网络线程:main_window.closeEvent 会 close() 本页,running + # QThread 被销毁会触发 qFatal。只读网络线程,terminate 安全。 + if self._doctor_thread is not None and self._doctor_thread.isRunning(): + self._doctor_thread.terminate() + self._doctor_thread.wait(1000) + super().closeEvent(event) + + def _on_finished(self, checks: list[Check]): + self.has_results = True + self.is_running = False + self.runButton.setEnabled(True) + self.runButton.setText(tr("doctor.btn.rerun")) + self._refresh_task_strip(checks) + items = _items_from_checks(checks) + self.panel.setItems(items, finished=True) + errors = sum(item.status == ItemStatus.ERROR for item in items) + warnings = sum(item.status == ItemStatus.WARNING for item in items) + if errors: + InfoBar.error( + tr("doctor.done.title"), + tr("doctor.done.errors", count=errors), + duration=INFOBAR_DURATION_ERROR, + parent=self, + ) + elif warnings: + InfoBar.warning( + tr("doctor.done.title"), + tr("doctor.done.warnings", count=warnings), + duration=INFOBAR_DURATION_SUCCESS, + parent=self, + ) + else: + InfoBar.success( + tr("doctor.done.title"), + tr("doctor.done.all_passed"), + duration=INFOBAR_DURATION_SUCCESS, + parent=self, + ) + + def _on_error(self, message: str): + self.is_running = False + self.runButton.setEnabled(True) + self.runButton.setText(tr("doctor.btn.rerun")) + self.panel.setItems(_pending_items()) + InfoBar.error(tr("doctor.failed.title"), message, duration=INFOBAR_DURATION_ERROR, parent=self) + + def _handle_action(self, action: ItemAction): + if action == ItemAction.DOWNLOAD_HELP: + from videocaptioner.core.download.net import cookies_file + + # 帮助文案要读完:常驻直到手动关闭,不能用几秒就消失的 toast; + # cookies.txt 给出本机具体路径,"应用数据目录"用户找不到 + InfoBar.info( + tr("doctor.download"), + tr("doctor.help.download") + f"\ncookies.txt:{cookies_file()}", + duration=-1, + parent=self, + ) + return + if action == ItemAction.TOOL_HELP: + InfoBar.info( + "FFmpeg", + tr("doctor.help.ffmpeg"), + duration=INFOBAR_DURATION_SUCCESS, + parent=self, + ) + return + if action == ItemAction.DOWNLOAD_DEPENDENCIES: + self._open_dependency_dialog() + return + page_key = _settings_page_for_action(action) + if page_key: + self._open_settings_page(page_key) + + def _open_dependency_dialog(self): + from videocaptioner.ui.components.dependency_download_dialog import ( + DependencyDownloadDialog, + ) + + dialog = DependencyDownloadDialog(parent=self) + # 装好任意依赖后自动重跑诊断,卡片状态即时刷新 + dialog.depsChanged.connect(self._run) + dialog.exec() + + def _open_settings_page(self, page_key: str): + window = self.window() + if hasattr(window, "openSettingsPage"): + if window.openSettingsPage(page_key) is False: # type: ignore[attr-defined] + InfoBar.error( + tr("doctor.jump_failed.title"), + tr("doctor.jump_failed.body"), + duration=INFOBAR_DURATION_ERROR, + parent=self, + ) + + +def _clear_layout(layout): + while layout.count(): + item = layout.takeAt(0) + if widget := item.widget(): + widget.hide() + widget.setParent(None) + widget.deleteLater() + + +def _status_text(status: ItemStatus) -> str: + return { + ItemStatus.OK: N_("doctor.status.ok"), + ItemStatus.WARNING: N_("doctor.status.warning"), + ItemStatus.ERROR: N_("doctor.status.error"), + ItemStatus.CHECKING: N_("doctor.status.checking"), + ItemStatus.PENDING: N_("doctor.status.pending"), + }[status] + + +def _status_level(status: ItemStatus) -> str: + return { + ItemStatus.OK: "success", + ItemStatus.WARNING: "warning", + ItemStatus.ERROR: "danger", + ItemStatus.CHECKING: "neutral", + ItemStatus.PENDING: "neutral", + }[status] + + +def _with_status(items: list[DiagnosticItem], status: ItemStatus) -> list[DiagnosticItem]: + return [ + DiagnosticItem( + key=item.key, + title=item.title, + description=item.description, + action=item.action, + button_text=item.button_text, + status=status, + ) + for item in items + ] + + +def _pending_items() -> list[DiagnosticItem]: + return _base_items() + + +def _base_items() -> list[DiagnosticItem]: + items = [ + DiagnosticItem( + key="ffmpeg", + title="FFmpeg", + description=tr("doctor.ffmpeg.desc"), + action=ItemAction.TOOL_HELP, + button_text=tr("doctor.btn.install_tool"), + ), + DiagnosticItem( + key="transcribe", + title=tr("doctor.transcribe.title"), + description=_transcribe_description(), + action=ItemAction.TRANSCRIBE_SETTINGS, + button_text=tr("doctor.btn.transcribe_settings"), + ), + DiagnosticItem( + key="download", + title=tr("doctor.download"), + description=tr("doctor.download.desc"), + action=ItemAction.DOWNLOAD_HELP, + button_text=tr("doctor.btn.usage"), + ), + ] + if _needs_llm(): + items.append( + DiagnosticItem( + key="llm", + title=_llm_item_title(), + description=_llm_description(), + action=ItemAction.LLM_SETTINGS, + button_text=tr("doctor.btn.llm_settings"), + ) + ) + if cfg.need_translate.value: + items.append( + DiagnosticItem( + key="translate", + title=tr("doctor.translate.title"), + description=_translate_description(), + action=ItemAction.TRANSLATE_SETTINGS, + button_text=tr("doctor.btn.translate_settings"), + ) + ) + items.append( + DiagnosticItem( + key="dubbing", + title=tr("doctor.dubbing.title"), + description=_dubbing_description(), + action=ItemAction.DUBBING_SETTINGS, + button_text=tr("doctor.btn.dubbing_settings"), + ) + ) + items.append( + DiagnosticItem( + key="live_caption", + title=tr("doctor.live_caption.title"), + description=tr("doctor.live_caption.desc"), + action=ItemAction.LIVE_CAPTION_SETTINGS, + button_text=tr("doctor.btn.live_caption_settings"), + ) + ) + return items + + +def _items_from_checks(checks: list[Check]) -> list[DiagnosticItem]: + checks_by_name = {check.name: check for check in checks} + items: list[DiagnosticItem] = [] + + ffmpeg_ass_check = checks_by_name.get("ffmpeg.ass_filter") + ffmpeg_status = _combined_status([checks_by_name.get("ffmpeg"), checks_by_name.get("ffprobe"), ffmpeg_ass_check]) + ffmpeg_ass_failed = _check_status(ffmpeg_ass_check) == ItemStatus.ERROR + # 缺二进制 → 直接下载安装;ASS 滤镜不全是构建变体问题 → 仍给文字处理建议 + ffmpeg_missing = _is_problem(ffmpeg_status) and not ffmpeg_ass_failed + items.append( + DiagnosticItem( + key="ffmpeg", + title=( + tr("doctor.ffmpeg.no_ass.title") + if ffmpeg_ass_failed + else tr("doctor.ffmpeg.missing.title") + if _is_problem(ffmpeg_status) + else "FFmpeg / FFprobe" + ), + description=( + tr("doctor.ffmpeg.no_ass.desc") + if ffmpeg_ass_failed + else tr("doctor.ffmpeg.missing.desc") + if _is_problem(ffmpeg_status) + else tr("doctor.ffmpeg.ok.desc") + ), + action=ItemAction.DOWNLOAD_DEPENDENCIES if ffmpeg_missing else ItemAction.TOOL_HELP, + button_text=( + tr("doctor.btn.download_install") + if ffmpeg_missing + else tr("doctor.btn.how_to_handle") + if ffmpeg_ass_failed + else tr("doctor.btn.install_tool") + ), + status=ffmpeg_status, + ) + ) + + transcribe_checks = _checks_with_prefix( + checks, ("transcribe", "whisper", "whisper-cpp", "faster-whisper") + ) + transcribe_status = _combined_status(transcribe_checks) + items.append( + DiagnosticItem( + key="transcribe", + title=tr("doctor.transcribe.title"), + description=( + tr("doctor.transcribe.fail.desc") + if _is_problem(transcribe_status) + else tr("doctor.transcribe.ok.desc") + ), + action=ItemAction.TRANSCRIBE_SETTINGS, + button_text=tr("doctor.btn.transcribe_settings"), + status=transcribe_status, + ) + ) + + download_checks = _checks_with_prefix(checks, ("api.download",)) + if download_checks: + failed = [check for check in download_checks if check.status != "ok"] + if failed: + # 检查与真实下载共用同一条回退链路(含浏览器登录态), + # 走到这里说明兜底也被拒绝,是真不可用。 + detail = ";".join(f"{check.message}" for check in failed) + description = tr("doctor.download.unavailable", detail=detail) + elif any("登录态" in check.message for check in download_checks): + description = tr("doctor.download.ok_login") + else: + description = tr("doctor.download.ok") + items.append( + DiagnosticItem( + key="download", + title=tr("doctor.download"), + description=description, + action=ItemAction.DOWNLOAD_HELP, + button_text=tr("doctor.btn.usage"), + status=_combined_status(download_checks), + ) + ) + + if _needs_llm(): + llm_checks = _checks_with_prefix(checks, ("llm",)) + llm_status = _combined_status(llm_checks) + items.append( + DiagnosticItem( + key="llm", + title=tr("doctor.llm.unavailable.title") if _is_problem(llm_status) else _llm_item_title(), + description=( + tr("doctor.llm.fail.desc") + if _is_problem(llm_status) + else tr("doctor.llm.ok.desc") + ), + action=ItemAction.LLM_SETTINGS, + button_text=tr("doctor.btn.llm_settings"), + status=llm_status, + ) + ) + + if cfg.need_translate.value: + items.append( + DiagnosticItem( + key="translate", + title=tr("doctor.translate.title"), + description=( + tr("doctor.translate.uses_llm.desc") + if _translate_uses_llm() + else tr("doctor.translate.ok.desc") + ), + action=ItemAction.TRANSLATE_SETTINGS, + button_text=tr("doctor.btn.translate_settings"), + status=ItemStatus.OK, + ) + ) + + dubbing_checks = _checks_with_prefix(checks, ("dubbing", "api.dubbing")) + dubbing_status = _combined_status(dubbing_checks) + items.append( + DiagnosticItem( + key="dubbing", + title=tr("doctor.dubbing.title"), + description=( + tr("doctor.dubbing.fail.desc") + if _is_problem(dubbing_status) + else tr("doctor.dubbing.ok.desc") + ), + action=ItemAction.DUBBING_SETTINGS, + button_text=tr("doctor.btn.dubbing_settings"), + status=dubbing_status, + ) + ) + + live_caption_checks = _checks_with_prefix(checks, ("live_caption",)) + live_caption_status = _combined_status(live_caption_checks) + # 只有「缺 voxgate 本地程序」才走下载;fun-asr 缺 Key / 远程服务问题 → 去设置 + lc_needs_download = _is_problem(live_caption_status) and any( + c.name == "live_caption.voxgate" for c in live_caption_checks + ) + lc_is_funasr = any(c.name == "live_caption.funasr" for c in live_caption_checks) + if _is_problem(live_caption_status): + lc_desc = ( + tr("doctor.live_caption.funasr_no_key.desc") + if lc_is_funasr + else tr("doctor.live_caption.not_found.desc") + ) + else: + lc_desc = tr("doctor.live_caption.ok.desc") + items.append( + DiagnosticItem( + key="live_caption", + title=tr("doctor.live_caption.title"), + description=lc_desc, + action=ItemAction.DOWNLOAD_DEPENDENCIES if lc_needs_download else ItemAction.LIVE_CAPTION_SETTINGS, + button_text=tr("doctor.btn.download_voxgate") if lc_needs_download else tr("doctor.btn.live_caption_settings"), + status=live_caption_status, + ) + ) + return items + + +def _is_problem(status: ItemStatus) -> bool: + """需要用户关注的状态(红错误或琥珀警告),用于选「问题」文案而非「就绪」文案。""" + return status in (ItemStatus.ERROR, ItemStatus.WARNING) + + +def _checks_with_prefix(checks: list[Check], prefixes: tuple[str, ...]) -> list[Check]: + return [check for check in checks if check.name.startswith(prefixes)] + + +def _combined_status(checks: list[Check | None]) -> ItemStatus: + present = [check for check in checks if check is not None] + if not present: + return ItemStatus.OK + # error 红、warn 琥珀(可选项缺失),两者区分:error 才算"未通过" + if any(check.status == "error" for check in present): + return ItemStatus.ERROR + if any(check.status == "warn" for check in present): + return ItemStatus.WARNING + if any(check.status == "checking" for check in present): + return ItemStatus.CHECKING + return ItemStatus.OK + + +def _check_status(check: Check | None) -> ItemStatus: + if check is None: + return ItemStatus.OK + if check.status == "error": + return ItemStatus.ERROR + if check.status == "warn": + return ItemStatus.WARNING + if check.status == "checking": + return ItemStatus.CHECKING + if check.status == "pending": + return ItemStatus.PENDING + return ItemStatus.OK + + +def _task_chips(checks: list[Check] | None = None) -> list[TaskChipData]: + chips = [ + TaskChipData(tr("doctor.chip.transcribe"), _transcribe_label()), + ] + if cfg.need_optimize.value or cfg.need_split.value: + chips.append(TaskChipData(tr("doctor.chip.subtitle"), _subtitle_processing_label())) + if cfg.need_translate.value: + chips.append(TaskChipData(tr("doctor.chip.translate"), cfg.translator_service.value.value)) + chips.append(TaskChipData(tr("doctor.chip.dubbing"), _dubbing_label())) + chips.append(TaskChipData(tr("doctor.chip.export"), _export_label())) + return chips + + +def _transcribe_label() -> str: + return getattr(cfg.transcribe_model.value, "value", str(cfg.transcribe_model.value)) + + +def _subtitle_processing_label() -> str: + parts = [] + if cfg.need_optimize.value: + parts.append(tr("doctor.label.optimize")) + if cfg.need_split.value: + parts.append(tr("doctor.label.split")) + return " + ".join(parts) or tr("doctor.label.disabled") + + +def _dubbing_label() -> str: + return get_provider_option(cfg.dubbing_provider.value).title + + +def _export_label() -> str: + pieces = [tr("doctor.label.subtitle")] + if cfg.need_video.value: + pieces.insert(0, tr("doctor.label.video")) + if cfg.dubbing_enabled.value: + pieces.append(tr("doctor.label.dubbing")) + if not cfg.need_video.value and not cfg.dubbing_enabled.value: + return tr("doctor.label.subtitle_file") + return " + ".join(pieces) + + +def _needs_llm() -> bool: + return bool(cfg.need_optimize.value or cfg.need_split.value or _translate_uses_llm()) + + +def _translate_uses_llm() -> bool: + return cfg.need_translate.value and cfg.translator_service.value == TranslatorServiceEnum.OPENAI + + +def _llm_item_title() -> str: + if cfg.need_optimize.value and cfg.need_split.value: + return tr("doctor.llm.title.optimize_split") + if cfg.need_optimize.value: + return tr("doctor.llm.title.optimize") + if cfg.need_split.value: + return tr("doctor.llm.title.split") + return tr("doctor.llm.title.translate") + + +def _llm_description() -> str: + if _translate_uses_llm() and not (cfg.need_optimize.value or cfg.need_split.value): + return tr("doctor.llm.desc.translate") + return tr("doctor.llm.desc.default") + + +def _transcribe_description() -> str: + if cfg.transcribe_model.value.name in {"BIJIAN", "JIANYING"}: + return tr("doctor.transcribe.desc.free") + if cfg.transcribe_model.value.name == "WHISPER_API": + return tr("doctor.transcribe.desc.whisper") + return tr("doctor.transcribe.desc.local") + + +def _translate_description() -> str: + if _translate_uses_llm(): + return tr("doctor.translate.desc.uses_llm") + return tr("doctor.translate.desc.default") + + +def _dubbing_description() -> str: + return tr("doctor.dubbing.desc") + + +def _settings_page_for_action(action: ItemAction) -> str | None: + return { + ItemAction.TRANSCRIBE_SETTINGS: "transcribe", + ItemAction.LLM_SETTINGS: "llm", + ItemAction.TRANSLATE_SETTINGS: "translate-service", + ItemAction.DUBBING_SETTINGS: "dubbing", + ItemAction.LIVE_CAPTION_SETTINGS: "live-caption", + }.get(action) + + +def _page_styles(palette: AppPalette) -> str: + return f""" +QWidget#scrollWidget {{ + background: {palette.bg}; +}} +QFrame#taskStrip {{ + background: {palette.panel}; + border: 1px solid {palette.line}; + border-radius: 16px; +}} +QFrame#taskChip {{ + background: transparent; + border: none; +}} +QLabel#diagnosticPanelTitle {{ + color: {palette.text}; + background: transparent; +}} +QLabel#taskChipCategory {{ + color: {palette.subtle}; +}} +QLabel#taskChipTitle {{ + color: {palette.text}; +}} +QFrame#diagnosticPanel {{ + background: {palette.panel}; + border: 1px solid {palette.line}; + border-radius: 14px; +}} +QFrame#diagnosticHeader {{ + background: transparent; + border: none; + border-bottom: 1px solid {palette.line_soft}; +}} +QFrame#rowsFrame {{ + background: transparent; + border: none; +}} +QFrame#diagnosticRow {{ + background: {palette.panel}; + border-bottom: 1px solid {palette.line_soft}; +}} +QFrame#diagnosticRow[last="true"] {{ + border-bottom: none; +}} +QFrame#diagnosticRow[status="error"] {{ + background: {rgba(palette.danger, 0.08)}; + border-left: 3px solid {palette.danger}; +}} +QFrame#diagnosticRow[status="warning"] {{ + background: {rgba(palette.warn, 0.08)}; + border-left: 3px solid {palette.warn}; +}} +QLabel#rowTitle {{ + color: {palette.text}; +}} +QLabel#rowDescription {{ + color: {palette.muted}; +}} +""" + + +def _build_doctor_config() -> dict: + provider = cfg.dubbing_provider.value + asr_name = cfg.transcribe_model.value.name.lower().replace("_", "-") + if cfg.transcribe_model.value == TranscribeModelEnum.BAILIAN_FUN_ASR: + asr_name = "fun-asr" + return { + "llm": { + "api_key": _current_llm_api_key(), + "api_base": _current_llm_api_base(), + "model": _current_llm_model(), + }, + "whisper_api": { + "api_key": str(cfg.whisper_api_key.value or "").strip(), + "api_base": str(cfg.whisper_api_base.value or "").strip(), + "model": str(cfg.whisper_api_model.value or "whisper-1").strip(), + }, + "fun_asr": { + "api_key": str(cfg.fun_asr_api_key.value or "").strip(), + "api_base": str(cfg.fun_asr_api_base.value or "").strip(), + "model": str(cfg.fun_asr_model.value or "fun-asr").strip(), + }, + "transcribe": { + "asr": asr_name, + "whisper_cpp": { + "model": getattr(cfg.whisper_model.value, "value", str(cfg.whisper_model.value)), + }, + "faster_whisper": { + "model": getattr( + cfg.faster_whisper_model.value, "value", str(cfg.faster_whisper_model.value) + ), + }, + }, + "subtitle": { + "optimize": cfg.need_optimize.value, + "split": cfg.need_split.value, + "translate": cfg.need_translate.value, + "render_mode": cfg.subtitle_render_mode.value.value, + }, + "translate": { + "service": "llm" if cfg.translator_service.value == TranslatorServiceEnum.OPENAI else cfg.translator_service.value.name.lower(), + }, + "dubbing": { + "provider": provider, + "preset": cfg.dubbing_preset.value, + "api_key": str(cfg.dubbing_api_key.value or "").strip(), + "api_base": str(cfg.dubbing_api_base.value or "").strip(), + "model": str(cfg.dubbing_model.value or "").strip(), + "voice": str(cfg.dubbing_voice.value or "").strip(), + "timing": "balanced", + "audio_mode": "replace", + }, + "live_caption": { + "provider": cfg.live_caption_provider.value, + "voxgate_binary": str(cfg.live_caption_voxgate_binary.value or "").strip(), + "api_key": str(cfg.fun_asr_api_key.value or "").strip(), + }, + } + + +def _current_llm_api_key() -> str: + service = cfg.llm_service.value + value = { + "OPENAI": cfg.openai_api_key.value, + "SILICON_CLOUD": cfg.silicon_cloud_api_key.value, + "DEEPSEEK": cfg.deepseek_api_key.value, + "OLLAMA": cfg.ollama_api_key.value, + "LM_STUDIO": cfg.lm_studio_api_key.value, + "GEMINI": cfg.gemini_api_key.value, + "CHATGLM": cfg.chatglm_api_key.value, + }.get(service.name, "") + return str(value or "").strip() + + +def _current_llm_api_base() -> str: + service = cfg.llm_service.value + value = { + "OPENAI": cfg.openai_api_base.value, + "SILICON_CLOUD": cfg.silicon_cloud_api_base.value, + "DEEPSEEK": cfg.deepseek_api_base.value, + "OLLAMA": cfg.ollama_api_base.value, + "LM_STUDIO": cfg.lm_studio_api_base.value, + "GEMINI": cfg.gemini_api_base.value, + "CHATGLM": cfg.chatglm_api_base.value, + }.get(service.name, "") + return str(value or "").strip() + + +def _current_llm_model() -> str: + service = cfg.llm_service.value + value = { + "OPENAI": cfg.openai_model.value, + "SILICON_CLOUD": cfg.silicon_cloud_model.value, + "DEEPSEEK": cfg.deepseek_model.value, + "OLLAMA": cfg.ollama_model.value, + "LM_STUDIO": cfg.lm_studio_model.value, + "GEMINI": cfg.gemini_model.value, + "CHATGLM": cfg.chatglm_model.value, + }.get(service.name, "") + return str(value or "").strip() diff --git a/videocaptioner/ui/view/dubbing_interface.py b/videocaptioner/ui/view/dubbing_interface.py new file mode 100644 index 00000000..d4c96a62 --- /dev/null +++ b/videocaptioner/ui/view/dubbing_interface.py @@ -0,0 +1,1348 @@ +import shutil +import subprocess +from pathlib import Path + +from PyQt5.QtCore import Qt, QTimer, QUrl, pyqtSignal +from PyQt5.QtGui import QColor, QPainter +from PyQt5.QtMultimedia import ( + QAudioEncoderSettings, + QAudioRecorder, + QMediaContent, + QMediaPlayer, + QMediaRecorder, + QMultimedia, +) +from PyQt5.QtWidgets import ( + QFileDialog, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QScrollArea, + QVBoxLayout, + QWidget, +) +from qfluentwidgets import ( + InfoBar, + ScrollArea, +) + +from videocaptioner.config import CACHE_PATH +from videocaptioner.core.constant import INFOBAR_DURATION_ERROR, INFOBAR_DURATION_SUCCESS +from videocaptioner.core.dubbing import get_dubbing_preset +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.config import cfg +from videocaptioner.ui.common.dubbing_options import ( + DUBBING_PROVIDERS, + DubbingVoiceOption, + get_provider_option, + get_provider_voices, + provider_desc, + provider_title, + tag_label, + voice_desc, +) +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.workbench import ( + AppTextEdit, + ClickableFrame, + CompactButton, + DangerButton, + FilterTabs, + SelectableCard, + StatusPill, + WorkbenchButton, + apply_font, + draw_rounded_surface, +) +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.voice_preview_thread import ( + VoicePreviewThread, + bundled_voice_preview, + playable_voice_preview, +) + +CONTROL_RADIUS = 9 # 与 workbench CompactButton 一致 +PANEL_RADIUS = 18 # 面板圆角(更圆润) +PAGE_MARGIN_X = 26 # 与批量/诊断等独立 nav 页根边距统一为 (26,20,26,22) +SECTION_GAP = 14 +BODY_GAP = 18 +PROVIDER_HEIGHT = 88 +TABLE_HEADER_HEIGHT = 52 +VOICE_ROW_HEIGHT = 92 # 容纳 标题 + 描述 + 标签三行 +VOICE_LIST_PADDING = 12 # 音色列表内边距 +VOICE_ROW_GAP = 8 # 音色卡片之间的留白 +VOICE_ROW_RADIUS = 14 # 音色卡片圆角(圆角卡片,非直角行) +AUDITION_BUTTON_WIDTH = 92 + + +def _tag_chip(text: str, parent=None) -> QLabel: + """音色标签小药丸:色值由页面 QSS 统一着色。""" + chip = QLabel(text, parent) + chip.setObjectName("voiceTag") + apply_font(chip, 11, 760) + return chip + + +def _provider_badge(option) -> tuple[str, str]: + """提供商卡右侧状态胶囊: + 免 Key(Edge)/ 可克隆(SiliconFlow)为 ok,其余需 Key 为 neutral。""" + if not option.needs_api_key: + return tr("dubbing.badge.no_key"), "ok" + if option.supports_clone: + return tr("dubbing.badge.clone"), "ok" + return tr("dubbing.badge.need_key"), "neutral" + +# 提供商卡左侧图标(与批量处理页模式卡同款图标盒):均为音频族图标, +# edge=扬声器、gemini=音符、siliconflow=麦克风(克隆录音语义贴切)。 +_PROVIDER_ICONS = { + "edge": AppIcon.VOLUME, + "gemini": AppIcon.MUSIC, + "siliconflow": AppIcon.MICROPHONE, +} +PREVIEW_PANEL_WIDTH = 376 +GENDER_FILTER_TAGS = {"女声", "男声"} + + +def _blend_color(foreground: str, background: str, alpha: float) -> QColor: + # foreground/background 恒为 app_palette() 的有效色;旧的无效兜底写死了非主题绿, + # 自定义主题时反而错,且从不触发,去掉。 + fg = QColor(foreground) + bg = QColor(background) + alpha = max(0.0, min(1.0, alpha)) + return QColor( + int(fg.red() * alpha + bg.red() * (1 - alpha)), + int(fg.green() * alpha + bg.green() * (1 - alpha)), + int(fg.blue() * alpha + bg.blue() * (1 - alpha)), + ) + + +class ThemedSimpleCard(QFrame): + """项目自绘卡:palette 颜色 + 可选选中态描边,不依赖 qfluent SimpleCardWidget + (它本就完全覆盖 paintEvent,qfluent 基类的视觉从未被用到)。""" + + def __init__(self, parent=None): + super().__init__(parent) + self._selected_visual = False + self._radius = PANEL_RADIUS + + def setBorderRadius(self, radius: int): + self._radius = radius + + def setSelectedVisual(self, selected: bool): + self._selected_visual = selected + self.update() + + def paintEvent(self, event): # noqa: N802 + palette = app_palette() + painter = QPainter(self) + painter.setRenderHints(QPainter.Antialiasing) + background = ( + _blend_color(palette.accent, palette.panel, 0.14) + if self._selected_visual + else QColor(palette.panel) + ) + border = QColor(palette.accent if self._selected_visual else palette.line) + painter.setPen(border) + painter.setBrush(background) + painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), self._radius, self._radius) + + +class _RoundedPanel(QFrame): + """自绘抗锯齿圆角面板:bg/border 按主题实时取色,半径固定。 + + 替代 QSS ``border-radius``(QSS 圆角不做抗锯齿,边缘有锯齿/不流畅)。 + """ + + def __init__(self, radius, bg_fn, border_fn, parent=None): + super().__init__(parent) + self._radius = radius + self._bg_fn = bg_fn # callable(palette) -> str + self._border_fn = border_fn # callable(palette) -> str + + def paintEvent(self, event): # noqa: N802 + palette = app_palette() + draw_rounded_surface(self, self._bg_fn(palette), self._border_fn(palette), self._radius) + + +class AuditionButton(ClickableFrame): + def __init__(self, text: str, parent=None): + super().__init__(parent) + self.setObjectName("auditionButton") + self.setFixedSize(AUDITION_BUTTON_WIDTH, 36) + + layout = QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.label = QLabel(text, self) + self.label.setObjectName("auditionButtonLabel") + self.label.setAlignment(Qt.AlignCenter) # type: ignore + apply_font(self.label, 13, 750) + layout.addWidget(self.label, 0, 0, Qt.AlignCenter) # type: ignore + self._sync_style() + + def setText(self, text: str): + self.label.setText(text) + + def text(self) -> str: + return self.label.text() + + def setEnabled(self, enabled: bool): + super().setEnabled(enabled) + self._sync_style() + + def mousePressEvent(self, event): + if self.isEnabled() and event.button() == Qt.LeftButton: # type: ignore + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def _sync_style(self): + palette = app_palette() + if not self.isEnabled(): + bg, fg, border = palette.disabled, palette.subtle, palette.line + else: + bg, fg, border = palette.field, palette.text, palette.line + self.setStyleSheet( + f""" + QFrame#auditionButton {{ + background: {bg}; + border: 1px solid {border}; + border-radius: {CONTROL_RADIUS}px; + }} + QLabel#auditionButtonLabel {{ + color: {fg}; + background: transparent; + border: none; + }} + """ + ) + + +class VoiceRow(QFrame): + previewRequested = pyqtSignal(str, object) + selectedRequested = pyqtSignal(str) + + def __init__(self, voice: DubbingVoiceOption, parent=None): + super().__init__(parent) + self.voice = voice + self.setObjectName("voiceRow") + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore 圆角卡片需自绘样式背景 + self.setFixedHeight(VOICE_ROW_HEIGHT) + self.setCursor(Qt.PointingHandCursor) # type: ignore + + layout = QGridLayout(self) + layout.setContentsMargins(16, 0, 16, 0) + layout.setHorizontalSpacing(18) + layout.setVerticalSpacing(0) + layout.setColumnStretch(0, 1) + layout.setColumnMinimumWidth(1, AUDITION_BUTTON_WIDTH) + + titleWidget = QWidget(self) + titleBox = QVBoxLayout(titleWidget) + titleBox.setContentsMargins(0, 0, 0, 0) + titleBox.setSpacing(5) + self.titleLabel = QLabel(voice.title, self) + apply_font(self.titleLabel, 16, 760) + self.descLabel = QLabel(voice_desc(voice), self) + self.descLabel.setObjectName("dubCaption") + apply_font(self.descLabel, 13, 500) + self.descLabel.setWordWrap(False) + titleBox.addWidget(self.titleLabel) + titleBox.addWidget(self.descLabel) + if voice.tags: + tagsRow = QHBoxLayout() + tagsRow.setContentsMargins(0, 1, 0, 0) + tagsRow.setSpacing(6) + for tag in voice.tags: + tagsRow.addWidget(_tag_chip(tag_label(tag), self)) + tagsRow.addStretch(1) + titleBox.addLayout(tagsRow) + layout.addWidget(titleWidget, 0, 0, Qt.AlignVCenter) # type: ignore + + self.previewButton = AuditionButton(tr("dubbing.btn.audition"), self) + layout.addWidget(self.previewButton, 0, 1, Qt.AlignRight | Qt.AlignVCenter) # type: ignore + self.previewButton.clicked.connect(lambda: self.previewRequested.emit(self.voice.preset, self.previewButton)) + + def setSelected(self, selected: bool): + self.setProperty("selected", selected) + self.style().unpolish(self) + self.style().polish(self) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore + self.selectedRequested.emit(self.voice.preset) + event.accept() + return + super().mousePressEvent(event) + + +class VoiceTable(QFrame): + previewRequested = pyqtSignal(str, object) + selectedRequested = pyqtSignal(str) + filterChanged = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("voiceTable") + self.rows: list[VoiceRow] = [] + self.filterTabs: FilterTabs | None = None + self._filter_keys: tuple[str, ...] = () + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) + self._add_header() # 表头固定在顶部 + # 音色卡列表区:内边距 + 卡间留白,每个音色是独立圆角卡片。 + # 放进内部 QScrollArea:表头不动,只有这块在长列表时滚动。 + self.listArea = QWidget() + self.listArea.setObjectName("voiceListArea") + self.listLayout = QVBoxLayout(self.listArea) + self.listLayout.setContentsMargins( + VOICE_LIST_PADDING, VOICE_LIST_PADDING, VOICE_LIST_PADDING, VOICE_LIST_PADDING + ) + self.listLayout.setSpacing(VOICE_ROW_GAP) + self.listScroll = QScrollArea(self) + self.listScroll.setObjectName("voiceScroll") + self.listScroll.setWidget(self.listArea) + self.listScroll.setWidgetResizable(True) + self.listScroll.setFrameShape(QFrame.NoFrame) # type: ignore[attr-defined] + self.listScroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[attr-defined] + self.listScroll.viewport().setAutoFillBackground(False) + self.layout.addWidget(self.listScroll, 1) # 填满表头下方剩余高度 + + def _add_header(self): + """表头:左「音色库 / 中文音色」标题 + 右分段筛选。""" + self.header = QFrame(self) + self.header.setObjectName("voiceHeader") + self.header.setFixedHeight(TABLE_HEADER_HEIGHT) + self._headerLayout = QHBoxLayout(self.header) + self._headerLayout.setContentsMargins(16, 0, 10, 0) + self._headerLayout.setSpacing(10) + self.headingLabel = QLabel(tr("dubbing.voice.library"), self.header) + self.headingLabel.setObjectName("voiceHeading") + apply_font(self.headingLabel, 19, 820) + self._headerLayout.addWidget(self.headingLabel) + self._headerLayout.addStretch(1) + self._build_filter( + [ + ("全部", tr("dubbing.filter.all")), + ("女声", tr("dubbing.filter.female")), + ("男声", tr("dubbing.filter.male")), + ] + ) + self.layout.addWidget(self.header) + + def _build_filter(self, items: list[tuple[str, str]]): + keys = tuple(k for k, _ in items) + if keys == self._filter_keys and self.filterTabs is not None: + return + self._filter_keys = keys + if self.filterTabs is not None: + self._headerLayout.removeWidget(self.filterTabs) + # setParent(None) 立即从表头移除并停止绘制;只 deleteLater 会留下残影旧分段 + self.filterTabs.setParent(None) + self.filterTabs.deleteLater() + self.filterTabs = FilterTabs(items, self.header) + self.filterTabs.changed.connect(self.filterChanged) + self._headerLayout.addWidget(self.filterTabs) + + def configure(self, heading: str, *, show_gender: bool, show_clone: bool): + """按提供商配置表头:标题文案 + 筛选项(性别/克隆按需出现)。""" + self.headingLabel.setText(heading) + items = [("全部", tr("dubbing.filter.all"))] + if show_gender: + items += [("女声", tr("dubbing.filter.female")), ("男声", tr("dubbing.filter.male"))] + if show_clone: + items += [("克隆", tr("dubbing.filter.clone"))] + self._build_filter(items) + + def setFilter(self, key: str): + if self.filterTabs is not None: + self.filterTabs.setCurrent(key) + + def setVoices(self, voices: list[DubbingVoiceOption], current: str): + # 清空旧行与尾部弹簧(弹簧不在 self.rows 里,必须连同清掉) + while self.listLayout.count(): + item = self.listLayout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.hide() + widget.setParent(None) + widget.deleteLater() + self.rows = [] + for voice in voices: + row = VoiceRow(voice, self.listArea) + row.setSelected(voice.preset == current) + row.previewRequested.connect(self.previewRequested) + row.selectedRequested.connect(self.selectedRequested) + self.listLayout.addWidget(row) + self.rows.append(row) + self.listLayout.addStretch(1) # 行少时顶部对齐;行多时由 listScroll 滚动,不再固定整表高度 + + +class PreviewPanel(ThemedSimpleCard): + layoutChanged = pyqtSignal() + customPreviewRequested = pyqtSignal() + chooseAudioRequested = pyqtSignal() + playAudioRequested = pyqtSignal() + recordRequested = pyqtSignal() + clearRequested = pyqtSignal() + cloneTextChanged = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("previewPanel") + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore + self.setBorderRadius(PANEL_RADIUS) + self.setFixedWidth(PREVIEW_PANEL_WIDTH) + self._clone_available = True + self._clone_audio_path = "" + + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 15, 16, 15) + layout.setSpacing(9) + + # 标题 + 描述在左,当前音色 pill 在右。 + # 用透明容器而非带边框的子卡——子卡圆角(15)和外层面板圆角(18)在顶部只差 ~16px, + # 会出现「两个圆角嵌套重叠」的观感。标题直接贴面板内边距即可,干净不重复。 + self.selectedCard = QFrame(self) + self.selectedCard.setObjectName("selectedCard") + header = QHBoxLayout(self.selectedCard) + header.setContentsMargins(0, 0, 0, 0) + header.setSpacing(10) + headText = QVBoxLayout() + headText.setContentsMargins(0, 0, 0, 0) + headText.setSpacing(3) + self.titleLabel = QLabel(tr("dubbing.preview.title"), self.selectedCard) + apply_font(self.titleLabel, 19, 700) + self.descLabel = QLabel(tr("dubbing.preview.desc"), self.selectedCard) + self.descLabel.setObjectName("dubCaption") + self.descLabel.setWordWrap(True) + apply_font(self.descLabel, 13, 500) + headText.addWidget(self.titleLabel) + headText.addWidget(self.descLabel) + self.voicePill = StatusPill("", "ok", self.selectedCard) # 当前音色 + self.voicePill.hide() + header.addLayout(headText, 1) + header.addWidget(self.voicePill, 0, Qt.AlignTop) + + self.previewInput = AppTextEdit(parent=self, min_height=104, radius=15) + self.previewInput.setObjectName("previewInput") + self.previewInput.setPlaceholderText(tr("dubbing.preview.input_placeholder")) + self.previewInput.setFixedHeight(104) + self.previewInput.setPlainText(tr("dubbing.preview.sample_text")) + apply_font(self.previewInput, 13, 700) + + meta = QHBoxLayout() + meta.setContentsMargins(0, 0, 0, 0) + meta.setSpacing(8) + self.countLabel = QLabel("", self) + self.countLabel.setObjectName("sampleMetaLabel") + self.countLabel.setMinimumWidth(50) + self.countLabel.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore + apply_font(self.countLabel, 11, 400) + meta.addStretch(1) + meta.addWidget(self.countLabel) + + self.customPreviewButton = WorkbenchButton( + tr("dubbing.btn.generate_preview"), AppIcon.PLAY, primary=True, height=40, parent=self + ) + + # 自绘抗锯齿圆角(绿调克隆框);QSS border-radius 会有锯齿 + self.cloneSection = _RoundedPanel( + 15, + lambda p: rgba(p.accent, 0.07), + lambda p: rgba(p.accent, 0.38), + self, + ) + self.cloneSection.setObjectName("cloneSection") + cloneLayout = QVBoxLayout(self.cloneSection) + cloneLayout.setContentsMargins(12, 12, 12, 12) + cloneLayout.setSpacing(8) + + cloneHeader = QHBoxLayout() + cloneTitle = QLabel(tr("dubbing.clone.title"), self.cloneSection) + apply_font(cloneTitle, 15, 700) + cloneHeader.addWidget(cloneTitle) + cloneHeader.addStretch(1) + + self.fileBox = QFrame(self.cloneSection) + self.fileBox.setObjectName("cloneFileBox") + self.fileBox.setFixedHeight(38) + fileLayout = QHBoxLayout(self.fileBox) + fileLayout.setContentsMargins(12, 0, 10, 0) + fileLayout.setSpacing(8) + self.fileLabel = QLabel(tr("dubbing.clone.no_audio"), self.fileBox) + self.fileLabel.setObjectName("dubCaption") + apply_font(self.fileLabel, 12, 600) + self.fileLabel.setWordWrap(False) + self.fileStatusPill = StatusPill(tr("dubbing.clone.uploaded"), "ok", self.fileBox) + self.fileStatusPill.hide() + fileLayout.addWidget(self.fileLabel, 1) + fileLayout.addWidget(self.fileStatusPill, 0, Qt.AlignVCenter) + + # 操作按钮统一 workbench 紧凑按钮,自适应宽度不挤压 + actions = QHBoxLayout() + actions.setContentsMargins(0, 0, 0, 0) + actions.setSpacing(8) + self.chooseButton = CompactButton(tr("dubbing.clone.upload"), AppIcon.FOLDER_ADD, self.cloneSection) + self.playButton = CompactButton(tr("dubbing.btn.audition"), AppIcon.PLAY, self.cloneSection) + self.recordButton = CompactButton(tr("dubbing.clone.record"), AppIcon.MICROPHONE, self.cloneSection) + self.clearButton = DangerButton(tr("dubbing.clone.clear"), AppIcon.DELETE, self.cloneSection) + actions.addWidget(self.chooseButton) + actions.addWidget(self.playButton) + actions.addWidget(self.recordButton) + actions.addWidget(self.clearButton) + actions.addStretch(1) + + self.cloneTextLabel = QLabel(tr("dubbing.clone.ref_text"), self.cloneSection) + self.cloneTextLabel.setObjectName("sampleMetaLabel") + apply_font(self.cloneTextLabel, 12, 600) + self.cloneTextInput = AppTextEdit(parent=self.cloneSection, min_height=48, radius=12) + self.cloneTextInput.setObjectName("cloneTextInput") + self.cloneTextInput.setPlaceholderText(tr("dubbing.clone.ref_text_placeholder")) + self.cloneTextInput.setFixedHeight(48) + apply_font(self.cloneTextInput, 12, 650) + + self.cloneHintLabel = QLabel(tr("dubbing.clone.hint"), self.cloneSection) + self.cloneHintLabel.setObjectName("sampleMetaLabel") + apply_font(self.cloneHintLabel, 11, 400) + self.cloneHintLabel.setWordWrap(True) + self.cloneHintLabel.hide() + + cloneLayout.addLayout(cloneHeader) + cloneLayout.addWidget(self.fileBox) + cloneLayout.addLayout(actions) + cloneLayout.addWidget(self.cloneTextLabel) + cloneLayout.addWidget(self.cloneTextInput) + cloneLayout.addWidget(self.cloneHintLabel) + + # 非克隆提供商:用「当前音色 / 生成类型」摘要替代克隆区 + self.formList = _RoundedPanel( + 14, lambda p: p.field, lambda p: p.line_soft, self + ) + self.formList.setObjectName("formList") + formLayout = QVBoxLayout(self.formList) + formLayout.setContentsMargins(14, 10, 14, 10) + formLayout.setSpacing(8) + self.formVoiceValue = QLabel("", self.formList) + self.formTypeValue = QLabel(tr("dubbing.form.type_preview"), self.formList) + formLayout.addLayout(self._form_row(tr("dubbing.form.current_voice"), self.formVoiceValue)) + formLayout.addLayout(self._form_row(tr("dubbing.form.gen_type"), self.formTypeValue)) + + layout.addWidget(self.selectedCard) + layout.addWidget(self.cloneSection) + layout.addWidget(self.previewInput) + layout.addLayout(meta) + layout.addWidget(self.formList) + layout.addWidget(self.customPreviewButton) + + self.previewInput.textChanged.connect(self._update_count) + self.customPreviewButton.clicked.connect(self.customPreviewRequested) + self.chooseButton.clicked.connect(self.chooseAudioRequested) + self.playButton.clicked.connect(self.playAudioRequested) + self.recordButton.clicked.connect(self.recordRequested) + self.clearButton.clicked.connect(self.clearRequested) + self.cloneTextInput.textChanged.connect( + lambda: self.cloneTextChanged.emit(self.cloneTextInput.toPlainText().strip()) + ) + self._update_count() + + def _form_row(self, label_text: str, value_label: QLabel) -> QHBoxLayout: + """form-list 一行:左键名 + 右值。""" + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + key = QLabel(label_text, self.formList) + key.setObjectName("formKey") + apply_font(key, 12, 600) + apply_font(value_label, 13, 700) + value_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore + row.addWidget(key) + row.addStretch(1) + row.addWidget(value_label) + return row + + def text(self) -> str: + return self.previewInput.toPlainText().strip() + + def customButtonLabel(self) -> str: + """主按钮应显示的文案:有参考音频的克隆态用「生成克隆音频」,否则「生成试听音频」。""" + if self._clone_available and bool(self._clone_audio_path): + return tr("dubbing.btn.generate_clone") + return tr("dubbing.btn.generate_preview") + + def setCurrentVoice(self, name: str): + """右栏「配音文案」标题旁显示当前音色名。""" + if name: + self.voicePill.setState(name, "ok") + self.voicePill.show() + else: + self.voicePill.hide() + self.formVoiceValue.setText(name or tr("dubbing.form.not_selected")) + + def setCloneAvailable(self, available: bool): + self._clone_available = available + self.cloneSection.setVisible(available) + self.formList.setVisible(not available) + if not available: + self.customPreviewButton.setText(tr("dubbing.btn.generate_preview")) + self.descLabel.setText(tr("dubbing.preview.desc")) + self.layoutChanged.emit() + else: + self.descLabel.setText(tr("dubbing.preview.desc_clone")) + self._sync_clone_state() + self.updateGeometry() + + def setAudioPath(self, path: str): + self._clone_audio_path = path.strip() + self.fileLabel.setText(self._format_file_line(self._clone_audio_path)) + self.fileStatusPill.setVisible(self._clone_audio_exists()) + if not self._clone_available: + self.playButton.setEnabled(False) + self.clearButton.setEnabled(False) + self.updateGeometry() + self.layoutChanged.emit() + return + self._sync_clone_state() + + def setCloneText(self, text: str): + if self.cloneTextInput.toPlainText() == text: + return + self.cloneTextInput.blockSignals(True) + self.cloneTextInput.setPlainText(text) + self.cloneTextInput.blockSignals(False) + + def setRecording(self, recording: bool): + self.recordButton.setText(tr("dubbing.btn.stop") if recording else tr("dubbing.clone.record")) + self.chooseButton.setEnabled(not recording) + self.playButton.setEnabled(False if recording else self._clone_audio_exists()) + self.clearButton.setEnabled(False if recording else bool(self._clone_audio_path)) + + def syncStyle(self): + self.customPreviewButton.syncStyle() + self.chooseButton.syncStyle() + self.playButton.syncStyle() + self.recordButton.syncStyle() + self.clearButton.syncStyle() + + def _update_clone_hint(self, path: str): + if path: + if Path(path).exists(): + self.cloneHintLabel.clear() + else: + self.cloneHintLabel.setText(tr("dubbing.clone.missing_file")) + else: + self.cloneHintLabel.clear() + + def _sync_clone_state(self): + if not self._clone_available: + self.cloneSection.hide() + self.updateGeometry() + return + has_audio = bool(self._clone_audio_path) + self.customPreviewButton.setText( + tr("dubbing.btn.generate_clone") if has_audio else tr("dubbing.btn.generate_preview") + ) + self._update_clone_hint(self._clone_audio_path) + self.cloneTextLabel.setVisible(has_audio) + self.cloneTextInput.setVisible(has_audio) + self.cloneHintLabel.setVisible(bool(self.cloneHintLabel.text())) + self.playButton.setEnabled(self._clone_audio_exists()) + self.clearButton.setEnabled(has_audio) + self.cloneSection.updateGeometry() + self.updateGeometry() + self.layoutChanged.emit() + + def _clone_audio_exists(self) -> bool: + return bool(self._clone_audio_path) and Path(self._clone_audio_path).exists() + + def _format_file_line(self, path: str) -> str: + """file-line 文案:「文件名 · 时长s」。""" + if not path: + return tr("dubbing.clone.no_audio") + name = Path(path).name + seconds = self._audio_seconds(path) + return f"{name} · {seconds}s" if seconds else name + + @staticmethod + def _audio_seconds(path: str) -> int: + if not path or not Path(path).exists(): + return 0 + try: + from videocaptioner.core.dubbing.audio import get_audio_duration_ms + + return round(get_audio_duration_ms(path) / 1000) + except Exception: + return 0 + + def _update_count(self): + self.countLabel.setText(tr("dubbing.preview.char_count", count=len(self.text()))) + + +class DubbingInterface(ScrollArea): + """配音音色库与试听页。""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setWindowTitle(tr("dubbing.title")) + self.preview_thread: VoicePreviewThread | None = None + self.player = QMediaPlayer(self) + self.player.stateChanged.connect(self._on_player_state_changed) + self.player.error.connect(self._on_player_error) + self.recorder = QAudioRecorder(self) + self._recording_output_path: Path | None = None + self.scrollWidget = QWidget() + self.contentLayout = QVBoxLayout(self.scrollWidget) + self.providerCards: dict[str, SelectableCard] = {} + self.genderFilter = "全部" + self._active_preview_button: QWidget | None = None # 合成中的按钮 + self._playing_button: QWidget | None = None # 播放中的按钮(可点停止) + self._playing_path = "" + self._fallback_player_process: subprocess.Popen | None = None + self._active_preview_cache_key: tuple[str, ...] | None = None + self._preview_cache: dict[tuple[str, ...], str] = {} + + self._init_ui() + self._connect_signals() + self._setup_recorder() + self._on_provider_changed(cfg.dubbing_provider.value) + + def _init_ui(self): + self.resize(1200, 820) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore + self.setViewportMargins(0, 0, 0, 0) + self.setWidget(self.scrollWidget) + self.setWidgetResizable(True) + self.setObjectName("dubbingInterface") + self.scrollWidget.setObjectName("scrollWidget") + self.enableTransparentBackground() + + # 页头:标题 + 描述,随内容滚动(与批量处理页 pageTitle/pageSubtitle + # 同款);不再用浮动绝对定位 + viewport 顶边距的旧写法。 + self.headerWidget = QWidget(self.scrollWidget) + headRow = QHBoxLayout(self.headerWidget) + headRow.setContentsMargins(0, 0, 0, 0) + headRow.setSpacing(12) + headText = QVBoxLayout() + headText.setContentsMargins(0, 0, 0, 0) + headText.setSpacing(3) + self.titleLabel = QLabel(tr("dubbing.title"), self.headerWidget) + self.titleLabel.setObjectName("pageTitle") + apply_font(self.titleLabel, 26, 860) + self.subtitleLabel = QLabel( + tr("dubbing.subtitle"), self.headerWidget + ) + self.subtitleLabel.setObjectName("pageSubtitle") + apply_font(self.subtitleLabel, 13, 720) + headText.addWidget(self.titleLabel) + headText.addWidget(self.subtitleLabel) + headRow.addLayout(headText, 1) + # 右侧:配音配置入口 + 当前提供商就绪状态 + self.configButton = CompactButton(tr("dubbing.btn.config"), AppIcon.SETTING, self.headerWidget) + self.configButton.clicked.connect(self._open_dubbing_config) + self.readyPill = StatusPill("", "neutral", self.headerWidget) + headRow.addWidget(self.configButton, 0, Qt.AlignBottom) # type: ignore[arg-type] + headRow.addWidget(self.readyPill, 0, Qt.AlignBottom) # type: ignore[arg-type] + + self.providerPanel = QWidget(self.scrollWidget) + self.providerPanel.setFixedHeight(PROVIDER_HEIGHT) + providerLayout = QHBoxLayout(self.providerPanel) + providerLayout.setContentsMargins(0, 0, 0, 0) + providerLayout.setSpacing(12) + for option in DUBBING_PROVIDERS: + card = SelectableCard( + option.key, + provider_title(option), + provider_desc(option), + _PROVIDER_ICONS.get(option.key), + self.providerPanel, + ) + text, level = _provider_badge(option) + card.setBadge(text, level) + card.clicked.connect(self._on_provider_changed) + providerLayout.addWidget(card, 1) + self.providerCards[option.key] = card + + self.bodyPanel = QWidget(self.scrollWidget) + bodyLayout = QHBoxLayout(self.bodyPanel) + bodyLayout.setContentsMargins(0, 0, 0, 0) + bodyLayout.setSpacing(BODY_GAP) + # 左侧音色库独立滚动:表头(音色库+筛选)固定,只有音色行在内部滚动;右侧预览面板固定不动。 + self.voiceTable = VoiceTable(self.bodyPanel) + self.sidePanel = QWidget(self.bodyPanel) + sideLayout = QVBoxLayout(self.sidePanel) + sideLayout.setContentsMargins(0, 0, 0, 0) + sideLayout.setSpacing(SECTION_GAP) + self.previewPanel = PreviewPanel(self.sidePanel) + sideLayout.addWidget(self.previewPanel) + sideLayout.addStretch(1) + bodyLayout.addWidget(self.voiceTable, 1) + bodyLayout.addWidget(self.sidePanel, 0, Qt.AlignTop) # type: ignore + + self.contentLayout.setSpacing(SECTION_GAP) + self.contentLayout.setContentsMargins(PAGE_MARGIN_X, 20, PAGE_MARGIN_X, 22) + self.contentLayout.addWidget(self.headerWidget) + self.contentLayout.addWidget(self.providerPanel) + self.contentLayout.addWidget(self.bodyPanel, 1) # 占满剩余高度;内部 voiceScroll 滚动 + + def _connect_signals(self): + self.voiceTable.filterChanged.connect(self._on_gender_filter) + self.voiceTable.previewRequested.connect(self._preview) + self.voiceTable.selectedRequested.connect(self._apply_preset) + self.previewPanel.customPreviewRequested.connect(self._preview_custom_text) + self.previewPanel.chooseAudioRequested.connect(self._choose_clone_audio) + self.previewPanel.playAudioRequested.connect(self._play_clone_audio) + self.previewPanel.recordRequested.connect(self._toggle_clone_recording) + self.previewPanel.clearRequested.connect(self._clear_clone_audio) + self.previewPanel.layoutChanged.connect(self._refresh_body_layout) + self.previewPanel.cloneTextChanged.connect( + lambda text: cfg.set(cfg.dubbing_clone_text, text, save=False) + ) + + def _setup_recorder(self): + settings = QAudioEncoderSettings() + settings.setCodec("audio/pcm") + settings.setSampleRate(16000) + settings.setChannelCount(1) + settings.setQuality(QMultimedia.NormalQuality) + self.recorder.setEncodingSettings(settings) + self.recorder.stateChanged.connect(self._on_recording_state_changed) + + def closeEvent(self, event): + # 退出/切走时停掉试听网络线程、播放器与录音:main_window.closeEvent 会 close() + # 本页,若 preview_thread 仍在跑,销毁 running QThread 会触发 qFatal。 + # 这是只读网络线程,terminate 安全。 + if self.preview_thread is not None and self.preview_thread.isRunning(): + self.preview_thread.terminate() + self.preview_thread.wait(1000) + self.player.stop() + if self.recorder.state() == QMediaRecorder.RecordingState: + self.recorder.stop() + super().closeEvent(event) + + def showEvent(self, event): + super().showEvent(event) + self._sync_page_background() + self._on_provider_changed(cfg.dubbing_provider.value) + + def _sync_page_background(self): + palette = app_palette() + style = f""" + QScrollArea {{ + border: none; + background: {palette.bg}; + }} + QWidget#scrollWidget {{ + background: {palette.bg}; + }} + QFrame#voiceTable {{ + background: {palette.panel}; + border: 1px solid {palette.line_soft}; + border-radius: 18px; + }} + QFrame#voiceHeader {{ + background: transparent; + border: none; + border-bottom: 1px solid {palette.line_soft}; + }} + QWidget#voiceListArea {{ background: transparent; }} + QScrollArea#voiceScroll {{ background: transparent; border: none; }} + QScrollArea#voiceScroll QScrollBar:vertical {{ + background: transparent; width: 8px; margin: 2px; border: none; + }} + QScrollArea#voiceScroll QScrollBar::handle:vertical {{ + background: {palette.line}; border-radius: 4px; min-height: 36px; + }} + QScrollArea#voiceScroll QScrollBar::add-line:vertical, + QScrollArea#voiceScroll QScrollBar::sub-line:vertical {{ height: 0; background: transparent; }} + QFrame#voiceRow {{ + background: {palette.card_surface}; + border: 1px solid {palette.line_soft}; + border-radius: {VOICE_ROW_RADIUS}px; + }} + QFrame#voiceRow:hover {{ + background: {palette.card_surface_hover}; + }} + QFrame#voiceRow[selected="true"] {{ + background: {palette.selected}; + border: 1px solid {palette.accent_border}; + }} + QFrame#cloneFileBox {{ + background: transparent; + border: 1px dashed {rgba(palette.muted, 0.34)}; + border-radius: 12px; + }} + QFrame#selectedCard {{ background: transparent; border: none; }} + QLabel#formKey {{ color: {palette.muted}; background: transparent; }} + QLabel {{ + color: {palette.text}; + background: transparent; + }} + QLabel#pageTitle {{ color: {palette.text}; background: transparent; }} + QLabel#pageSubtitle {{ color: {palette.muted}; background: transparent; }} + QLabel#dubCaption {{ + color: {palette.muted}; + }} + QLabel#sampleMetaLabel {{ + color: {palette.subtle}; + }} + QLabel#voiceHeading {{ color: {palette.text}; background: transparent; }} + QLabel#voiceTag {{ + color: {palette.subtle}; + background: {palette.card_surface}; + border: 1px solid {palette.line_soft}; + border-radius: 7px; + padding: 1px 8px; + }} + """ + self.setStyleSheet(style) + self.scrollWidget.setStyleSheet(f"QWidget#scrollWidget {{ background: {palette.bg}; }}") + self.previewPanel.syncStyle() + + def _open_dubbing_config(self): + """跳到设置页的「配音配置」(提供商 Key / 模型等)。""" + window = self.window() + if hasattr(window, "openSettingsPage"): + window.openSettingsPage("dubbing") + + def _on_provider_changed(self, provider: str): + # 切换提供商会重建音色/模型上下文:先彻底停掉进行中的试听播放、合成与录制。 + # 否则旧按钮仍指向播放中的音频,而下面 setCloneAvailable 会重置其文案 → 标签与行为相反。 + self._abort_preview() + if self.recorder.state() == QMediaRecorder.RecordingState: + self.recorder.stop() + cfg.set(cfg.dubbing_provider, provider) + option = get_provider_option(provider) + ready = { + "edge": (tr("dubbing.ready.no_key"), "ok"), + "gemini": (tr("dubbing.ready.need_key"), "neutral"), + "siliconflow": (tr("dubbing.ready.clone"), "ok"), + }.get(provider, (tr("dubbing.ready.default"), "ok")) + self.readyPill.setState(*ready) + if option.models and not cfg.dubbing_model.value: + cfg.set(cfg.dubbing_model, option.models[0]) + self.previewPanel.setCloneAvailable(option.supports_clone) + self.previewPanel.setAudioPath(cfg.dubbing_clone_audio.value) + self.previewPanel.setCloneText(cfg.dubbing_clone_text.value) + + presets = get_provider_voices(provider) + current = cfg.dubbing_preset.value + if current not in {voice.preset for voice in presets}: + preset = get_dubbing_preset(presets[0].preset) + cfg.set(cfg.dubbing_preset, presets[0].preset) + cfg.set(cfg.dubbing_voice, preset.voice) + cfg.set(cfg.dubbing_model, preset.model) + + for key, card in self.providerCards.items(): + card.setActive(key == provider) + self._sync_filter_visibility(provider, presets) + self._render_voice_table() + self.contentLayout.update() + + def _sync_filter_visibility(self, provider: str, voices: tuple[DubbingVoiceOption, ...]): + supports_gender = any(GENDER_FILTER_TAGS.intersection(voice.tags) for voice in voices) + supports_clone = any("克隆" in voice.tags for voice in voices) + heading = tr("dubbing.voice.library_zh") if provider == "siliconflow" else tr("dubbing.voice.library") + # 切换提供商时筛选项可能变化(如克隆 tab 出现/消失),统一回到「全部」 + self.genderFilter = "全部" + self.voiceTable.configure(heading, show_gender=supports_gender, show_clone=supports_clone) + self.voiceTable.setFilter("全部") + + def _on_gender_filter(self, value: str): + self.genderFilter = value + self._render_voice_table() + + def _filtered_voices(self) -> list[DubbingVoiceOption]: + voices = list(get_provider_voices(cfg.dubbing_provider.value)) + if self.genderFilter != "全部": + voices = [voice for voice in voices if self.genderFilter in voice.tags] + return voices + + def _render_voice_table(self): + # 行控件即将重建:先释放对旧行试听按钮的引用,避免悬挂指针 + if isinstance(self._playing_button, AuditionButton): + self._stop_playback() + if isinstance(self._active_preview_button, AuditionButton): + self._active_preview_button = None + voices = self._filtered_voices() + self.voiceTable.setVoices(voices, cfg.dubbing_preset.value) + self.previewPanel.setCurrentVoice(self._current_voice_title()) + self._refresh_body_layout() + + def _current_voice_title(self) -> str: + """当前选中音色的展示名(用于右栏「配音文案」标题旁的 pill)。""" + current = cfg.dubbing_preset.value + for voice in get_provider_voices(cfg.dubbing_provider.value): + if voice.preset == current: + return voice.title + return "" + + def _refresh_body_layout(self): + # 音色表高度由内容决定,在 voiceScroll 内滚动;bodyPanel 由布局拉伸占满视口, + # 不再固定整页高度(否则整页一起滚、右栏跟着滚走)。 + self.viewport().update() + + def _choose_clone_audio(self): + if not get_provider_option(cfg.dubbing_provider.value).supports_clone: + return + path, _ = QFileDialog.getOpenFileName( + self, + tr("dubbing.dialog.choose_audio"), + "", + tr("dubbing.dialog.audio_filter"), + ) + if not path: + return + cfg.set(cfg.dubbing_clone_audio, path, save=False) + self.previewPanel.setAudioPath(path) + self._discard_clone_preview_cache() + self._refresh_body_layout() + + def _toggle_clone_recording(self): + if not get_provider_option(cfg.dubbing_provider.value).supports_clone: + return + if self.recorder.state() == QMediaRecorder.RecordingState: + self.recorder.stop() + return + + output_dir = CACHE_PATH / "dubbing-clone" + output_dir.mkdir(parents=True, exist_ok=True) + self._recording_output_path = output_dir / "reference.wav" + if self._recording_output_path.exists(): + self._recording_output_path.unlink() + self.recorder.setOutputLocation(QUrl.fromLocalFile(str(self._recording_output_path))) + self.recorder.record() + self.previewPanel.setRecording(True) + + def _on_recording_state_changed(self, state: QMediaRecorder.State): + recording = state == QMediaRecorder.RecordingState + self.previewPanel.setRecording(recording) + if recording or not self._recording_output_path: + return + if self._recording_output_path.exists() and self._recording_output_path.stat().st_size > 0: + path = str(self._recording_output_path) + cfg.set(cfg.dubbing_clone_audio, path, save=False) + self.previewPanel.setAudioPath(path) + self._discard_clone_preview_cache() + self._refresh_body_layout() + InfoBar.success( + tr("dubbing.toast.record_done"), + tr("dubbing.toast.record_done_body"), + duration=INFOBAR_DURATION_SUCCESS, + parent=self, + ) + self._recording_output_path = None + + def _clear_clone_audio(self): + if self.recorder.state() == QMediaRecorder.RecordingState: + self.recorder.stop() + cfg.set(cfg.dubbing_clone_audio, "", save=False) + cfg.set(cfg.dubbing_clone_text, "", save=False) + self.previewPanel.setAudioPath("") + self.previewPanel.setCloneText("") + self._discard_clone_preview_cache() + self._refresh_body_layout() + + def _play_clone_audio(self): + path = cfg.dubbing_clone_audio.value.strip() + if not path or not Path(path).exists(): + InfoBar.warning( + tr("dubbing.toast.audio_missing"), + tr("dubbing.toast.audio_missing_body"), + duration=3000, + parent=self, + ) + self.previewPanel.setAudioPath("") + cfg.set(cfg.dubbing_clone_audio, "", save=False) + self._refresh_body_layout() + return + if self.previewPanel.playButton is self._playing_button: + self._stop_playback() + return + self._play_audio_file(path, self.previewPanel.playButton) + + def _apply_preset(self, preset_name: str): + preset = get_dubbing_preset(preset_name) + cfg.set(cfg.dubbing_provider, preset.provider) + cfg.set(cfg.dubbing_preset, preset_name) + cfg.set(cfg.dubbing_voice, preset.voice) + cfg.set(cfg.dubbing_model, preset.model) + if preset.api_base and not cfg.dubbing_api_base.value: + cfg.set(cfg.dubbing_api_base, preset.api_base) + self._on_provider_changed(preset.provider) + + def _preview_custom_text(self): + text = self.previewPanel.text() + if not text: + InfoBar.warning( + tr("dubbing.toast.empty_text"), + tr("dubbing.toast.empty_text_body"), + duration=3000, + parent=self, + ) + return + option = get_provider_option(cfg.dubbing_provider.value) + clone_audio_path = cfg.dubbing_clone_audio.value.strip() if option.supports_clone else "" + clone_audio_text = cfg.dubbing_clone_text.value.strip() if clone_audio_path else "" + if clone_audio_path and not clone_audio_text: + InfoBar.warning( + tr("dubbing.toast.missing_ref_text"), + tr("dubbing.toast.missing_ref_text_body"), + duration=3500, + parent=self, + ) + return + self._preview( + cfg.dubbing_preset.value, + self.previewPanel.customPreviewButton, + text=text, + clone_audio_path=clone_audio_path, + clone_audio_text=clone_audio_text, + ) + + # ------------------------------------------------- 试听按钮状态机 + # idle(试听)→ loading(合成中…,禁用)→ playing(停止,可点)→ idle。 + # 同一时刻只有一个按钮处于 loading 或 playing;点击播放中的按钮即停止。 + + def _preview_idle_text(self, button: QWidget) -> str: + # 自定义试听按钮播完后要复原成「生成试听音频 / 生成克隆音频」(随克隆态变), + # 而非旧文案「试听这句话」,否则按钮标签会与右栏当前模式不一致。 + if button is self.previewPanel.customPreviewButton: + return self.previewPanel.customButtonLabel() + return tr("dubbing.btn.audition") + + def _set_preview_button(self, button: QWidget | None, state: str): + if button is None: + return + if state == "loading": + button.setEnabled(False) + if hasattr(button, "setText"): + button.setText(tr("dubbing.btn.synthesizing")) + elif state == "playing": + button.setEnabled(True) + if hasattr(button, "setText"): + button.setText(tr("dubbing.btn.stop")) + if hasattr(button, "setIcon"): + button.setIcon(AppIcon.CANCEL) + else: + button.setEnabled(True) + if hasattr(button, "setText"): + button.setText(self._preview_idle_text(button)) + if hasattr(button, "setIcon"): + button.setIcon(AppIcon.PLAY) + + def _abort_preview(self): + """彻底中止任何进行中的试听:停止播放、终止合成线程、复位 loading 按钮。 + + 切换提供商/页面销毁时调用。终止 running QThread 与 closeEvent 同一处理方式, + 避免合成完成后回调用旧音色播放、或 loading 按钮卡在「合成中…」。 + """ + self._stop_playback() + if self.preview_thread is not None and self.preview_thread.isRunning(): + self.preview_thread.terminate() + self.preview_thread.wait(1000) + self._set_preview_button(self._active_preview_button, "idle") + self._active_preview_button = None + self._active_preview_cache_key = None + + def _stop_playback(self): + if self._playing_button is not None: + self._set_preview_button(self._playing_button, "idle") + self._playing_button = None + self._playing_path = "" + self.player.stop() + self._stop_fallback_player() + + def _on_player_state_changed(self, state): + # 自然播完(或外部停止):把"停止"复原成"试听" + if state == QMediaPlayer.StoppedState and self._playing_button is not None: + self._set_preview_button(self._playing_button, "idle") + self._playing_button = None + self._playing_path = "" + + def _on_player_error(self, error): + if not self._playing_path or error == QMediaPlayer.NoError: + return + button = self._playing_button + path = self._playing_path + self.player.stop() + if self._play_audio_with_external_player(path, button): + return + self._set_preview_button(button, "idle") + self._playing_button = None + self._playing_path = "" + InfoBar.error( + tr("dubbing.toast.play_failed"), + tr("dubbing.toast.play_failed_body"), + duration=INFOBAR_DURATION_ERROR, + parent=self, + ) + + def _preview( + self, + preset_name: str, + button: QWidget | None = None, + *, + text: str = "", + clone_audio_path: str = "", + clone_audio_text: str = "", + ): + if button is not None and button is self._playing_button: + # 播放中点同一按钮 = 停止 + self._stop_playback() + return + if self.preview_thread and self.preview_thread.isRunning(): + InfoBar.info( + tr("dubbing.toast.please_wait"), + tr("dubbing.toast.please_wait_body"), + duration=2000, + parent=self, + ) + return + preset = get_dubbing_preset(preset_name) + requires_api = text or clone_audio_path or clone_audio_text or not bundled_voice_preview(preset_name) + if preset.provider != "edge" and not cfg.dubbing_api_key.value.strip() and requires_api: + InfoBar.warning( + tr("dubbing.toast.need_api_key"), + tr("dubbing.toast.need_api_key_body"), + duration=3500, + parent=self, + ) + return + cache_key = self._preview_cache_key( + preset_name, + text=text, + clone_audio_path=clone_audio_path, + clone_audio_text=clone_audio_text, + ) + cached_path = self._preview_cache.get(cache_key, "") + if cached_path and Path(cached_path).exists(): + self._play_audio_file(cached_path, button) + return + self._active_preview_button = button + self._active_preview_cache_key = cache_key + self._set_preview_button(button, "loading") + self.preview_thread = VoicePreviewThread( + preset_name, + text=text, + clone_audio_path=clone_audio_path, + clone_audio_text=clone_audio_text, + ) + self.preview_thread.finished.connect(self._on_preview_finished) + self.preview_thread.error.connect(self._on_preview_error) + self.preview_thread.start() + + def _on_preview_finished(self, path: str): + if self._active_preview_cache_key: + self._preview_cache[self._active_preview_cache_key] = path + self._active_preview_cache_key = None + button = self._active_preview_button + self._active_preview_button = None + self._set_preview_button(button, "idle") + # 播放状态由 _play_audio_file 接管(按钮翻成"停止"),不再弹成功通知 + self._play_audio_file(path, button) + + def _on_preview_error(self, message: str): + self._active_preview_cache_key = None + self._set_preview_button(self._active_preview_button, "idle") + self._active_preview_button = None + InfoBar.error( + tr("dubbing.toast.preview_failed"), + message, + duration=INFOBAR_DURATION_ERROR, + parent=self, + ) + + def _play_audio_file(self, path: str, button: QWidget | None = None): + playable_path = playable_voice_preview(Path(path)) + self._stop_playback() + self.player.setMedia(QMediaContent(QUrl.fromLocalFile(str(playable_path)))) + self._playing_button = button + self._playing_path = str(playable_path) + self._set_preview_button(button, "playing") + self.player.play() + + def _play_audio_with_external_player(self, path: str, button: QWidget | None = None) -> bool: + ffplay = shutil.which("ffplay") + if ffplay: + command = [ + ffplay, + "-nodisp", + "-autoexit", + "-loglevel", + "error", + path, + ] + else: + paplay = shutil.which("paplay") + if not paplay: + return False + command = [paplay, path] + self._stop_fallback_player() + try: + self._fallback_player_process = subprocess.Popen( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError: + self._fallback_player_process = None + return False + self._playing_button = button + self._playing_path = path + self._set_preview_button(button, "playing") + QTimer.singleShot(300, self._poll_fallback_player) + return True + + def _poll_fallback_player(self): + process = self._fallback_player_process + if process is None: + return + if process.poll() is None: + QTimer.singleShot(300, self._poll_fallback_player) + return + self._fallback_player_process = None + if self._playing_button is not None: + self._set_preview_button(self._playing_button, "idle") + self._playing_button = None + self._playing_path = "" + + def _stop_fallback_player(self): + process = self._fallback_player_process + self._fallback_player_process = None + if process is None or process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + + def _preview_cache_key( + self, + preset_name: str, + *, + text: str = "", + clone_audio_path: str = "", + clone_audio_text: str = "", + ) -> tuple[str, ...]: + audio_signature = "" + if clone_audio_path: + audio_file = Path(clone_audio_path) + if audio_file.exists(): + stat = audio_file.stat() + audio_signature = f"{audio_file.resolve()}:{stat.st_size}:{stat.st_mtime_ns}" + else: + audio_signature = clone_audio_path + return ( + preset_name, + cfg.dubbing_provider.value.strip(), + cfg.dubbing_model.value.strip(), + cfg.dubbing_voice.value.strip(), + text.strip(), + audio_signature, + clone_audio_text.strip(), + ) + + def _discard_clone_preview_cache(self): + self._preview_cache.clear() diff --git a/videocaptioner/ui/view/hardsub_interface.py b/videocaptioner/ui/view/hardsub_interface.py new file mode 100644 index 00000000..94891b33 --- /dev/null +++ b/videocaptioner/ui/view/hardsub_interface.py @@ -0,0 +1,998 @@ +"""硬字幕提取页:OCR 提取烧录字幕为可编辑字幕。 + +拖入视频 → 自动识别区域(可拖框微调) → 提取(实时入表) → 改字/删行 → 导出或送入字幕优化。 +页面只做 UI 与状态机;耗时走 ui.thread.hardsub_thread,core 回调经 Qt 信号回 GUI 线程。 +""" + +from __future__ import annotations + +from enum import Enum, auto +from pathlib import Path +from typing import Optional + +from PyQt5.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt, pyqtSignal +from PyQt5.QtWidgets import ( + QAbstractItemView, + QFileDialog, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QStackedWidget, + QStyle, + QStyledItemDelegate, + QTableView, + QVBoxLayout, + QWidget, +) +from qfluentwidgets import Action, RoundMenu + +from videocaptioner.core.asr.asr_data import ASRData, ASRDataSeg +from videocaptioner.core.hardsub.config import LANGUAGES, HardsubConfig, RecognizeMode +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.app_dialog import ConfirmDialog +from videocaptioner.ui.components.roi_selector import RoiSelector +from videocaptioner.ui.components.workbench import ( + CompactButton, + DropZone, + ElidedLabel, + ErrorCard, + IconBox, + PillSelect, + ProgressBarLine, + StatusPill, + WorkbenchButton, + WorkbenchPanel, + apply_font, + icon_pixmap, + to_qcolor, +) +from videocaptioner.ui.i18n import N_, tr + +_VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".flv", ".m4v", ".ts"} + + +class _State(Enum): + EMPTY = auto() # 等待视频 + REGION = auto() # 已载入,确认/调整字幕区域 + PROCESSING = auto() # 提取中 + DONE = auto() # 完成 + NO_SUBTITLE = auto() # 未识别到字幕 + ENGINE_MISSING = auto() # OCR 依赖未就绪 + + +def _fmt_ts(ms: int) -> str: + """毫秒 → MM:SS.cc(厘秒,表格显示用,简洁可读,对字幕足够)。""" + s, msec = divmod(max(0, int(ms)), 1000) + return f"{s // 60:02d}:{s % 60:02d}.{msec // 10:02d}" + + +def _parse_ts(text: str) -> Optional[int]: + """宽松解析 MM:SS.cc / SS.cc / 秒 → 毫秒;失败返回 None。""" + text = text.strip() + if not text: + return None + try: + if ":" in text: + mm, rest = text.split(":", 1) + return int(round((int(mm) * 60 + float(rest)) * 1000)) + return int(round(float(text) * 1000)) + except ValueError: + return None + + +class HardsubInterface(QWidget): + """硬字幕提取页(单文件工作台)。""" + + # 送入字幕优化页(main_window 路由):传提取出的字幕文件路径 + 源视频路径 + sendToOptimize = pyqtSignal(str, str) + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setObjectName("HardsubInterface") + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.setAcceptDrops(True) + + self._state = _State.EMPTY + self._video_path: Optional[str] = None + self._duration = 0.0 + self._lang = "ch" + self._engine = None + self._prepare_thread = None + self._region_thread = None + self._extract_thread = None + # 框是否由用户手动框选/调整(而非自动检测回填)→ 提取所见即所得,见 HardsubConfig.roi_is_manual。 + self._roi_manual = False + # 自动检测顺带学到的主导字号,供提取复用。 + self._region_font_height: Optional[float] = None + # 载入代次:每次载入新视频 +1,线程创建时打上代次,迟到的回调按 _stale 作废(防旧视频检测覆盖新的)。 + self._load_gen = 0 + + self._build_ui() + self._apply_state(_State.EMPTY) + + # ------------------------------------------------------------------ UI + + def _build_ui(self) -> None: + palette = app_palette() + self.setStyleSheet(f"QWidget#HardsubInterface {{ background: {palette.bg}; }}") + root = QVBoxLayout(self) + root.setContentsMargins(24, 20, 24, 16) + root.setSpacing(18) + + root.addLayout(self._build_page_head()) + + work = QHBoxLayout() + work.setSpacing(18) + work.addWidget(self._build_stage_panel(), 5) + work.addWidget(self._build_result_panel(), 3) + root.addLayout(work, 1) + + def _build_page_head(self) -> QHBoxLayout: + palette = app_palette() + head = QHBoxLayout() + head.setSpacing(10) + + titles = QVBoxLayout() + titles.setSpacing(6) + self.titleLabel = QLabel(tr("hardsub.title"), self) + apply_font(self.titleLabel, 26, 950) + self.titleLabel.setStyleSheet(f"color: {palette.text}; background: transparent;") + self.subtitleLabel = QLabel( + tr("hardsub.subtitle"), self + ) + apply_font(self.subtitleLabel, 13, 760) + self.subtitleLabel.setStyleSheet(f"color: {palette.muted}; background: transparent;") + titles.addWidget(self.titleLabel) + titles.addWidget(self.subtitleLabel) + head.addLayout(titles) + head.addStretch(1) + + self.replaceBtn = WorkbenchButton(tr("hardsub.btn.replace_video"), AppIcon.VIDEO, parent=self) + self.replaceBtn.clicked.connect(self._on_pick_file) + self.autoRegionBtn = WorkbenchButton(tr("hardsub.btn.auto_region"), AppIcon.SYNC, parent=self) + self.autoRegionBtn.clicked.connect(self._on_auto_region) + head.addWidget(self.replaceBtn) + head.addWidget(self.autoRegionBtn) + return head + + def _build_stage_panel(self) -> QWidget: + palette = app_palette() + panel = WorkbenchPanel(self, padded=False) + self._stagePanel = panel + layout = panel.bodyLayout + + # 头:视频文件行 + 状态胶囊(页面大标题已是「硬字幕提取」,这里不再重复, + # 改为显示当前视频名——空态显示「视频预览」)。 + header = QFrame(panel) + header.setObjectName("stageHead") + header.setFixedHeight(56) + hl = QHBoxLayout(header) + hl.setContentsMargins(22, 0, 22, 0) + hl.setSpacing(9) + self.stageFileIcon = QLabel(header) + self.stageFileIcon.setObjectName("stageFileIcon") + self.stageFileIcon.hide() + hl.addWidget(self.stageFileIcon) + self.stageFile = ElidedLabel(tr("hardsub.stage.video_preview"), header) + self.stageFile.setObjectName("stageFileName") + apply_font(self.stageFile, 16, 860) + hl.addWidget(self.stageFile, 1) + hl.addSpacing(8) + self.stagePill = StatusPill(tr("hardsub.status.waiting_video"), "neutral", header) + hl.addWidget(self.stagePill) + # object-name 作用域:避免 border-bottom 级联到子标签(否则每个标签后面都出现矩形线框)。 + header.setStyleSheet( + f""" + QFrame#stageHead {{ + background: transparent; + border: none; + border-bottom: 1px solid {palette.line_soft}; + }} + QLabel#stageFileName, QLabel#stageFileIcon {{ + color: {palette.text}; background: transparent; border: none; + }} + """ + ) + layout.addWidget(header) + + # 主体:空态拖放 / 预览框选 + self.stageStack = QStackedWidget(panel) + layout.addWidget(self.stageStack, 1) + + drop_host = QWidget(panel) + dh = QVBoxLayout(drop_host) + dh.setContentsMargins(22, 22, 22, 22) + self.dropZone = DropZone( + icon=AppIcon.VIDEO, + title=tr("hardsub.drop.title"), + pick_text=tr("hardsub.drop.pick"), + pick_icon=AppIcon.FOLDER_ADD, + formats_line="mp4 / mov / mkv", + parent=drop_host, + ) + self.dropZone.browseRequested.connect(self._on_pick_file) + dh.addWidget(self.dropZone) + self.stageStack.addWidget(drop_host) # index 0 + + preview_host = QWidget(panel) + ph = QVBoxLayout(preview_host) + ph.setContentsMargins(20, 18, 20, 18) + ph.setSpacing(12) + self.roiSelector = RoiSelector(preview_host) + self.roiSelector.roi_changed.connect(self._on_roi_changed) + ph.addWidget(self.roiSelector, 1) + # 进度行:固定高度、始终占位(只在提取时显示内容)——否则它显隐会把上方视频挤大挤小、形成跳变。 + self.progressRow = QWidget(preview_host) + self.progressRow.setFixedHeight(24) + pr = QHBoxLayout(self.progressRow) + pr.setContentsMargins(0, 0, 0, 0) + pr.setSpacing(14) + self.progressTitle = QLabel(tr("hardsub.progress.title"), self.progressRow) + apply_font(self.progressTitle, 14, 900) + self.progressTitle.setStyleSheet(f"color: {palette.text}; background: transparent;") + self.progressBar = ProgressBarLine(self.progressRow) + self.progressPercent = QLabel("0%", self.progressRow) + apply_font(self.progressPercent, 15, 900) + self.progressPercent.setStyleSheet(f"color: {palette.accent_text}; background: transparent;") + self.progressPercent.setFixedWidth(56) + self.progressPercent.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + pr.addWidget(self.progressTitle) + pr.addWidget(self.progressBar, 1) + pr.addWidget(self.progressPercent) + ph.addWidget(self.progressRow) + self.stageStack.addWidget(preview_host) # index 1 + return panel + + def _build_result_panel(self) -> QWidget: + palette = app_palette() + panel = WorkbenchPanel(self, padded=False) + panel.setMinimumWidth(300) + layout = panel.bodyLayout + + # 头:字幕结果 + 语言下拉 / 计数 + header = QFrame(panel) + header.setObjectName("resultHead") + hl = QHBoxLayout(header) + hl.setContentsMargins(22, 0, 18, 0) + header.setFixedHeight(56) + title = QLabel(tr("hardsub.result.title"), header) + title.setObjectName("resultHeadTitle") + apply_font(title, 16, 900) + hl.addWidget(title) + hl.addStretch(1) + self.langSelect = PillSelect(header) + self.langSelect.setItems([name for _, name in LANGUAGES], LANGUAGES[0][1]) + self.langSelect.currentTextChanged.connect(self._on_lang_changed) + self.countPill = StatusPill("", "neutral", header) + self.countPill.hide() + hl.addWidget(self.langSelect) + hl.addWidget(self.countPill) + header.setStyleSheet( + f""" + QFrame#resultHead {{ + background: transparent; + border: none; + border-bottom: 1px solid {palette.line_soft}; + }} + QLabel#resultHeadTitle {{ color: {palette.text}; background: transparent; border: none; }} + """ + ) + layout.addWidget(header) + + # 主体:占位(空/区域/错误)或 结果表 + self.resultStack = QStackedWidget(panel) + layout.addWidget(self.resultStack, 1) + + self.placeholder = _Placeholder(panel) + self.resultStack.addWidget(self.placeholder) # index 0 + + self.table = _ResultTable(panel) + self.table.locateRequested.connect(self._on_locate) + self.resultStack.addWidget(self.table) # index 1 + + # 底部:提示 + 动作 + footer = QFrame(panel) + footer.setObjectName("resultFooter") + fl = QHBoxLayout(footer) + fl.setContentsMargins(18, 12, 18, 14) + fl.setSpacing(10) + self.footerNote = QLabel("", footer) + self.footerNote.setObjectName("resultFooterNote") + apply_font(self.footerNote, 12, 760) + self.footerNote.setWordWrap(True) + fl.addWidget(self.footerNote, 1) + + # 完成后可回流:重新提取(回到框选态,可调区域/语言后再来一遍)。 + self.redoBtn = CompactButton(tr("hardsub.btn.redo"), AppIcon.SYNC, parent=footer) + self.redoBtn.clicked.connect(self._on_redo) + self.exportBtn = CompactButton(tr("hardsub.btn.export"), AppIcon.DOWNLOAD, parent=footer) + self.exportBtn.clicked.connect(self._on_export) + self.cancelBtn = CompactButton(tr("common.cancel"), AppIcon.CANCEL, parent=footer) + self.cancelBtn.clicked.connect(self._on_cancel) + self.startBtn = WorkbenchButton(tr("hardsub.btn.start"), AppIcon.PLAY, primary=True, parent=footer) + self.startBtn.clicked.connect(self._on_start) + self.sendBtn = WorkbenchButton( + tr("hardsub.btn.send_optimize"), AppIcon.RIGHT_ARROW, primary=True, parent=footer + ) + self.sendBtn.clicked.connect(self._on_send_optimize) + for btn in (self.redoBtn, self.exportBtn, self.cancelBtn, self.startBtn, self.sendBtn): + fl.addWidget(btn) + footer.setStyleSheet( + f""" + QFrame#resultFooter {{ + background: transparent; + border: none; + border-top: 1px solid {palette.line_soft}; + }} + QLabel#resultFooterNote {{ color: {palette.subtle}; background: transparent; border: none; }} + """ + ) + layout.addWidget(footer) + return panel + + # ------------------------------------------------------------- 状态机 + + def _apply_state(self, state: _State) -> None: + self._state = state + is_empty = state == _State.EMPTY + has_video = state not in (_State.EMPTY, _State.ENGINE_MISSING) + + self.replaceBtn.setVisible(not is_empty) + self.autoRegionBtn.setVisible(state in (_State.REGION, _State.DONE, _State.NO_SUBTITLE)) + # 头部文件行:有视频显示文件名 + 图标,否则回到「视频预览」占位。 + self.stageFileIcon.setVisible(has_video) + if not has_video: + self.stageFile.setText(tr("hardsub.stage.video_preview")) + + # 左栏:空态/引擎缺失 → 拖放区;其余 → 预览 + if state in (_State.EMPTY, _State.ENGINE_MISSING): + self.stageStack.setCurrentIndex(0) + else: + self.stageStack.setCurrentIndex(1) + # 进度行始终占位(固定高度),只切换内容显隐——避免视频区被挤大挤小的跳变。 + processing = state == _State.PROCESSING + for w in (self.progressTitle, self.progressBar, self.progressPercent): + w.setVisible(processing) + + # 状态胶囊 + pill_spec = { + _State.EMPTY: (tr("hardsub.status.waiting_video"), "neutral"), + _State.ENGINE_MISSING: (tr("hardsub.status.engine_missing"), "fail"), + _State.REGION: (tr("hardsub.status.region_detected"), "ok"), + _State.PROCESSING: (tr("hardsub.status.processing"), "warn"), + _State.DONE: (tr("hardsub.status.done"), "ok"), + _State.NO_SUBTITLE: (tr("hardsub.status.need_action"), "fail"), + }[state] + self.stagePill.setState(*pill_spec) + + # 右栏主体 + if state in (_State.PROCESSING, _State.DONE): + self.resultStack.setCurrentIndex(1) + else: + self.resultStack.setCurrentIndex(0) + self.placeholder.set_for_state(state) + + # 语言下拉只在「待提取」阶段可改 + self.langSelect.setVisible(state in (_State.REGION, _State.NO_SUBTITLE)) + self.langSelect.setEnabled(state in (_State.REGION, _State.NO_SUBTITLE)) + + # 计数胶囊 + if state == _State.PROCESSING: + self.countPill.show() + self.countPill.setState(tr("hardsub.count.recognized", n=0), "warn") + elif state == _State.DONE: + self.countPill.show() + self.countPill.setState(tr("hardsub.count.total", n=self.table.rowCount()), "ok") + else: + self.countPill.hide() + + # 底部按钮 + self.redoBtn.setVisible(state == _State.DONE) + self.exportBtn.setVisible(state == _State.DONE) + self.cancelBtn.setVisible(state == _State.PROCESSING) + self.sendBtn.setVisible(state == _State.DONE) + self.startBtn.setVisible(state in (_State.REGION, _State.NO_SUBTITLE)) + notes = { + _State.EMPTY: tr("hardsub.note.empty"), + _State.ENGINE_MISSING: tr("hardsub.note.engine_missing"), + _State.REGION: tr("hardsub.note.region"), + _State.PROCESSING: tr("hardsub.note.processing"), + _State.DONE: tr("hardsub.note.done"), + _State.NO_SUBTITLE: tr("hardsub.note.no_subtitle"), + } + self.footerNote.setText(notes[state]) + # 完成态底部三个按钮占满,提示文案会被挤成窄列;此时让按钮独占,提示交给表头/右键。 + self.footerNote.setVisible(state != _State.DONE) + + # --------------------------------------------------------------- 交互 + + def _on_pick_file(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, tr("hardsub.dialog.pick_video"), "", + "Video (*.mp4 *.mov *.mkv *.avi *.webm *.flv *.m4v *.ts);;All files (*)", + ) + if path: + self._load_video(path) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + self.dropZone.setDragActive(True) + + def dragLeaveEvent(self, event): + self.dropZone.setDragActive(False) + + def dropEvent(self, event): + self.dropZone.setDragActive(False) + for url in event.mimeData().urls(): + path = url.toLocalFile() + if path and Path(path).suffix.lower() in _VIDEO_EXTS: + self._load_video(path) + break + + def _load_video(self, path: str) -> None: + from videocaptioner.ui.thread.hardsub_thread import PrepareThread, ocr_ready + + missing = ocr_ready() + if missing is not None: + self._show_engine_missing(missing) + return + + # 探测/抽首帧是 ffmpeg 子进程、会卡 GUI——放后台线程,期间显示「载入中」遮罩。 + self._video_path = path + self._engine = None + self._roi_manual = False # 新视频:等自动检测回填或用户重新框 + self._region_font_height = None + self._load_gen += 1 # 作废上个视频仍在跑的探测/区域检测的迟到回调 + self.table.clear_rows() + self.stageFile.setText(Path(path).name) + self.stageFileIcon.setPixmap(icon_pixmap(AppIcon.VIDEO, app_palette().muted, 18)) + self._apply_state(_State.REGION) + self.roiSelector.set_busy(tr("hardsub.busy.loading_video")) + thread = PrepareThread(path) + thread._gen = self._load_gen + thread.ready.connect(self._on_prepared) + thread.error.connect(self._on_prepare_error) + self._prepare_thread = thread + thread.start() + + def _on_prepared(self, width: int, height: int, duration: float, first_frame) -> None: + from videocaptioner.core.hardsub.frames import grab_frame + if self._stale() or not self._video_path: + return + self._duration = duration + path = self._video_path + self.roiSelector.set_video( + lambda t: grab_frame(path, t, max_width=1280), + QSize(width, height), duration, Path(path).name, first_frame=first_frame, + ) + self._start_region_detect() + + def _on_prepare_error(self, msg: str) -> None: + self.roiSelector.set_busy(None) + self._toast(tr("hardsub.error.read_video", msg=msg)) + self._apply_state(_State.EMPTY) + + def _ensure_engine(self): + from videocaptioner.ui.thread.hardsub_thread import make_engine + cfg = HardsubConfig.from_mode(self._video_path or "", lang=self._lang) + if self._engine is None: + self._engine = make_engine(cfg) + return self._engine + + def _start_region_detect(self) -> None: + from videocaptioner.ui.thread.hardsub_thread import RegionDetectThread + if not self._video_path: + return + self.autoRegionBtn.setEnabled(False) + self.roiSelector.set_busy(tr("hardsub.busy.detecting_region")) + thread = RegionDetectThread(self._video_path, self._ensure_engine()) + thread._gen = self._load_gen + thread.detected.connect(self._on_region_detected) + thread.progress.connect(self._on_region_progress) + thread.error.connect(self._on_region_error) + thread.finished.connect(self._on_region_finished) + self._region_thread = thread + thread.start() + + def _on_region_progress(self, percent: int, _text: str) -> None: + if self._stale(): + return + self.roiSelector.set_busy(tr("hardsub.busy.detecting_region_pct", percent=percent)) + + def _on_region_detected(self, result) -> None: + if self._stale(): + return + self.roiSelector.set_busy(None) + if result is not None: + self.roiSelector.set_roi_src(result.roi) + self._roi_manual = False # 自动检测回填的框:提取按自动模式过滤杂质 + self._region_font_height = result.font_height # 复用主导字号,提取不再重复学 + else: + # 没检测到稳定字幕带:提示手动框选(不强行用默认带产出杂质)。 + self._toast(tr("hardsub.toast.no_auto_region")) + + def _on_region_error(self, _msg: str) -> None: + self.roiSelector.set_busy(None) + self.autoRegionBtn.setEnabled(True) + + def _on_region_finished(self) -> None: + self.roiSelector.set_busy(None) + self.autoRegionBtn.setEnabled(True) + + def _on_auto_region(self) -> None: + self._start_region_detect() + + def _stale(self) -> bool: + """迟到回调判废:发信线程创建时的载入代次已被新载入超越(切到别的视频了)。""" + return getattr(self.sender(), "_gen", self._load_gen) != self._load_gen + + def _on_roi_changed(self, _roi) -> None: + # 仅用户鼠标编辑才发此信号(自动回填走 set_roi_src 不发)→ 标记手动指定。 + self._roi_manual = True + + def _on_lang_changed(self, name: str) -> None: + for code, label in LANGUAGES: + if label == name: + if code != self._lang: + self._lang = code + self._engine = None # 换语言要换识别模型 + break + + def _on_start(self) -> None: + from videocaptioner.core.hardsub.pipeline import EXTRACT_DET_LIMIT + from videocaptioner.ui.thread.hardsub_thread import HardsubExtractThread, make_engine + if not self._video_path: + return + roi = self.roiSelector.roi_src() + cfg = HardsubConfig.from_mode( + self._video_path, mode=RecognizeMode.STANDARD, lang=self._lang, + roi=roi, roi_is_manual=self._roi_manual, + font_height=None if self._roi_manual else self._region_font_height, + ) + self.table.clear_rows() + self._apply_state(_State.PROCESSING) + # 提取用 768 引擎;区域检测仍用 _ensure_engine 的 960。 + thread = HardsubExtractThread(cfg, make_engine(cfg, det_limit_side_len=EXTRACT_DET_LIMIT)) + thread.cue_ready.connect(self._on_cue) + thread.progress.connect(self._on_progress) + thread.error.connect(self._on_error) + thread.finished.connect(self._on_finished) + self._extract_thread = thread + thread.start() + + def _on_cancel(self) -> None: + if self._extract_thread is not None: + self._extract_thread.request_cancel() + + def _on_redo(self) -> None: + """完成后重新提取:回到框选态(保留视频与已识别区域,可调区域/语言后再来一遍)。""" + if not self._video_path: + return + self.table.clear_rows() + self._apply_state(_State.REGION) + + def _on_locate(self, seconds: float) -> None: + """结果表点行「定位到此画面」:左栏预览跳到该时刻,让用户核对该条字幕真实画面(剔杂质用)。""" + if not self._video_path: + return + self.stageStack.setCurrentIndex(1) # 确保显示视频预览 + self.roiSelector.seek_to(seconds) + + def _on_cue(self, cue) -> None: + self.table.append_cue(cue.start, cue.end, cue.text) + self.countPill.setState(tr("hardsub.count.recognized", n=self.table.rowCount()), "warn") + + def _on_progress(self, percent: int, _text: str) -> None: + self.progressBar.setValue(percent) + self.progressPercent.setText(f"{percent}%") + + def _on_error(self, msg: str) -> None: + self._toast(msg) + self._apply_state(_State.REGION) + + def _on_finished(self, data) -> None: + if self.table.rowCount() == 0: + self._apply_state(_State.NO_SUBTITLE) + return + self._apply_state(_State.DONE) + + def _on_export(self) -> None: + data = self.table.to_asrdata() + if not data.has_data(): + return + default = "" + if self._video_path: + from videocaptioner.core.application import output_paths + default = str(output_paths.product_path( + Path(self._video_path), output_paths.TAG_HARDSUB, ext=".srt")) + path, _ = QFileDialog.getSaveFileName( + self, tr("hardsub.dialog.export"), default, + "SRT (*.srt);;ASS (*.ass);;Plain text (*.txt)", + ) + if path: + data.save(path) + self._toast(tr("hardsub.toast.exported", name=Path(path).name)) + + def _on_send_optimize(self) -> None: + data = self.table.to_asrdata() + if not data.has_data() or not self._video_path: + return + from videocaptioner.core.application import output_paths + out = output_paths.product_path( + Path(self._video_path), output_paths.TAG_HARDSUB, ext=".srt") + data.save(str(out)) + self.sendToOptimize.emit(str(out), self._video_path) + + # --------------------------------------------------------------- 杂项 + + def _show_engine_missing(self, reason: str) -> None: + self._apply_state(_State.ENGINE_MISSING) + self.placeholder.set_error( + tr("hardsub.engine.title"), + tr("hardsub.engine.desc"), + ) + self.dropZone.setVisible(False) + card = ErrorCard(reason, title=tr("hardsub.engine.card_title"), parent=self._stagePanel) + card.setMaximumWidth(520) # 紧凑居中的错误卡;直接 addWidget 会撑满舞台、内部文字被拉散 + host = self.stageStack.widget(0) + lay = host.layout() + if lay is not None and lay.count() and not getattr(self, "_engine_card_shown", False): + self.dropZone.hide() + lay.addWidget(card, 0, Qt.AlignCenter) # type: ignore[call-arg] + self._engine_card_shown = True + + def _toast(self, text: str) -> None: + ConfirmDialog(tr("common.tip"), text, parent=self, cancel_text=None).exec() + + def closeEvent(self, event): + for thread in (self._prepare_thread, self._region_thread, self._extract_thread): + if thread is not None and thread.isRunning(): + thread.stop() + super().closeEvent(event) + + +class _Placeholder(QWidget): + """右栏空/区域/错误占位:图标 + 标题 + 说明。""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(28, 28, 28, 28) + layout.addStretch(1) + self.iconBox = IconBox(AppIcon.SUBTITLE, self, size=58) + layout.addWidget(self.iconBox, 0, Qt.AlignHCenter) # type: ignore[arg-type] + layout.addSpacing(12) + self.titleLabel = QLabel("", self) + apply_font(self.titleLabel, 20, 900) + self.titleLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + layout.addWidget(self.titleLabel) + layout.addSpacing(6) + self.subLabel = QLabel("", self) + apply_font(self.subLabel, 13, 760) + self.subLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self.subLabel.setWordWrap(True) + layout.addWidget(self.subLabel) + layout.addStretch(1) + self._sync() + + def _sync(self) -> None: + palette = app_palette() + self.titleLabel.setStyleSheet(f"color: {palette.text}; background: transparent;") + self.subLabel.setStyleSheet(f"color: {palette.muted}; background: transparent;") + + def set_for_state(self, state) -> None: + from videocaptioner.ui.view.hardsub_interface import _State + specs = { + _State.EMPTY: (AppIcon.SUBTITLE, tr("hardsub.ph.empty.title"), tr("hardsub.ph.empty.sub")), + _State.REGION: (AppIcon.LAYOUT, tr("hardsub.ph.region.title"), tr("hardsub.ph.region.sub")), + _State.NO_SUBTITLE: (AppIcon.SUBTITLE, tr("hardsub.ph.no_subtitle.title"), tr("hardsub.ph.no_subtitle.sub")), + _State.ENGINE_MISSING: (AppIcon.SUBTITLE, tr("hardsub.ph.engine_missing.title"), tr("hardsub.ph.engine_missing.sub")), + } + icon, title, sub = specs.get(state, specs[_State.EMPTY]) + self.iconBox.setIcon(icon) + self.titleLabel.setText(title) + self.subLabel.setText(sub) + self._sync() + + def set_error(self, title: str, sub: str) -> None: + self.iconBox.setIcon(AppIcon.SUBTITLE) + self.titleLabel.setText(title) + self.subLabel.setText(sub) + self._sync() + + +class _ResultModel(QAbstractTableModel): + """字幕结果表模型:开始 / 结束 / 文本,三列均可双击编辑。 + + 内部存原始毫秒 + 文本;时间列显示为 MM:SS.cc,编辑时宽松解析回毫秒 + (解析失败则拒绝该次编辑,保留原值)。 + """ + + HEADERS = (N_("hardsub.col.start"), N_("hardsub.col.end"), N_("hardsub.col.text")) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._rows: list[list] = [] # [start_ms, end_ms, text] + + def append(self, start_s: float, end_s: float, text: str) -> None: + row = len(self._rows) + self.beginInsertRows(QModelIndex(), row, row) + self._rows.append([int(start_s * 1000), int(end_s * 1000), text]) + self.endInsertRows() + + def clear(self) -> None: + self.beginResetModel() + self._rows = [] + self.endResetModel() + + def remove_row(self, row: int) -> None: + if not 0 <= row < len(self._rows): + return + self.beginRemoveRows(QModelIndex(), row, row) + self._rows.pop(row) + self.endRemoveRows() + + def start_seconds(self, row: int) -> Optional[float]: + if 0 <= row < len(self._rows): + return self._rows[row][0] / 1000.0 + return None + + def to_asrdata(self) -> ASRData: + segs = [ + ASRDataSeg(text.strip(), start, end) + for start, end, text in self._rows + if text.strip() + ] + return ASRData(segs) + + # ----- Qt 模型接口 ----- + + def rowCount(self, parent: Optional[QModelIndex] = None) -> int: + return len(self._rows) + + def columnCount(self, parent: Optional[QModelIndex] = None) -> int: + return 3 + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole): # type: ignore[assignment] + if not index.isValid(): + return None + row, col = index.row(), index.column() + if role in (Qt.DisplayRole, Qt.EditRole): # type: ignore[attr-defined] + if col == 2: + return self._rows[row][2] + return _fmt_ts(self._rows[row][col]) + if role == Qt.TextAlignmentRole and col < 2: # type: ignore[attr-defined] + return int(Qt.AlignLeft | Qt.AlignVCenter) # type: ignore[arg-type] + return None + + def setData(self, index: QModelIndex, value, role: int = Qt.EditRole) -> bool: # type: ignore[assignment] + if not index.isValid() or role != Qt.EditRole: # type: ignore[attr-defined] + return False + row, col = index.row(), index.column() + if col == 2: + self._rows[row][2] = str(value) + else: + ms = _parse_ts(str(value)) + if ms is None: + return False + self._rows[row][col] = ms + self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole]) + return True + + def headerData(self, section: int, orientation, role: int = Qt.DisplayRole): # type: ignore[assignment] + if role == Qt.DisplayRole and orientation == Qt.Horizontal: # type: ignore[attr-defined] + return tr(self.HEADERS[section]) + return None + + def flags(self, index: QModelIndex): + if not index.isValid(): + return Qt.NoItemFlags # type: ignore[attr-defined] + return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable # type: ignore[attr-defined] + + +class _ResultEditDelegate(QStyledItemDelegate): + """单元格编辑委托:悬浮行淡色高亮 + 深色行内编辑器(accent 边框、光标落末尾不全选)。""" + + def __init__(self, view: "_ResultTable") -> None: + super().__init__(view) + self._view = view + + def paint(self, painter, option, index): + if ( + index.row() == self._view.hover_row() + and not option.state & QStyle.State_Selected # type: ignore[attr-defined] + ): + painter.fillRect(option.rect, to_qcolor(app_palette().card_surface_hover)) + super().paint(painter, option, index) + + def createEditor(self, parent, option, index): + palette = app_palette() + editor = QLineEdit(parent) + apply_font(editor, 14, 650) + editor.setStyleSheet( + f""" + QLineEdit {{ + background: {palette.panel_deep}; + color: {palette.text}; + border: 1px solid {rgba(palette.accent, 0.85)}; + border-radius: 6px; + padding: 0 8px; + selection-background-color: {rgba(palette.accent, 0.35)}; + selection-color: {palette.text}; + }} + """ + ) + return editor + + def setEditorData(self, editor, index): + editor.setText(index.data(Qt.EditRole) or "") # type: ignore[attr-defined] + editor.deselect() + editor.end(False) + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect.adjusted(4, 6, -4, -6)) + + +class _ResultTable(QTableView): + """字幕结果表(QTableView + 模型):开始 / 结束 / 文本。 + + 双击编辑、右键「定位到此画面 / 删除」(项目 RoundMenu,非原生菜单)、Delete 删除选中行。 + 样式与字幕优化页表格一致:透明底、主题色选中、行/列分隔线。 + """ + + locateRequested = pyqtSignal(float) # 请求在视频里定位到该条字幕的起始秒 + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._model = _ResultModel(self) + self.setModel(self._model) + self.setObjectName("hardsubResultTable") + self._hover_row = -1 + self.setMouseTracking(True) + self.setItemDelegate(_ResultEditDelegate(self)) + + header = self.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Stretch) + header.setSectionResizeMode(0, QHeaderView.Fixed) + header.setSectionResizeMode(1, QHeaderView.Fixed) + self.setColumnWidth(0, 112) + self.setColumnWidth(1, 112) + header.setFixedHeight(42) + header.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) # type: ignore[arg-type] + self.verticalHeader().setVisible(False) + self.verticalHeader().setDefaultSectionSize(46) + self.setShowGrid(False) + self.setWordWrap(False) + self.setFrameShape(QFrame.NoFrame) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setSelectionMode(QAbstractItemView.SingleSelection) + self.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed) + self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) + self.setContextMenuPolicy(Qt.CustomContextMenu) # type: ignore[arg-type] + self.customContextMenuRequested.connect(self._on_context_menu) + self._apply_style() + + # ----- 页面用的薄 API(保持调用点不变)----- + + def append_cue(self, start: float, end: float, text: str) -> None: + at_bottom = self.verticalScrollBar().value() >= self.verticalScrollBar().maximum() - 4 + self._model.append(start, end, text) + if at_bottom: + self.scrollToBottom() + + def clear_rows(self) -> None: + self._model.clear() + + def rowCount(self) -> int: + return self._model.rowCount() + + def to_asrdata(self) -> ASRData: + return self._model.to_asrdata() + + def hover_row(self) -> int: + return self._hover_row + + # ----- 交互 ----- + + def _on_context_menu(self, pos) -> None: + index = self.indexAt(pos) + if not index.isValid(): + return + row = index.row() + menu = RoundMenu(parent=self) + locate = Action(tr("hardsub.menu.locate")) + locate.triggered.connect(lambda: self._emit_locate(row)) + menu.addAction(locate) + remove = Action(tr("hardsub.menu.delete_row")) + remove.triggered.connect(lambda: self._model.remove_row(row)) + menu.addAction(remove) + menu.exec(self.viewport().mapToGlobal(pos)) + + def _emit_locate(self, row: int) -> None: + start = self._model.start_seconds(row) + if start is not None: + self.locateRequested.emit(start) + + def keyPressEvent(self, event): + if ( + event.key() in (Qt.Key_Delete, Qt.Key_Backspace) # type: ignore[attr-defined] + and self.state() != QAbstractItemView.EditingState + and self.currentIndex().isValid() + ): + self._model.remove_row(self.currentIndex().row()) + return + super().keyPressEvent(event) + + def mouseMoveEvent(self, event): + row = self.rowAt(event.pos().y()) + if row != self._hover_row: + self._hover_row = row + self.viewport().update() + super().mouseMoveEvent(event) + + def leaveEvent(self, event): + if self._hover_row != -1: + self._hover_row = -1 + self.viewport().update() + super().leaveEvent(event) + + def _apply_style(self) -> None: + palette = app_palette() + selection_bg = rgba(palette.accent, 0.10) + self.setStyleSheet( + f""" + QTableView#hardsubResultTable {{ + background: transparent; + border: none; + color: {palette.muted}; + font-size: 14px; + selection-background-color: {selection_bg}; + selection-color: {palette.text}; + outline: none; + }} + QTableView#hardsubResultTable::item {{ + padding: 0 14px; + border-bottom: 1px solid {palette.line_soft}; + border-right: 1px solid {palette.line_soft}; + }} + QTableView#hardsubResultTable::item:selected {{ + background: {selection_bg}; + color: {palette.text}; + }} + """ + ) + self.horizontalHeader().setStyleSheet( + f""" + QHeaderView {{ background: {palette.panel_deep}; border: none; }} + QHeaderView::section {{ + background: {palette.panel_deep}; + color: {palette.muted}; + border: none; + border-bottom: 1px solid {palette.line_soft}; + border-right: 1px solid {palette.line_soft}; + padding-left: 14px; + font-size: 14px; + font-weight: bold; + }} + """ + ) + self.verticalScrollBar().setStyleSheet( + f""" + QScrollBar:vertical {{ + background: transparent; width: 5px; margin: 0; border: none; + }} + QScrollBar::handle:vertical {{ + background: {palette.line}; border-radius: 2px; min-height: 32px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; width: 0; background: transparent; border: none; + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: transparent; + }} + """ + ) diff --git a/videocaptioner/ui/view/home_interface.py b/videocaptioner/ui/view/home_interface.py index 9aeff244..2a54053d 100644 --- a/videocaptioner/ui/view/home_interface.py +++ b/videocaptioner/ui/view/home_interface.py @@ -1,34 +1,41 @@ +from pathlib import Path from typing import Optional +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QSizePolicy, QStackedWidget, QVBoxLayout, QWidget from qfluentwidgets import SegmentedWidget +from videocaptioner.core.asr.asr_data import ASRData from videocaptioner.core.llm.context import generate_task_id +from videocaptioner.core.utils.logger import setup_logger +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.i18n import tr from videocaptioner.ui.task_factory import TaskFactory from videocaptioner.ui.view.subtitle_interface import SubtitleInterface from videocaptioner.ui.view.task_creation_interface import TaskCreationInterface from videocaptioner.ui.view.transcription_interface import TranscriptionInterface from videocaptioner.ui.view.video_synthesis_interface import VideoSynthesisInterface +logger = setup_logger("home_interface") + class HomeInterface(QWidget): def __init__(self, parent=None): super().__init__(parent) self._current_task_id: Optional[str] = None # 当前流程的任务 ID + # 当前流程的任务目录:转录/字幕中间产物落盘处,链尾(合成页)负责清理 + self._current_task_dir: Optional[str] = None - # 设置对象名称和样式 self.setObjectName("HomeInterface") - self.setStyleSheet( - """ - HomeInterface{background: white} - """ - ) + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] # 创建分段控件和堆叠控件 self.pivot = SegmentedWidget(self) + self.pivot.setObjectName("homePivot") self.pivot.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) self.stackedWidget = QStackedWidget(self) + self.stackedWidget.setObjectName("homeStack") self.vBoxLayout = QVBoxLayout(self) # 添加子界面 @@ -38,20 +45,24 @@ def __init__(self, parent=None): self.video_synthesis_interface = VideoSynthesisInterface(self) self.addSubInterface( - self.task_creation_interface, "TaskCreationInterface", self.tr("任务创建") + self.task_creation_interface, + "TaskCreationInterface", + tr("homeflow.tab.task_creation"), ) self.addSubInterface( - self.transcription_interface, "TranscriptionInterface", self.tr("语音转录") + self.transcription_interface, + "TranscriptionInterface", + tr("homeflow.tab.transcription"), ) self.addSubInterface( self.subtitle_optimization_interface, "SubtitleInterface", - self.tr("字幕优化与翻译"), + tr("homeflow.tab.subtitle_optimize"), ) self.addSubInterface( self.video_synthesis_interface, "VideoSynthesisInterface", - self.tr("字幕视频合成"), + tr("homeflow.tab.video_synthesis"), ) self.vBoxLayout.addWidget(self.pivot) @@ -69,35 +80,130 @@ def __init__(self, parent=None): self.subtitle_optimization_interface.finished.connect( self.switch_to_video_synthesis ) + self._sync_style() + + def showEvent(self, event): + super().showEvent(event) + self._sync_style() + + def _sync_style(self): + palette = app_palette() + self.setStyleSheet( + f""" + QWidget#HomeInterface {{ + background: {palette.bg}; + }} + /* 只作用于主页自己的页面栈:未限定的 QStackedWidget 规则会级联进 + 子页面内部的栈,把面板中间“挖空”成页面底色。 */ + QStackedWidget#homeStack {{ + background: {palette.bg}; + border: none; + }} + SegmentedWidget#homePivot {{ + background: {palette.panel}; + border: 1px solid {palette.line_soft}; + border-radius: 8px; + }} + SegmentedWidget#homePivot QPushButton {{ + color: {palette.muted}; + background: transparent; + border: none; + min-height: 36px; + font-weight: bold; + }} + SegmentedWidget#homePivot QPushButton:hover, + SegmentedWidget#homePivot QPushButton:checked {{ + color: {palette.text}; + background: {palette.selected}; + }} + SegmentedWidget#homePivot QLabel {{ + color: {palette.text}; + background: transparent; + }} + """ + ) - def switch_to_transcription(self, file_path): - # 流程开始,生成新的 task_id + def switch_to_transcription(self, file_path, subtitle_path=None): + # 流程开始,生成新的 task_id 与任务目录 self._current_task_id = generate_task_id() + self._current_task_dir = None + # 下载时带回的字幕可直接用时跳过转录(路径由下载线程显式传递) + if subtitle_path and self._subtitle_usable(str(subtitle_path)): + self.switch_to_subtitle_optimization(str(subtitle_path), file_path) + return + + self._current_task_dir = TaskFactory.new_task_dir(file_path, "transcribe") transcribe_task = TaskFactory.create_transcribe_task( - file_path, need_next_task=True, task_id=self._current_task_id + file_path, + need_next_task=True, + task_id=self._current_task_id, + task_dir=self._current_task_dir, ) self.transcription_interface.set_task(transcribe_task) self.transcription_interface.process() self.stackedWidget.setCurrentWidget(self.transcription_interface) self.pivot.setCurrentItem("TranscriptionInterface") + @staticmethod + def _subtitle_usable(subtitle_path: str) -> bool: + """下载字幕必须可解析且非空:站点可能返回弹幕 xml 或空文件。""" + if not Path(subtitle_path).exists(): + return False + try: + segments = ASRData.from_subtitle_file(subtitle_path).segments + except Exception: + logger.warning("下载字幕无法解析,转为转录:%s", subtitle_path) + return False + if not segments: + logger.warning("下载字幕为空,转为转录:%s", subtitle_path) + return False + return True + def switch_to_subtitle_optimization(self, file_path, video_path): - # 继续使用同一个 task_id + # 继续使用同一个 task_id / 任务目录(下载字幕跳转录时这里才建目录) + if not self._current_task_dir: + self._current_task_dir = TaskFactory.new_task_dir(video_path or file_path, "transcribe") subtitle_task = TaskFactory.create_subtitle_task( - file_path, video_path, need_next_task=True, task_id=self._current_task_id + file_path, + video_path, + need_next_task=True, + task_id=self._current_task_id, + task_dir=self._current_task_dir, ) self.subtitle_optimization_interface.set_task(subtitle_task) self.subtitle_optimization_interface.process() self.stackedWidget.setCurrentWidget(self.subtitle_optimization_interface) self.pivot.setCurrentItem("SubtitleInterface") + def load_subtitle_for_optimize(self, subtitle_path: str, video_path: str = "") -> None: + """外部(硬字幕提取)送入字幕:载入字幕优化页并显示,等用户自行配置后开始(不自动跑 LLM)。""" + if not self._current_task_dir: + self._current_task_dir = TaskFactory.new_task_dir( + video_path or subtitle_path, "transcribe" + ) + subtitle_task = TaskFactory.create_subtitle_task( + subtitle_path, + video_path or "", + need_next_task=False, + task_id=self._current_task_id, + task_dir=self._current_task_dir, + ) + self.subtitle_optimization_interface.set_task(subtitle_task) + self.stackedWidget.setCurrentWidget(self.subtitle_optimization_interface) + self.pivot.setCurrentItem("SubtitleInterface") + def switch_to_video_synthesis(self, video_path, subtitle_path): - # 继续使用同一个 task_id,流程结束后清空 + # 继续使用同一个 task_id;任务目录交给合成页在收尾时清理 synthesis_task = TaskFactory.create_synthesis_task( - video_path, subtitle_path, need_next_task=True, task_id=self._current_task_id + video_path, + subtitle_path, + need_next_task=True, + task_id=self._current_task_id, + task_dir=self._current_task_dir, ) self._current_task_id = None # 流程结束 + self._current_task_dir = None self.video_synthesis_interface.set_task(synthesis_task) self.video_synthesis_interface.process() self.stackedWidget.setCurrentWidget(self.video_synthesis_interface) diff --git a/videocaptioner/ui/view/live_caption_interface.py b/videocaptioner/ui/view/live_caption_interface.py new file mode 100644 index 00000000..add37328 --- /dev/null +++ b/videocaptioner/ui/view/live_caption_interface.py @@ -0,0 +1,621 @@ +# -*- coding: utf-8 -*- +"""实时字幕页:会话 / 历史 / 详情三视图的宿主。 + +视图(``ui/components/live_caption``)只呈现 + 发信号;本宿主编排开始/暂停/结束、桌面浮窗、 +录制存盘(``LiveCaptionStore``)、历史与详情回放。线程回调经 Qt 信号 queued 回 GUI 线程。 +""" + +from __future__ import annotations + +import time +from typing import List, Optional + +from PyQt5.QtCore import QTimer +from PyQt5.QtWidgets import QStackedWidget, QVBoxLayout, QWidget +from qfluentwidgets import InfoBar, InfoBarPosition + +from videocaptioner.core.realtime.audio.capture import AudioDevice, list_input_devices +from videocaptioner.core.realtime.audio.system_mac import system_audio_supported +from videocaptioner.core.realtime.backends.base import TranscriberState +from videocaptioner.core.realtime.config import LiveCaptionConfig, LiveCaptionSource +from videocaptioner.core.realtime.recording.history import ( + LiveCaptionRecord, + LiveCaptionStore, + default_root, +) +from videocaptioner.core.translate.types import TranslatorType +from videocaptioner.ui.common.config import ( + cfg, + source_language_options, +) +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.app_dialog import ConfirmDialog, InputDialog +from videocaptioner.ui.components.caption_overlay import CaptionOverlay +from videocaptioner.ui.components.live_caption.views import ( + MODE_ENDED, + MODE_ERROR, + MODE_LIVE, + MODE_PAUSED, + MODE_READY, + DetailView, + HistoryView, + SessionView, + _fmt_pos, +) +from videocaptioner.ui.config_adapter import _llm_from_ui +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.live_caption_thread import LiveCaptionThread + +_PAGE_SESSION, _PAGE_HISTORY, _PAGE_DETAIL = 0, 1, 2 +_SYSTEM_AUDIO_INDEX = -2 # 「系统声音(本机播放)」合成项的设备索引(非真实设备,走 SCK 原生捕获) + + +def _clock(seconds: int) -> str: + m, s = divmod(max(0, seconds), 60) + h, m = divmod(m, 60) + return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}" + + +class LiveCaptionInterface(QWidget): + """实时字幕主页(三视图宿主)。""" + + def __init__(self, parent=None) -> None: + super().__init__(parent=parent) + self.setObjectName("liveCaptionInterface") + self.setWindowTitle(tr("live.title")) + # 根背景走 palette.bg,否则面板浮在未着色底上像格格不入的色块。 + self.setStyleSheet( + f"QWidget#liveCaptionInterface {{ background: {app_palette().bg}; }}" + ) + + # 实时字幕历史归入工作目录(用户工作产物)。旧 APPDATA 记录的一次性迁移放在 GUI 启动 + # (main.py)做,不在此处——否则任何构造本页的测试/smoke 都会误迁真实数据。 + self._store = LiveCaptionStore(root=default_root(cfg.get(cfg.work_dir))) + self._thread: Optional[LiveCaptionThread] = None + self._overlay: Optional[CaptionOverlay] = None + self._retiring: List[LiveCaptionThread] = [] + self._starting = False + self._devices: List[AudioDevice] = [] + self._session_start = 0.0 + self._elapsed = 0 + self._paused = False + self._seen_segs: set = set() + self._last_record: Optional[LiveCaptionRecord] = None + self._got_record = False + self._errored = False # 本次会话是否以错误收场(阻止 finished 把错误页重置回就绪) + self._records_cache: Optional[List[LiveCaptionRecord]] = None # 历史列表内存缓存 + self._detail_from = _PAGE_SESSION # 详情返回目标(来源页) + + self.session = SessionView(self) + self.history = HistoryView(self) + self.detail = DetailView(self) + self._stack = QStackedWidget(self) + for view in (self.session, self.history, self.detail): + page = QWidget(self._stack) + pl = QVBoxLayout(page) + pl.setContentsMargins(26, 20, 26, 22) + pl.addWidget(view) + self._stack.addWidget(page) + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.addWidget(self._stack) + + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self._tick) + + self._wire() + self._init_settings_state() + self._refresh_devices() + self._refresh_recent() + self.session.set_mode(MODE_READY) + + # ----- 接线 ----- + + def _wire(self) -> None: + s = self.session + s.startClicked.connect(self._start) # 错误态「重试」复用同一按钮 + s.pauseClicked.connect(self._pause) + s.resumeClicked.connect(self._resume) + s.stopClicked.connect(self._finish) + s.newSessionClicked.connect(self._reset_ready) # 结束态「新建会话」→ 回就绪 + s.historyClicked.connect(self._show_history) + s.recordOpened.connect(self._show_detail) + s.exportClicked.connect(self._export_current) + s.configClicked.connect(self._open_config) + s.deviceChanged.connect(self._on_device) + s.translateToggled.connect( + lambda on: cfg.set(cfg.live_caption_translate, on, save=True)) + s.targetLanguageChanged.connect( + lambda lang: cfg.set(cfg.live_caption_target_language, lang, save=True)) + s.sourceLanguageChanged.connect( + lambda v: cfg.set(cfg.live_caption_source_language, v, save=True)) + s.overlayToggled.connect( + lambda on: cfg.set(cfg.live_caption_show_overlay, on, save=True)) + # 设置页切转录引擎 → 立即按新引擎刷新主页识别语言下拉(不同引擎支持的语言不同)。 + cfg.live_caption_provider.valueChanged.connect(self._refresh_source_languages) + # 工作目录改了:无会话进行时立即把历史 store 指向新目录(有会话则等本次结束、下次启动)。 + cfg.work_dir.valueChanged.connect(self._on_work_dir_changed) + + h = self.history + h.backClicked.connect(self._show_session) + h.refreshClicked.connect(self._refresh_history) + h.openDirClicked.connect(self._open_dir) + h.recordOpened.connect(self._show_detail) + h.renameRequested.connect(self._rename_record) + h.exportRequested.connect(lambda r: self._export_record(r, "srt")) + h.deleteRequested.connect(self._delete_record) + h.searchChanged.connect(self._on_search) + + self.detail.backClicked.connect(self._back_from_detail) + self.detail.homeClicked.connect(self._reset_ready) # 详情「主页」→ 真正回到实时字幕初始首页 + self.detail.exportRequested.connect(self._export_detail) + self.detail.openFolderRequested.connect(self._open_record_dir) + + def _init_settings_state(self) -> None: + self.session.set_translate(cfg.get(cfg.live_caption_translate)) + self.session.set_overlay(cfg.get(cfg.live_caption_show_overlay)) + # 翻译语言(目标语言):选项=TargetLanguage 枚举,标签用其中文 .value + lang_opts = cfg.live_caption_target_language.validator.options + self.session.set_target_languages( + [(o, getattr(o, "value", str(o))) for o in lang_opts], + cfg.get(cfg.live_caption_target_language), + ) + self._refresh_source_languages() + + def _on_work_dir_changed(self) -> None: + """工作目录变更:无会话进行时把历史 store 重指向新目录并刷新;有会话则保持本次不变。""" + if self._thread is not None: + return + self._store = LiveCaptionStore(root=default_root(cfg.get(cfg.work_dir))) + self._records_cache = None + self._refresh_recent() + + def _refresh_source_languages(self) -> None: + """重填识别语言下拉:语言集随转录引擎而变。已存语言不在新引擎支持集时落库回退 auto。""" + provider = cfg.get(cfg.live_caption_provider) + options = source_language_options(provider) + current = cfg.get(cfg.live_caption_source_language) + if current not in {code for code, _ in options}: + current = "auto" + cfg.set(cfg.live_caption_source_language, current, save=True) + self.session.set_source_languages(options, current) + + def showEvent(self, event) -> None: + # 回到本页时按当前引擎刷新识别语言列表(构造时填的可能已过时)。 + self._refresh_source_languages() + super().showEvent(event) + + # ----- 设备 ----- + + def _refresh_devices(self) -> None: + try: + self._devices = list_input_devices() + except Exception: + self._devices = [] + items: List[tuple] = [(-1, tr("live.device.default_input"))] + # macOS:原生「系统声音」(ScreenCaptureKit,免装 BlackHole)。其它平台仍靠选回环设备。 + if system_audio_supported(): + items.append((_SYSTEM_AUDIO_INDEX, tr("live.device.system_audio"))) + for dev in self._devices: + tag = tr("live.device.default_tag") if dev.is_default else "" + items.append((dev.index, f"{dev.name}{tag}")) + current = cfg.get(cfg.live_caption_device_index) + self.session.set_devices(items, current) + + def _on_device(self, index) -> None: + cfg.set(cfg.live_caption_device_index, index, save=True) + + # ----- 导航 ----- + + def _show_session(self) -> None: + self._stack.setCurrentIndex(_PAGE_SESSION) + + def _reset_ready(self) -> None: + """回到初始首页:停回放、清当前转录、就绪态、刷新最近、切会话页。「新建会话」与详情「主页」共用。""" + self.detail.stop_playback() + self._got_record = False + self._last_record = None + self.session.set_current_record(None) + self.session.transcript.clear() + self.session.set_mode(MODE_READY) + self._refresh_recent() + self._show_session() + + def _show_history(self) -> None: + self._refresh_history() + self._stack.setCurrentIndex(_PAGE_HISTORY) + + def _show_detail(self, record: LiveCaptionRecord) -> None: + fresh = self._store.load(record.id) or record + self.detail.load(fresh) + # 返回目标只取 历史/会话,不能是详情页本身(否则返回是 no-op = 死按钮) + self._detail_from = ( + _PAGE_HISTORY if self._stack.currentIndex() == _PAGE_HISTORY else _PAGE_SESSION + ) + self._stack.setCurrentIndex(_PAGE_DETAIL) + + def _back_from_detail(self) -> None: + self.detail.stop_playback() + self._stack.setCurrentIndex(getattr(self, "_detail_from", _PAGE_SESSION)) + + def _open_config(self) -> None: + window = self.window() + if hasattr(window, "openSettingsPage"): + window.openSettingsPage("live-caption") + + def _open_dir(self) -> None: + from PyQt5.QtCore import QUrl + from PyQt5.QtGui import QDesktopServices + + self._store.root.mkdir(parents=True, exist_ok=True) + QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._store.root))) + + def _open_record_dir(self) -> None: + """详情「打开文件夹」:打开该条记录自己的目录。""" + from PyQt5.QtCore import QUrl + from PyQt5.QtGui import QDesktopServices + + rec = self.detail._record + if rec is None: + return + path = self._store.dir_for(rec.id) + if path.is_dir(): + QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) + + # ----- 历史 ----- + + def _records(self, *, refresh: bool = False) -> List[LiveCaptionRecord]: + """历史记录列表(内存缓存,让搜索过滤不必每次按键都扫盘)。增删改后传 refresh=True 失效重载。""" + if refresh or self._records_cache is None: + self._records_cache = self._store.list() + return self._records_cache + + def _refresh_recent(self) -> None: + self.session.set_recent(self._records(refresh=True)) + + def _refresh_history(self) -> None: + self.history.set_records(self._records(refresh=True)) + + def _on_search(self, text: str) -> None: + self.history.set_records([r for r in self._records() if r.matches(text)]) + + # 整行卡片在 mouseReleaseEvent 发 opened,行内按钮走 clicked;在这些处理器里直接 exec() 模态会 + # 错乱本次按下/释放的鼠标抓取(点重命名却误触整行 opened)。延一轮事件循环再弹框。 + def _rename_record(self, record: LiveCaptionRecord) -> None: + QTimer.singleShot(0, lambda: self._do_rename(record)) + + def _do_rename(self, record: LiveCaptionRecord) -> None: + dlg = InputDialog( + tr("live.rename.title"), + text=record.name, + placeholder=tr("live.rename.placeholder"), + parent=self, + ) + if dlg.exec(): + new_name = dlg.value() + # 只改显示名,文件夹仍以时间戳 id 为不可变主键;名为空 / 未变则不动盘 + if new_name and new_name != record.name and self._store.rename(record.id, new_name): + self._refresh_history() + self._refresh_recent() + + def _delete_record(self, record: LiveCaptionRecord) -> None: + QTimer.singleShot(0, lambda: self._do_delete(record)) + + def _do_delete(self, record: LiveCaptionRecord) -> None: + dlg = ConfirmDialog( + tr("live.delete.title"), + tr("live.delete.confirm", name=record.name), + parent=self, + danger=True, + ) + if dlg.exec(): + self._store.delete(record.id) + self._refresh_history() + self._refresh_recent() + + def _export_record(self, record: LiveCaptionRecord, fmt: str) -> None: + fresh = self._store.load(record.id) or record + QTimer.singleShot(0, lambda: self._do_export(fresh, fmt)) + + def _export_detail(self, fmt: str) -> None: + if self.detail._record is not None: + self._do_export(self.detail._record, fmt) + + def _export_current(self) -> None: + if self._last_record is not None: + self._do_export(self._last_record, "srt") + + def _do_export(self, record: LiveCaptionRecord, fmt: str) -> None: + from PyQt5.QtWidgets import QFileDialog + + ext = "srt" if fmt == "srt" else "txt" + suggested = f"{record.name}.{ext}" + path, _ = QFileDialog.getSaveFileName( + self, tr("live.export.dialog_title"), suggested, f"{ext.upper()} (*.{ext})" + ) + if not path: + return + content = record.export_srt() if fmt == "srt" else record.export_txt() + try: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + InfoBar.success(tr("live.export.success"), path, duration=3500, + position=InfoBarPosition.BOTTOM, parent=self) + except Exception as exc: + InfoBar.error(tr("live.export.failed"), str(exc), duration=5000, + position=InfoBarPosition.BOTTOM, parent=self) + + # ----- 启停 ----- + + def _build_config(self) -> LiveCaptionConfig: + device_index = cfg.get(cfg.live_caption_device_index) + native_system = device_index == _SYSTEM_AUDIO_INDEX # macOS 原生系统声音(SCK) + dev = next((d for d in self._devices if d.index == device_index), None) + loopback = native_system or bool(dev and _looks_like_loopback(dev.name)) + # 随会话传下当前 LLM provider 的 key/base/model,否则「大模型翻译」会因环境变量未设置而报错。 + llm = _llm_from_ui(cfg) + return LiveCaptionConfig( + backend=cfg.get(cfg.live_caption_provider), + voxgate_binary=cfg.get(cfg.live_caption_voxgate_binary), + api_key=str(cfg.get(cfg.fun_asr_api_key) or "").strip(), + asr_model=cfg.get(cfg.live_caption_fun_asr_model), + source_language=cfg.get(cfg.live_caption_source_language), + source=LiveCaptionSource.SYSTEM if loopback else LiveCaptionSource.MICROPHONE, + device_index=None if device_index in (None, -1, _SYSTEM_AUDIO_INDEX) else int(device_index), + system_audio_native=native_system, + translate_enabled=cfg.get(cfg.live_caption_translate), + translator_type=TranslatorType[cfg.get(cfg.live_caption_translator_service).name], + target_language=cfg.get(cfg.live_caption_target_language), + llm_model=llm.model or "gpt-4o-mini", + llm_api_key=llm.api_key, + llm_api_base=llm.api_base, + ) + + def _start(self) -> None: + if self._starting or (self._thread is not None and self._thread.isRunning()): + return + self._starting = True + self._elapsed = 0 + self._paused = False + self._seen_segs.clear() + self._got_record = False + self._errored = False + self.session.transcript.clear() + name = LiveCaptionStore.display_name() + self.session.set_record_title(name, tr("live.status.connecting_with_time")) + self.session.set_timer("00:00", tr("live.status.connecting")) + self.session.set_mode(MODE_LIVE) + QTimer.singleShot(0, self._launch) + + def _launch(self) -> None: + if not self._starting: + return + try: + self._do_launch() + except Exception as exc: + self._starting = False + self._teardown_thread() + self.session.set_mode(MODE_ERROR) + self.session.set_error(tr("live.error.start_failed"), str(exc)) + self.session.set_record_title(tr("live.error.start_failed"), tr("live.error.start_failed_desc")) + return + self._starting = False + + def _do_launch(self) -> None: + config = self._build_config() + self._session_start = time.time() + + overlay = None + if cfg.get(cfg.live_caption_show_overlay): + overlay = self._make_overlay() + self._overlay = overlay + + thread = LiveCaptionThread(config, self._store, self) + thread.caption.connect(self._on_caption) + thread.stateChanged.connect(self._on_state) + thread.error.connect(self._on_error) + thread.level.connect(self.session.set_level) # 拾音电平 → 会话页波形 + thread.recorded.connect(self._on_recorded) + thread.finished.connect(self._on_finished) + self._thread = thread + self._timer.start() + thread.start() + + def _make_overlay(self) -> CaptionOverlay: + overlay = CaptionOverlay() + overlay.set_display_mode(cfg.get(cfg.live_caption_display_mode)) + overlay.set_bg_style(cfg.get(cfg.live_caption_bg_style)) + overlay.apply_font_scale(cfg.get(cfg.live_caption_font_scale)) + saved_mode = cfg.get(cfg.live_caption_overlay_mode) + overlay.set_mode(saved_mode) + overlay.restore_size( + saved_mode, cfg.get(cfg.live_caption_overlay_w), cfg.get(cfg.live_caption_overlay_h) + ) + overlay.displayModeChanged.connect( + lambda v: cfg.set(cfg.live_caption_display_mode, v, save=True)) + overlay.bgStyleChanged.connect( + lambda v: cfg.set(cfg.live_caption_bg_style, v, save=True)) + overlay.fontScaleChanged.connect( + lambda v: cfg.set(cfg.live_caption_font_scale, v, save=True)) + overlay.modeChanged.connect( + lambda v: cfg.set(cfg.live_caption_overlay_mode, v, save=True)) + overlay.sizeChanged.connect(self._on_overlay_size) + overlay.requestClose.connect(self._finish) + overlay.pauseToggled.connect(self._on_overlay_pause) + self._place_overlay(overlay) + overlay.show() + return overlay + + def _pause(self) -> None: + if self._thread is not None: + self._thread.set_paused(True) + self._paused = True + if self._overlay is not None: + self._overlay.set_paused(True) + self.session.set_mode(MODE_PAUSED) + self.session.set_timer(_clock(self._elapsed), tr("live.status.paused")) + + def _resume(self) -> None: + if self._thread is not None: + self._thread.set_paused(False) + self._paused = False + if self._overlay is not None: + self._overlay.set_paused(False) + self.session.set_mode(MODE_LIVE) + + def _finish(self) -> None: + """结束保存:停链路、立即给「保存中…」反馈并禁按钮防连点。收尾在工作线程异步进行, + 记录经 recorded 信号回来后切 ended(空会话丢弃)。""" + if self._thread is None and not self._starting: + return # 已在收尾 / 未运行:忽略重复触发 + if self._thread is None and self._starting: + # 启动窗口期(线程还没建)就点结束:直接复位回就绪。不能走 set_saving—— + # 没有线程就没有 finished/recorded 回来复位,会永久卡「保存中…」。 + self._starting = False + self._timer.stop() + self.session.set_mode(MODE_READY) + return + self.session.set_saving() # 立即禁用按钮 + 提示「正在保存…」(非阻塞) + self._timer.stop() + self._teardown_thread() + + def _release_thread_and_overlay(self, *, blocking: bool) -> None: + """统一拆线程 + 拆浮窗(_teardown_thread 与 closeEvent 共用,关键不变量只一处定义)。 + + 必须先断 线程→浮窗 的跨线程信号再销毁浮窗:收尾期间后端线程仍可能 emit,排队信号投递到 + 已释放的浮窗 C++ 对象会硬 abort。blocking=True(退出)阻塞 stop 确保无 QThread 残留; + blocking=False(会话内拆换)非阻塞 request_cancel + 退役队列,不卡 GUI。 + """ + thread = self._thread + overlay = self._overlay + self._thread = None + self._overlay = None + if thread is not None: + self._disconnect_signals(thread) + if blocking: + thread.stop() + else: + thread.request_cancel() + self._retiring.append(thread) + thread.finished.connect(lambda t=thread: self._retire(t)) + if overlay is not None: + overlay.hide() + overlay.deleteLater() + + def _teardown_thread(self) -> None: + self._starting = False + self._release_thread_and_overlay(blocking=False) + + def _disconnect_signals(self, thread) -> None: + """断开 线程→本页 的 caption 信号。只需断 caption:它是唯一触达浮窗的实时信号; + thread.level 接的是 SessionView(存活期长于线程),浮窗不接 level。""" + try: + thread.caption.disconnect(self._on_caption) + except (TypeError, RuntimeError): + pass + + def _retire(self, thread: LiveCaptionThread) -> None: + if thread in self._retiring: + self._retiring.remove(thread) + thread.deleteLater() + + # ----- 线程回调(GUI 线程)----- + + def _on_caption(self, entry) -> None: + rel = max(0.0, entry.started_at - self._session_start) + self.session.transcript.upsert(entry, rel, _fmt_pos) + if self._overlay is not None: + self._overlay.upsert_caption(entry) + self._seen_segs.add(entry.seg_id) + + def _on_state(self, state: str) -> None: + if state == TranscriberState.LISTENING.value and self._timer.isActive(): + if not self._paused: + self.session.set_timer(_clock(self._elapsed), tr("live.status.recording")) + + def _on_recorded(self, record: LiveCaptionRecord) -> None: + self._got_record = True + self._last_record = record + self.session.set_current_record(record) + self.session.set_record_title(record.name, record.summary) + self.session.set_timer(record.duration_label, tr("live.status.saved")) + self.session.set_mode(MODE_ENDED) + self._refresh_recent() + + def _on_finished(self) -> None: + # 线程结束但没有产生记录(空会话)→ 回到就绪态; + # 错误收场除外:error 信号先于 finished 到达,就绪态会把错误页盖掉(用户就什么都看不到了) + if not self._got_record and not self._errored and self._stack.currentIndex() == _PAGE_SESSION: + self.session.set_mode(MODE_READY) + self.session.set_timer("00:00", tr("live.status.waiting")) + self.session.set_record_title(tr("live.ready.title"), tr("live.ready.desc")) + + def _on_error(self, message: str) -> None: + try: + self._errored = True + lowered = message.lower() + if "quota" in lowered or "concurren" in lowered or "并发" in message: + friendly = tr("live.error.concurrency_full") + else: + friendly = message + self._timer.stop() + self._teardown_thread() + self.session.set_mode(MODE_ERROR) + self.session.set_error(tr("live.error.start_failed"), friendly) + self.session.set_record_title(tr("live.error.start_failed"), tr("live.error.transcribe_failed")) + except Exception: + import logging + + logging.getLogger("live_caption_ui").exception("处理实时字幕错误时异常(已忽略)") + + def _tick(self) -> None: + if self._thread is None or self._paused: + return + self._elapsed += 1 + self.session.set_timer(_clock(self._elapsed), tr("live.status.recording")) + self.session.set_record_title( + LiveCaptionStore.display_name(self._session_start), + tr("live.status.progress", time=_clock(self._elapsed), count=len(self._seen_segs)), + ) + + # ----- 浮窗 ----- + + def _on_overlay_pause(self, paused: bool) -> None: + if paused: + self._pause() + else: + self._resume() + + def _on_overlay_size(self, cw: int, ch: int) -> None: + cfg.set(cfg.live_caption_overlay_w, cw, save=True) + cfg.set(cfg.live_caption_overlay_h, ch, save=True) + + def _place_overlay(self, overlay: CaptionOverlay) -> None: + win = self.window().windowHandle() + screen = win.screen() if win is not None else None + if screen is None: + from PyQt5.QtWidgets import QApplication + + screen = QApplication.primaryScreen() + if screen is None: + return + geo = screen.availableGeometry() + overlay.adjustSize() + x = geo.center().x() - overlay.width() // 2 + y = geo.bottom() - overlay.height() - 120 + overlay.move(max(geo.left(), x), max(geo.top(), y)) + + def closeEvent(self, event) -> None: + self._timer.stop() + self._release_thread_and_overlay(blocking=True) # 退出走阻塞收尾,确保无 QThread 残留 + for t in list(self._retiring): + t.stop() + self._retiring.clear() + super().closeEvent(event) + + +def _looks_like_loopback(name: str) -> bool: + low = name.lower() + return any(k in low for k in ("blackhole", "loopback", "stereo mix", "立体声混音", + "vb-audio", "soundflower", "系统", "voicemeeter")) diff --git a/videocaptioner/ui/view/llm_logs_interface.py b/videocaptioner/ui/view/llm_logs_interface.py index 571080ea..c63ae0c7 100644 --- a/videocaptioner/ui/view/llm_logs_interface.py +++ b/videocaptioner/ui/view/llm_logs_interface.py @@ -1,267 +1,474 @@ -"""LLM 请求日志查看界面""" +"""LLM 请求日志页(排查工具)。 + +工具栏(搜索 + 刷新 + 清空)、一屏内的日志表格 +(时间/任务ID/文件/阶段/模型/耗时/Tokens)、底部统计 + 分页;空日志时表格换成空态面板。 +双击行打开完整 JSON 详情。全部用第一方 workbench 组件 + 主题 token,qfluent 仅作底层 +TableWidget/PlainTextEdit 来源。 +""" import json from typing import Any, Dict, List from PyQt5.QtCore import QFileSystemWatcher, Qt +from PyQt5.QtGui import QColor, QFont, QPalette from PyQt5.QtWidgets import ( QApplication, + QFrame, QHBoxLayout, QHeaderView, + QLabel, + QLineEdit, + QPlainTextEdit, + QStackedWidget, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) -from qfluentwidgets import ( - BodyLabel, - CaptionLabel, - InfoBar, - InfoBarPosition, - MessageBox, - MessageBoxBase, - PillPushButton, - PlainTextEdit, - PushButton, - SearchLineEdit, - SubtitleLabel, - TableWidget, - ToolButton, - setCustomStyleSheet, -) -from qfluentwidgets import FluentIcon as FIF +from qfluentwidgets import InfoBar, InfoBarPosition from videocaptioner.config import LLM_LOG_FILE, LOG_PATH +from videocaptioner.ui.common.app_icons import AppIcon, render_svg_icon +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.app_dialog import AppDialog, ConfirmDialog +from videocaptioner.ui.components.workbench import ( + AppLineEdit, + CompactButton, + IconBox, + RoundIconButton, + SectionLabel, + StatusPill, + WorkbenchButton, + WorkbenchPanel, + apply_font, +) +from videocaptioner.ui.i18n import N_, tr PAGE_SIZE = 50 -class LogDetailDialog(MessageBoxBase): - """日志详情对话框""" +class _LogTable(QTableWidget): + """日志表格:构造时表格在隐藏的 stack 页上,Qt 首次显示会重新 show 行号竖表头, + 构造期的 setVisible(False) 不持久;故在每次 showEvent 后再隐藏一次,彻底去掉左侧行号槽。""" + + def showEvent(self, event): + super().showEvent(event) + self.verticalHeader().setVisible(False) + + +class LogDetailDialog(AppDialog): + """日志详情:标题副行 + 元信息条(时间/阶段/耗时/Tokens/结果)+ 请求体 / 响应体并排, + + 每块独立复制;请求失败时右侧切「错误响应」并染危险色。用第一方 AppDialog + workbench 风格实现。""" def __init__(self, log_entry: Dict[str, Any], parent=None): - super().__init__(parent) self.log_entry = log_entry - self._setup_ui() - - def _setup_ui(self): - self.titleLabel = SubtitleLabel(self.tr("请求详情")) - self.viewLayout.addWidget(self.titleLabel) - - # 提取信息 - time_str = self.log_entry.get("time", "") - model = self.log_entry.get("request", {}).get("model", "未知") - duration = self.log_entry.get("duration_ms", 0) / 1000 - stage = self.log_entry.get("stage", "") or "-" - - usage = self.log_entry.get("response", {}).get("usage", {}) - prompt_tokens = usage.get("prompt_tokens", 0) - completion_tokens = usage.get("completion_tokens", 0) - - # 顶部信息栏 - info_row = QHBoxLayout() - info_row.setSpacing(8) - info_row.setContentsMargins(0, 0, 0, 8) - - # 用 PillPushButton 展示各项信息(禁用点击) - items = [ - time_str, - stage, - model, - f"{duration:.1f}s", - f"input token: {prompt_tokens}", - f"output token: {completion_tokens}", + super().__init__(tr("llmlog.detail.title"), icon=AppIcon.DOCUMENT, parent=parent, width=1000) + self._build() + + def _is_failure(self) -> bool: + status = self.log_entry.get("status", 0) or 0 + response = self.log_entry.get("response") or {} + if isinstance(response, dict) and response.get("error"): + return True + if status: + return not (200 <= status < 300) + return not response # 无状态码且响应为空,视为失败 + + def _build(self): + entry = self.log_entry + request = entry.get("request", {}) or {} + response = entry.get("response", {}) or {} + model = request.get("model") or entry.get("model") or tr("llmlog.unknown") + stage = entry.get("stage") or "-" + file_name = entry.get("file_name") or "-" + failed = self._is_failure() + usage = response.get("usage") or {} + ptok = usage.get("prompt_tokens", 0) + ctok = usage.get("completion_tokens", 0) + tokens = f"{ptok} / {ctok}" if (ptok or ctok) else "-" + duration = entry.get("duration_ms", 0) / 1000 + + # 标题副行:文件 · 阶段 · 模型 + subtitle = QLabel(" · ".join(x for x in (file_name, stage, model) if x and x != "-")) + subtitle.setObjectName("logSubtitle") + apply_font(subtitle, 13, 720) + self.bodyLayout.addWidget(subtitle) + + # 元信息条 + strip = QHBoxLayout() + strip.setSpacing(10) + metas = [ + (tr("llmlog.meta.time"), entry.get("time", "-")), + (tr("llmlog.meta.stage"), stage), + (tr("llmlog.meta.duration"), f"{duration:.1f}s"), + ("Tokens", tokens), + (tr("llmlog.meta.result"), tr("llmlog.result.failed") if failed else tr("llmlog.result.done")), ] - for text in items: - if text: - pill = PillPushButton(str(text)) - pill.setCheckable(False) - pill.setEnabled(False) - pill.setFixedHeight(24) - info_row.addWidget(pill) - - info_row.addStretch() - self.viewLayout.addLayout(info_row) - - # Request - self.viewLayout.addWidget(SubtitleLabel("Request")) - self.request_edit = PlainTextEdit() - self.request_edit.setReadOnly(True) - self.request_edit.setMinimumHeight(180) - request_text = json.dumps(self.log_entry.get("request", {}), indent=2, ensure_ascii=False) - self.request_edit.setPlainText(request_text) - self.viewLayout.addWidget(self.request_edit) - - # Response - self.viewLayout.addWidget(SubtitleLabel("Response")) - self.response_edit = PlainTextEdit() - self.response_edit.setReadOnly(True) - self.response_edit.setMinimumHeight(180) - response_text = json.dumps(self.log_entry.get("response", {}), indent=2, ensure_ascii=False) - self.response_edit.setPlainText(response_text) - self.viewLayout.addWidget(self.response_edit) - - # 底部按钮:替换默认按钮 - self.yesButton.setText(self.tr("关闭")) - self.cancelButton.hide() # type: ignore - - copy_req_btn = PushButton(FIF.COPY, self.tr("复制请求")) - copy_req_btn.clicked.connect(self._copy_request) - self.buttonLayout.insertWidget(0, copy_req_btn) # type: ignore - - copy_resp_btn = PushButton(FIF.COPY, self.tr("复制响应")) - copy_resp_btn.clicked.connect(self._copy_response) - self.buttonLayout.insertWidget(1, copy_resp_btn) # type: ignore - - self.widget.setMinimumWidth(700) - - def _copy_request(self): - text = json.dumps(self.log_entry.get("request", {}), indent=2, ensure_ascii=False) - clipboard = QApplication.clipboard() - if clipboard: - clipboard.setText(text) - InfoBar.success( - title="", - content=self.tr("已复制"), - parent=self, - position=InfoBarPosition.TOP, - duration=1500, + for idx, (label, value) in enumerate(metas): + strip.addWidget(self._meta_item(label, value, result=(idx == len(metas) - 1), failed=failed)) + self.bodyLayout.addLayout(strip) + + # 请求体 / 响应体 并排(请求更宽,失败时右侧为错误响应) + row = QHBoxLayout() + row.setSpacing(12) + req_panel, self.copyReqBtn = self._payload_panel( + tr("llmlog.panel.request"), tr("llmlog.panel.request.desc"), request, danger=False ) - - def _copy_response(self): - text = json.dumps(self.log_entry.get("response", {}), indent=2, ensure_ascii=False) + resp_panel, self.copyRespBtn = self._payload_panel( + tr("llmlog.panel.error") if failed else tr("llmlog.panel.response"), + tr("llmlog.panel.error.desc") if failed else tr("llmlog.panel.response.desc"), + response, + danger=failed, + ) + row.addWidget(req_panel, 3) + row.addWidget(resp_panel, 2) + self.bodyLayout.addLayout(row, 1) + self.copyReqBtn.clicked.connect(lambda: self._copy("request")) + self.copyRespBtn.clicked.connect(lambda: self._copy("response")) + + # 底栏:提示 + 关闭 + hint = QLabel(tr("llmlog.detail.foot_hint")) + hint.setObjectName("logFootHint") + apply_font(hint, 12, 600) + self.footerLayout.addWidget(hint) + self.addFooterStretch() + self.addFooterButton(tr("common.close"), kind="accent").clicked.connect(lambda: self.done(0)) + self.syncStyle() + + def _meta_item(self, label: str, value: str, *, result: bool = False, failed: bool = False) -> QFrame: + card = QFrame(self.widget) + card.setObjectName("logMeta") + box = QVBoxLayout(card) + box.setContentsMargins(13, 9, 13, 10) + box.setSpacing(4) + lab = QLabel(label, card) + lab.setObjectName("logMetaLabel") + apply_font(lab, 11, 840) + val = QLabel(value, card) + apply_font(val, 15, 780) + val.setObjectName(("logResultFail" if failed else "logResultOk") if result else "logMetaValue") + box.addWidget(lab) + box.addWidget(val) + return card + + def _payload_panel(self, title: str, desc: str, payload, *, danger: bool): + palette = app_palette() + panel = QFrame(self.widget) + panel.setObjectName("logPanel") + panel.setProperty("danger", "true" if danger else "false") + col = QVBoxLayout(panel) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(0) + + head = QFrame(panel) + head.setObjectName("logPanelHead") + hl = QHBoxLayout(head) + hl.setContentsMargins(14, 9, 11, 9) + hl.setSpacing(10) + titles = QVBoxLayout() + titles.setSpacing(2) + t = QLabel(title, head) + t.setObjectName("logPanelTitle") + apply_font(t, 15, 820) + d = QLabel(desc, head) + d.setObjectName("logPanelDesc") + apply_font(d, 11, 600) + titles.addWidget(t) + titles.addWidget(d) + hl.addLayout(titles, 1) + copy_btn = CompactButton(tr("common.copy"), AppIcon.COPY, head, pad_h=8) + hl.addWidget(copy_btn, 0, Qt.AlignVCenter) # type: ignore[arg-type] + col.addWidget(head) + + code = QPlainTextEdit(panel) + code.setObjectName("logCode") + code.setReadOnly(True) + code.setMinimumHeight(300) + code.setFrameShape(QFrame.NoFrame) # type: ignore[attr-defined] + mono = QFont("SF Mono") + mono.setStyleHint(QFont.Monospace) + mono.setPointSize(12) + code.setFont(mono) + # 只读代码区背景走 QPalette(QSS background 到不了 viewport,会露白底) + pal = code.palette() + pal.setColor(QPalette.Base, QColor(palette.panel_deep)) + pal.setColor(QPalette.Text, QColor(palette.text)) + code.setPalette(pal) + # 滚动条样式直接设在控件上(祖先样式表的后代选择器到不了原生滚动条) + code.setStyleSheet( + f""" + QPlainTextEdit#logCode {{ background: transparent; border: none; padding: 6px 10px; }} + QScrollBar:vertical {{ background: transparent; width: 7px; margin: 4px 2px; border: none; }} + QScrollBar::handle:vertical {{ background: {palette.line}; border-radius: 3px; min-height: 30px; }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; background: transparent; }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }} + QScrollBar:horizontal {{ height: 0; }} + """ + ) + code.setPlainText( + json.dumps(payload, indent=2, ensure_ascii=False) if payload else tr("llmlog.empty_payload") + ) + col.addWidget(code, 1) + return panel, copy_btn + + def extraStyleRules(self, palette) -> str: + return f""" + QLabel#logSubtitle {{ color: {palette.muted}; background: transparent; }} + QLabel#logFootHint {{ color: {palette.subtle}; background: transparent; }} + QFrame#logMeta {{ + background: {palette.card_surface}; + border: 1px solid {palette.line_soft}; + border-radius: 12px; + }} + QLabel#logMetaLabel {{ color: {palette.subtle}; background: transparent; }} + QLabel#logMetaValue {{ color: {palette.text}; background: transparent; }} + QLabel#logResultOk {{ color: {palette.accent_text}; background: transparent; }} + QLabel#logResultFail {{ color: {palette.danger_fg}; background: transparent; }} + QFrame#logPanel {{ + background: {palette.panel_deep}; + border: 1px solid {palette.line_soft}; + border-radius: 14px; + }} + QFrame#logPanel[danger="true"] {{ + border: 1px solid {rgba(palette.danger, 0.5)}; + }} + QFrame#logPanelHead {{ + background: transparent; + border: none; + border-bottom: 1px solid {palette.line_soft}; + }} + QLabel#logPanelTitle {{ color: {palette.text}; background: transparent; }} + QLabel#logPanelDesc {{ color: {palette.subtle}; background: transparent; }} + """ + + def _copy(self, key: str): + text = json.dumps(self.log_entry.get(key, {}), indent=2, ensure_ascii=False) clipboard = QApplication.clipboard() if clipboard: clipboard.setText(text) - InfoBar.success( - title="", - content=self.tr("已复制"), - parent=self, - position=InfoBarPosition.TOP, - duration=1500, - ) + InfoBar.success("", tr("llmlog.copied"), parent=self.window(), + position=InfoBarPosition.TOP, duration=1500) class LLMLogsInterface(QWidget): - """LLM 请求日志界面""" + """LLM 请求日志界面。""" + + # (列宽, 是否拉伸);文件/模型两列自适应,其余定宽。 + # (label, fixed_width, mode);mode ∈ {fixed, stretch, content} + # 时间/阶段/模型 按内容自适应(绝不截断,时间是固定格式必须完整显示);文件做唯一弹性列吸收余宽; + # 耗时/Tokens 定宽数字列。任务ID 对用户无意义、且占宽,已移除(搜索仍可匹配 task_id)。 + _COLUMNS = ( + (N_("llmlog.col.time"), 0, "content"), + (N_("llmlog.col.file"), 0, "stretch"), + (N_("llmlog.col.stage"), 0, "content"), + (N_("llmlog.col.model"), 0, "content"), + (N_("llmlog.col.duration"), 80, "fixed"), + ("Tokens", 92, "fixed"), + ) def __init__(self, parent=None): super().__init__(parent) self.setObjectName("llmLogsInterface") - self.setWindowTitle(self.tr("LLM 请求日志")) + self.setWindowTitle(tr("llmlog.title")) + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] self.all_logs: List[Dict[str, Any]] = [] self.filtered_logs: List[Dict[str, Any]] = [] self.current_page = 0 - self._setup_ui() + self._build_ui() self._connect_signals() self._load_logs() self._setup_file_watcher() - def _setup_ui(self): - self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(20, 20, 20, 20) - self.main_layout.setSpacing(12) + # ---------------------------------------------------------------- 构建 - self._setup_toolbar() - self._setup_table() - self._setup_footer() + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(26, 20, 26, 22) + root.setSpacing(14) + root.addLayout(self._build_toolbar()) - def _setup_toolbar(self): - toolbar = QHBoxLayout() - toolbar.setSpacing(10) + self.contentStack = QStackedWidget() + self.contentStack.addWidget(self._build_table()) # 0 表格 + self.contentStack.addWidget(self._build_empty()) # 1 空态 + root.addWidget(self.contentStack, 1) - self.search_edit = SearchLineEdit() - self.search_edit.setPlaceholderText(self.tr("搜索任务ID、文件名、模型...")) - self.search_edit.setFixedWidth(280) - toolbar.addWidget(self.search_edit) + root.addLayout(self._build_footer()) + self._sync_style() - toolbar.addStretch() + def _build_toolbar(self) -> QHBoxLayout: + toolbar = QHBoxLayout() + toolbar.setSpacing(12) + + self.search_edit = AppLineEdit(height=44) + self.search_edit.setPlaceholderText(tr("llmlog.search.placeholder")) + self.search_edit.setMinimumWidth(320) + self.search_action = self.search_edit.addAction( + render_svg_icon(AppIcon.SEARCH, app_palette().muted, 16), + QLineEdit.LeadingPosition, # type: ignore[attr-defined] + ) + toolbar.addWidget(self.search_edit, 1) - self.refresh_btn = PushButton(FIF.SYNC, self.tr("刷新")) - toolbar.addWidget(self.refresh_btn) + self.empty_pill = StatusPill(tr("llmlog.no_records"), "neutral") + self.empty_pill.setVisible(False) + toolbar.addWidget(self.empty_pill) - self.clear_btn = PushButton(FIF.DELETE, self.tr("清空日志")) + self.refresh_btn = WorkbenchButton(tr("llmlog.btn.refresh"), AppIcon.SYNC, height=44) + self.clear_btn = WorkbenchButton(tr("llmlog.btn.clear"), AppIcon.DELETE, height=44) + toolbar.addWidget(self.refresh_btn) toolbar.addWidget(self.clear_btn) + return toolbar - self.main_layout.addLayout(toolbar) - - def _setup_table(self): - self.table = TableWidget() - self.table.setColumnCount(7) - self.table.setHorizontalHeaderLabels( - [ - self.tr("时间"), - self.tr("任务ID"), - self.tr("文件"), - self.tr("阶段"), - self.tr("模型"), - self.tr("耗时"), - self.tr("Tokens"), - ] - ) + def _build_table(self) -> QWidget: + # 用原生 QTableWidget(_LogTable)而非 qfluent TableWidget:后者会在显示时强制 + # 重现行号竖表头(setVisible/setFixedWidth 都被覆盖),且暗色下交替行发白。样式全部自管。 + self.table = _LogTable() + self.table.setObjectName("llmLogTable") + self.table.setColumnCount(len(self._COLUMNS)) + self.table.setHorizontalHeaderLabels([tr(c[0]) for c in self._COLUMNS]) header = self.table.horizontalHeader() if header: - header.setSectionResizeMode(0, QHeaderView.Fixed) - header.setSectionResizeMode(1, QHeaderView.Fixed) - header.setSectionResizeMode(2, QHeaderView.Stretch) # 文件 - 自适应 - header.setSectionResizeMode(3, QHeaderView.Fixed) - header.setSectionResizeMode(4, QHeaderView.Stretch) # 模型 - 自适应 - header.setSectionResizeMode(5, QHeaderView.Fixed) - header.setSectionResizeMode(6, QHeaderView.Fixed) - - self.table.setColumnWidth(0, 130) # 时间 - self.table.setColumnWidth(1, 100) # 任务ID - self.table.setColumnWidth(3, 90) # 阶段 - self.table.setColumnWidth(5, 70) # 耗时 - self.table.setColumnWidth(6, 70) # Tokens - + header.setFixedHeight(48) + header.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) # type: ignore[operator] + header.setMinimumSectionSize(72) # 拖窄时任何列都不塌到不可读 + modes = { + "fixed": QHeaderView.Fixed, + "stretch": QHeaderView.Stretch, + "content": QHeaderView.ResizeToContents, + } + for i, (_label, width, mode) in enumerate(self._COLUMNS): + header.setSectionResizeMode(i, modes[mode]) + if mode == "fixed": + self.table.setColumnWidth(i, width) v_header = self.table.verticalHeader() if v_header: - v_header.setVisible(False) + v_header.setDefaultSectionSize(54) + v_header.setVisible(False) # 原生表格不再强塞行号槽 self.table.setEditTriggers(self.table.NoEditTriggers) self.table.setSelectionBehavior(self.table.SelectRows) self.table.setSelectionMode(self.table.SingleSelection) - self.table.setBorderVisible(True) - self.table.setBorderRadius(8) - - # 减少单元格内边距,让文字显示更多 - qss = "QTableView::item { padding-left: 8px; padding-right: 8px; }" - setCustomStyleSheet(self.table, qss, qss) - - self.main_layout.addWidget(self.table) - - def _setup_footer(self): - """底部:记录数 + 提示 + 分页""" + self.table.setShowGrid(False) + self.table.setAlternatingRowColors(False) + self.table.setFrameShape(self.table.NoFrame) + return self.table + + def _build_empty(self) -> QWidget: + panel = WorkbenchPanel() + lay = panel.bodyLayout + lay.addStretch(1) + self.empty_mark = IconBox(AppIcon.HISTORY, panel, size=64) + lay.addWidget(self.empty_mark, 0, Qt.AlignHCenter) # type: ignore[arg-type] + lay.addSpacing(16) + self.empty_title = SectionLabel(tr("llmlog.empty.title")) + apply_font(self.empty_title, 19, 860) + self.empty_title.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + lay.addWidget(self.empty_title, 0, Qt.AlignHCenter) # type: ignore[arg-type] + lay.addSpacing(8) + self.empty_hint = QLabel(tr("llmlog.empty.hint")) + self.empty_hint.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.empty_hint, 13, 680) + lay.addWidget(self.empty_hint, 0, Qt.AlignHCenter) # type: ignore[arg-type] + lay.addStretch(1) + return panel + + def _build_footer(self) -> QHBoxLayout: footer = QHBoxLayout() - footer.setSpacing(15) - - # 记录数 - self.status_label = BodyLabel(self.tr("共 0 条")) + footer.setSpacing(12) + self.status_label = QLabel(tr("llmlog.status.total_zero")) + apply_font(self.status_label, 13, 740) footer.addWidget(self.status_label) + footer.addStretch(1) - # 双击提示 - hint_label = CaptionLabel(self.tr("双击查看详情")) - hint_label.setStyleSheet("color: gray;") - footer.addWidget(hint_label) - - footer.addStretch() - - # 右侧:分页 - self.prev_btn = ToolButton(FIF.LEFT_ARROW) + self.prev_btn = RoundIconButton(AppIcon.ARROW_LEFT) self.prev_btn.setEnabled(False) + self.page_label = QLabel("1 / 1") + apply_font(self.page_label, 13, 760) + self.next_btn = RoundIconButton(AppIcon.RIGHT_ARROW) + self.next_btn.setEnabled(False) footer.addWidget(self.prev_btn) - - self.page_label = BodyLabel("1 / 1") footer.addWidget(self.page_label) - - self.next_btn = ToolButton(FIF.RIGHT_ARROW) - self.next_btn.setEnabled(False) footer.addWidget(self.next_btn) + return footer + + # ---------------------------------------------------------------- 样式 + + def showEvent(self, event): + super().showEvent(event) + self._sync_style() + + def _sync_style(self): + palette = app_palette() + self.setStyleSheet(f"QWidget#llmLogsInterface {{ background: {palette.bg}; }}") + self.search_action.setIcon(render_svg_icon(AppIcon.SEARCH, palette.muted, 16)) + self.search_edit.syncStyle() + self.refresh_btn.syncStyle() + self.clear_btn.syncStyle() + self.empty_pill.syncStyle() + self.empty_mark.syncStyle() + for label in (self.status_label, self.page_label): + label.setStyleSheet(f"color: {palette.muted}; background: transparent;") + self.empty_title.setStyleSheet(f"color: {palette.text}; background: transparent;") + self.empty_hint.setStyleSheet(f"color: {palette.muted}; background: transparent;") + self._style_table(palette) + + def _style_table(self, palette): + selection_bg = rgba(palette.accent, 0.08) + self.table.setStyleSheet( + f""" + QTableView#llmLogTable {{ + background: {palette.panel}; + border: 1px solid {palette.line}; + border-radius: 14px; + color: {palette.muted}; + font-size: 14px; + selection-background-color: {selection_bg}; + selection-color: {palette.text}; + outline: none; + }} + QTableView#llmLogTable::item {{ + padding: 0 12px; + border-bottom: 1px solid {palette.line_soft}; + }} + QTableView#llmLogTable::item:selected {{ + background: {selection_bg}; color: {palette.text}; + }} + """ + ) + header = self.table.horizontalHeader() + if header: + header.setStyleSheet( + f""" + QHeaderView {{ background: transparent; border: none; }} + QHeaderView::section {{ + background: {palette.panel_deep}; + color: {palette.subtle}; + border: none; + border-bottom: 1px solid {palette.line_soft}; + padding-left: 12px; + font-size: 13px; + font-weight: bold; + }} + /* 表头不透明矩形会盖住面板左右上角圆角;首尾段同步圆角 (14 面板半径 - 1 边框) */ + QHeaderView::section:first {{ border-top-left-radius: 13px; }} + QHeaderView::section:last {{ border-top-right-radius: 13px; }} + """ + ) + scrollbar = self.table.verticalScrollBar() + if scrollbar: + scrollbar.setStyleSheet( + f""" + QScrollBar:vertical {{ background: transparent; width: 6px; margin: 2px; border: none; }} + QScrollBar::handle:vertical {{ background: {palette.line}; border-radius: 3px; min-height: 32px; }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; background: transparent; }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }} + """ + ) - self.main_layout.addLayout(footer) + # ---------------------------------------------------------------- 信号 / 数据 def _connect_signals(self): self.refresh_btn.clicked.connect(self._on_refresh_clicked) @@ -272,47 +479,33 @@ def _connect_signals(self): self.next_btn.clicked.connect(self._next_page) def _setup_file_watcher(self): - """设置文件监控,日志文件变化时自动刷新""" self.file_watcher = QFileSystemWatcher(self) if LLM_LOG_FILE.exists(): self.file_watcher.addPath(str(LLM_LOG_FILE)) - # 同时监控目录,以便检测文件创建 self.file_watcher.addPath(str(LOG_PATH)) self.file_watcher.fileChanged.connect(self._on_file_changed) self.file_watcher.directoryChanged.connect(self._on_dir_changed) def _on_file_changed(self, path: str): - """日志文件内容变化时自动刷新""" self._load_logs() - # 文件变化后可能需要重新添加监控 if LLM_LOG_FILE.exists() and str(LLM_LOG_FILE) not in self.file_watcher.files(): self.file_watcher.addPath(str(LLM_LOG_FILE)) def _on_dir_changed(self, path: str): - """目录变化时检查日志文件是否创建""" if LLM_LOG_FILE.exists() and str(LLM_LOG_FILE) not in self.file_watcher.files(): self.file_watcher.addPath(str(LLM_LOG_FILE)) self._load_logs() def _on_refresh_clicked(self): - """手动刷新按钮点击""" self._load_logs() - InfoBar.success( - title="", - content=self.tr("刷新成功"), - parent=self, - position=InfoBarPosition.TOP, - duration=1000, - ) + InfoBar.success("", tr("llmlog.refresh.success"), parent=self, + position=InfoBarPosition.TOP, duration=1000) def _load_logs(self): - """加载日志文件""" self.all_logs = [] - if not LLM_LOG_FILE.exists(): - self._update_table() + self._filter_logs() return - try: with open(LLM_LOG_FILE, "r", encoding="utf-8") as f: for line in f: @@ -323,151 +516,133 @@ def _load_logs(self): except json.JSONDecodeError: continue except Exception as e: - InfoBar.error( - title=self.tr("错误"), - content=str(e), - parent=self, - position=InfoBarPosition.TOP, - duration=3000, - ) + InfoBar.error(tr("common.error"), str(e), parent=self, + position=InfoBarPosition.TOP, duration=3000) return - self.all_logs.reverse() self._filter_logs() def _filter_logs(self): - """根据搜索词过滤日志""" search_text = self.search_edit.text().lower() - if not search_text: self.filtered_logs = self.all_logs.copy() else: - self.filtered_logs = [] - for log in self.all_logs: - model = log.get("request", {}).get("model", "").lower() - task_id = log.get("task_id", "").lower() - file_name = log.get("file_name", "").lower() - stage = log.get("stage", "").lower() - messages = json.dumps(log.get("request", {}).get("messages", [])) - response = json.dumps(log.get("response", {})) - - if ( - search_text in model - or search_text in task_id - or search_text in file_name - or search_text in stage - or search_text in messages.lower() - or search_text in response.lower() - ): - self.filtered_logs.append(log) - + self.filtered_logs = [log for log in self.all_logs if self._matches(log, search_text)] self.current_page = 0 - self._update_table() + self._update_view() + + @staticmethod + def _matches(log: Dict[str, Any], text: str) -> bool: + request = log.get("request", {}) + haystacks = ( + request.get("model", ""), + log.get("task_id", ""), + log.get("file_name", ""), + log.get("stage", ""), + json.dumps(request.get("messages", []), ensure_ascii=False), + json.dumps(log.get("response", {}), ensure_ascii=False), + ) + return any(text in str(h).lower() for h in haystacks) + + # ---------------------------------------------------------------- 视图 + + def _update_view(self): + has_logs = bool(self.filtered_logs) + self.contentStack.setCurrentIndex(0 if has_logs else 1) + self.empty_pill.setVisible(not self.all_logs) + if not has_logs: + self._apply_empty_text() + self._fill_table() + + def _apply_empty_text(self): + if self.all_logs: # 有日志但筛选无结果 + self.empty_title.setText(tr("llmlog.empty.no_match.title")) + self.empty_hint.setText(tr("llmlog.empty.no_match.hint")) + else: + self.empty_title.setText(tr("llmlog.empty.title")) + self.empty_hint.setText(tr("llmlog.empty.hint")) - def _update_table(self): - """更新表格显示""" + def _fill_table(self): self.table.setRowCount(0) - total_pages = max(1, (len(self.filtered_logs) + PAGE_SIZE - 1) // PAGE_SIZE) - start_idx = self.current_page * PAGE_SIZE - end_idx = min(start_idx + PAGE_SIZE, len(self.filtered_logs)) - - for log in self.filtered_logs[start_idx:end_idx]: + start = self.current_page * PAGE_SIZE + for log in self.filtered_logs[start:start + PAGE_SIZE]: row = self.table.rowCount() self.table.insertRow(row) - - # 时间(不显示年份:MM-DD HH:MM:SS) time_str = log.get("time", "") - if time_str and len(time_str) > 5: + if len(time_str) > 5: time_str = time_str[5:] # 去掉 "YYYY-" - self.table.setItem(row, 0, self._create_item(time_str)) - - # 任务ID - task_id = log.get("task_id", "") or "-" - self.table.setItem(row, 1, self._create_item(task_id)) - - # 文件 - file_name = log.get("file_name", "") or "-" - self.table.setItem(row, 2, self._create_item(file_name, align_left=True)) - - # 阶段 - stage = log.get("stage", "") or "-" - self.table.setItem(row, 3, self._create_item(stage)) - - # 模型 - model = log.get("request", {}).get("model", "未知") - self.table.setItem(row, 4, self._create_item(model)) - - # 耗时 - duration = log.get("duration_ms", 0) / 1000 - self.table.setItem(row, 5, self._create_item(f"{duration:.1f}s")) - - # 总 Tokens usage = log.get("response", {}).get("usage") or {} - total_tokens = usage.get("total_tokens", 0) - if not total_tokens: - total_tokens = usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0) - self.table.setItem(row, 6, self._create_item(str(total_tokens))) + total_tokens = usage.get("total_tokens") or ( + usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0) + ) + values = ( + time_str, + log.get("file_name", "") or "-", + log.get("stage", "") or "-", + log.get("request", {}).get("model", tr("llmlog.unknown")), + f"{log.get('duration_ms', 0) / 1000:.1f}s", + str(total_tokens), + ) + for col, value in enumerate(values): + item = QTableWidgetItem(value) + # 耗时/Tokens 是数字,右对齐更易读;其余左对齐 + if col in (4, 5): + item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[operator] + else: + item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) # type: ignore[operator] + item.setToolTip(value) # 极窄时仍可悬停看全文,省略号不丢信息 + self.table.setItem(row, col, item) - # 更新分页和统计 self.page_label.setText(f"{self.current_page + 1} / {total_pages}") self.prev_btn.setEnabled(self.current_page > 0) self.next_btn.setEnabled(self.current_page < total_pages - 1) - self.status_label.setText(f"共 {len(self.filtered_logs)} 条") + self.status_label.setText(self._status_text()) - def _create_item(self, text: str, align_left: bool = False) -> QTableWidgetItem: - """创建表格项""" - item = QTableWidgetItem(text) - if align_left: - item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) # type: ignore - else: - item.setTextAlignment(Qt.AlignCenter) # type: ignore - return item + def _status_text(self) -> str: + total = len(self.all_logs) + shown = len(self.filtered_logs) + if total == 0: + return tr("llmlog.status.total_zero") + if shown != total: + return tr("llmlog.status.filtered", total=total, shown=shown) + return tr("llmlog.status.total", total=total) def _show_detail(self, index): - """显示日志详情""" actual_idx = self.current_page * PAGE_SIZE + index.row() if 0 <= actual_idx < len(self.filtered_logs): - dialog = LogDetailDialog(self.filtered_logs[actual_idx], self) - dialog.exec() + LogDetailDialog(self.filtered_logs[actual_idx], self).exec() def _prev_page(self): if self.current_page > 0: self.current_page -= 1 - self._update_table() + self._fill_table() def _next_page(self): total_pages = (len(self.filtered_logs) + PAGE_SIZE - 1) // PAGE_SIZE if self.current_page < total_pages - 1: self.current_page += 1 - self._update_table() + self._fill_table() def _clear_logs(self): - """清空日志""" - w = MessageBox( - self.tr("确认清空"), - self.tr("确定要清空所有日志吗?此操作不可恢复。"), + dialog = ConfirmDialog( + tr("llmlog.clear.confirm_title"), + tr("llmlog.clear.confirm_body"), self, + confirm_text=tr("llmlog.clear.confirm_btn"), + danger=True, + icon=AppIcon.DELETE, ) - if w.exec(): - try: - if LLM_LOG_FILE.exists(): - LLM_LOG_FILE.unlink() - self.all_logs = [] - self.filtered_logs = [] - self._update_table() - InfoBar.success( - title="", - content=self.tr("日志已清空"), - parent=self, - position=InfoBarPosition.TOP, - duration=2000, - ) - except Exception as e: - InfoBar.error( - title=self.tr("错误"), - content=str(e), - parent=self, - position=InfoBarPosition.TOP, - duration=3000, - ) + if not dialog.exec(): + return + try: + if LLM_LOG_FILE.exists(): + LLM_LOG_FILE.unlink() + self.all_logs = [] + self.filtered_logs = [] + self._update_view() + InfoBar.success("", tr("llmlog.clear.success"), parent=self, + position=InfoBarPosition.TOP, duration=2000) + except Exception as e: + InfoBar.error(tr("common.error"), str(e), parent=self, + position=InfoBarPosition.TOP, duration=3000) diff --git a/videocaptioner/ui/view/log_window.py b/videocaptioner/ui/view/log_window.py index 0b67ff0f..9c9cc9d9 100644 --- a/videocaptioner/ui/view/log_window.py +++ b/videocaptioner/ui/view/log_window.py @@ -1,26 +1,22 @@ from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QTextCursor from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget -from qfluentwidgets import FluentStyleSheet, PushButton, TextEdit, isDarkTheme +from qfluentwidgets import TextEdit -from videocaptioner.config import LOG_PATH, RESOURCE_PATH +from videocaptioner.config import LOG_PATH from videocaptioner.core.utils.platform_utils import reveal_in_explorer +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.workbench import WorkbenchButton, apply_font +from videocaptioner.ui.i18n import tr class LogWindow(QWidget): def __init__(self, parent=None): super().__init__(parent) - self.setWindowTitle("日志查看器") + self.setWindowTitle(tr("logwin.title")) self.resize(800, 600) - FluentStyleSheet.FLUENT_WINDOW.apply(self) - - theme = "dark" if isDarkTheme() else "light" - with open( - RESOURCE_PATH / "assets" / "qss" / theme / "demo.qss", encoding="utf-8" - ) as f: - self.setStyleSheet(f.read()) - # 设置为非模态对话框 self.setWindowModality(Qt.NonModal) # type: ignore # 设置窗口标志 @@ -34,7 +30,9 @@ def __init__(self, parent=None): # 创建顶部按钮布局 top_layout = QHBoxLayout() - self.open_folder_btn = PushButton("打开日志文件夹", self) + self.open_folder_btn = WorkbenchButton( + tr("logwin.btn.open_folder"), AppIcon.FOLDER, parent=self + ) self.open_folder_btn.clicked.connect(self.open_log_folder) top_layout.addWidget(self.open_folder_btn) top_layout.addStretch() @@ -44,6 +42,7 @@ def __init__(self, parent=None): self.log_text = TextEdit(self) self.log_text.setReadOnly(True) layout.addWidget(self.log_text) + self._sync_style() # 设置定时器用于更新日志 self.timer = QTimer(self) @@ -56,10 +55,12 @@ def __init__(self, parent=None): self.log_file = open(self.log_path, "r", encoding="utf-8") self.load_last_lines(20480) self.log_text.moveCursor(QTextCursor.End) - self.log_text.insertPlainText(f"\n{'=' * 25}以上是历史日志{'=' * 25}\n\n") + self.log_text.insertPlainText( + f"\n{'=' * 25}{tr('logwin.history_divider')}{'=' * 25}\n\n" + ) except Exception as e: self.log_file = None - self.log_text.setPlainText(f"打开日志文件失败: {str(e)}") + self.log_text.setPlainText(tr("logwin.error.open_failed", error=str(e))) # 添加文件大小跟踪 self.last_position = self.log_file.tell() @@ -108,14 +109,31 @@ def load_last_lines(self, read_size): ) except Exception as e: - self.log_text.setPlainText(f"读取日志文件失败: {str(e)}") + self.log_text.setPlainText(tr("logwin.error.read_failed", error=str(e))) + + def _sync_style(self): + palette = app_palette() + self.setStyleSheet(f"QWidget {{ background: {palette.bg}; }}") + apply_font(self.log_text, 13, 500) + self.log_text.setStyleSheet( + f""" + TextEdit {{ + background: {palette.field}; + color: {palette.text}; + border: 1px solid {palette.line}; + border-radius: 10px; + padding: 10px; + }} + """ + ) - # def closeEvent(self, event): - # # 关闭窗口时同时关闭文件和定时器 - # self.timer.stop() - # if self.log_file: - # self.log_file.close() - # event.accept() + def closeEvent(self, event): + # 关闭时停定时器并关文件,避免后台 timer 持续触发 + 文件句柄泄漏。 + self.timer.stop() + if self.log_file: + self.log_file.close() + self.log_file = None + super().closeEvent(event) def on_scroll_changed(self, value): """监听滚动条变化""" @@ -150,7 +168,7 @@ def update_log(self): ) except Exception as e: - self.log_text.setPlainText(f"读取日志文件出错: {str(e)}") + self.log_text.setPlainText(tr("logwin.error.update_failed", error=str(e))) def open_log_folder(self): """打开日志文件所在文件夹""" diff --git a/videocaptioner/ui/view/main_window.py b/videocaptioner/ui/view/main_window.py index 8804e584..6eb80561 100644 --- a/videocaptioner/ui/view/main_window.py +++ b/videocaptioner/ui/view/main_window.py @@ -1,56 +1,81 @@ import atexit import os import shutil +from pathlib import Path import psutil -from PyQt5.QtCore import QSize, QThread, QUrl -from PyQt5.QtGui import QDesktopServices, QIcon +from PyQt5.QtCore import QEvent, QSize, QUrl +from PyQt5.QtGui import QColor, QDesktopServices, QIcon from PyQt5.QtWidgets import QApplication -from qfluentwidgets import FluentIcon as FIF from qfluentwidgets import ( FluentWindow, InfoBar, InfoBarPosition, - MessageBox, - NavigationItemPosition, SplashScreen, ) -from videocaptioner.config import ASSETS_PATH, GITHUB_REPO_URL +from videocaptioner.config import ASSETS_PATH, CACHE_PATH, GITHUB_REPO_URL from videocaptioner.core.constant import INFOBAR_DURATION_FOREVER +from videocaptioner.core.update import apply_update, can_self_update +from videocaptioner.core.utils.cache import get_version_state_cache +from videocaptioner.ui.common.app_icons import AppIcon from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.components.DonateDialog import DonateDialog -from videocaptioner.ui.thread.version_checker_thread import VersionChecker +from videocaptioner.ui.common.theme_tokens import BG_DARK, BG_LIGHT +from videocaptioner.ui.components.app_dialog import ConfirmDialog +from videocaptioner.ui.components.donate_dialog import DonateDialog +from videocaptioner.ui.components.sidebar import EXPANDED_WIDTH, Sidebar +from videocaptioner.ui.components.update_banner import UpdateBanner +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.update_thread import UpdateCheckThread from videocaptioner.ui.view.batch_process_interface import BatchProcessInterface +from videocaptioner.ui.view.doctor_interface import DoctorInterface +from videocaptioner.ui.view.dubbing_interface import DubbingInterface +from videocaptioner.ui.view.hardsub_interface import HardsubInterface from videocaptioner.ui.view.home_interface import HomeInterface +from videocaptioner.ui.view.live_caption_interface import LiveCaptionInterface from videocaptioner.ui.view.llm_logs_interface import LLMLogsInterface -from videocaptioner.ui.view.setting_interface import SettingInterface +from videocaptioner.ui.view.setting_interface import SettingsDialog from videocaptioner.ui.view.subtitle_style_interface import SubtitleStyleInterface LOGO_PATH = ASSETS_PATH / "logo.png" +# 字幕样式页是三栏布局,最窄需约 950px;窗口最小宽要容纳它 + 导航栏,否则右栏被切。 +WINDOW_MINIMUM_WIDTH = 1020 +TITLEBAR_GAP = 12 # 标题栏文字与侧栏右缘的间隙 +# 内容区 < 该宽度时自动收纳侧栏(展开态侧栏挤压三栏页面);再变宽且非手动收纳时自动还原 +SIDEBAR_AUTO_COLLAPSE_WIDTH = WINDOW_MINIMUM_WIDTH + EXPANDED_WIDTH - 64 class MainWindow(FluentWindow): def __init__(self): super().__init__() self.initWindow() + # 窗口底色与调色板对齐:否则 qfluent 默认窗口底与页面自绘的 + # palette.bg 形成两层颜色,页面区域看起来像浮在窗口上的色块。 + self.setCustomBackgroundColor(QColor(BG_LIGHT), QColor(BG_DARK)) # 创建子界面 self.homeInterface = HomeInterface(self) - self.settingInterface = SettingInterface(self) + # 设置不再是导航 tab,而是定制大弹窗;SettingInterface 内嵌在弹窗里(长生命周期单例) + self.settingsDialog = SettingsDialog(self) + self.settingInterface = self.settingsDialog.settingInterface self.subtitleStyleInterface = SubtitleStyleInterface(self) + self.hardsubInterface = HardsubInterface(self) + self.dubbingInterface = DubbingInterface(self) + self.liveCaptionInterface = LiveCaptionInterface(self) + self.doctorInterface = DoctorInterface(self) self.batchProcessInterface = BatchProcessInterface(self) self.llmLogsInterface = LLMLogsInterface(self) - # 初始化版本检查器 - self.versionChecker = VersionChecker() - self.versionChecker.newVersionAvailable.connect(self.onNewVersion) - self.versionChecker.announcementAvailable.connect(self.onAnnouncement) + # 硬字幕提取「送入字幕优化」:切到主页字幕优化 tab 并载入提取出的字幕 + self.hardsubInterface.sendToOptimize.connect(self._on_hardsub_to_optimize) - self.versionThread = QThread() - self.versionChecker.moveToThread(self.versionThread) - self.versionThread.started.connect(self.versionChecker.perform_check) - self.versionThread.start() + # 设置页「检查更新」按钮 → 主动走同一套更新流程 + self.settingInterface.checkUpdateRequested.connect(self._on_manual_update_check) + + # 启动时后台检查更新;有新版时弹出更新提示条(下载 + 重启安装一键完成) + self.updateBanner = None + self.updateCheckThread = None + self._check_updates() # 初始化导航界面 self.initNavigation() @@ -63,46 +88,95 @@ def __init__(self): atexit.register(self.stop) def initNavigation(self): - """初始化导航栏""" - # 添加导航项 - self.addSubInterface(self.homeInterface, FIF.HOME, self.tr("主页")) - self.addSubInterface(self.batchProcessInterface, FIF.VIDEO, self.tr("批量处理")) - self.addSubInterface(self.subtitleStyleInterface, FIF.FONT, self.tr("字幕样式")) - self.addSubInterface(self.llmLogsInterface, FIF.HISTORY, self.tr("请求日志")) - - self.navigationInterface.addSeparator() - - # 在底部添加自定义小部件 - self.navigationInterface.addItem( - routeKey="avatar", - text="GitHub", - icon=FIF.GITHUB, - onClick=self.onGithubDialog, - position=NavigationItemPosition.BOTTOM, + """初始化导航:自研 Sidebar 替代 qfluent NavigationInterface(后者隐藏不用)。""" + self.navigationInterface.hide() + + pages = [ + ("home", self.homeInterface, AppIcon.HOME, tr("app.nav.home")), + ("batch", self.batchProcessInterface, AppIcon.VIDEO, tr("app.nav.batch")), + ("substyle", self.subtitleStyleInterface, AppIcon.SUBTITLE, tr("app.nav.subtitle_style")), + ("dubbing", self.dubbingInterface, AppIcon.VOLUME, tr("app.nav.dubbing")), + ("live", self.liveCaptionInterface, AppIcon.MICROPHONE, tr("app.nav.live_caption")), + ("hardsub", self.hardsubInterface, AppIcon.HARDSUB, tr("app.nav.hardsub")), + ("logs", self.llmLogsInterface, AppIcon.HISTORY, tr("app.nav.request_logs")), + ("doctor", self.doctorInterface, AppIcon.DIAGNOSTIC, tr("app.nav.doctor")), + ] + self._page_by_key = {key: page for key, page, _, _ in pages} + + self.sidebar = Sidebar(self, top_inset=self.titleBar.height()) + for key, page, icon, label in pages: + self.stackedWidget.addWidget(page) + self.sidebar.add_page(key, icon, label) + self.sidebar.add_action("github", AppIcon.GITHUB, "GitHub", self.onGithubDialog) + self.sidebar.add_action( + "settings", AppIcon.SETTING, tr("app.nav.settings"), + lambda: self.openSettingsPage("transcribe"), ) - self.addSubInterface( - self.settingInterface, - FIF.SETTING, - self.tr("Settings"), - NavigationItemPosition.BOTTOM, + self.hBoxLayout.insertWidget(0, self.sidebar) + self.sidebar.installEventFilter(self) # 宽度动画期间标题栏持续跟随 + self._place_titlebar() + + self.sidebar.currentChanged.connect( + lambda key: self.switchTo(self._page_by_key[key]) ) + # 程序化 switchTo(如硬字幕→字幕优化)也要同步侧栏高亮 + self.stackedWidget.currentChanged.connect(self._sync_sidebar_highlight) + self._sidebar_user_collapsed = False # 手动收纳后窗口变宽不自动展开 + self.sidebar.expandedChanged.connect(self._on_sidebar_toggled) - # 设置默认界面 + # 设置默认界面(home 是 index 0,currentChanged 不触发,需显式点亮) self.switchTo(self.homeInterface) + self.sidebar.set_current("home") + + def _sync_sidebar_highlight(self, _index: int) -> None: + widget = self.stackedWidget.currentWidget() + for key, page in self._page_by_key.items(): + if page is widget: + self.sidebar.set_current(key) + return + + def _on_sidebar_toggled(self, expanded: bool) -> None: + # 只把「窗口够宽时的手动收纳」记为用户意愿;窄窗自动收纳不算 + if self.width() >= SIDEBAR_AUTO_COLLAPSE_WIDTH: + self._sidebar_user_collapsed = not expanded + + def _auto_fit_sidebar(self) -> None: + """窄窗自动收纳,变宽且非手动收纳时还原(展开态侧栏会挤压三栏页面)。""" + if self.width() < SIDEBAR_AUTO_COLLAPSE_WIDTH: + if self.sidebar.is_expanded(): + self.sidebar.set_expanded(False) + elif not self._sidebar_user_collapsed and not self.sidebar.is_expanded(): + self.sidebar.set_expanded(True) + + def _on_hardsub_to_optimize(self, subtitle_path: str, video_path: str) -> None: + """硬字幕提取 → 主页字幕优化页(载入提取出的字幕,等用户配置优化/翻译)。""" + self.switchTo(self.homeInterface) + self.homeInterface.load_subtitle_for_optimize(subtitle_path, video_path) def switchTo(self, interface): if interface.windowTitle(): self.setWindowTitle(interface.windowTitle()) else: - self.setWindowTitle(self.tr("卡卡字幕助手 -- VideoCaptioner")) + self.setWindowTitle(tr("app.window_title")) self.stackedWidget.setCurrentWidget(interface, popOut=False) + def openSettingsPage(self, page_key: str) -> bool: # noqa: N802 + """弹出设置 modal 并切到指定分类;分类无效返回 False。""" + return self.settingsDialog.open_at(page_key) + def initWindow(self): """初始化窗口""" - self.resize(1050, 800) - self.setMinimumWidth(700) + # 初始尺寸自适应屏幕:默认 1050x760,但绝不超过可用屏幕(留出菜单栏/Dock/标题栏余量)。 + # 否则在小屏笔记本上窗口会被「撑」到比屏幕还高,底部内容(如播放条/按钮)看不全。 + avail = QApplication.desktop().availableGeometry() + win_w = max(WINDOW_MINIMUM_WIDTH, min(1050, avail.width() - 80)) + win_h = max(560, min(760, avail.height() - 100)) + self.resize(win_w, win_h) + self.setMinimumWidth(WINDOW_MINIMUM_WIDTH) + # 防御:任何页面的最小高度都不能把窗口顶出屏幕(否则底部播放条/按钮看不到)。 + self.setMaximumHeight(avail.height() - 40) self.setWindowIcon(QIcon(str(LOGO_PATH))) - self.setWindowTitle(self.tr("卡卡字幕助手 -- VideoCaptioner")) + self.setWindowTitle(tr("app.window_title")) self.setMicaEffectEnabled(cfg.get(cfg.micaEnabled)) @@ -111,86 +185,181 @@ def initWindow(self): self.splashScreen.setIconSize(QSize(106, 106)) self.splashScreen.raise_() - # 设置窗口位置, 居中 - desktop = QApplication.desktop().availableGeometry() - w, h = desktop.width(), desktop.height() - self.move(w // 2 - self.width() // 2, h // 2 - self.height() // 2) + # 设置窗口位置, 居中(用可用区原点偏移,避开菜单栏/任务栏,多屏也正确) + self.move( + avail.x() + (avail.width() - self.width()) // 2, + avail.y() + (avail.height() - self.height()) // 2, + ) self.show() QApplication.processEvents() def onGithubDialog(self): """打开GitHub""" - w = MessageBox( - self.tr("GitHub信息"), - self.tr( - "VideoCaptioner 由本人在课余时间独立开发完成,目前托管在GitHub上,欢迎Star和Fork。项目诚然还有很多地方需要完善,遇到软件的问题或者BUG欢迎提交Issue。\n\n https://github.com/WEIFENG2333/VideoCaptioner" - ), + w = ConfirmDialog( + tr("app.github.title"), + tr("app.github.body"), self, + confirm_text=tr("app.github.open"), + cancel_text=tr("app.github.support_author"), + icon=AppIcon.GITHUB, ) - w.yesButton.setText(self.tr("打开 GitHub")) - w.cancelButton.setText(self.tr("支持作者")) + # 「支持作者」是动作而非放弃:点它打开捐赠弹窗,Esc/关闭则什么都不做 + open_donate = [] + assert w.cancelButton is not None + w.cancelButton.clicked.connect(lambda: open_donate.append(True)) if w.exec(): QDesktopServices.openUrl(QUrl(GITHUB_REPO_URL)) - else: - # 点击"支持作者"按钮时打开捐赠对话框 - donate_dialog = DonateDialog(self) - donate_dialog.exec_() - - def onNewVersion(self, version, update_required, update_info, download_url): - """新版本提示""" - if update_required: - title = "发现新版本, 需要更新" - content = f"发现新版本 {version}\n\n" f"更新内容:\n{update_info}" - else: - title = "发现新版本" - content = f"发现新版本 {version}\n\n{update_info}" - - w = MessageBox(title, content, self) - w.yesButton.setText("立即更新") - w.cancelButton.setText("稍后再说") + elif open_donate: + DonateDialog(self).exec_() + + def _check_updates(self, *, manual=False): + """启动后台检查更新。manual=True 时(设置页按钮触发)额外提示「已是最新/检查失败」。""" + if self.updateCheckThread is not None and self.updateCheckThread.isRunning(): + return + self._manual_update_check = manual + self.updateCheckThread = UpdateCheckThread(self) + self.updateCheckThread.resultReady.connect(self._on_check_result) + self.updateCheckThread.checkFailed.connect(self._on_check_failed) + self.updateCheckThread.start() + + def _on_check_result(self, result): + """一次检查的结果:公告(去重弹)+ 封禁/更新(提示条)。""" + if result.announcement is not None: + self._on_announcement(result.announcement) + if result.block or result.update is not None: + self._show_update_banner(result.update, result.block) + elif getattr(self, "_manual_update_check", False): + self._on_up_to_date() + + def _on_announcement(self, ann): + """展示线上公告(按 id 去重,只弹一次)。公告与是否有新版无关。""" + cache = get_version_state_cache() + key = f"announcement_shown_{ann.id}" + if cache.get(key, default=False): + return + cache.set(key, True) + ConfirmDialog( + ann.title or tr("app.announcement.title"), + ann.content, + self, + confirm_text=tr("app.announcement.got_it"), + cancel_text=None, + icon=AppIcon.DOCUMENT, + ).exec() + + def _on_manual_update_check(self): + if self.updateBanner is not None: # 已有提示条在展示,直接复用 + return + if self.updateCheckThread is not None and self.updateCheckThread.isRunning(): + # 启动检查还没跑完就点了「检查更新」:给反馈,别让按钮像没反应(死点击) + InfoBar.info( + title=tr("app.update.checking"), + content="", + isClosable=True, + position=InfoBarPosition.TOP, + duration=2000, + parent=self, + ) + return + self._check_updates(manual=True) + + def _show_update_banner(self, info, block): + """展示更新提示条(可用→下载中→重启安装)。版本被封禁且能自更新时锁死整个应用。""" + if self.updateBanner is not None: # 防重复(手动检查叠加自动检查) + return + self.updateBanner = UpdateBanner( + self, info, CACHE_PATH / "update", self._install_update, blocked=block + ) + self.updateBanner.show() + # 版本被封禁且能自更新:锁死整个应用,只留更新入口(提示条不可关)。不能自更新 + # (开发态/pip/安装目录不可写)时不锁,否则把用户卡死在只能「前往下载」的死胡同。 + if block and can_self_update(): + self.stackedWidget.setEnabled(False) + + def _on_up_to_date(self): + InfoBar.success( + title=tr("app.update.up_to_date"), + content="", + isClosable=True, + position=InfoBarPosition.TOP, + duration=3000, + parent=self, + ) - if w.exec() or update_required: - QDesktopServices.openUrl(QUrl(download_url)) + def _on_check_failed(self, message): + # 仅手动「检查更新」时提示失败;启动自动检查失败静默忽略,不打扰 + if not getattr(self, "_manual_update_check", False): + return + InfoBar.warning( + title=tr("app.update.check_failed"), + content=message, + isClosable=True, + position=InfoBarPosition.TOP, + duration=4000, + parent=self, + ) - if update_required: - self.homeInterface.setEnabled(False) - self.batchProcessInterface.setEnabled(False) + def _install_update(self, zip_path): + """下载完成、用户点「重启并安装」:启动 helper 替换并退出本进程。""" + try: + apply_update(Path(zip_path)) + except Exception as exc: # noqa: BLE001 — 安装失败提示用户手动更新 InfoBar.error( - title="需要更新", - content=self.tr("当前版本部分功能已被禁用。请尽快更新。"), - isClosable=False, - position=InfoBarPosition.BOTTOM, + title=tr("app.update.install_failed"), + content=str(exc), + isClosable=True, + position=InfoBarPosition.TOP, duration=-1, parent=self, ) + return + QApplication.quit() + + def _place_titlebar(self) -> None: + """标题栏从侧栏右缘开始(侧栏宽度动画期间经 eventFilter 持续跟随)。""" + inset = (self.sidebar.width() if hasattr(self, "sidebar") else 0) + TITLEBAR_GAP + self.titleBar.move(inset, 0) + self.titleBar.resize(self.width() - inset, self.titleBar.height()) - def onAnnouncement(self, content): - """显示公告""" - w = MessageBox("公告", content, self) - w.yesButton.setText("我知道了") - w.cancelButton.hide() - w.exec() + def eventFilter(self, obj, event): + if hasattr(self, "sidebar") and obj is self.sidebar and event.type() == QEvent.Resize: + self._place_titlebar() + return super().eventFilter(obj, event) def resizeEvent(self, e): super().resizeEvent(e) + self._place_titlebar() if hasattr(self, "splashScreen"): self.splashScreen.resize(self.size()) + if hasattr(self, "sidebar"): + self._auto_fit_sidebar() def closeEvent(self, event): - # 关闭所有子界面 - # self.homeInterface.close() - # self.batchProcessInterface.close() - # self.subtitleStyleInterface.close() - # self.settingInterface.close() - super().closeEvent(event) + # 退出前关停所有子界面:各页 closeEvent/shutdown 会取消正在跑的 + # QThread。Qt 只给顶层窗口派发 closeEvent,子 widget 必须显式 + # close(),否则解释器 teardown 销毁 running QThread 会触发 + # "QThread: Destroyed while thread is still running" abort。 + for interface in ( + self.homeInterface, + self.batchProcessInterface, + self.hardsubInterface, + self.subtitleStyleInterface, + self.dubbingInterface, + self.liveCaptionInterface, + self.doctorInterface, + self.settingInterface, + ): + interface.close() - # 强制退出应用程序 - QApplication.quit() + # 停掉更新检查/下载线程,避免退出时销毁运行中的 QThread 触发 abort + if self.updateBanner is not None: + self.updateBanner.stop() + if self.updateCheckThread is not None and self.updateCheckThread.isRunning(): + self.updateCheckThread.wait(2000) - # 确保所有线程和进程都被终止 要是一些错误退出就不会处理了。 - # import os - # os._exit(0) + super().closeEvent(event) + QApplication.quit() def stop(self): # 找到 FFmpeg 进程并关闭 @@ -202,8 +371,8 @@ def _check_ffmpeg(self): """检查 FFmpeg 是否已安装""" if shutil.which("ffmpeg") is None: InfoBar.warning( - self.tr("FFmpeg 未安装"), - self.tr("软件处理音视频文件时需要 FFmpeg,请先安装"), + tr("app.ffmpeg.missing_title"), + tr("app.ffmpeg.missing_body"), duration=INFOBAR_DURATION_FOREVER, position=InfoBarPosition.BOTTOM, parent=self, diff --git a/videocaptioner/ui/view/setting_interface.py b/videocaptioner/ui/view/setting_interface.py index e862a208..6ce90806 100644 --- a/videocaptioner/ui/view/setting_interface.py +++ b/videocaptioner/ui/view/setting_interface.py @@ -1,1037 +1,2181 @@ -import webbrowser - -from PyQt5.QtCore import Qt, QThread, QUrl, pyqtSignal -from PyQt5.QtGui import QDesktopServices -from PyQt5.QtWidgets import QFileDialog, QLabel, QWidget -from qfluentwidgets import ( - ComboBoxSettingCard, - CustomColorSettingCard, - ExpandLayout, - HyperlinkCard, - InfoBar, - OptionsSettingCard, - PrimaryPushSettingCard, - PushSettingCard, - RangeSettingCard, - ScrollArea, - SettingCardGroup, - SwitchSettingCard, - setTheme, - setThemeColor, +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +from PyQt5.QtCore import ( + QEasingCurve, + QProcess, + QPropertyAnimation, + QRectF, + Qt, + QThread, + QTimer, + QUrl, + pyqtSignal, ) -from qfluentwidgets import FluentIcon as FIF - -from videocaptioner.config import AUTHOR, FEEDBACK_URL, HELP_URL, RELEASE_URL, VERSION, YEAR +from PyQt5.QtGui import QColor, QDesktopServices, QPainterPath, QRegion +from PyQt5.QtWidgets import ( + QApplication, + QDialog, + QFileDialog, + QGraphicsOpacityEffect, + QHBoxLayout, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from qfluentwidgets import InfoBar, Theme, setTheme, setThemeColor +from qfluentwidgets.components.dialog_box.mask_dialog_base import MaskDialogBase + +from videocaptioner.config import ( + AUTHOR, + HELP_URL, + MODEL_PATH, + VERSION, + YEAR, +) +from videocaptioner.core.application import TaskBuilder +from videocaptioner.core.asr.check import check_transcribe from videocaptioner.core.constant import ( INFOBAR_DURATION_ERROR, INFOBAR_DURATION_SUCCESS, INFOBAR_DURATION_WARNING, ) -from videocaptioner.core.entities import LLMServiceEnum, TranscribeModelEnum, TranslatorServiceEnum -from videocaptioner.core.llm import check_whisper_connection +from videocaptioner.core.download import ( + detect_program, + iter_models, + model_install_state, +) +from videocaptioner.core.dubbing import build_dubbing_config, get_dubbing_preset +from videocaptioner.core.entities import ( + LLMServiceEnum, + TranscribeLanguageEnum, + TranscribeModelEnum, + TranslatorServiceEnum, + transcribe_languages_for, +) +from videocaptioner.core.llm import free_model from videocaptioner.core.llm.check_llm import check_llm_connection, get_available_models +from videocaptioner.core.realtime.check import check_live_caption +from videocaptioner.core.realtime.config import LiveCaptionConfig +from videocaptioner.core.speech import ( + SpeechProviderConfig, + SynthesisRequest, + create_speech_synthesizer, +) from videocaptioner.core.utils.cache import disable_cache, enable_cache -from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.common.signal_bus import signalBus -from videocaptioner.ui.components.EditComboBoxSettingCard import EditComboBoxSettingCard -from videocaptioner.ui.components.LineEditSettingCard import LineEditSettingCard - - -class SettingInterface(ScrollArea): - """设置界面""" +from videocaptioner.ui.common.app_icons import AppIcon +from videocaptioner.ui.common.config import ( + DEFAULT_THEME_COLOR, + ThemeMode, + cfg, + source_language_options, +) +from videocaptioner.ui.common.dubbing_options import ( + get_provider_option, + get_provider_voices, + is_provider_default_base, + provider_title, +) +from videocaptioner.ui.common.model_options import ( + FUN_ASR_MODEL_OPTIONS, + WHISPER_API_MODEL_OPTIONS, +) +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.app_dialog import ConfirmDialog +from videocaptioner.ui.components.feedback_dialog import FeedbackDialog +from videocaptioner.ui.components.model_manager_dialog import ModelManagerDialog +from videocaptioner.ui.components.settings_controls import ( + CONTROL_WIDTH, + BoundComboBox, + BoundEditableComboBox, + BoundFloatSlider, + BoundLineEdit, + BoundSlider, + BoundSwitch, + ColorSwatchButton, + FolderPickerControl, + Option, + SettingRow, + SettingsGroup, + SettingsShell, + make_button, + options_from, +) +from videocaptioner.ui.components.workbench import CompactButton, RoundIconButton +from videocaptioner.ui.i18n import tr + +SETTINGS_PAGE_ALIASES = { + "asr": "transcribe", + "transcription": "transcribe", + "transcribe": "transcribe", + "llm": "llm", + "model": "llm", + "models": "llm", + "translate-service": "translate-service", + "translator": "translate-service", + "translation-service": "translate-service", + "translate": "translate", + "translation": "translate", + "optimize": "translate", + "subtitle": "subtitle", + "subtitle-synthesis": "subtitle", + "video-synthesis": "subtitle", + "dubbing": "dubbing", + "tts": "dubbing", + "voice": "dubbing", + "save": "save", + "output": "save", + "personal": "personal", + "appearance": "personal", + "about": "about", +} + + +def normalize_settings_page_key(page_key: str) -> str: + return SETTINGS_PAGE_ALIASES.get(str(page_key or "").strip().lower(), page_key) + + +def _to_qfluent_theme(theme: ThemeMode) -> Theme: + if theme == ThemeMode.LIGHT: + return Theme.LIGHT + if theme == ThemeMode.AUTO: + return Theme.AUTO + return Theme.DARK + + +class SettingInterface(SettingsShell): + """First-party settings page backed by the shared TOML config.""" + + # 内嵌在 SettingsDialog 里时无法直接 self.window() 拿到主窗口跳转字幕样式 tab, + # 改发信号由 SettingsDialog 接管(先关弹窗再切主窗口)。 + openStylePageRequested = pyqtSignal() + # 「检查更新」交给主窗口的更新流程(应用内下载 + 重启安装),不在设置页里自己开浏览器。 + checkUpdateRequested = pyqtSignal() def __init__(self, parent=None): super().__init__(parent=parent) - self.setWindowTitle(self.tr("设置")) - self.scrollWidget = QWidget() - self.expandLayout = ExpandLayout(self.scrollWidget) - self.settingLabel = QLabel(self.tr("设置"), self) - - # 初始化所有设置组 - self.__initGroups() - # 初始化所有配置卡片 - self.__initCards() - # 初始化界面 - self.__initWidget() - # 初始化布局 - self.__initLayout() - # 连接信号和槽 - self.__connectSignalToSlot() - - def __initGroups(self): - """初始化所有设置组""" - # 转录配置组 - self.transcribeGroup = SettingCardGroup(self.tr("转录配置"), self.scrollWidget) - # LLM配置组 - self.llmGroup = SettingCardGroup(self.tr("LLM配置"), self.scrollWidget) - # 翻译服务组 - self.translate_serviceGroup = SettingCardGroup( - self.tr("翻译服务"), self.scrollWidget - ) - # 翻译与优化组 - self.translateGroup = SettingCardGroup(self.tr("翻译与优化"), self.scrollWidget) - # 字幕合成配置组 - self.subtitleGroup = SettingCardGroup( - self.tr("字幕合成配置"), self.scrollWidget - ) - # 保存配置组 - self.saveGroup = SettingCardGroup(self.tr("保存配置"), self.scrollWidget) - # 个性化组 - self.personalGroup = SettingCardGroup(self.tr("个性化"), self.scrollWidget) - # 关于组 - self.aboutGroup = SettingCardGroup(self.tr("关于"), self.scrollWidget) - - def __initCards(self): - """初始化所有配置卡片""" - - # ASR 服务配置卡片 - self.__createASRServiceCards() - - # LLM配置卡片 - self.__createLLMServiceCards() - - # 翻译配置卡片 - self.__createTranslateServiceCards() - - # 翻译与优化配置卡片 - self.subtitleCorrectCard = SwitchSettingCard( - FIF.EDIT, - self.tr("字幕校正"), - self.tr("字幕处理过程是否对生成的字幕错别字、名词等进行校正"), - cfg.need_optimize, - self.translateGroup, - ) - self.subtitleTranslateCard = SwitchSettingCard( - FIF.LANGUAGE, - self.tr("字幕翻译"), - self.tr("字幕处理过程是否对生成的字幕进行翻译"), - cfg.need_translate, - self.translateGroup, - ) - self.targetLanguageCard = ComboBoxSettingCard( - cfg.target_language, - FIF.LANGUAGE, - self.tr("目标语言"), - self.tr("选择翻译字幕的目标语言"), - texts=[lang.value for lang in cfg.target_language.validator.options], # type: ignore - parent=self.translateGroup, - ) - - # 字幕合成配置卡片 - self.subtitleStyleCard = HyperlinkCard( - "", - self.tr("修改"), - FIF.FONT, - self.tr("字幕样式"), - self.tr("选择字幕的样式(颜色、大小、字体等)"), - self.subtitleGroup, - ) - self.subtitleLayoutCard = HyperlinkCard( - "", - self.tr("修改"), - FIF.FONT, - self.tr("字幕布局"), - self.tr("选择字幕的布局(单语、双语)"), - self.subtitleGroup, - ) - self.needVideoCard = SwitchSettingCard( - FIF.VIDEO, - self.tr("需要合成视频"), - self.tr("开启时触发合成视频,关闭时跳过"), - cfg.need_video, - self.subtitleGroup, - ) - self.softSubtitleCard = SwitchSettingCard( - FIF.FONT, - self.tr("软字幕"), - self.tr("开启时字幕可在播放器中关闭或调整,关闭时字幕烧录到视频画面上"), - cfg.soft_subtitle, - self.subtitleGroup, - ) - self.videoQualityCard = ComboBoxSettingCard( - cfg.video_quality, - FIF.SPEED_HIGH, - self.tr("视频合成质量"), - self.tr("硬字幕视频合成时的质量等级(质量越高文件越大,编码时间越长)"), - texts=[quality.value for quality in cfg.video_quality.validator.options], # type: ignore - parent=self.subtitleGroup, - ) - - # 保存配置卡片 - self.savePathCard = PushSettingCard( - self.tr("工作文件夹"), - FIF.SAVE, - self.tr("工作目录路径"), - cfg.get(cfg.work_dir), - self.saveGroup, - ) - - # 个性化配置卡片 - self.cacheEnabledCard = SwitchSettingCard( - FIF.HISTORY, - self.tr("启用缓存"), - self.tr("相同配置下会复用之前的 ASR 和 LLM 结果;关闭缓存后每次重新生成"), - cfg.cache_enabled, - self.personalGroup, - ) - self.themeCard = OptionsSettingCard( - cfg.themeMode, - FIF.BRUSH, - self.tr("应用主题"), - self.tr("更改应用程序的外观"), - texts=[self.tr("浅色"), self.tr("深色"), self.tr("使用系统设置")], - parent=self.personalGroup, - ) - self.themeColorCard = CustomColorSettingCard( - cfg.themeColor, - FIF.PALETTE, - self.tr("主题颜色"), - self.tr("更改应用程序的主题颜色"), - self.personalGroup, - ) - self.zoomCard = OptionsSettingCard( - cfg.dpiScale, - FIF.ZOOM, - self.tr("界面缩放"), - self.tr("更改小部件和字体的大小"), - texts=["100%", "125%", "150%", "175%", "200%", self.tr("使用系统设置")], - parent=self.personalGroup, + self.setWindowTitle(tr("settings.title")) + self._threads: list[QThread] = [] + + self._build_pages() + self._connect_signals() + self._refresh_transcribe_rows(cfg.transcribe_model.value) + self._refresh_local_model_state() + self._refresh_llm_rows(cfg.llm_service.value) + self._refresh_translate_rows(cfg.translator_service.value) + self._refresh_dubbing_rows(cfg.dubbing_provider.value) + self._refresh_lc_rows(cfg.live_caption_provider.value) + self._sync_theme_color_swatch(cfg.themeColor.value) + self.setCurrentPage("transcribe") + + def _build_pages(self) -> None: + self.transcribePage = self.addPage("transcribe", tr("settings.page.transcribe")) + self.llmPage = self.addPage("llm", tr("settings.page.llm")) + self.translateServicePage = self.addPage("translate-service", tr("settings.page.translate_service")) + self.translatePage = self.addPage("translate", tr("settings.page.translate")) + self.subtitlePage = self.addPage("subtitle", tr("settings.page.subtitle")) + self.dubbingPage = self.addPage("dubbing", tr("settings.page.dubbing")) + self.liveCaptionPage = self.addPage("live-caption", tr("settings.page.live_caption")) + self.savePage = self.addPage("save", tr("settings.page.save")) + self.personalPage = self.addPage("personal", tr("settings.page.personal")) + self.aboutPage = self.addPage("about", tr("settings.page.about")) + + self._build_transcribe_page() + self._build_llm_page() + self._build_translate_service_page() + self._build_translate_page() + self._build_subtitle_page() + self._build_dubbing_page() + self._build_live_caption_page() + self._build_save_page() + self._build_personal_page() + self._build_about_page() + + def setCurrentPage(self, key: str) -> bool: # noqa: N802 + return super().setCurrentPage(normalize_settings_page_key(key)) + + def _build_transcribe_page(self) -> None: + group = SettingsGroup("", self.transcribePage.container) + self.transcribeModelControl = BoundComboBox( + cfg.transcribe_model, + options_from(cfg.transcribe_model.validator.options), + group, ) - self.languageCard = ComboBoxSettingCard( - cfg.language, - FIF.LANGUAGE, - self.tr("语言"), - self.tr("设置您偏好的界面语言"), - texts=["简体中文", "繁體中文", "English", self.tr("使用系统设置")], - parent=self.personalGroup, - ) - - # 关于卡片 - self.helpCard = HyperlinkCard( - HELP_URL, - self.tr("打开帮助页面"), - FIF.HELP, - self.tr("帮助"), - self.tr("发现新功能并了解有关VideoCaptioner的使用技巧"), - self.aboutGroup, - ) - self.feedbackCard = PrimaryPushSettingCard( - self.tr("提供反馈"), - FIF.FEEDBACK, - self.tr("提供反馈"), - self.tr("提供反馈帮助我们改进VideoCaptioner"), - self.aboutGroup, - ) - self.aboutCard = PrimaryPushSettingCard( - self.tr("检查更新"), - FIF.INFO, - self.tr("关于"), - "© " - + self.tr("版权所有") - + f" {YEAR}, {AUTHOR}. " - + self.tr("版本") - + " " - + VERSION, - self.aboutGroup, - ) - - # 添加卡片到对应的组 - self.translateGroup.addSettingCard(self.subtitleCorrectCard) - self.translateGroup.addSettingCard(self.subtitleTranslateCard) - self.translateGroup.addSettingCard(self.targetLanguageCard) - - self.subtitleGroup.addSettingCard(self.subtitleStyleCard) - self.subtitleGroup.addSettingCard(self.subtitleLayoutCard) - self.subtitleGroup.addSettingCard(self.needVideoCard) - self.subtitleGroup.addSettingCard(self.softSubtitleCard) - self.subtitleGroup.addSettingCard(self.videoQualityCard) - - self.saveGroup.addSettingCard(self.savePathCard) - self.saveGroup.addSettingCard(self.cacheEnabledCard) - - self.personalGroup.addSettingCard(self.themeCard) - self.personalGroup.addSettingCard(self.themeColorCard) - self.personalGroup.addSettingCard(self.zoomCard) - self.personalGroup.addSettingCard(self.languageCard) - - self.aboutGroup.addSettingCard(self.helpCard) - self.aboutGroup.addSettingCard(self.feedbackCard) - self.aboutGroup.addSettingCard(self.aboutCard) - - def __createLLMServiceCards(self): - """创建LLM服务相关的配置卡片""" - # 服务选择卡片 - self.llmServiceCard = ComboBoxSettingCard( - cfg.llm_service, - FIF.ROBOT, - self.tr("LLM 提供商"), - self.tr("选择大模型提供商,用于字幕断句、优化、翻译"), - texts=[service.value for service in cfg.llm_service.validator.options], # type: ignore - parent=self.llmGroup, - ) - self.llmServiceCard.comboBox.setMinimumWidth(150) - - # 创建OPENAI官方API链接卡片 - self.openaiOfficialApiCard = HyperlinkCard( - "https://api.videocaptioner.cn/register?aff=UrLB", - self.tr("访问"), - FIF.DEVELOPER_TOOLS, - self.tr("VideoCaptioner 官方API"), - self.tr("集成多种大语言模型,支持高并发字幕优化、翻译"), - self.llmGroup, - ) - # 默认隐藏 - self.openaiOfficialApiCard.setVisible(False) - - # 定义每个服务的配置 - service_configs = { - LLMServiceEnum.OPENAI: { - "prefix": "openai", - "api_key_cfg": cfg.openai_api_key, - "api_base_cfg": cfg.openai_api_base, - "model_cfg": cfg.openai_model, - "default_base": "https://api.openai.com/v1", - "default_models": [ - "gemini-2.5-pro", - "gpt-5", - "claude-sonnet-4-5-20250929", - "gemini-2.5-flash", - "claude-haiku-4-5-20251001", - ], - }, - LLMServiceEnum.SILICON_CLOUD: { - "prefix": "silicon_cloud", - "api_key_cfg": cfg.silicon_cloud_api_key, - "api_base_cfg": cfg.silicon_cloud_api_base, - "model_cfg": cfg.silicon_cloud_model, - "default_base": "https://api.siliconflow.cn/v1", - "default_models": [ - "moonshotai/Kimi-K2-Instruct-0905", - "deepseek-ai/DeepSeek-V3", - ], - }, - LLMServiceEnum.DEEPSEEK: { - "prefix": "deepseek", - "api_key_cfg": cfg.deepseek_api_key, - "api_base_cfg": cfg.deepseek_api_base, - "model_cfg": cfg.deepseek_model, - "default_base": "https://api.deepseek.com/v1", - "default_models": ["deepseek-chat", "deepseek-reasoner"], - }, - LLMServiceEnum.OLLAMA: { - "prefix": "ollama", - "api_key_cfg": cfg.ollama_api_key, - "api_base_cfg": cfg.ollama_api_base, - "model_cfg": cfg.ollama_model, - "default_base": "http://localhost:11434/v1", - "default_models": ["qwen3:8b"], - }, - LLMServiceEnum.LM_STUDIO: { - "prefix": "LM Studio", - "api_key_cfg": cfg.lm_studio_api_key, - "api_base_cfg": cfg.lm_studio_api_base, - "model_cfg": cfg.lm_studio_model, - "default_base": "http://localhost:1234/v1", - "default_models": ["qwen3:8b"], - }, - LLMServiceEnum.GEMINI: { - "prefix": "gemini", - "api_key_cfg": cfg.gemini_api_key, - "api_base_cfg": cfg.gemini_api_base, - "model_cfg": cfg.gemini_model, - "default_base": "https://generativelanguage.googleapis.com/v1beta/openai/", - "default_models": [ - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.0-flash-lite", - ], - }, - LLMServiceEnum.CHATGLM: { - "prefix": "chatglm", - "api_key_cfg": cfg.chatglm_api_key, - "api_base_cfg": cfg.chatglm_api_base, - "model_cfg": cfg.chatglm_model, - "default_base": "https://open.bigmodel.cn/api/paas/v4", - "default_models": ["glm-4-plus", "glm-4-air-250414", "glm-4-flash"], - }, - } - - # 创建服务配置映射 - self.llm_service_configs = {} - - # 为每个服务创建配置卡片 - for service, config in service_configs.items(): - prefix = config["prefix"] - - # 创建API Key卡片 - api_key_card = LineEditSettingCard( - config["api_key_cfg"], - FIF.FINGERPRINT, - self.tr("API Key"), - self.tr(f"输入您的 {service.value} API Key"), - "sk-" if service != LLMServiceEnum.OLLAMA else "", - self.llmGroup, - ) - setattr(self, f"{prefix}_api_key_card", api_key_card) - - # 创建Base URL卡片 - api_base_card = LineEditSettingCard( - config["api_base_cfg"], - FIF.LINK, - self.tr("Base URL"), - self.tr(f"输入 {service.value} Base URL"), - config["default_base"], - self.llmGroup, - ) - setattr(self, f"{prefix}_api_base_card", api_base_card) - - # 设置只读状态:只有 OpenAI、Ollama、LM Studio 可以编辑 Base URL - if service not in [ - LLMServiceEnum.OPENAI, - LLMServiceEnum.OLLAMA, - LLMServiceEnum.LM_STUDIO, - ]: - api_base_card.lineEdit.setReadOnly(True) - - # 创建模型选择卡片 - model_card = EditComboBoxSettingCard( - config["model_cfg"], - FIF.ROBOT, # type: ignore - self.tr("模型"), - self.tr(f"选择 {service.value} 模型"), - config["default_models"], - self.llmGroup, - ) - setattr(self, f"{prefix}_model_card", model_card) - - # 存储服务配置 - cards = [api_key_card, api_base_card, model_card] - - self.llm_service_configs[service] = { - "cards": cards, - "api_base": api_base_card, - "api_key": api_key_card, - "model": model_card, - } - - # 创建检查连接卡片 - self.checkLLMConnectionCard = PushSettingCard( - self.tr("检查连接"), - FIF.LINK, - self.tr("检查 LLM 连接"), - self.tr("点击检查 API 连接是否正常,并获取模型列表"), - self.llmGroup, + self.transcribeModelRow = group.addRow( + SettingRow( + tr("settings.transcribe.model"), + tr("settings.transcribe.model.desc"), + self.transcribeModelControl, + group, + ) ) - # 初始化显示状态 - self.__onLLMServiceChanged(self.llmServiceCard.comboBox.currentText()) + self.transcribeOutputRow = group.addRow( + SettingRow( + tr("settings.transcribe.output_format"), + tr("settings.transcribe.output_format.desc"), + BoundComboBox( + cfg.transcribe_output_format, + options_from(cfg.transcribe_output_format.validator.options), + group, + ), + group, + ) + ) + self.transcribeLanguageControl = BoundComboBox( + cfg.transcribe_language, + options_from(cfg.transcribe_language.validator.options), + group, + ) + self.transcribeLanguageRow = group.addRow( + SettingRow( + tr("settings.transcribe.source_language"), + tr("settings.transcribe.source_language.desc"), + self.transcribeLanguageControl, + group, + ) + ) - def __createASRServiceCards(self): - """创建 Whisper API 配置卡片""" - # 转录配置卡片 - self.transcribeModelCard = ComboBoxSettingCard( - cfg.transcribe_model, - FIF.MICROPHONE, - self.tr("转录模型"), - self.tr("语音转换文字要使用的语音识别服务"), - texts=[model.value for model in cfg.transcribe_model.validator.options], # type: ignore - parent=self.transcribeGroup, - ) - self.transcribeModelCard.comboBox.setMinimumWidth(150) - - # API Base URL - self.whisperApiBaseCard = LineEditSettingCard( - cfg.whisper_api_base, - FIF.LINK, - self.tr("Whisper API Base URL"), - self.tr("输入 Whisper API Base URL"), - "https://api.openai.com/v1", - self.transcribeGroup, - ) - - # API Key - self.whisperApiKeyCard = LineEditSettingCard( - cfg.whisper_api_key, - FIF.FINGERPRINT, - self.tr("Whisper API Key"), - self.tr("输入 Whisper API Key"), - "sk-", - self.transcribeGroup, - ) - - # 模型选择 - self.whisperApiModelCard = EditComboBoxSettingCard( + self.whisperApiBaseRow = group.addRow( + SettingRow( + tr("settings.transcribe.whisper_api.base"), + tr("settings.transcribe.whisper_api.base.desc"), + BoundLineEdit(cfg.whisper_api_base, "https://api.openai.com/v1", group), + group, + ) + ) + self.whisperApiKeyRow = group.addRow( + SettingRow( + tr("settings.transcribe.whisper_api.key"), + tr("settings.transcribe.whisper_api.key.desc"), + BoundLineEdit(cfg.whisper_api_key, "sk-", group, password=True), + group, + ) + ) + self.whisperApiModelControl = BoundEditableComboBox( cfg.whisper_api_model, - FIF.ROBOT, # type: ignore - self.tr("Whisper 模型"), - self.tr("选择 Whisper 模型"), - [ - "whisper-1", - "whisper-large-v3-turbo", - ], - self.transcribeGroup, + WHISPER_API_MODEL_OPTIONS, + group, ) - - # 测试连接按钮 - self.checkWhisperConnectionCard = PushSettingCard( - self.tr("测试 Whisper 连接"), - FIF.CONNECT, - self.tr("测试 Whisper API 连接"), - self.tr("点击测试 API 连接是否正常"), - self.transcribeGroup, + self.whisperApiModelRow = group.addRow( + SettingRow( + tr("settings.transcribe.whisper_api.model"), + tr("settings.transcribe.whisper_api.model.desc"), + self.whisperApiModelControl, + group, + ) ) - - # 默认隐藏 Whisper API 配置卡片(仅在选择 Whisper API 时显示) - self.whisperApiBaseCard.setVisible(False) - self.whisperApiKeyCard.setVisible(False) - self.whisperApiModelCard.setVisible(False) - self.checkWhisperConnectionCard.setVisible(False) - - def __createTranslateServiceCards(self): - """创建翻译服务相关的配置卡片""" - # 翻译服务选择卡片 - self.translatorServiceCard = ComboBoxSettingCard( - cfg.translator_service, - FIF.ROBOT, - self.tr("翻译服务"), - self.tr("选择翻译服务"), - texts=[ - service.value - for service in cfg.translator_service.validator.options # type: ignore - ], - parent=self.translate_serviceGroup, - ) - self.translatorServiceCard.comboBox.setMinimumWidth(150) - - # 反思翻译开关 - self.needReflectTranslateCard = SwitchSettingCard( - FIF.EDIT, - self.tr("需要反思翻译"), - self.tr("启用反思翻译可以提高翻译质量,但耗费更多时间和token"), - cfg.need_reflect_translate, - self.translate_serviceGroup, - ) - - # DeepLx端点配置 - self.deeplxEndpointCard = LineEditSettingCard( - cfg.deeplx_endpoint, - FIF.LINK, - self.tr("DeepLx 后端"), - self.tr("输入 DeepLx 的后端地址(开启deeplx翻译时必填)"), - "https://api.deeplx.org/translate", - self.translate_serviceGroup, - ) - - # 批处理大小配置 - self.batchSizeCard = RangeSettingCard( - cfg.batch_size, - FIF.ALIGNMENT, - self.tr("批处理大小"), - self.tr("每批处理字幕的数量,建议为 10 的倍数"), - parent=self.translate_serviceGroup, - ) - - # 线程数配置 - self.threadNumCard = RangeSettingCard( - cfg.thread_num, - FIF.SPEED_HIGH, - self.tr("线程数"), - self.tr( - "请求并行处理的数量,模型服务商允许的情况下建议尽可能大,数值越大速度越快" - ), - parent=self.translate_serviceGroup, + self.whisperApiPromptRow = group.addRow( + SettingRow( + tr("settings.transcribe.prompt"), + tr("settings.transcribe.prompt.desc"), + BoundLineEdit(cfg.whisper_api_prompt, tr("settings.placeholder.empty"), group), + group, + ) ) - - # 添加卡片到翻译服务组 - self.translate_serviceGroup.addSettingCard(self.translatorServiceCard) - self.translate_serviceGroup.addSettingCard(self.needReflectTranslateCard) - self.translate_serviceGroup.addSettingCard(self.deeplxEndpointCard) - self.translate_serviceGroup.addSettingCard(self.batchSizeCard) - self.translate_serviceGroup.addSettingCard(self.threadNumCard) - - # 初始化显示状态 - self.__onTranslatorServiceChanged( - self.translatorServiceCard.comboBox.currentText() + # 只列已下载的模型(下载入口在「管理模型」弹窗);选项由 + # _refresh_model_choices 按本地文件动态过滤。 + self.whisperCppModelControl = BoundComboBox( + cfg.whisper_model, + options_from(cfg.whisper_model.validator.options), + group, ) - - def __initWidget(self): - self.resize(1000, 800) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore - self.setViewportMargins(0, 80, 0, 20) - self.setWidget(self.scrollWidget) - self.setWidgetResizable(True) - self.setObjectName("settingInterface") - - # 初始化样式表 - self.scrollWidget.setObjectName("scrollWidget") - self.settingLabel.setObjectName("settingLabel") - - # 初始化转录模型配置卡片的显示状态 - self.__onTranscribeModelChanged(self.transcribeModelCard.comboBox.currentText()) - - # 初始化翻译服务配置卡片的显示状态 - self.__onTranslatorServiceChanged( - self.translatorServiceCard.comboBox.currentText() + self.whisperCppModelRow = group.addRow( + SettingRow( + tr("settings.transcribe.whisper_cpp.model"), + tr("settings.transcribe.whisper_cpp.model.desc"), + self.whisperCppModelControl, + group, + ) ) - - self.setStyleSheet( - """ - SettingInterface, #scrollWidget { - background-color: transparent; - } - QScrollArea { - border: none; - background-color: transparent; - } - QLabel#settingLabel { - font: 33px 'Microsoft YaHei'; - background-color: transparent; - color: white; - } - """ + # 程序安装与模型下载集中在「管理模型」弹窗;状态写进行描述, + # 不与上方模型选择重复(需要行动时按钮转主题色)。 + self.whisperCppManageButton = make_button(tr("settings.transcribe.manage_models"), parent=group) + self.whisperCppModelEntryRow = group.addRow( + SettingRow( + tr("settings.transcribe.local_model"), + tr("settings.transcribe.local_model.desc"), + self.whisperCppManageButton, + group, + ) ) - def __initLayout(self): - """初始化布局""" - self.settingLabel.move(36, 30) - - # 添加转录配置卡片 - self.transcribeGroup.addSettingCard(self.transcribeModelCard) - # 添加 Whisper API 配置卡片 - self.transcribeGroup.addSettingCard(self.whisperApiBaseCard) - self.transcribeGroup.addSettingCard(self.whisperApiKeyCard) - self.transcribeGroup.addSettingCard(self.whisperApiModelCard) - self.transcribeGroup.addSettingCard(self.checkWhisperConnectionCard) - - # 添加LLM配置卡片 - self.llmGroup.addSettingCard(self.llmServiceCard) - # 添加OPENAI官方API链接卡片 - self.llmGroup.addSettingCard(self.openaiOfficialApiCard) - for config in self.llm_service_configs.values(): - for card in config["cards"]: - self.llmGroup.addSettingCard(card) - self.llmGroup.addSettingCard(self.checkLLMConnectionCard) - - # 将所有组添加到布局 - self.expandLayout.setSpacing(28) - self.expandLayout.setContentsMargins(36, 10, 36, 0) - self.expandLayout.addWidget(self.transcribeGroup) - self.expandLayout.addWidget(self.llmGroup) - self.expandLayout.addWidget(self.translate_serviceGroup) - self.expandLayout.addWidget(self.translateGroup) - self.expandLayout.addWidget(self.subtitleGroup) - self.expandLayout.addWidget(self.saveGroup) - self.expandLayout.addWidget(self.personalGroup) - self.expandLayout.addWidget(self.aboutGroup) - - def __connectSignalToSlot(self): - """连接信号与槽""" - cfg.appRestartSig.connect(self.__showRestartTooltip) - - # LLM服务切换 - self.llmServiceCard.comboBox.currentTextChanged.connect( - self.__onLLMServiceChanged + self.fasterWhisperModelControl = BoundComboBox( + cfg.faster_whisper_model, + options_from(cfg.faster_whisper_model.validator.options), + group, + ) + self.fasterWhisperModelRow = group.addRow( + SettingRow( + tr("settings.transcribe.faster_whisper.model"), + tr("settings.transcribe.faster_whisper.model.desc"), + self.fasterWhisperModelControl, + group, + ) + ) + self.fasterWhisperDirControl = FolderPickerControl(group, placeholder=tr("settings.placeholder.not_selected")) + self.fasterWhisperDirControl.setPath(str(cfg.faster_whisper_model_dir.value or "")) + self.fasterWhisperDirRow = group.addRow( + SettingRow( + tr("settings.transcribe.faster_whisper.model_dir"), + tr("settings.transcribe.faster_whisper.model_dir.desc"), + self.fasterWhisperDirControl, + group, + ) + ) + self.fasterWhisperManageButton = make_button(tr("settings.transcribe.manage_models"), parent=group) + self.fasterWhisperModelEntryRow = group.addRow( + SettingRow( + tr("settings.transcribe.local_model"), + tr("settings.transcribe.local_model.desc"), + self.fasterWhisperManageButton, + group, + ) + ) + self.fasterWhisperDeviceRow = group.addRow( + SettingRow( + tr("settings.transcribe.faster_whisper.device"), + tr("settings.transcribe.faster_whisper.device.desc"), + BoundComboBox( + cfg.faster_whisper_device, + options_from(cfg.faster_whisper_device.validator.options), + group, + ), + group, + ) + ) + self.fasterWhisperVadFilterRow = group.addRow( + SettingRow( + tr("settings.transcribe.faster_whisper.vad_filter"), + tr("settings.transcribe.faster_whisper.vad_filter.desc"), + BoundSwitch(cfg.faster_whisper_vad_filter, group), + group, + ) + ) + self.fasterWhisperVadThresholdRow = group.addRow( + SettingRow( + tr("settings.transcribe.faster_whisper.vad_threshold"), + tr("settings.transcribe.faster_whisper.vad_threshold.desc"), + BoundFloatSlider(cfg.faster_whisper_vad_threshold, 2, group), + group, + ) + ) + self.fasterWhisperVadMethodRow = group.addRow( + SettingRow( + tr("settings.transcribe.faster_whisper.vad_method"), + tr("settings.transcribe.faster_whisper.vad_method.desc"), + BoundComboBox( + cfg.faster_whisper_vad_method, + options_from(cfg.faster_whisper_vad_method.validator.options), + group, + ), + group, + ) + ) + self.fasterWhisperVoiceExtractionRow = group.addRow( + SettingRow( + tr("settings.transcribe.faster_whisper.voice_extraction"), + tr("settings.transcribe.faster_whisper.voice_extraction.desc"), + BoundSwitch(cfg.faster_whisper_ff_mdx_kim2, group), + group, + ) + ) + self.fasterWhisperOneWordRow = group.addRow( + SettingRow( + tr("settings.transcribe.faster_whisper.one_word"), + tr("settings.transcribe.faster_whisper.one_word.desc"), + BoundSwitch(cfg.faster_whisper_one_word, group), + group, + ) + ) + self.fasterWhisperPromptRow = group.addRow( + SettingRow( + tr("settings.transcribe.prompt"), + tr("settings.transcribe.prompt.desc"), + BoundLineEdit(cfg.faster_whisper_prompt, tr("settings.placeholder.empty"), group), + group, + ) ) - # 翻译服务切换 - self.translatorServiceCard.comboBox.currentTextChanged.connect( - self.__onTranslatorServiceChanged + self.funAsrKeyRow = group.addRow( + SettingRow( + tr("settings.transcribe.fun_asr.key"), + tr("settings.transcribe.fun_asr.key.desc"), + BoundLineEdit(cfg.fun_asr_api_key, "sk-", group, password=True), + group, + ) ) + self.funAsrModelControl = BoundEditableComboBox( + cfg.fun_asr_model, + FUN_ASR_MODEL_OPTIONS, + group, + ) + self.funAsrModelRow = group.addRow( + SettingRow( + tr("settings.transcribe.fun_asr.model"), + tr("settings.transcribe.fun_asr.model.desc"), + self.funAsrModelControl, + group, + ) + ) + # 统一的真实转录测试:对所有服务(含 B/J 接口与本地模型)可用, + # 与 doctor --check-api 共用 core 的 check_transcribe 入口。 + self.checkTranscribeButton = make_button(tr("settings.test_transcribe"), parent=group) + self.checkTranscribeRow = group.addRow( + SettingRow( + tr("settings.test_transcribe"), + tr("settings.transcribe.test.desc"), + self.checkTranscribeButton, + group, + ) + ) + self.transcribePage.addGroup(group) - # 转录模型切换 - self.transcribeModelCard.comboBox.currentTextChanged.connect( - self.__onTranscribeModelChanged + def _build_llm_page(self) -> None: + group = SettingsGroup("", self.llmPage.container) + self.llmServiceControl = BoundComboBox( + cfg.llm_service, + options_from(cfg.llm_service.validator.options), + group, + ) + self.llmServiceRow = group.addRow( + SettingRow( + tr("settings.llm.provider"), + tr("settings.llm.provider.desc"), + self.llmServiceControl, + group, + ) ) - # 检查 LLM 连接 - self.checkLLMConnectionCard.clicked.connect(self.checkLLMConnection) + self.llmProviderRows: dict[LLMServiceEnum, list[SettingRow]] = {} + self.llmApiBaseRows: dict[LLMServiceEnum, SettingRow] = {} + self.llmDefaultBases: dict[LLMServiceEnum, str] = {} + self.llmProviderSpecs = self._llm_provider_specs() + self.llmProviderControls: dict[LLMServiceEnum, dict[str, BoundLineEdit | BoundEditableComboBox]] = {} + for service, provider in self.llmProviderSpecs.items(): + api_key = BoundLineEdit(provider["api_key"], "sk-", group, password=True) + api_base = BoundLineEdit(provider["api_base"], provider["default_base"], group) + model = BoundEditableComboBox( + provider["model"], + self._llm_model_options_for_provider(provider), + group, + ) + api_key_row = group.addRow( + SettingRow( + tr("settings.llm.api_key"), + tr("settings.llm.api_key.desc", service=service.value), + api_key, + group, + ) + ) + api_base_row = group.addRow( + SettingRow( + tr("settings.llm.base_url"), + tr("settings.llm.base_url.desc"), + api_base, + group, + ) + ) + model_row = group.addRow( + SettingRow( + tr("settings.llm.model"), + tr("settings.llm.model.desc"), + model, + group, + ) + ) + rows = [api_key_row, api_base_row, model_row] + self.llmApiBaseRows[service] = api_base_row + self.llmDefaultBases[service] = str(provider["default_base"]) + self.llmProviderRows[service] = rows + self.llmProviderControls[service] = { + "api_key": api_key, + "api_base": api_base, + "model": model, + } - # 检查 Whisper 连接 - self.checkWhisperConnectionCard.clicked.connect(self.checkWhisperConnection) + # 选中「公益大模型」时显示:免费免 key + 稳定性预防针;无任何可配置项 + self.llmImmersiveHintRow = group.addRow( + SettingRow( + tr("settings.llm.immersive_hint.title"), + tr("settings.llm.immersive_hint.desc"), + None, + group, + ) + ) + self.loadLLMModelsButton = make_button(tr("settings.llm.load_models"), parent=group) + self.checkLLMButton = make_button(tr("settings.llm.test_connection"), parent=group) + self.checkLLMRow = group.addRow( + SettingRow( + tr("settings.llm.model_service"), + tr("settings.llm.model_service.desc"), + self._two_controls(self.loadLLMModelsButton, self.checkLLMButton, group), + group, + ) + ) + self.llmPage.addGroup(group) - # 保存路径 - self.savePathCard.clicked.connect(self.__onsavePathCardClicked) + def _build_translate_service_page(self) -> None: + group = SettingsGroup("", self.translateServicePage.container) + self.translatorServiceControl = BoundComboBox( + cfg.translator_service, + options_from(cfg.translator_service.validator.options), + group, + ) + self.translatorServiceRow = group.addRow( + SettingRow( + tr("settings.translate_service.service"), + tr("settings.translate_service.service.desc"), + self.translatorServiceControl, + group, + ) + ) + self.needReflectTranslateRow = group.addRow( + SettingRow( + tr("settings.translate_service.reflect"), + tr("settings.translate_service.reflect.desc"), + BoundSwitch(cfg.need_reflect_translate, group), + group, + ) + ) + self.deeplxEndpointRow = group.addRow( + SettingRow( + tr("settings.translate_service.deeplx_endpoint"), + tr("settings.translate_service.deeplx_endpoint.desc"), + BoundLineEdit(cfg.deeplx_endpoint, "https://api.deeplx.org/translate", group), + group, + ) + ) + self.batchSizeRow = group.addRow( + SettingRow( + tr("settings.translate_service.batch_size"), + tr("settings.translate_service.batch_size.desc"), + BoundSlider(cfg.batch_size, group), + group, + ) + ) + self.threadNumRow = group.addRow( + SettingRow( + tr("settings.translate_service.thread_num"), + tr("settings.translate_service.thread_num.desc"), + BoundSlider(cfg.thread_num, group), + group, + ) + ) + self.translateServicePage.addGroup(group) + + def _build_translate_page(self) -> None: + group = SettingsGroup("", self.translatePage.container) + group.addRow( + SettingRow( + tr("settings.translate.optimize"), + tr("settings.translate.optimize.desc"), + BoundSwitch(cfg.need_optimize, group), + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.translate.translate"), + tr("settings.translate.translate.desc"), + BoundSwitch(cfg.need_translate, group), + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.translate.split"), + tr("settings.translate.split.desc"), + BoundSwitch(cfg.need_split, group), + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.translate.target_language"), + tr("settings.translate.target_language.desc"), + BoundComboBox( + cfg.target_language, + options_from(cfg.target_language.validator.options), + group, + ), + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.translate.cjk_length"), + tr("settings.translate.cjk_length.desc"), + BoundSlider(cfg.max_word_count_cjk, group), + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.translate.english_length"), + tr("settings.translate.english_length.desc"), + BoundSlider(cfg.max_word_count_english, group), + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.translate.custom_prompt"), + tr("settings.translate.custom_prompt.desc"), + BoundLineEdit(cfg.custom_prompt_text, tr("settings.placeholder.empty"), group), + group, + ) + ) + self.translatePage.addGroup(group) + + def _build_subtitle_page(self) -> None: + synth_group = SettingsGroup("", self.subtitlePage.container) + self.subtitleStyleButton = make_button(tr("settings.subtitle.open_style"), parent=synth_group) + synth_group.addRow( + SettingRow( + tr("settings.subtitle.style"), + tr("settings.subtitle.style.desc"), + self.subtitleStyleButton, + synth_group, + ) + ) + synth_group.addRow( + SettingRow( + tr("settings.subtitle.layout"), + tr("settings.subtitle.layout.desc"), + BoundComboBox( + cfg.subtitle_layout, + options_from(cfg.subtitle_layout.validator.options), + synth_group, + ), + synth_group, + ) + ) + synth_group.addRow( + SettingRow( + tr("settings.subtitle.render_mode"), + tr("settings.subtitle.render_mode.desc"), + BoundComboBox( + cfg.subtitle_render_mode, + options_from(cfg.subtitle_render_mode.validator.options), + synth_group, + ), + synth_group, + ) + ) + synth_group.addRow( + SettingRow( + tr("settings.subtitle.need_video"), + tr("settings.subtitle.need_video.desc"), + BoundSwitch(cfg.need_video, synth_group), + synth_group, + ) + ) + synth_group.addRow( + SettingRow( + tr("settings.subtitle.soft"), + tr("settings.subtitle.soft.desc"), + BoundSwitch(cfg.soft_subtitle, synth_group), + synth_group, + ) + ) + synth_group.addRow( + SettingRow( + tr("settings.subtitle.video_quality"), + tr("settings.subtitle.video_quality.desc"), + BoundComboBox( + cfg.video_quality, + options_from(cfg.video_quality.validator.options), + synth_group, + ), + synth_group, + ) + ) + self.subtitlePage.addGroup(synth_group) + + def _build_dubbing_page(self) -> None: + group = SettingsGroup("", self.dubbingPage.container) + group.addRow( + SettingRow( + tr("settings.dubbing.enabled"), + tr("settings.dubbing.enabled.desc"), + BoundSwitch(cfg.dubbing_enabled, group), + group, + ) + ) + self.dubbingProviderControl = BoundComboBox( + cfg.dubbing_provider, + [Option(option.key, provider_title(option)) for option in self._dubbing_provider_options()], + group, + ) + self.dubbingProviderRow = group.addRow( + SettingRow( + tr("settings.dubbing.provider"), + tr("settings.dubbing.provider.desc"), + self.dubbingProviderControl, + group, + ) + ) + self.dubbingPresetControl = BoundComboBox(cfg.dubbing_preset, [], group) + self.dubbingPresetRow = group.addRow( + SettingRow( + tr("settings.dubbing.preset"), + tr("settings.dubbing.preset.desc"), + self.dubbingPresetControl, + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.dubbing.text_track"), + tr("settings.dubbing.text_track.desc"), + BoundComboBox( + cfg.dubbing_text_track, + [ + Option("auto", tr("settings.dubbing.text_track.auto")), + Option("first", tr("settings.dubbing.text_track.first")), + Option("second", tr("settings.dubbing.text_track.second")), + ], + group, + ), + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.dubbing.timing"), + tr("settings.dubbing.timing.desc"), + BoundComboBox( + cfg.dubbing_timing, + [ + Option("natural", tr("settings.dubbing.timing.natural")), + Option("balanced", tr("settings.dubbing.timing.balanced")), + Option("strict", tr("settings.dubbing.timing.strict")), + Option("none", tr("settings.dubbing.timing.none")), + ], + group, + ), + group, + ) + ) + group.addRow( + SettingRow( + tr("settings.dubbing.audio_mode"), + tr("settings.dubbing.audio_mode.desc"), + BoundComboBox( + cfg.dubbing_audio_mode, + [ + Option("replace", tr("settings.dubbing.audio_mode.replace")), + Option("mix", tr("settings.dubbing.audio_mode.mix")), + Option("duck", tr("settings.dubbing.audio_mode.duck")), + ], + group, + ), + group, + ) + ) + self.dubbingApiKeyControl = BoundLineEdit( + cfg.dubbing_api_key, "sk-", group, password=True + ) + self.dubbingApiKeyRow = group.addRow( + SettingRow( + tr("settings.dubbing.api_key"), + tr("settings.dubbing.api_key.desc"), + self.dubbingApiKeyControl, + group, + ) + ) + self.dubbingModelControl = BoundEditableComboBox(cfg.dubbing_model, [], group) + self.dubbingModelRow = group.addRow( + SettingRow( + tr("settings.dubbing.model"), + tr("settings.dubbing.model.desc"), + self.dubbingModelControl, + group, + ) + ) + self.dubbingWorkersRow = group.addRow( + SettingRow( + tr("settings.dubbing.workers"), + tr("settings.dubbing.workers.desc"), + BoundSlider(cfg.dubbing_tts_workers, group), + group, + ) + ) + self.checkDubbingButton = make_button(tr("settings.dubbing.test_button"), parent=group) + self.checkDubbingRow = group.addRow( + SettingRow( + tr("settings.dubbing.test"), + tr("settings.dubbing.test.desc"), + self.checkDubbingButton, + group, + ) + ) + self.dubbingPage.addGroup(group) - # 字幕样式修改跳转 - self.subtitleStyleCard.linkButton.clicked.connect( - lambda: self.window().switchTo(self.window().subtitleStyleInterface) # type: ignore + def _build_live_caption_page(self) -> None: + display_labels = { + "bilingual": tr("settings.live_caption.display.bilingual"), + "target": tr("settings.live_caption.display.target"), + "source": tr("settings.live_caption.display.source"), + } + bg_labels = { + "translucent": tr("settings.live_caption.bg.translucent"), + "black": tr("settings.live_caption.bg.black"), + } + # 下拉项只显引擎名 / 模型名,不带括号解释(说明留给行副标题)。 + provider_labels = {"voxgate": "voxgate", "fun-asr": "Fun-ASR", "qwen-asr": "Qwen-ASR"} + + # 1) 转录引擎(Provider):voxgate 本地免费无密钥;fun-asr 阿里云实时(需 Key,中英日更准) + engine_group = SettingsGroup(tr("settings.live_caption.engine.group"), self.liveCaptionPage.container) + engine_group.addRow( + SettingRow( + tr("settings.live_caption.engine"), + tr("settings.live_caption.engine.desc"), + BoundComboBox( + cfg.live_caption_provider, + options_from( + cfg.live_caption_provider.validator.options, + lambda v: provider_labels.get(v, v), + ), + engine_group, + ), + engine_group, + ) + ) + # voxgate:下载/检测本地程序 + deps_button = CompactButton(tr("settings.live_caption.deps_button"), AppIcon.DOWNLOAD, engine_group) + deps_button.clicked.connect(self._open_live_caption_deps) + self.lcVoxgateRow = engine_group.addRow( + SettingRow( + tr("settings.live_caption.voxgate"), + tr("settings.live_caption.voxgate.desc"), + deps_button, + engine_group, + ) + ) + # fun-asr / qwen-asr:API Key(复用百炼 Key)/ 模型 / 识别语言 + self.lcFunKeyRow = engine_group.addRow( + SettingRow( + tr("settings.live_caption.fun_key"), + tr("settings.live_caption.fun_key.desc"), + BoundLineEdit(cfg.fun_asr_api_key, "sk-", engine_group, password=True), + engine_group, + ) + ) + self.lcFunModelRow = engine_group.addRow( + SettingRow( + tr("settings.live_caption.asr_model"), + tr("settings.live_caption.asr_model.desc"), + BoundComboBox( + cfg.live_caption_fun_asr_model, + options_from(cfg.live_caption_fun_asr_model.validator.options), + engine_group, + ), + engine_group, + ) + ) + # 识别语言列表随引擎不同(voxgate 中/英、Fun-ASR 7 种、Qwen-ASR 27 种),由 _refresh_lc_rows + # 按 provider 重填;这里按当前 provider 给初始项。三家都含 auto = 自动识别。 + langs = source_language_options(cfg.live_caption_provider.value) + self.lcSourceLangCombo = BoundComboBox( + cfg.live_caption_source_language, + options_from([c for c, _ in langs], lambda v: dict(langs).get(v, v)), + engine_group, + ) + self.lcSourceLangRow = engine_group.addRow( + SettingRow( + tr("settings.live_caption.source_language"), + tr("settings.live_caption.source_language.desc"), + self.lcSourceLangCombo, + engine_group, + ) ) - self.subtitleLayoutCard.linkButton.clicked.connect( - lambda: self.window().switchTo(self.window().subtitleStyleInterface) # type: ignore + # 统一的真实转录测试:对所选引擎(voxgate / Fun-ASR)用内置短音频真实跑一次, + # 与转录配置页的「测试转录」同思路,但走实时后端(core.realtime.check)。 + self.checkLiveCaptionButton = make_button(tr("settings.test_transcribe"), parent=engine_group) + engine_group.addRow( + SettingRow( + tr("settings.test_transcribe"), + tr("settings.live_caption.test.desc"), + self.checkLiveCaptionButton, + engine_group, + ) + ) + self.liveCaptionPage.addGroup(engine_group) + + # 2) 翻译 + translate_group = SettingsGroup(tr("settings.live_caption.translate.group"), self.liveCaptionPage.container) + translate_group.addRow( + SettingRow( + tr("settings.live_caption.translate"), + tr("settings.live_caption.translate.desc"), + BoundSwitch(cfg.live_caption_translate, translate_group), + translate_group, + ) + ) + translate_group.addRow( + SettingRow( + tr("settings.live_caption.target_language"), + tr("settings.live_caption.target_language.desc"), + BoundComboBox( + cfg.live_caption_target_language, + options_from(cfg.live_caption_target_language.validator.options), + translate_group, + ), + translate_group, + ) + ) + translate_group.addRow( + SettingRow( + tr("settings.live_caption.translator_service"), + tr("settings.live_caption.translator_service.desc"), + BoundComboBox( + cfg.live_caption_translator_service, + options_from(cfg.live_caption_translator_service.validator.options), + translate_group, + ), + translate_group, + ) + ) + self.liveCaptionPage.addGroup(translate_group) + + # 3) 浮窗显示 + overlay_group = SettingsGroup(tr("settings.live_caption.overlay.group"), self.liveCaptionPage.container) + overlay_group.addRow( + SettingRow( + tr("settings.live_caption.display_mode"), + tr("settings.live_caption.display_mode.desc"), + BoundComboBox( + cfg.live_caption_display_mode, + options_from( + cfg.live_caption_display_mode.validator.options, + lambda v: display_labels.get(v, v), + ), + overlay_group, + ), + overlay_group, + ) ) + overlay_group.addRow( + SettingRow( + tr("settings.live_caption.bg_style"), + tr("settings.live_caption.bg_style.desc"), + BoundComboBox( + cfg.live_caption_bg_style, + options_from( + cfg.live_caption_bg_style.validator.options, + lambda v: bg_labels.get(v, v), + ), + overlay_group, + ), + overlay_group, + ) + ) + overlay_group.addRow( + SettingRow( + tr("settings.live_caption.font_scale"), + tr("settings.live_caption.font_scale.desc"), + BoundSlider(cfg.live_caption_font_scale, overlay_group), + overlay_group, + ) + ) + self.liveCaptionPage.addGroup(overlay_group) - # 个性化 - self.cacheEnabledCard.checkedChanged.connect(self.__onCacheEnabledChanged) - self.themeCard.optionChanged.connect(lambda ci: setTheme(cfg.get(ci))) - self.themeColorCard.colorChanged.connect(setThemeColor) + def _open_live_caption_deps(self) -> None: + from videocaptioner.ui.components.dependency_download_dialog import ( + DependencyDownloadDialog, + ) - # 反馈 - self.feedbackCard.clicked.connect( - lambda: QDesktopServices.openUrl(QUrl(FEEDBACK_URL)) # type: ignore + DependencyDownloadDialog(parent=self._toast_parent()).exec() + + def _refresh_lc_rows(self, value: Any) -> None: + """实时字幕转录引擎切换:voxgate 显本地程序行;fun-asr/qwen-asr 显 Key(共用百炼); + 识别模型行仅 fun-asr(qwen 单模型)。识别语言三家都有(voxgate 中/英、Fun-ASR 7、 + Qwen-ASR 27),故该行恒显,列表随引擎重填。""" + is_cloud = value in ("fun-asr", "qwen-asr") + self.lcVoxgateRow.setVisible(not is_cloud) + self.lcFunKeyRow.setVisible(is_cloud) + self.lcFunModelRow.setVisible(value == "fun-asr") + self.lcSourceLangRow.setVisible(True) + # 识别语言随引擎重填;当前选择若不在新列表里则退回「自动识别」。 + langs = source_language_options(value) + codes = {c for c, _ in langs} + cur = cfg.live_caption_source_language.value + self.lcSourceLangCombo.setOptions( + options_from([c for c, _ in langs], lambda v: dict(langs).get(v, v)), + keep_value=cur if cur in codes else "auto", ) - # 关于 - self.aboutCard.clicked.connect(self.checkUpdate) + def _build_save_page(self) -> None: + save_group = SettingsGroup("", self.savePage.container) + self.workDirControl = FolderPickerControl(save_group) + self.workDirControl.setPath(str(cfg.work_dir.value or "")) + save_group.addRow( + SettingRow( + tr("settings.save.work_dir"), + tr("settings.save.work_dir.desc"), + self.workDirControl, + save_group, + ) + ) + save_group.addRow( + SettingRow( + tr("settings.save.keep_intermediates"), + tr("settings.save.keep_intermediates.desc"), + BoundSwitch(cfg.keep_intermediates, save_group), + save_group, + ) + ) + save_group.addRow( + SettingRow( + tr("settings.save.cache"), + tr("settings.save.cache.desc"), + BoundSwitch(cfg.cache_enabled, save_group), + save_group, + ) + ) + self.savePage.addGroup(save_group) - # 全局 signalBus - self.transcribeModelCard.comboBox.currentTextChanged.connect( - signalBus.transcription_model_changed + def _build_personal_page(self) -> None: + ui_group = SettingsGroup("", self.personalPage.container) + self.themeControl = BoundComboBox( + cfg.themeMode, + [ + Option(option, text) + for option, text in zip( + cfg.themeMode.validator.options, + [tr("settings.personal.theme.light"), tr("settings.personal.theme.dark"), tr("settings.personal.follow_system")], + ) + ], + ui_group, ) - self.subtitleCorrectCard.checkedChanged.connect( - signalBus.subtitle_optimization_changed + ui_group.addRow( + SettingRow( + tr("settings.personal.theme"), + tr("settings.personal.theme.desc"), + self.themeControl, + ui_group, + ) ) - self.subtitleTranslateCard.checkedChanged.connect( - signalBus.subtitle_translation_changed + self.themeColorSwatch = ColorSwatchButton( + cfg.themeColor.value if isinstance(cfg.themeColor.value, QColor) else QColor(str(cfg.themeColor.value)), + ui_group, ) - self.targetLanguageCard.comboBox.currentTextChanged.connect( - signalBus.target_language_changed + self.themeColorResetButton = make_button(tr("settings.personal.theme_color.reset"), parent=ui_group) + self.themeColorResetButton.setToolTip(tr("settings.personal.theme_color.reset_tip")) + ui_group.addRow( + SettingRow( + tr("settings.personal.theme_color"), + tr("settings.personal.theme_color.desc"), + self._two_controls(self.themeColorSwatch, self.themeColorResetButton, ui_group), + ui_group, + ) ) - self.softSubtitleCard.checkedChanged.connect(signalBus.soft_subtitle_changed) - self.needVideoCard.checkedChanged.connect(signalBus.need_video_changed) - self.videoQualityCard.comboBox.currentTextChanged.connect( - signalBus.video_quality_changed + self.zoomControl = BoundComboBox( + cfg.dpiScale, + [ + Option(1, "100%"), + Option(1.25, "125%"), + Option(1.5, "150%"), + Option(1.75, "175%"), + Option(2, "200%"), + Option("Auto", tr("settings.personal.follow_system")), + ], + ui_group, ) - - def __showRestartTooltip(self): - """显示重启提示""" - InfoBar.success( - self.tr("更新成功"), - self.tr("配置将在重启后生效"), - duration=INFOBAR_DURATION_SUCCESS, - parent=self, + ui_group.addRow( + SettingRow( + tr("settings.personal.zoom"), + tr("settings.personal.restart_required"), + self.zoomControl, + ui_group, + ) ) + self.languageControl = BoundComboBox( + cfg.language, + [ + Option(option, text) + for option, text in zip( + cfg.language.validator.options, + ["简体中文", "繁體中文", "English", tr("settings.personal.follow_system")], + ) + ], + ui_group, + ) + ui_group.addRow( + SettingRow( + tr("settings.personal.language"), + tr("settings.personal.restart_required"), + self.languageControl, + ui_group, + ) + ) + self.personalPage.addGroup(ui_group) + + def _build_about_page(self) -> None: + about_group = SettingsGroup("", self.aboutPage.container) + self.helpButton = make_button(tr("settings.about.help_button"), parent=about_group) + about_group.addRow( + SettingRow( + tr("settings.about.help"), + tr("settings.about.help.desc"), + self.helpButton, + about_group, + ) + ) + self.feedbackButton = make_button(tr("settings.about.feedback_button"), primary=True, parent=about_group) + about_group.addRow( + SettingRow( + tr("settings.about.feedback"), + tr("settings.about.feedback.desc"), + self.feedbackButton, + about_group, + ) + ) + self.updateButton = make_button(tr("settings.about.update_button"), primary=True, parent=about_group) + about_group.addRow( + SettingRow( + tr("settings.about.version"), + f"© {YEAR}, {AUTHOR}. {tr('settings.about.current_version')} {VERSION}", + self.updateButton, + about_group, + ) + ) + self.aboutPage.addGroup(about_group) + + def _connect_signals(self) -> None: + cfg.appRestartSig.connect(self._show_restart_tip) + cfg.themeChanged.connect(lambda theme: setTheme(_to_qfluent_theme(theme))) + cfg.themeChanged.connect(lambda _theme: self._sync_visual_style()) + cfg.themeColorChanged.connect(self._apply_theme_color) + cfg.themeColorChanged.connect(lambda _color: self._sync_visual_style()) + self.transcribeModelControl.currentValueChanged.connect(self._refresh_transcribe_rows) + cfg.transcribe_model.valueChanged.connect(self._refresh_transcribe_rows) + self.checkTranscribeButton.clicked.connect(self.check_transcribe_connection) + self.fasterWhisperDirControl.changeRequested.connect(self._choose_faster_whisper_dir) + + self.llmServiceControl.currentValueChanged.connect(self._refresh_llm_rows) + cfg.llm_service.valueChanged.connect(self._refresh_llm_rows) + self.loadLLMModelsButton.clicked.connect(self.load_llm_models) + self.checkLLMButton.clicked.connect(self.check_llm_connection) + + self.translatorServiceControl.currentValueChanged.connect(self._refresh_translate_rows) + cfg.translator_service.valueChanged.connect(self._refresh_translate_rows) + + self.subtitleStyleButton.clicked.connect(self._open_subtitle_style_page) + + self.dubbingProviderControl.currentValueChanged.connect(self._refresh_dubbing_rows) + cfg.dubbing_provider.valueChanged.connect(self._refresh_dubbing_rows) + cfg.live_caption_provider.valueChanged.connect(self._refresh_lc_rows) + self.checkLiveCaptionButton.clicked.connect(self.check_live_caption_connection) + self.dubbingPresetControl.currentValueChanged.connect(self._on_dubbing_preset_changed) + self.checkDubbingButton.clicked.connect(self.check_dubbing_connection) + + self.workDirControl.changeRequested.connect(self._choose_work_dir) + cfg.work_dir.valueChanged.connect( + lambda value: self.workDirControl.setPath(str(value or "")) + ) + cfg.faster_whisper_model_dir.valueChanged.connect( + lambda value: self.fasterWhisperDirControl.setPath(str(value or "")) + ) + self.whisperCppManageButton.clicked.connect( + lambda: self._open_model_manager("whisper-cpp") + ) + self.fasterWhisperManageButton.clicked.connect( + lambda: self._open_model_manager("faster-whisper") + ) + cfg.whisper_model.valueChanged.connect(lambda _v: self._refresh_model_entries()) + cfg.faster_whisper_model.valueChanged.connect(lambda _v: self._refresh_model_entries()) + cfg.faster_whisper_model_dir.valueChanged.connect( + lambda _v: self._refresh_local_model_state() + ) + cfg.cache_enabled.valueChanged.connect(self._on_cache_enabled_changed) + self.themeColorSwatch.clicked.connect(self._choose_theme_color) + self.themeColorResetButton.clicked.connect(self._reset_theme_color) + cfg.themeColor.valueChanged.connect(self._sync_theme_color_swatch) + self.helpButton.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(HELP_URL))) + self.feedbackButton.clicked.connect(lambda: FeedbackDialog(self).exec()) + self.updateButton.clicked.connect(self.checkUpdateRequested.emit) + + def _refresh_transcribe_rows(self, value: Any) -> None: + is_whisper_api = value == TranscribeModelEnum.WHISPER_API + is_fun_asr = value == TranscribeModelEnum.BAILIAN_FUN_ASR + is_whisper_cpp = value == TranscribeModelEnum.WHISPER_CPP + is_faster_whisper = value == TranscribeModelEnum.FASTER_WHISPER + for row in [ + self.whisperApiBaseRow, + self.whisperApiKeyRow, + self.whisperApiModelRow, + self.whisperApiPromptRow, + ]: + row.setVisible(is_whisper_api) + self.whisperCppModelRow.setVisible(is_whisper_cpp) + self.whisperCppModelEntryRow.setVisible(is_whisper_cpp) + for row in [ + self.fasterWhisperModelRow, + self.fasterWhisperDirRow, + self.fasterWhisperModelEntryRow, + self.fasterWhisperDeviceRow, + self.fasterWhisperVadFilterRow, + self.fasterWhisperVadThresholdRow, + self.fasterWhisperVadMethodRow, + self.fasterWhisperVoiceExtractionRow, + self.fasterWhisperOneWordRow, + self.fasterWhisperPromptRow, + ]: + row.setVisible(is_faster_whisper) + for row in [ + self.funAsrKeyRow, + self.funAsrModelRow, + ]: + row.setVisible(is_fun_asr) + if is_fun_asr and cfg.fun_asr_api_base.value.strip() != "https://dashscope.aliyuncs.com": + cfg.set(cfg.fun_asr_api_base, "https://dashscope.aliyuncs.com") + # 源语言按接口能力收窄:B/J 接口只识别中英,其余接口提供全语种。 + # 当前选择若不在新接口的支持集内,回落到自动检测。 + languages = transcribe_languages_for(value) + if cfg.transcribe_language.value in languages: + self.transcribeLanguageControl.setOptions(options_from(languages)) + else: + self.transcribeLanguageControl.setOptions( + options_from(languages), keep_value=TranscribeLanguageEnum.AUTO + ) + # 模型行的最终可见性还取决于"有没有已下载的模型" + self._refresh_model_choices() + + # ------------------------------------------------------------ 本地模型入口 + + def _open_model_manager(self, kind: str) -> None: + dialog = ModelManagerDialog(kind, self.window()) + dialog.modelsChanged.connect(self._refresh_local_model_state) + dialog.exec() + self._refresh_local_model_state() + + def _model_entry_target(self, kind: str) -> tuple[str, Path]: + """入口行对应的当前模型名与模型目录。""" + if kind == "whisper-cpp": + name = getattr(cfg.whisper_model.value, "value", str(cfg.whisper_model.value)) + return str(name), Path(MODEL_PATH) + name = getattr( + cfg.faster_whisper_model.value, "value", str(cfg.faster_whisper_model.value) + ) + return str(name), Path(cfg.faster_whisper_model_dir.value or MODEL_PATH) + + def _installed_model_options(self, kind: str) -> list[Any]: + """已下载模型对应的枚举选项(按清单顺序)。""" + _name, models_dir = self._model_entry_target(kind) + installed = { + spec.name + for spec in iter_models(kind) + if model_install_state(spec, models_dir) + } + field = cfg.whisper_model if kind == "whisper-cpp" else cfg.faster_whisper_model + return [ + option + for option in field.validator.options + if getattr(option, "value", str(option)) in installed + ] - def __onsavePathCardClicked(self): - """处理保存路径卡片点击事件""" - folder = QFileDialog.getExistingDirectory(self, self.tr("选择文件夹"), "./") - if not folder or cfg.get(cfg.work_dir) == folder: + def _refresh_local_model_state(self) -> None: + self._refresh_model_choices() + self._refresh_model_entries() + + def _refresh_model_choices(self) -> None: + """模型下拉只列已下载的;一个都没有时隐藏整行,由入口引导下载。""" + is_cpp = cfg.transcribe_model.value == TranscribeModelEnum.WHISPER_CPP + is_fw = cfg.transcribe_model.value == TranscribeModelEnum.FASTER_WHISPER + for kind, control, row, provider_active in ( + ("whisper-cpp", self.whisperCppModelControl, self.whisperCppModelRow, is_cpp), + ("faster-whisper", self.fasterWhisperModelControl, self.fasterWhisperModelRow, is_fw), + ): + options = self._installed_model_options(kind) + row.setVisible(provider_active and bool(options)) + if not options: + continue + field = control.config_item + current = field.value if field.value in options else options[0] + control.setOptions(options_from(options), keep_value=current) + + def _refresh_model_entries(self) -> None: + entries = { + "whisper-cpp": (self.whisperCppModelEntryRow, self.whisperCppManageButton), + "faster-whisper": (self.fasterWhisperModelEntryRow, self.fasterWhisperManageButton), + } + for kind, (row, button) in entries.items(): + _name, models_dir = self._model_entry_target(kind) + if not detect_program(kind).installed: + desc = tr("settings.transcribe.local_model.not_installed") + needs_action = True + elif not self._installed_model_options(kind): + desc = tr("settings.transcribe.local_model.no_model") + needs_action = True + else: + desc = tr("settings.transcribe.local_model.desc") + needs_action = False + row.descLabel.setText(desc) + button.setProperty("settingsPrimary", needs_action) + row.syncStyle() # 重新应用按钮主次样式 + button.setToolTip(str(models_dir)) + + def _refresh_llm_rows(self, value: Any) -> None: + current = value if isinstance(value, LLMServiceEnum) else LLMServiceEnum(str(value)) + # 公益大模型无可配置项:隐藏 provider 行与「加载模型」(模型固定),保留「测试连接」 + is_immersive = current == LLMServiceEnum.IMMERSIVE + self.llmImmersiveHintRow.setVisible(is_immersive) + self.loadLLMModelsButton.setVisible(not is_immersive) + custom_base_services = {LLMServiceEnum.OPENAI, LLMServiceEnum.OLLAMA, LLMServiceEnum.LM_STUDIO} + for service, rows in self.llmProviderRows.items(): + for row in rows: + row.setVisible(service == current) + base_row = self.llmApiBaseRows.get(service) + if base_row is not None: + base_row.setVisible(service == current and service in custom_base_services) + + controls = self.llmProviderControls.get(current) + if controls is not None: + self._apply_llm_model_options(current, self._llm_model_options(current)) + if current not in custom_base_services: + default_base = self.llmDefaultBases.get(current, "") + api_base_control = controls["api_base"] + if default_base and api_base_control.text().strip() != default_base: + cfg.set(api_base_control.config_item, default_base) + if current == LLMServiceEnum.OLLAMA and not controls["api_key"].text(): + controls["api_key"].setText("ollama") + elif current == LLMServiceEnum.LM_STUDIO and not controls["api_key"].text(): + controls["api_key"].setText("lm-studio") + + def _refresh_translate_rows(self, value: Any) -> None: + service = value if isinstance(value, TranslatorServiceEnum) else TranslatorServiceEnum(str(value)) + is_llm = service == TranslatorServiceEnum.OPENAI + is_deeplx = service == TranslatorServiceEnum.DEEPLX + self.needReflectTranslateRow.setVisible(is_llm) + self.batchSizeRow.setVisible(is_llm) + self.threadNumRow.setVisible(is_llm) + self.deeplxEndpointRow.setVisible(is_deeplx) + + def _refresh_dubbing_rows(self, provider: Any) -> None: + provider_key = str(provider) + option = get_provider_option(provider_key) + voice_options = get_provider_voices(provider_key) + preset_options = [Option(voice.preset, voice.title) for voice in voice_options] + current = cfg.dubbing_preset.value + if current not in {voice.preset for voice in voice_options}: + current = voice_options[0].preset + self.dubbingPresetControl.setOptions(preset_options, keep_value=current) + self.dubbingModelControl.setItems(option.models) + if cfg.dubbing_model.value not in option.models: + cfg.set(cfg.dubbing_model, option.models[0] if option.models else "") + if option.default_base: + cfg.set(cfg.dubbing_api_base, option.default_base) + for row in [self.dubbingApiKeyRow, self.dubbingModelRow]: + row.setVisible(option.needs_api_key) + # Edge 免费,并发由程序内部固定(pipeline.EDGE_TTS_WORKERS),不暴露给用户 + self.dubbingWorkersRow.setVisible(provider_key != "edge") + self._on_dubbing_preset_changed(current) + + def _on_dubbing_preset_changed(self, preset_name: Any) -> None: + try: + preset = get_dubbing_preset(str(preset_name)) + except ValueError: + return + cfg.set(cfg.dubbing_provider, preset.provider) + cfg.set(cfg.dubbing_voice, preset.voice) + cfg.set(cfg.dubbing_model, preset.model) + option = get_provider_option(preset.provider) + if option.needs_api_key and is_provider_default_base(cfg.dubbing_api_base.value): + cfg.set(cfg.dubbing_api_base, preset.api_base or option.default_base) + + def _choose_work_dir(self) -> None: + folder = QFileDialog.getExistingDirectory(self, tr("settings.save.choose_work_dir"), cfg.work_dir.value) + if not folder: return cfg.set(cfg.work_dir, folder) - self.savePathCard.setContent(folder) - def __onCacheEnabledChanged(self, is_enabled: bool): - """处理缓存开关变化""" - if is_enabled: + def _choose_faster_whisper_dir(self) -> None: + folder = QFileDialog.getExistingDirectory( + self, + tr("settings.transcribe.faster_whisper.choose_dir"), + cfg.faster_whisper_model_dir.value or cfg.work_dir.value, + ) + if not folder: + return + cfg.set(cfg.faster_whisper_model_dir, folder) + + def _on_cache_enabled_changed(self, enabled: bool) -> None: + if enabled: enable_cache() InfoBar.success( - self.tr("缓存已启用"), - self.tr("ASR、翻译等操作将优先使用缓存"), + tr("settings.save.cache_enabled"), + tr("settings.save.cache_enabled.detail"), duration=INFOBAR_DURATION_SUCCESS, - parent=self, + parent=self._toast_parent(), ) else: disable_cache() InfoBar.warning( - self.tr("缓存已禁用"), - self.tr("所有操作将重新生成,不使用缓存(建议开启缓存)"), + tr("settings.save.cache_disabled"), + tr("settings.save.cache_disabled.detail"), duration=INFOBAR_DURATION_WARNING, - parent=self, + parent=self._toast_parent(), ) - def checkLLMConnection(self): - """检查 LLM 连接""" - # 保存当前滚动位置 - scroll_position = self.verticalScrollBar().value() + def _choose_theme_color(self) -> None: + from videocaptioner.ui.components.color_picker import ColorPickerDialog - # 获取当前选中的服务 - current_service = LLMServiceEnum(self.llmServiceCard.comboBox.currentText()) + color = ColorPickerDialog.get_color( + cfg.themeColor.value, parent=self._toast_parent(), alpha=False, title=tr("settings.personal.choose_theme_color") + ) + if color is None or not color.isValid(): + return + cfg.set(cfg.themeColor, color) - # 获取服务配置 - service_config = self.llm_service_configs.get(current_service) - if not service_config: + def _reset_theme_color(self) -> None: + default_color = QColor(DEFAULT_THEME_COLOR) + current_color = cfg.themeColor.value if isinstance(cfg.themeColor.value, QColor) else QColor(str(cfg.themeColor.value)) + if current_color.isValid() and current_color.name(QColor.HexRgb).lower() == default_color.name(QColor.HexRgb).lower(): return + cfg.set(cfg.themeColor, default_color) - api_base = ( - service_config["api_base"].lineEdit.text() - if service_config["api_base"] - else "" - ) - api_key = ( - service_config["api_key"].lineEdit.text() - if service_config["api_key"] - else "" - ) - model = ( - service_config["model"].comboBox.currentText() - if service_config["model"] - else "" + def _apply_theme_color(self, color: Any, attempt: int = 0) -> None: + try: + setThemeColor(color) + except RuntimeError: + if attempt >= 2: + raise + retry_color = QColor(color) + QTimer.singleShot(0, lambda: self._apply_theme_color(retry_color, attempt + 1)) + + def _sync_theme_color_swatch(self, value: Any) -> None: + color = value if isinstance(value, QColor) else QColor(str(value)) + if not color.isValid(): + color = QColor(DEFAULT_THEME_COLOR) + self.themeColorSwatch.setColor(color) + self.themeColorSwatch.setToolTip( + tr("settings.personal.theme_color.pick_tip", color=color.name(QColor.HexRgb)) ) + if hasattr(self, "themeColorResetButton"): + default_color = QColor(DEFAULT_THEME_COLOR).name(QColor.HexRgb).lower() + is_default = color.name(QColor.HexRgb).lower() == default_color + self.themeColorResetButton.setEnabled(not is_default) + self.themeColorResetButton.setToolTip( + tr("settings.personal.theme_color.is_default") + if is_default + else tr("settings.personal.theme_color.reset_tip") + ) - # 禁用检查按钮,显示加载状态 - self.checkLLMConnectionCard.button.setEnabled(False) - self.checkLLMConnectionCard.button.setText(self.tr("正在检查...")) - - # 立即恢复滚动位置(防止按钮状态改变导致的自动滚动) - self.verticalScrollBar().setValue(scroll_position) + def _sync_visual_style(self) -> None: + self.syncStyle() + if hasattr(self, "themeColorSwatch"): + self._sync_theme_color_swatch(cfg.themeColor.value) - # 创建并启动线程 - self.connection_thread = LLMConnectionThread(api_base, api_key, model) - self.connection_thread.finished.connect(self.onConnectionCheckFinished) - self.connection_thread.error.connect(self.onConnectionCheckError) - self.connection_thread.start() + def _open_subtitle_style_page(self) -> None: + # 弹窗内:发信号让 SettingsDialog 关闭弹窗并切到主窗口的字幕样式 tab + self.openStylePageRequested.emit() - def onConnectionCheckError(self, message): - """处理连接检查错误事件""" - self.checkLLMConnectionCard.button.setEnabled(True) - self.checkLLMConnectionCard.button.setText(self.tr("检查连接")) - InfoBar.error( - self.tr("LLM 连接测试错误"), - message, - duration=INFOBAR_DURATION_ERROR, + def _show_restart_tip(self) -> None: + # 语言/显示等 restart 项变更后:确认即真重启(QProcess 重拉 + 退出),不再只弹提示。 + confirmed = ConfirmDialog( + tr("settings.restart.title"), + tr("settings.restart.message"), parent=self, + confirm_text=tr("settings.restart.confirm"), + cancel_text=tr("settings.restart.later"), + ).exec() + if confirmed: + QProcess.startDetached(sys.executable, sys.argv) + QApplication.quit() + + def check_llm_connection(self) -> None: + service = cfg.llm_service.value + if service == LLMServiceEnum.IMMERSIVE: + # 公益大模型 base/model 固定、无用户凭证;真实令牌在 check 内实时换取 + api_base, api_key, model = ( + free_model.BASE_URL, + free_model.PLACEHOLDER_KEY, + free_model.MODEL, + ) + else: + controls = self.llmProviderControls.get(service) + if controls is None: + return + api_base = controls["api_base"].text().strip() + api_key = controls["api_key"].text().strip() + model = controls["model"].currentText().strip() + if not api_base or not api_key or not model: + InfoBar.warning( + tr("settings.warn.incomplete"), + tr("settings.llm.warn.need_all"), + duration=INFOBAR_DURATION_WARNING, + parent=self._toast_parent(), + ) + return + self._run_button_thread( + self.checkLLMButton, + tr("settings.llm.test_connection"), + tr("settings.busy.testing"), + LLMConnectionThread(api_base, api_key, model), + self._on_llm_check_finished, + self._on_llm_check_error, ) - def onConnectionCheckFinished(self, is_success, message, models): - """处理连接检查完成事件""" - self.checkLLMConnectionCard.button.setEnabled(True) - self.checkLLMConnectionCard.button.setText(self.tr("检查连接")) - - # 获取当前服务 - current_service = LLMServiceEnum(self.llmServiceCard.comboBox.currentText()) - - if models: - # 更新当前服务的模型列表 - service_config = self.llm_service_configs.get(current_service) - if service_config and service_config["model"]: - temp = service_config["model"].comboBox.currentText() - service_config["model"].setItems(models) - service_config["model"].comboBox.setCurrentText(temp) + def load_llm_models(self) -> None: + service = cfg.llm_service.value + controls = self.llmProviderControls.get(service) + if controls is None: + return + api_base = controls["api_base"].text().strip() + api_key = controls["api_key"].text().strip() + if not api_base or not api_key: + InfoBar.warning( + tr("settings.warn.incomplete"), + tr("settings.llm.warn.need_base_key"), + duration=INFOBAR_DURATION_WARNING, + parent=self._toast_parent(), + ) + return + self._run_button_thread( + self.loadLLMModelsButton, + tr("settings.llm.load_models"), + tr("settings.busy.loading"), + LLMModelLoadThread(service, api_base, api_key), + self._on_llm_models_loaded, + self._on_llm_models_load_error, + ) + def _on_llm_check_finished(self, success: bool, message: str) -> None: + if success: InfoBar.success( - self.tr("获取模型列表成功:"), - self.tr("一共") + str(len(models)) + self.tr("个模型"), + tr("settings.llm.connect_success"), + message, duration=INFOBAR_DURATION_SUCCESS, - parent=self, + parent=self._toast_parent(), ) - if not is_success: + else: InfoBar.error( - self.tr("LLM 连接测试错误"), + tr("settings.llm.connect_failed"), message, duration=INFOBAR_DURATION_ERROR, - parent=self, + parent=self._toast_parent(), ) - else: - InfoBar.success( - self.tr("LLM 连接测试成功"), - message, - duration=INFOBAR_DURATION_SUCCESS, - parent=self, - ) - - def checkUpdate(self): - webbrowser.open(RELEASE_URL) - - def __onLLMServiceChanged(self, service): - """处理LLM服务切换事件""" - current_service = LLMServiceEnum(service) - - # 隐藏所有卡片 - for config in self.llm_service_configs.values(): - for card in config["cards"]: - card.setVisible(False) - - # 隐藏OPENAI官方API链接卡片 - self.openaiOfficialApiCard.setVisible(False) - - # 显示选中服务的卡片 - if current_service in self.llm_service_configs: - for card in self.llm_service_configs[current_service]["cards"]: - card.setVisible(True) - - # 为OLLAMA和LM_STUDIO设置默认API Key - service_config = self.llm_service_configs[current_service] - if current_service == LLMServiceEnum.OLLAMA and service_config["api_key"]: - # 如果API Key为空,设置默认值"ollama" - if not service_config["api_key"].lineEdit.text(): - service_config["api_key"].lineEdit.setText("ollama") - if ( - current_service == LLMServiceEnum.LM_STUDIO - and service_config["api_key"] - ): - # 如果API Key为空,设置默认值 "lm-studio" - if not service_config["api_key"].lineEdit.text(): - service_config["api_key"].lineEdit.setText("lm-studio") - - # 如果是OPENAI服务,显示官方API链接卡片 - if current_service == LLMServiceEnum.OPENAI: - self.openaiOfficialApiCard.setVisible(True) - - # 更新布局 - self.llmGroup.adjustSize() - self.expandLayout.update() - - def __onTranslatorServiceChanged(self, service): - openai_cards = [ - self.needReflectTranslateCard, - self.batchSizeCard, - ] - deeplx_cards = [self.deeplxEndpointCard] - - all_cards = openai_cards + deeplx_cards - for card in all_cards: - card.setVisible(False) - - # 根据选择的服务显示相应的配置卡片 - if service in [TranslatorServiceEnum.DEEPLX.value]: - for card in deeplx_cards: - card.setVisible(True) - elif service in [TranslatorServiceEnum.OPENAI.value]: - for card in openai_cards: - card.setVisible(True) - - # 更新布局 - self.translate_serviceGroup.adjustSize() - self.expandLayout.update() - - def __onTranscribeModelChanged(self, model_name): - """处理转录模型切换事件""" - # Whisper API 配置卡片 - whisper_api_cards = [ - self.whisperApiBaseCard, - self.whisperApiKeyCard, - self.whisperApiModelCard, - self.checkWhisperConnectionCard, - ] - - # 根据选择的模型显示/隐藏 Whisper API 配置 - is_whisper_api = model_name == TranscribeModelEnum.WHISPER_API.value - for card in whisper_api_cards: - card.setVisible(is_whisper_api) - # 更新布局 - self.transcribeGroup.adjustSize() - self.expandLayout.update() - - def checkWhisperConnection(self): - """检查 Whisper API 连接""" - # 保存当前滚动位置 - scroll_position = self.verticalScrollBar().value() - - # 获取配置 - base_url = self.whisperApiBaseCard.lineEdit.text().strip() - api_key = self.whisperApiKeyCard.lineEdit.text().strip() - model = self.whisperApiModelCard.comboBox.currentText().strip() + def _on_llm_check_error(self, message: str) -> None: + InfoBar.error( + tr("settings.llm.connect_error"), + message, + duration=INFOBAR_DURATION_ERROR, + parent=self._toast_parent(), + ) - # 验证必填字段 - if not base_url: + def _on_llm_models_loaded(self, service: object, models: list[str]) -> None: + try: + service = service if isinstance(service, LLMServiceEnum) else LLMServiceEnum(str(service)) + except ValueError: + service = cfg.llm_service.value + models = self._clean_model_options(models) + if not models: InfoBar.warning( - self.tr("配置不完整"), - self.tr("请输入 Whisper API Base URL"), - duration=INFOBAR_DURATION_ERROR, - parent=self, + tr("settings.llm.no_models"), + tr("settings.llm.no_models.desc"), + duration=INFOBAR_DURATION_WARNING, + parent=self._toast_parent(), ) return + self._save_llm_model_options(service, models) + if cfg.llm_service.value == service: + self._apply_llm_model_options(service, models) + InfoBar.success( + tr("settings.llm.models_loaded"), + tr("settings.llm.models_loaded.desc", count=len(models)), + duration=INFOBAR_DURATION_SUCCESS, + parent=self._toast_parent(), + ) - if not api_key: - InfoBar.warning( - self.tr("配置不完整"), - self.tr("请输入 Whisper API Key"), - duration=INFOBAR_DURATION_ERROR, - parent=self, - ) - return + def _on_llm_models_load_error(self, message: str) -> None: + InfoBar.error( + tr("settings.llm.models_load_failed"), + message, + duration=INFOBAR_DURATION_ERROR, + parent=self._toast_parent(), + ) - if not model: + def check_transcribe_connection(self) -> None: + """统一测试转录:先做提供商必填项快检,再真实跑短音频。""" + missing = self._transcribe_check_missing() + if missing: InfoBar.warning( - self.tr("配置不完整"), - self.tr("请输入 Whisper 模型名称"), - duration=INFOBAR_DURATION_ERROR, - parent=self, + tr("settings.warn.incomplete"), + missing, + duration=INFOBAR_DURATION_WARNING, + parent=self._toast_parent(), ) return + from videocaptioner.ui.config_adapter import app_config_from_ui - # 禁用按钮,显示加载状态 - self.checkWhisperConnectionCard.button.setEnabled(False) - self.checkWhisperConnectionCard.button.setText(self.tr("正在测试...")) - - # 立即恢复滚动位置(防止按钮状态改变导致的自动滚动) - self.verticalScrollBar().setValue(scroll_position) - - # 创建并启动测试线程 - self.whisper_connection_thread = WhisperConnectionThread( - base_url, api_key, model + config = TaskBuilder(app_config_from_ui(cfg)).create_transcribe_config( + need_word_timestamp=False ) - self.whisper_connection_thread.finished.connect( - self.onWhisperConnectionCheckFinished + self._run_button_thread( + self.checkTranscribeButton, + tr("settings.test_transcribe"), + tr("settings.busy.transcribing"), + TranscribeCheckThread(config), + self._on_transcribe_check_finished, + self._on_transcribe_check_error, ) - self.whisper_connection_thread.error.connect(self.onWhisperConnectionCheckError) - self.whisper_connection_thread.start() - - def onWhisperConnectionCheckFinished(self, success, result): - """处理 Whisper 连接检查完成事件""" - # 恢复按钮状态 - self.checkWhisperConnectionCard.button.setEnabled(True) - self.checkWhisperConnectionCard.button.setText(self.tr("测试 Whisper 连接")) + def _transcribe_check_missing(self) -> str: + """当前转录服务缺少的必填配置;齐全返回空串。""" + model = cfg.transcribe_model.value + if model == TranscribeModelEnum.WHISPER_API: + if not ( + cfg.whisper_api_base.value.strip() + and cfg.whisper_api_key.value.strip() + and cfg.whisper_api_model.value.strip() + ): + return tr("settings.transcribe.missing.whisper_api") + elif model == TranscribeModelEnum.BAILIAN_FUN_ASR: + if not cfg.fun_asr_api_key.value.strip(): + return tr("settings.transcribe.missing.fun_asr_key") + elif model == TranscribeModelEnum.WHISPER_CPP: + if not self._installed_model_options("whisper-cpp"): + return tr("settings.transcribe.missing.local_model") + elif model == TranscribeModelEnum.FASTER_WHISPER: + if not self._installed_model_options("faster-whisper"): + return tr("settings.transcribe.missing.local_model") + return "" + + def _on_transcribe_check_finished(self, success: bool, detail: str) -> None: if success: + text = detail if len(detail) <= 80 else detail[:79] + "…" InfoBar.success( - self.tr("连接成功"), - self.tr("Whisper API 连接成功!\n转录结果:") + result, + tr("settings.transcribe.test_success"), + tr("settings.transcribe.test_result", text=text), duration=INFOBAR_DURATION_SUCCESS, - parent=self, + parent=self._toast_parent(), ) else: InfoBar.error( - self.tr("连接失败"), - self.tr(f"Whisper API 连接失败!\n{result}"), + tr("settings.transcribe.test_failed"), + detail, duration=INFOBAR_DURATION_ERROR, - parent=self, + parent=self._toast_parent(), ) - def onWhisperConnectionCheckError(self, message): - """处理 Whisper 连接检查错误事件""" - # 恢复按钮状态 - self.checkWhisperConnectionCard.button.setEnabled(True) - self.checkWhisperConnectionCard.button.setText(self.tr("测试 Whisper 连接")) - + def _on_transcribe_check_error(self, message: str) -> None: InfoBar.error( - self.tr("测试错误"), - message, - duration=INFOBAR_DURATION_ERROR, - parent=self, + tr("settings.transcribe.test_error"), message, duration=INFOBAR_DURATION_ERROR, parent=self._toast_parent() + ) + + def check_live_caption_connection(self) -> None: + if cfg.live_caption_provider.value in ("fun-asr", "qwen-asr") and not cfg.fun_asr_api_key.value.strip(): + InfoBar.warning( + tr("settings.warn.incomplete"), + tr("settings.transcribe.missing.fun_asr_key"), + duration=INFOBAR_DURATION_WARNING, + parent=self._toast_parent(), + ) + return + config = LiveCaptionConfig( + backend=cfg.live_caption_provider.value, + voxgate_binary=cfg.live_caption_voxgate_binary.value, + api_key=str(cfg.fun_asr_api_key.value or "").strip(), + asr_model=cfg.live_caption_fun_asr_model.value, + source_language=cfg.live_caption_source_language.value, + translate_enabled=False, + ) + self._run_button_thread( + self.checkLiveCaptionButton, + tr("settings.test_transcribe"), + tr("settings.busy.transcribing"), + LiveCaptionCheckThread(config), + self._on_transcribe_check_finished, + self._on_transcribe_check_error, ) + def check_dubbing_connection(self) -> None: + preset_name = str(cfg.dubbing_preset.value) + try: + preset = get_dubbing_preset(preset_name) + except ValueError as exc: + InfoBar.error(tr("settings.dubbing.config_error"), str(exc), duration=INFOBAR_DURATION_ERROR, parent=self._toast_parent()) + return -class WhisperConnectionThread(QThread): - """Whisper API 连接测试线程""" + api_key = cfg.dubbing_api_key.value.strip() + api_base = cfg.dubbing_api_base.value.strip() or preset.api_base + model = cfg.dubbing_model.value.strip() or preset.model + if preset.provider != "edge" and not api_key: + InfoBar.warning( + tr("settings.warn.incomplete"), + tr("settings.dubbing.need_api_key"), + duration=INFOBAR_DURATION_WARNING, + parent=self._toast_parent(), + ) + return - finished = pyqtSignal(bool, str) + output_dir = Path(cfg.work_dir.value) / "dubbing-test" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{preset_name}.wav" + self._run_button_thread( + self.checkDubbingButton, + tr("settings.dubbing.test_button"), + tr("settings.busy.testing"), + DubbingConnectionThread( + provider=preset.provider, + api_key=api_key if preset.provider != "edge" else "", + api_base=api_base if preset.provider != "edge" else "", + model=model if preset.provider != "edge" else preset.model, + voice=preset.voice, + output_path=str(output_path), + style_prompt=preset.style_prompt, + ), + self._on_dubbing_check_finished, + self._on_dubbing_check_error, + ) + + def _on_dubbing_check_finished(self, audio_path: str, provider: str) -> None: + InfoBar.success( + tr("settings.dubbing.test_success"), + tr("settings.dubbing.test_success.detail", provider=provider, path=audio_path), + duration=INFOBAR_DURATION_SUCCESS, + parent=self._toast_parent(), + ) + + def _on_dubbing_check_error(self, message: str) -> None: + InfoBar.error(tr("settings.dubbing.test_failed"), message, duration=INFOBAR_DURATION_ERROR, parent=self._toast_parent()) + + def _toast_parent(self): + """toast / 弹窗的 parent。 + + 设置页是覆盖主窗口的子级遮罩(MaskDialogBase,非顶层窗口)。toast 必须 parent + 到这个遮罩才能盖在它之上、铺满整窗:parent 到主窗口会被遮罩盖住,parent 到内容 + 卡片(self)则被裁在卡片右上角(贴着关闭叉)。独立使用时回退到 window()/self。 + """ + node = self.parent() + while node is not None: + if isinstance(node, MaskDialogBase): + return node + node = node.parent() + return self.window() or self + + def _run_button_thread( + self, + button, + idle_text: str, + busy_text: str, + thread: QThread, + finished_slot, + error_slot, + ) -> None: + button.setEnabled(False) + button.setText(busy_text) + + def restore_button(*_args): + button.setEnabled(True) + button.setText(idle_text) + if thread in self._threads: + self._threads.remove(thread) + + thread.finished.connect(restore_button) + thread.finished.connect(finished_slot) + thread.error.connect(restore_button) + thread.error.connect(error_slot) + self._threads.append(thread) + thread.start() + + def closeEvent(self, event): + # 退出时停掉所有检查网络线程(LLM/转录/配音,分钟级):main_window.closeEvent + # 会 close() 本页,running QThread 被销毁触发 qFatal。只读网络线程,terminate 安全。 + for thread in list(self._threads): + if thread.isRunning(): + thread.terminate() + thread.wait(1000) + self._threads.clear() + super().closeEvent(event) + + @staticmethod + def _llm_provider_specs() -> dict[LLMServiceEnum, dict[str, Any]]: + return { + LLMServiceEnum.OPENAI: { + "api_key": cfg.openai_api_key, + "api_base": cfg.openai_api_base, + "model": cfg.openai_model, + "model_options": cfg.openai_model_options, + "default_base": "https://api.openai.com/v1", + "models": [ + "gemini-2.5-pro", + "gpt-5", + "claude-sonnet-4-5-20250929", + "gemini-2.5-flash", + "claude-haiku-4-5-20251001", + ], + }, + LLMServiceEnum.SILICON_CLOUD: { + "api_key": cfg.silicon_cloud_api_key, + "api_base": cfg.silicon_cloud_api_base, + "model": cfg.silicon_cloud_model, + "model_options": cfg.silicon_cloud_model_options, + "default_base": "https://api.siliconflow.cn/v1", + "models": ["moonshotai/Kimi-K2-Instruct-0905", "deepseek-ai/DeepSeek-V3"], + }, + LLMServiceEnum.DEEPSEEK: { + "api_key": cfg.deepseek_api_key, + "api_base": cfg.deepseek_api_base, + "model": cfg.deepseek_model, + "model_options": cfg.deepseek_model_options, + "default_base": "https://api.deepseek.com/v1", + "models": ["deepseek-chat", "deepseek-reasoner"], + }, + LLMServiceEnum.OLLAMA: { + "api_key": cfg.ollama_api_key, + "api_base": cfg.ollama_api_base, + "model": cfg.ollama_model, + "model_options": cfg.ollama_model_options, + "default_base": "http://localhost:11434/v1", + "models": ["qwen3:8b"], + }, + LLMServiceEnum.LM_STUDIO: { + "api_key": cfg.lm_studio_api_key, + "api_base": cfg.lm_studio_api_base, + "model": cfg.lm_studio_model, + "model_options": cfg.lm_studio_model_options, + "default_base": "http://localhost:1234/v1", + "models": ["qwen3:8b"], + }, + LLMServiceEnum.GEMINI: { + "api_key": cfg.gemini_api_key, + "api_base": cfg.gemini_api_base, + "model": cfg.gemini_model, + "model_options": cfg.gemini_model_options, + "default_base": "https://generativelanguage.googleapis.com/v1beta/openai/", + "models": ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash-lite"], + }, + LLMServiceEnum.CHATGLM: { + "api_key": cfg.chatglm_api_key, + "api_base": cfg.chatglm_api_base, + "model": cfg.chatglm_model, + "model_options": cfg.chatglm_model_options, + "default_base": "https://open.bigmodel.cn/api/paas/v4", + "models": ["glm-4-plus", "glm-4-air-250414", "glm-4-flash"], + }, + } + + def _llm_model_options(self, service: LLMServiceEnum) -> list[str]: + provider = self.llmProviderSpecs.get(service) + if provider is None: + return [] + return self._llm_model_options_for_provider(provider) + + def _llm_model_options_for_provider(self, provider: dict[str, Any]) -> list[str]: + cached = self._clean_model_options(provider["model_options"].value) + if cached: + return cached + return self._clean_model_options(provider["models"]) + + def _apply_llm_model_options(self, service: LLMServiceEnum, models: list[str]) -> None: + controls = self.llmProviderControls.get(service) + if controls is None: + return + model_control = controls["model"] + current = model_control.currentText().strip() + model_control.setItems(models) + if not current and models: + model_control.setValue(models[0]) + cfg.set(model_control.config_item, models[0]) + + def _save_llm_model_options(self, service: LLMServiceEnum, models: list[str]) -> None: + provider = self.llmProviderSpecs.get(service) + if provider is None: + return + cfg.set(provider["model_options"], self._clean_model_options(models)) + + @staticmethod + def _clean_model_options(models: Any) -> list[str]: + if not isinstance(models, list): + return [] + options: list[str] = [] + seen: set[str] = set() + for item in models: + model = str(item or "").strip() + if not model or model in seen: + continue + seen.add(model) + options.append(model) + return options + + @staticmethod + def _dubbing_provider_options(): + from videocaptioner.ui.common.dubbing_options import DUBBING_PROVIDERS + + return DUBBING_PROVIDERS + + @staticmethod + def _two_controls(left, right, parent): + container = QWidget(parent) + # 容器必须显式透明,否则在 qfluent 暗色样式下被涂成黑块, + # 两个控件之间会露出一条黑色背景缝。 + container.setObjectName("settingsControlPair") + container.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + container.setStyleSheet( + "QWidget#settingsControlPair { background: transparent; }" + ) + container.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + if left.objectName() == "settingsValueLabel" and left.maximumWidth() > 10000: + left.setFixedWidth(CONTROL_WIDTH) + left.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + right.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + layout.addWidget(left) + layout.addWidget(right) + return container + + +class DubbingConnectionThread(QThread): + finished = pyqtSignal(str, str) error = pyqtSignal(str) - def __init__(self, base_url, api_key, model): + def __init__( + self, + provider: str, + api_key: str, + api_base: str, + model: str, + voice: str, + output_path: str, + style_prompt: str = "", + ): super().__init__() - self.base_url = base_url + self.provider = provider self.api_key = api_key + self.api_base = api_base self.model = model + self.voice = voice + self.output_path = output_path + self.style_prompt = style_prompt - def run(self): - """执行连接测试""" + def run(self) -> None: try: - success, result = check_whisper_connection( - self.base_url, self.api_key, self.model + core_config = build_dubbing_config( + provider=self.provider, + api_key=self.api_key, + api_base=self.api_base, + model=self.model, + voice=self.voice, + style_prompt=self.style_prompt, ) - self.finished.emit(success, result) - except Exception as e: - self.error.emit(str(e)) + response_format = core_config.response_format + if core_config.provider == "gemini": + response_format = "wav" + elif core_config.provider == "edge": + response_format = "mp3" + synthesizer = create_speech_synthesizer( + SpeechProviderConfig( + provider=core_config.provider, + api_key=core_config.api_key, + base_url=core_config.base_url, + model=core_config.model, + default_voice=core_config.voice, + response_format=response_format, + sample_rate=core_config.sample_rate, + speed=core_config.speed, + gain=core_config.gain, + timeout=core_config.timeout, + style_prompt=core_config.style_prompt, + ) + ) + result = synthesizer.synthesize( + SynthesisRequest( + text="你好,这是卡卡字幕助手的配音测试。", + output_path=self.output_path, + voice=core_config.voice, + style_prompt=core_config.style_prompt or None, + ) + ) + self.finished.emit(result.output_path, core_config.provider) + except Exception as exc: + self.error.emit(str(exc)) + + +class TranscribeCheckThread(QThread): + """跑一次真实短音频转录(core.asr.check.check_transcribe)。""" + + finished = pyqtSignal(bool, str) + error = pyqtSignal(str) + + def __init__(self, config): + super().__init__() + self.config = config + + def run(self) -> None: + try: + result = check_transcribe(self.config) + self.finished.emit(result.success, result.detail) + except Exception as exc: + self.error.emit(str(exc)) + + +class LiveCaptionCheckThread(QThread): + """跑一次真实短音频实时转录(core.realtime.check.check_live_caption)。""" + + finished = pyqtSignal(bool, str) + error = pyqtSignal(str) + + def __init__(self, config): + super().__init__() + self.config = config + + def run(self) -> None: + try: + result = check_live_caption(self.config) + self.finished.emit(result.success, result.detail) + except Exception as exc: + self.error.emit(str(exc)) class LLMConnectionThread(QThread): - finished = pyqtSignal(bool, str, list) + finished = pyqtSignal(bool, str) error = pyqtSignal(str) - def __init__(self, api_base, api_key, model): + def __init__(self, api_base: str, api_key: str, model: str): super().__init__() self.api_base = api_base self.api_key = api_key self.model = model - def run(self): - """检查 LLM 连接并获取模型列表""" + def run(self) -> None: + try: + success, message = check_llm_connection(self.api_base, self.api_key, self.model) + self.finished.emit(success, message) + except Exception as exc: + self.error.emit(str(exc)) + + +class LLMModelLoadThread(QThread): + finished = pyqtSignal(object, list) + error = pyqtSignal(str) + + def __init__(self, service: LLMServiceEnum, api_base: str, api_key: str): + super().__init__() + self.service = service + self.api_base = api_base + self.api_key = api_key + + def run(self) -> None: try: - is_success, message = check_llm_connection( - self.api_base, self.api_key, self.model - ) models = get_available_models(self.api_base, self.api_key) - self.finished.emit(is_success, message, models) - except Exception as e: - self.error.emit(str(e)) + self.finished.emit(self.service, models) + except Exception as exc: + self.error.emit(str(exc)) + + +class SettingsDialog(MaskDialogBase): + """设置弹窗:为设置定制的大尺寸 modal(不复用通用确认框 AppDialog/ConfirmDialog)。 + + 遮罩 + 居中大卡片,卡片内嵌 SettingInterface(左侧分类侧栏 + 右侧滚动内容)。 + 侧栏顶部「返回应用」即关闭;Esc 同样关闭。长生命周期单例复用:保证内部 check + 线程的 closeEvent 终止契约(见 SettingInterface.closeEvent)继续有效。 + """ + + CARD_RADIUS = 16 + + def __init__(self, parent=None): + main_window = parent.window() if parent is not None else None + super().__init__(main_window) + self._main_window = main_window + # 卡片阴影是静态的(淡入/淡出只动遮罩、不动卡片,见 showEvent/done), + # 不再每帧重栅格,因此可以用更柔和的大 blur 让卡片更有浮起感。 + self.setShadowEffect(48, (0, 12), QColor(0, 0, 0, 130)) + self.setMaskColor(QColor(0, 0, 0, 150)) + self.setClosableOnMaskClicked(True) # 点遮罩空白处关闭设置弹窗 + # 卡片按固定尺寸居中,而非被遮罩拉满 + self._hBoxLayout.setAlignment(self.widget, Qt.AlignCenter) # type: ignore[arg-type] + + card = self.widget + card.setObjectName("settingsDialogCard") + layout = QVBoxLayout(card) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.settingInterface = SettingInterface(card) + self.settingInterface.backRequested.connect(lambda: self.done(0)) + self.settingInterface.openStylePageRequested.connect(self._go_subtitle_style) + layout.addWidget(self.settingInterface) + # 右上角关闭叉(更普适):覆盖在内容之上,点它 / Esc / 点遮罩都能关 + self.closeButton = RoundIconButton(AppIcon.CLOSE, parent=card) + self.closeButton.clicked.connect(lambda: self.done(0)) + self._sync_card_style() + + def _go_subtitle_style(self) -> None: + """关闭设置弹窗并切到主窗口的字幕样式 tab。""" + self.done(0) + target = getattr(self._main_window, "subtitleStyleInterface", None) + switch_to = getattr(self._main_window, "switchTo", None) + if target is not None and callable(switch_to): + switch_to(target) + + def open_at(self, page_key: str) -> bool: + """切到指定分类并弹出;分类无效则不弹、返回 False。""" + if not self.settingInterface.setCurrentPage(page_key): + return False + self.exec() + return True + + def showEvent(self, event): # noqa: N802 + self._resize_card() + # 关键优化:不走 MaskDialogBase 的「整窗淡入」——它给整窗(遮罩 + 940x680 + # 卡片 + 阴影)套一个 opacity 动画,每帧都要把大卡片重栅格化(实测约 9ms/帧, + # 淡入期间累计重栅格 ≈100ms+),表现为「右侧内容像是慢慢才加载出来」。 + # 改为只淡入遮罩(纯色矩形,约 0.4ms/帧),卡片即时满不透明出现:内容立刻可见、 + # 开/关都跟手。 + QDialog.showEvent(self, event) + self._fade_mask(0.0, 1.0, 150) + + def done(self, code): # noqa: N802 + # 关闭同理:只淡出遮罩,不对大卡片做 opacity 动画(点遮罩空白处关闭也因此跟手)。 + self._fade_mask(1.0, 0.0, 110, on_finish=lambda: QDialog.done(self, code)) + + def _fade_mask(self, start: float, end: float, duration: int, on_finish=None) -> None: + effect = QGraphicsOpacityEffect(self.windowMask) + self.windowMask.setGraphicsEffect(effect) + animation = QPropertyAnimation(effect, b"opacity", self) + animation.setStartValue(start) + animation.setEndValue(end) + animation.setDuration(duration) + animation.setEasingCurve(QEasingCurve.OutCubic) + animation.finished.connect(lambda: self.windowMask.setGraphicsEffect(None)) + if on_finish is not None: + animation.finished.connect(on_finish) + animation.start() + self._mask_animation = animation # 持引用,防止动画被回收中断 + + def resizeEvent(self, event): # noqa: N802 + super().resizeEvent(event) + self._resize_card() + + def _resize_card(self) -> None: + host = self.parent() + if host is not None and host.width() > 0: + pw, ph = host.width(), host.height() + else: + pw, ph = self.width() or 1050, self.height() or 800 + w = max(720, min(940, int(pw * 0.88))) + h = max(520, min(680, int(ph * 0.90))) + self.widget.setFixedSize(w, h) + # 等效 overflow:hidden——圆角裁剪,使内嵌内容的直角不戳出卡片圆角 + path = QPainterPath() + path.addRoundedRect(QRectF(0, 0, w, h), self.CARD_RADIUS, self.CARD_RADIUS) + self.widget.setMask(QRegion(path.toFillPolygon().toPolygon())) + # 关闭叉钉在右上角,始终压在最上层 + self.closeButton.move(w - self.closeButton.width() - 14, 14) + self.closeButton.raise_() + + def _sync_card_style(self) -> None: + palette = app_palette() + self.widget.setStyleSheet( + f"QWidget#settingsDialogCard {{ background: {palette.bg};" + f" border-radius: {self.CARD_RADIUS}px; }}" + ) diff --git a/videocaptioner/ui/view/subtitle_interface.py b/videocaptioner/ui/view/subtitle_interface.py index 42c8e246..922265b2 100644 --- a/videocaptioner/ui/view/subtitle_interface.py +++ b/videocaptioner/ui/view/subtitle_interface.py @@ -1,44 +1,64 @@ # -*- coding: utf-8 -*- -import json +"""字幕优化与翻译页:两栏审校工作台。 + +左侧是字幕表格面板(文件行 + 可编辑表格 + 底部状态条), +右侧是固定 340px 的处理设置栏(选项卡片 + 主操作按钮)。 + +页面状态机(PageState): + + EMPTY 未加载字幕:表格区为拖放导入空态,按钮禁用 + READY 准备处理:字幕已载入表格,可编辑、可开始 + RUNNING 处理中:底部进度条 + 当前条数,表格未处理行变暗,可取消 + DONE 完成检查:表格展示处理结果,按钮变“进入合成” + FAILED 配置未就绪 / 处理失败:错误卡片 + 引导去配置或重试 + +线程统一由 SubtitleProcessController 持有;表格数据由 SubtitleTableModel +管理(双击编辑原文/译文,右键合并 / 删除 / 重新翻译,快捷键 +Ctrl+M / Delete / Ctrl+T)。 + +对外接口(HomeInterface 依赖,保持兼容): + finished(str, str) 视频路径、处理后字幕路径 + set_task(task) / process() / close() +""" + +from __future__ import annotations + import os -import sys -import tempfile +from enum import Enum, auto from pathlib import Path -from typing import Any, Dict, List, Optional, Union - -from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, QTime, pyqtSignal -from PyQt5.QtGui import QCloseEvent, QColor, QDragEnterEvent, QDropEvent, QKeyEvent +from typing import Any, Callable, Dict, Optional, Union + +from PyQt5.QtCore import ( + QAbstractTableModel, + QEvent, + QModelIndex, + QObject, + Qt, + QTimer, + pyqtSignal, +) +from PyQt5.QtGui import QColor from PyQt5.QtWidgets import ( QAbstractItemView, - QApplication, QFileDialog, + QFrame, QHBoxLayout, QHeaderView, + QLabel, + QLineEdit, + QStackedWidget, + QStyle, + QStyledItemDelegate, + QTableView, QVBoxLayout, QWidget, ) -from qfluentwidgets import ( - Action, - BodyLabel, - CommandBar, - InfoBar, - InfoBarPosition, - MessageBoxBase, - PrimaryPushButton, - ProgressBar, - PushButton, - RoundMenu, - TableView, - TextEdit, - TransparentDropDownPushButton, -) +from qfluentwidgets import Action, InfoBar, RoundMenu from qfluentwidgets import FluentIcon as FIF -from videocaptioner.config import SUBTITLE_STYLE_PATH from videocaptioner.core.asr.asr_data import ASRData from videocaptioner.core.constant import ( INFOBAR_DURATION_ERROR, - INFOBAR_DURATION_INFO, INFOBAR_DURATION_SUCCESS, INFOBAR_DURATION_WARNING, ) @@ -47,113 +67,234 @@ SubtitleLayoutEnum, SubtitleTask, SupportedSubtitleFormats, + TranslatorServiceEnum, ) from videocaptioner.core.subtitle import get_subtitle_style from videocaptioner.core.translate.types import TargetLanguage from videocaptioner.core.utils.platform_utils import open_folder, reveal_in_explorer +from videocaptioner.ui.common.app_icons import AppIcon from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.common.signal_bus import signalBus -from videocaptioner.ui.components.SubtitleSettingDialog import SubtitleSettingDialog +from videocaptioner.ui.common.enum_labels import enum_from_label, enum_label, enum_options +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.app_dialog import AppDialog, ConfirmDialog +from videocaptioner.ui.components.workbench import ( + AppTextEdit, + CollapsibleSideHost, + CompactButton, + DangerButton, + DropZone, + ElidedLabel, + ErrorCard, + OptionCard, + PanelHeader, + PillSelect, + ProgressBarLine, + RoundIconButton, + StatusPill, + ToggleSwitch, + WorkbenchButton, + WorkbenchPanel, + apply_font, + icon_pixmap, + to_qcolor, +) +from videocaptioner.ui.i18n import N_, tr from videocaptioner.ui.task_factory import TaskFactory from videocaptioner.ui.thread.subtitle_thread import RetranslateThread, SubtitleThread +_SUBTITLE_FORMATS = {fmt.value for fmt in SupportedSubtitleFormats} +_FORMATS_PILL_TEXT = " / ".join(fmt.value.upper() for fmt in SupportedSubtitleFormats) + + +class PageState(Enum): + EMPTY = auto() + READY = auto() + RUNNING = auto() + DONE = auto() + FAILED = auto() + + +def _format_table_clock(ms: int) -> str: + """表格时间戳短格式:00:01.12,超过 1 小时带小时位。""" + centis = (max(0, int(ms)) % 1000) // 10 + total = max(0, int(ms)) // 1000 + hours, rest = divmod(total, 3600) + minutes, secs = divmod(rest, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}" + return f"{minutes:02d}:{secs:02d}.{centis:02d}" + + +# --------------------------------------------------------------------------- +# 线程编排 +# --------------------------------------------------------------------------- + + +class SubtitleProcessController(QObject): + """持有字幕处理 / 重新翻译线程,页面只消费信号。""" + + progressChanged = pyqtSignal(int, str) + rowsUpdated = pyqtSignal(dict) + allUpdated = pyqtSignal(dict) + completed = pyqtSignal(str, str) # video_path, output_path + failed = pyqtSignal(str) + retranslated = pyqtSignal(dict) + retranslateFailed = pyqtSignal(str) + + def __init__(self, parent: Optional[QObject] = None): + super().__init__(parent) + self._process_thread: Optional[SubtitleThread] = None + self._retranslate_thread: Optional[RetranslateThread] = None + + def start_processing(self, task: SubtitleTask, prompt: str) -> bool: + if self.is_processing(): + return False + thread = SubtitleThread(task) + thread.set_custom_prompt_text(prompt) + thread.finished.connect(self.completed) + thread.progress.connect(self.progressChanged) + thread.update.connect(self.rowsUpdated) + thread.update_all.connect(self.allUpdated) + thread.error.connect(self.failed) + self._process_thread = thread + thread.start() + return True + + def retranslate(self, rows: Dict[str, Any], config, file_name: str) -> bool: + if self.is_processing(): + return False + thread = RetranslateThread(rows, config, file_name) + thread.finished.connect(self.retranslated) + thread.progress.connect(self.progressChanged) + thread.error.connect(self.retranslateFailed) + self._retranslate_thread = thread + thread.start() + return True + + def is_processing(self) -> bool: + return any( + thread is not None and thread.isRunning() + for thread in (self._process_thread, self._retranslate_thread) + ) + + def cancel(self) -> None: + thread = self._process_thread + self._process_thread = None + if thread is not None and thread.isRunning(): + try: + thread.finished.disconnect() + thread.progress.disconnect() + thread.update.disconnect() + thread.update_all.disconnect() + thread.error.disconnect() + except TypeError: + pass + thread.stop() + + def shutdown(self) -> None: + """页面关闭时调用:协作停止(基类内部超时才强杀)。""" + for thread in (self._process_thread, self._retranslate_thread): + if thread is not None: + thread.stop() + + +# --------------------------------------------------------------------------- +# 表格模型 +# --------------------------------------------------------------------------- + class SubtitleTableModel(QAbstractTableModel): - def __init__(self, data: Union[str, Dict[str, Any]] = ""): + """字幕表格模型:开始 / 结束 / 原文 / 译文,原文与译文可编辑。 + + 数据结构与 ASRData.to_json() 一致:{"1": {start_time, end_time, + original_subtitle, translated_subtitle}, ...}。 + """ + + HEADER_KEYS = ( + N_("subtitle.col.start"), + N_("subtitle.col.end"), + N_("subtitle.col.original"), + N_("subtitle.col.translated"), + ) + + def __init__(self, data: Union[Dict[str, Any], None] = None): super().__init__() - self._data: Dict[str, Any] = {} - if isinstance(data, str): - self.load_data(data) - else: - self._data = data + self._data: Dict[str, Any] = data or {} + self._dim_from: Optional[int] = None - def load_data(self, data: str): - """加载字幕数据""" - try: - self._data = json.loads(data) - self.layoutChanged.emit() - except json.JSONDecodeError: - pass + # ----- 数据存取 ----- - def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any: # type: ignore - if not index.isValid() or not self._data: - return None + def raw(self) -> Dict[str, Any]: + return self._data - row = index.row() - col = index.column() - segment = self._data.get(str(row + 1)) + def replace_all(self, data: Dict[str, Any]) -> None: + self.beginResetModel() + self._data = data + self._dim_from = None + self.endResetModel() - if not segment: - return None + def merge_translations(self, new_data: Dict[str, str]) -> None: + """合并增量翻译结果(key 为行号字符串)。""" + updated = set() + keys = list(self._data.keys()) + for key, value in new_data.items(): + if key in self._data: + self._data[key]["translated_subtitle"] = value + updated.add(keys.index(key)) + if updated: + top, bottom = min(updated), max(updated) + self.dataChanged.emit( + self.index(top, 2), self.index(bottom, 3), [Qt.DisplayRole] + ) - if role == Qt.DisplayRole or role == Qt.EditRole: # type: ignore - if col == 0: - return ( - QTime(0, 0) - .addMSecs(segment["start_time"]) - .toString("hh:mm:ss.zzz")[:-2] - ) - elif col == 1: - return ( - QTime(0, 0) - .addMSecs(segment["end_time"]) - .toString("hh:mm:ss.zzz")[:-2] + def set_dim_from(self, row: Optional[int]) -> None: + """处理中:row 之后的行变暗(running 态的未处理行)。""" + if row != self._dim_from: + self._dim_from = row + if self.rowCount(): + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount() - 1, 3), + [Qt.ForegroundRole], ) - elif col == 2: - return segment["original_subtitle"] - elif col == 3: - return segment["translated_subtitle"] - elif role == Qt.TextAlignmentRole: # type: ignore - if col in [0, 1]: - return Qt.AlignCenter # type: ignore - return None - def setData(self, index: QModelIndex, value: Any, role: int = Qt.EditRole) -> bool: # type: ignore - if not index.isValid() or not self._data: - return False + def segment_at(self, row: int) -> Optional[Dict[str, Any]]: + keys = list(self._data.keys()) + if 0 <= row < len(keys): + return self._data[keys[row]] + return None - if role == Qt.EditRole: # type: ignore - row = index.row() - col = index.column() - segment = self._data.get(str(row + 1)) + def remove_rows(self, rows: list[int]) -> None: + keys = list(self._data.keys()) + keep = [key for index, key in enumerate(keys) if index not in set(rows)] + self.replace_all( + {str(i + 1): self._data[key] for i, key in enumerate(keep)} + ) - if not segment: - return False + def merge_rows(self, rows: list[int]) -> None: + """把连续选中的行合并为一条(时间取首尾,文本拼接)。""" + if len(rows) < 2: + return + keys = list(self._data.keys()) + items = [self._data[keys[row]] for row in rows] + merged = { + "start_time": items[0]["start_time"], + "end_time": items[-1]["end_time"], + "original_subtitle": " ".join(i["original_subtitle"] for i in items), + "translated_subtitle": " ".join( + i["translated_subtitle"] for i in items if i["translated_subtitle"] + ), + } + selected = set(rows) + new_items = [] + for index, key in enumerate(keys): + if index == rows[0]: + new_items.append(merged) + elif index not in selected: + new_items.append(self._data[key]) + self.replace_all({str(i + 1): item for i, item in enumerate(new_items)}) - if col == 2: - segment["original_subtitle"] = value - elif col == 3: - segment["translated_subtitle"] = value - else: - return False - - self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole]) # type: ignore - return True - return False - - def headerData( - self, - section: int, - orientation: Qt.Orientation, - role: int = Qt.DisplayRole, # type: ignore - ) -> Any: # type: ignore - if role == Qt.DisplayRole: # type: ignore - if orientation == Qt.Horizontal: # type: ignore - return [ - self.tr("开始时间"), - self.tr("结束时间"), - self.tr("字幕内容"), - ( - self.tr("翻译字幕") - if cfg.need_translate.value - else self.tr("优化字幕") - ), - ][section] - elif orientation == Qt.Vertical: # type: ignore - return str(section + 1) # 显示行号 - elif role == Qt.TextAlignmentRole: # type: ignore - return Qt.AlignCenter # type: ignore # 居中对齐 - return None + # ----- Qt 模型接口 ----- def rowCount(self, parent: Optional[QModelIndex] = None) -> int: return len(self._data) @@ -161,882 +302,1211 @@ def rowCount(self, parent: Optional[QModelIndex] = None) -> int: def columnCount(self, parent: Optional[QModelIndex] = None) -> int: return 4 - def flags(self, index: QModelIndex) -> Qt.ItemFlags: + def data(self, index: QModelIndex, role: int = Qt.DisplayRole): # type: ignore[assignment] if not index.isValid(): - return Qt.NoItemFlags # type: ignore - if index.column() in [2, 3]: - return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable # type: ignore - return Qt.ItemIsEnabled | Qt.ItemIsSelectable # type: ignore + return None + segment = self.segment_at(index.row()) + if segment is None: + return None + column = index.column() + if role in (Qt.DisplayRole, Qt.EditRole): # type: ignore[attr-defined] + if column == 0: + return _format_table_clock(segment["start_time"]) + if column == 1: + return _format_table_clock(segment["end_time"]) + if column == 2: + return segment["original_subtitle"] + return segment["translated_subtitle"] + if role == Qt.ForegroundRole and self._dim_from is not None: # type: ignore[attr-defined] + if index.row() >= self._dim_from: + return QColor(app_palette().subtle) + return None - def update_data(self, new_data: Dict[str, str]) -> None: - """更新字幕数据""" - updated_rows = set() + def setData(self, index: QModelIndex, value, role: int = Qt.EditRole) -> bool: # type: ignore[assignment] + if not index.isValid() or role != Qt.EditRole: # type: ignore[attr-defined] + return False + segment = self.segment_at(index.row()) + if segment is None or index.column() not in (2, 3): + return False + field = "original_subtitle" if index.column() == 2 else "translated_subtitle" + segment[field] = value + self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole]) + return True + + def headerData(self, section: int, orientation, role: int = Qt.DisplayRole): # type: ignore[assignment] + if role == Qt.DisplayRole and orientation == Qt.Horizontal: # type: ignore[attr-defined] + return tr(self.HEADER_KEYS[section]) + return None - # 更新内部数据 - for key, value in new_data.items(): - if key in self._data: - self._data[key]["translated_subtitle"] = value - row = list(self._data.keys()).index(key) - updated_rows.add(row) - - # 如果有更新,发出dataChanged信号 - if updated_rows: - min_row = min(updated_rows) - max_row = max(updated_rows) - top_left = self.index(min_row, 2) - bottom_right = self.index(max_row, 3) - self.dataChanged.emit(top_left, bottom_right, [Qt.DisplayRole, Qt.EditRole]) # type: ignore - - def update_all(self, data: Dict[str, Any]) -> None: - """更新所有数据""" - self._data = data - self.layoutChanged.emit() + def flags(self, index: QModelIndex): + if not index.isValid(): + return Qt.NoItemFlags # type: ignore[attr-defined] + base = Qt.ItemIsEnabled | Qt.ItemIsSelectable # type: ignore[attr-defined] + if index.column() in (2, 3): + return base | Qt.ItemIsEditable # type: ignore[attr-defined] + return base -class SubtitleInterface(QWidget): - finished = pyqtSignal(str, str) +# --------------------------------------------------------------------------- +# 左侧:表格面板 +# --------------------------------------------------------------------------- - def __init__(self, parent: Optional[QWidget] = None): + +class SubtitleTableView(QTableView): + """带悬浮行高亮的表格视图(专业编辑器的基础体验)。""" + + def __init__(self, parent=None): super().__init__(parent) - self.setAcceptDrops(True) - self.task: Optional[SubtitleTask] = None - self.subtitle_path: Optional[str] = None - self.custom_prompt_text: str = cfg.custom_prompt_text.value - self.setAttribute(Qt.WA_DeleteOnClose) # type: ignore - self._init_ui() - self._setup_signals() - self._update_prompt_button_style() - self.set_values() - - def _init_ui(self): - self.main_layout = QVBoxLayout(self) - self.main_layout.setObjectName("main_layout") - self.main_layout.setSpacing(20) - - self._setup_top_layout() - self._setup_subtitle_table() - self._setup_bottom_layout() - - def set_values(self): - self.layout_button.setText( - cfg.subtitle_layout.value.value - ) # Get enum's string value - self.translate_button.setChecked(cfg.need_translate.value) - self.optimize_button.setChecked(cfg.need_optimize.value) - self.target_language_button.setText(cfg.target_language.value.value) - self.target_language_button.setEnabled(cfg.need_translate.value) - - def _setup_top_layout(self): - # 创建水平布局 - top_layout = QHBoxLayout() - - # 创建命令栏 - self.command_bar = CommandBar(self) - self.command_bar.setToolButtonStyle( - Qt.ToolButtonTextBesideIcon # type: ignore - ) # 设置图标和文字并排显示 - top_layout.addWidget(self.command_bar, 1) # 设置stretch为1,使其尽可能占用空间 - - # 创建保存按钮的下拉菜单 - save_menu = RoundMenu(parent=self) - save_menu.view.setMaxVisibleItems(8) # 设置菜单最大高度 - for format in OutputSubtitleFormatEnum: - action = Action(text=format.value) - action.triggered.connect( - lambda checked, f=format.value: self.on_save_format_clicked(f) - ) - save_menu.addAction(action) + self._hover_row = -1 + self._dbl_global = None # 最近一次双击的全局坐标,供行内编辑器定位光标 + self.setMouseTracking(True) - # 添加保存按钮(带下拉菜单) - save_button = TransparentDropDownPushButton(self.tr("保存"), self, FIF.SAVE) - save_button.setMenu(save_menu) - save_button.setFixedHeight(34) - self.command_bar.addWidget(save_button) + def hoverRow(self) -> int: + return self._hover_row - # 添加字幕排布下拉按钮 - self.layout_button = TransparentDropDownPushButton( - self.tr("字幕排布"), self, FIF.LAYOUT - ) - self.layout_button.setFixedHeight(34) - self.layout_button.setMinimumWidth(125) - self.layout_menu = RoundMenu(parent=self) - for layout in ["译文在上", "原文在上", "仅译文", "仅原文"]: - action = Action(text=layout) - action.triggered.connect( - lambda checked, layout_value=layout: signalBus.subtitle_layout_changed.emit( - layout_value - ) - ) - self.layout_menu.addAction(action) - self.layout_button.setMenu(self.layout_menu) - self.command_bar.addWidget(self.layout_button) - - self.command_bar.addSeparator() - - # 添加字幕优化按钮 - self.optimize_button = Action( - FIF.EDIT, - self.tr("字幕校正"), - triggered=self.on_subtitle_optimization_changed, - checkable=True, - ) - self.command_bar.addAction(self.optimize_button) - - # 添加字幕翻译按钮 - self.translate_button = Action( - FIF.LANGUAGE, - self.tr("字幕翻译"), - triggered=self.on_subtitle_translation_changed, - checkable=True, - ) - self.command_bar.addAction(self.translate_button) + def takeDoubleClickPos(self): + pos, self._dbl_global = self._dbl_global, None + return pos - # 添加翻译语言选择 - self.target_language_button = TransparentDropDownPushButton( - self.tr("翻译语言"), self, FIF.LANGUAGE - ) - self.target_language_button.setFixedHeight(34) - self.target_language_button.setMinimumWidth(125) - self.target_language_menu = RoundMenu(parent=self) - self.target_language_menu.setMaxVisibleItems(10) - for lang in TargetLanguage: - action = Action(text=lang.value) - action.triggered.connect( - lambda checked, lang_value=lang.value: signalBus.target_language_changed.emit( - lang_value - ) - ) - self.target_language_menu.addAction(action) - self.target_language_button.setMenu(self.target_language_menu) + def mouseDoubleClickEvent(self, event): + self._dbl_global = event.globalPos() + super().mouseDoubleClickEvent(event) - self.command_bar.addWidget(self.target_language_button) + def mouseMoveEvent(self, event): + row = self.rowAt(event.pos().y()) + if row != self._hover_row: + self._hover_row = row + self.viewport().update() + super().mouseMoveEvent(event) - self.command_bar.addSeparator() + def leaveEvent(self, event): + if self._hover_row != -1: + self._hover_row = -1 + self.viewport().update() + super().leaveEvent(event) - # 添加文稿提示按钮 - self.prompt_button = Action( - FIF.DOCUMENT, self.tr("Prompt"), triggered=self.show_prompt_dialog - ) - self.command_bar.addAction(self.prompt_button) - # 添加设置按钮 - self.command_bar.addAction( - Action(FIF.SETTING, "", triggered=self.show_subtitle_settings) - ) +class SubtitleLineEditor(QLineEdit): + """字幕行内编辑器:双击进入编辑不再全选整格——光标落在点击处(取不到则置于末尾), - # 添加视频播放按钮 - # self.command_bar.addAction(Action(FIF.VIDEO, "", triggered=self.show_video_player)) + 与专业字幕工具(Aegisub / Subtitle Edit)一致:双击定位、可直接续编而非误删全行。""" - # 添加打开文件夹按钮 - self.command_bar.addAction( - Action(FIF.FOLDER, "", triggered=self.on_open_folder_clicked) + def __init__(self, parent=None): + super().__init__(parent) + self._click_global = None + + def setClickPoint(self, global_pos): + self._click_global = global_pos + + def focusInEvent(self, event): + super().focusInEvent(event) + # Qt 默认聚焦即 selectAll;改为按点击处定位光标、不选中 + self.deselect() + pos = len(self.text()) + if self._click_global is not None: + local = self.mapFromGlobal(self._click_global) + if self.rect().contains(local): + pos = self.cursorPositionAt(local) + self._click_global = None + self.setCursorPosition(pos) + + +class SubtitleEditDelegate(QStyledItemDelegate): + """字幕单元格编辑委托。 + + - 深色行内编辑器(替换系统白色编辑框),accent 边框、与单元格排版对齐 + - 双击定位光标(不全选);Enter 提交并编辑下一条、Shift+Enter 上一条、Esc 取消 + - 悬浮行画淡色高亮 + """ + + def __init__(self, view: SubtitleTableView): + super().__init__(view) + self._view = view + + def paint(self, painter, option, index): + if ( + index.row() == self._view.hoverRow() + and not option.state & QStyle.State_Selected # type: ignore[attr-defined] + ): + hover = app_palette().card_surface_hover + painter.fillRect(option.rect, to_qcolor(hover)) + super().paint(painter, option, index) + + def createEditor(self, parent, option, index): + palette = app_palette() + editor = SubtitleLineEditor(parent) + editor.setClickPoint(self._view.takeDoubleClickPos()) + apply_font(editor, 15, 650) + editor.setStyleSheet( + f""" + QLineEdit {{ + background: {palette.panel_deep}; + color: {palette.text}; + border: 1px solid {rgba(palette.accent, 0.85)}; + border-radius: 6px; + padding: 0 10px; + selection-background-color: {rgba(palette.accent, 0.35)}; + selection-color: {palette.text}; + }} + """ + ) + return editor + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect.adjusted(4, 7, -4, -7)) + + def eventFilter(self, editor, event): + if event.type() == QEvent.KeyPress: + key = event.key() + if key == Qt.Key_Escape: # type: ignore[attr-defined] + # 取消编辑、还原原文(Qt 默认也会处理,这里显式以确保一致) + self.closeEditor.emit(editor, QStyledItemDelegate.RevertModelCache) + return True + if key in (Qt.Key_Return, Qt.Key_Enter): # type: ignore[attr-defined] + self.commitData.emit(editor) + self.closeEditor.emit(editor, QStyledItemDelegate.NoHint) + # Shift+Enter 编辑上一条,Enter 编辑下一条(连续审校流) + step = -1 if event.modifiers() & Qt.ShiftModifier else 1 # type: ignore[attr-defined] + current = self._view.currentIndex() + target = current.sibling(current.row() + step, current.column()) + if target.isValid(): + QTimer.singleShot(0, lambda: self._edit_at(target)) + return True + return super().eventFilter(editor, event) + + def _edit_at(self, index): + self._view.setCurrentIndex(index) + self._view.scrollTo(index) + self._view.edit(index) + + +class TableBottomBar(QFrame): + """表格底部状态条:按状态展示条数 / 进度 / 输出信息。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("tableBottomBar") + self.setFixedHeight(40) + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 0, 14, 0) + layout.setSpacing(14) + + self.leftPill = StatusPill("", "fail", self) + layout.addWidget(self.leftPill) + self.infoLabel = QLabel(self) + self.infoLabel.setObjectName("bottomInfo") + apply_font(self.infoLabel, 14, 730) + layout.addWidget(self.infoLabel) + self.hintLabel = QLabel(self) + self.hintLabel.setObjectName("bottomHint") + apply_font(self.hintLabel, 14, 730) + layout.addWidget(self.hintLabel) + self.progressLine = ProgressBarLine(self) + self.progressLine.setFixedWidth(320) + layout.addWidget(self.progressLine) + self.percentLabel = QLabel(self) + self.percentLabel.setObjectName("bottomInfo") + self.percentLabel.setMinimumWidth(46) + apply_font(self.percentLabel, 14, 730) + layout.addWidget(self.percentLabel) + layout.addStretch(1) + self.rightLabel = QLabel(self) + self.rightLabel.setObjectName("bottomHint") + apply_font(self.rightLabel, 14, 730) + layout.addWidget(self.rightLabel) + self.rightPill = StatusPill("", "ok", self) + self.rightPill.setMinimumWidth(118) + layout.addWidget(self.rightPill) + self.syncStyle() + + def _show(self, **visible): + widgets = { + "left_pill": self.leftPill, + "info": self.infoLabel, + "hint": self.hintLabel, + "progress": self.progressLine, + "percent": self.percentLabel, + "right_label": self.rightLabel, + "right_pill": self.rightPill, + } + for name, widget in widgets.items(): + widget.setVisible(visible.get(name, False)) + + def showReady(self, count: int): + self.infoLabel.setText(tr("subtitle.bottom.count", count=count)) + self.hintLabel.setText(tr("subtitle.bottom.hint")) + self.rightPill.setState(tr("subtitle.bottom.loaded"), "ok") + self._show(info=True, hint=True, right_pill=True) + + def showRunning(self, stage: str, percent: int, current: int, total: int): + self.infoLabel.setText(stage) + self.progressLine.setValue(percent) + self.percentLabel.setText(f"{percent}%") + self.rightPill.setState( + tr("subtitle.bottom.progress", current=current, total=total), "warn" + ) + self._show(info=True, progress=True, percent=True, right_pill=True) + + def showFailed(self, title: str, detail: str): + self.leftPill.setState(title, "fail") + self.infoLabel.setText(detail) + self._show(left_pill=True, info=True) + + def showDone(self, output_name: str): + self.infoLabel.setText(tr("subtitle.bottom.output", name=output_name)) + self.rightPill.setState(tr("subtitle.bottom.can_synthesize"), "ok") + self._show(info=True, right_pill=True) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f""" + QFrame#tableBottomBar {{ + background: transparent; + border: none; + border-top: 1px solid {palette.line_soft}; + }} + QLabel#bottomInfo {{ color: {palette.muted}; background: transparent; }} + QLabel#bottomHint {{ color: {palette.subtle}; background: transparent; }} + """ ) - self.command_bar.addSeparator() - # 添加文件选择按钮 - self.command_bar.addAction( - Action(FIF.FOLDER_ADD, "", triggered=self.on_file_select) +class SubtitleTablePanel(WorkbenchPanel): + """左侧表格面板:文件行 + 头部操作 + 可编辑表格 / 空态 + 底部状态条。""" + + browseRequested = pyqtSignal() + saveFormatRequested = pyqtSignal(str) + openFolderRequested = pyqtSignal() + resetRequested = pyqtSignal() + + def __init__(self, model: SubtitleTableModel, parent=None): + super().__init__(parent, padded=False) + + # 头部:文件行 + 操作按钮。56 高 + 22 左边距与各页标题栏统一; + # 空态显示分区标题字号(20/860),载入后切回文件名字号(15/840)。 + self.head = QFrame(self) + self.head.setObjectName("tableHead") + self.head.setFixedHeight(56) + head_layout = QHBoxLayout(self.head) + # 左右 22 与各页 PanelHeader 标题栏一致(右距曾是 14,导致折叠态 + # 展开钮比转录/合成页偏右 8px) + head_layout.setContentsMargins(22, 0, 22, 0) + head_layout.setSpacing(9) # 与 PanelHeader 的按钮间距一致 + self.fileIcon = QLabel(self.head) + self.fileIcon.hide() + head_layout.addWidget(self.fileIcon) + self.fileName = ElidedLabel(tr("subtitle.no_file"), self.head) + self.fileName.setObjectName("tableFileName") + apply_font(self.fileName, 20, 860) + head_layout.addWidget(self.fileName, 1) + head_layout.addSpacing(8) + + self.saveButton = CompactButton(tr("common.save"), AppIcon.SAVE, self.head) + self.saveButton.clicked.connect(self._show_save_menu) + head_layout.addWidget(self.saveButton) + self.folderButton = CompactButton(tr("subtitle.btn.folder"), AppIcon.FOLDER, self.head) + self.folderButton.clicked.connect(self.openFolderRequested) + head_layout.addWidget(self.folderButton) + self.replaceButton = CompactButton(tr("subtitle.btn.replace"), AppIcon.FOLDER_ADD, self.head) + self.replaceButton.clicked.connect(self.browseRequested) + head_layout.addWidget(self.replaceButton) + # 清空当前字幕、回到初始态(报错/完成后可一键重来) + self.resetButton = DangerButton(tr("subtitle.btn.clear"), AppIcon.DELETE, self.head) + self.resetButton.clicked.connect(self.resetRequested) + head_layout.addWidget(self.resetButton) + # 右栏折叠时的主操作入口(状态与右栏主按钮同步,32 高与头部按钮组一致)。 + self.headStartButton = WorkbenchButton( + tr("subtitle.btn.start"), AppIcon.PLAY, primary=True, height=32, parent=self.head ) - - # 添加开始按钮到水平布局 - self.start_button = PrimaryPushButton(self.tr("开始"), self, icon=FIF.PLAY) - self.start_button.clicked.connect( - lambda: self.start_subtitle_optimization(need_create_task=True) + self.headStartButton.setMinimumWidth(104) + self.headStartButton.hide() + head_layout.addWidget(self.headStartButton) + # 右栏折叠后的展开入口(固定在头部,位置不漂移)。 + self.expandButton = RoundIconButton(AppIcon.LAYOUT, diameter=32, parent=self.head) + self.expandButton.setToolTip(tr("subtitle.tip.expand_settings")) + self.expandButton.hide() + head_layout.addWidget(self.expandButton) + self.bodyLayout.addWidget(self.head) + + # 主体:空态拖放区 / 表格 + self.stack = QStackedWidget(self) + # 空态与转录页完全同构:虚线框 + 辉光 + 同尺寸图标与标题。 + self.dropZone = DropZone( + icon=AppIcon.SUBTITLE, + title=tr("subtitle.drop.title"), + pick_text=tr("subtitle.drop.pick"), + pick_icon=AppIcon.FOLDER_ADD, + formats_line=_FORMATS_PILL_TEXT.lower(), + parent=self, ) - self.start_button.setFixedHeight(34) - top_layout.addWidget(self.start_button) - - self.main_layout.addLayout(top_layout) - - def _setup_subtitle_table(self): - self.subtitle_table = TableView(self) - self.model = SubtitleTableModel("") - self.subtitle_table.setModel(self.model) - self.subtitle_table.setBorderVisible(True) - self.subtitle_table.setBorderRadius(8) - self.subtitle_table.setWordWrap(True) - self.subtitle_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - self.subtitle_table.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.Fixed + self.dropZone.browseRequested.connect(self.browseRequested) + drop_host = QWidget(self) + drop_layout = QVBoxLayout(drop_host) + drop_layout.setContentsMargins(16, 16, 16, 16) + drop_layout.addWidget(self.dropZone) + self.stack.addWidget(drop_host) + + self.table = SubtitleTableView(self) + self.table.setModel(model) + self.table.setItemDelegate(SubtitleEditDelegate(self.table)) + self.table.setObjectName("subtitleTable") + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Fixed) + self.table.setColumnWidth(0, 108) + self.table.setColumnWidth(1, 108) + self.table.horizontalHeader().setFixedHeight(46) + self.table.horizontalHeader().setDefaultAlignment( + Qt.AlignLeft | Qt.AlignVCenter # type: ignore[arg-type] ) - self.subtitle_table.horizontalHeader().setSectionResizeMode( - 1, QHeaderView.Fixed + self.table.verticalHeader().setVisible(False) + self.table.verticalHeader().setDefaultSectionSize(54) + self.table.setShowGrid(False) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setEditTriggers( + QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed ) - self.subtitle_table.setColumnWidth(0, 120) - self.subtitle_table.setColumnWidth(1, 120) - - # 配置垂直表头 - self.subtitle_table.verticalHeader().setVisible(True) # 显示垂直表头 - self.subtitle_table.verticalHeader().setDefaultAlignment( - Qt.AlignCenter # type: ignore - ) # 居中对齐 - self.subtitle_table.verticalHeader().setDefaultSectionSize(50) # 行高 - self.subtitle_table.verticalHeader().setMinimumWidth(20) # 设置最小宽度 - - self.subtitle_table.setEditTriggers( - QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed # type: ignore + self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) + self.table.setWordWrap(False) + self.table.setFrameShape(QFrame.NoFrame) + self.stack.addWidget(self.table) + self.bodyLayout.addWidget(self.stack, 1) + + self.bottomBar = TableBottomBar(self) + self.bodyLayout.addWidget(self.bottomBar) + self.syncStyle() + + def setFile(self, name: str, loaded: bool): + palette = app_palette() + self.fileName.setText(name) + apply_font(self.fileName, 15 if loaded else 20, 840 if loaded else 860) + self.fileIcon.setVisible(loaded) + if loaded: + self.fileIcon.setPixmap(icon_pixmap(AppIcon.SUBTITLE, palette.muted, 18)) + + def setHeadState(self, state: PageState): + """头部操作随状态切换:空态只留标题(导入入口在拖放区), + 其余状态显示保存/目录/更换;状态与条数统一由底部状态条表达。""" + empty = state == PageState.EMPTY + running = state == PageState.RUNNING + for button in (self.saveButton, self.folderButton, self.replaceButton, self.resetButton): + button.setVisible(not empty) + button.setEnabled(not running) + + def _show_save_menu(self): + menu = RoundMenu(parent=self) + for fmt in OutputSubtitleFormatEnum: + action = Action(fmt.value.upper()) + action.triggered.connect( + lambda _=False, value=fmt.value: self.saveFormatRequested.emit(value) + ) + menu.addAction(action) + menu.exec(self.saveButton.mapToGlobal(self.saveButton.rect().bottomLeft())) + + def syncStyle(self): + super().syncStyle() + palette = app_palette() + selection_bg = rgba(palette.accent, 0.10) + self.head.setStyleSheet( + f""" + QFrame#tableHead {{ + background: transparent; + border: none; + border-bottom: 1px solid {palette.line_soft}; + }} + QLabel#tableFileName {{ color: {palette.text}; background: transparent; }} + """ ) - self.subtitle_table.clicked.connect(self.on_subtitle_clicked) - # 添加右键菜单支持 - self.subtitle_table.setContextMenuPolicy(Qt.CustomContextMenu) # type: ignore - self.subtitle_table.customContextMenuRequested.connect(self.show_context_menu) - self.main_layout.addWidget(self.subtitle_table) - - def _setup_bottom_layout(self): - self.bottom_layout = QHBoxLayout() - self.progress_bar = ProgressBar(self) - self.status_label = BodyLabel(self.tr("请拖入字幕文件"), self) - self.status_label.setMinimumWidth(100) - self.status_label.setAlignment(Qt.AlignCenter) # type: ignore - - # 添加取消按钮 - self.cancel_button = PushButton(self.tr("取消"), self, icon=FIF.CANCEL) - self.cancel_button.hide() # 初始隐藏 - self.cancel_button.clicked.connect(self.cancel_optimization) - - self.bottom_layout.addWidget(self.progress_bar, 1) - self.bottom_layout.addWidget(self.status_label) - self.bottom_layout.addWidget(self.cancel_button) - self.main_layout.addLayout(self.bottom_layout) - - def _setup_signals(self) -> None: - signalBus.subtitle_layout_changed.connect(self.on_subtitle_layout_changed) - signalBus.target_language_changed.connect(self.on_target_language_changed) - signalBus.subtitle_optimization_changed.connect( - self.on_subtitle_optimization_changed + self.table.setStyleSheet( + f""" + QTableView#subtitleTable {{ + background: transparent; + border: none; + color: {palette.muted}; + font-size: 15px; + selection-background-color: {selection_bg}; + selection-color: {palette.text}; + outline: none; + }} + QTableView#subtitleTable::item {{ + padding: 0 14px; + border-bottom: 1px solid {palette.line_soft}; + border-right: 1px solid {palette.line_soft}; + }} + QTableView#subtitleTable::item:selected {{ + background: {selection_bg}; + color: {palette.text}; + }} + QHeaderView::section {{ + background: {palette.panel_deep}; + color: {palette.muted}; + border: none; + border-bottom: 1px solid {palette.line_soft}; + border-right: 1px solid {palette.line_soft}; + padding-left: 14px; + font-size: 15px; + font-weight: bold; + text-align: left; + }} + QTableView QTableCornerButton::section {{ + background: {palette.panel_deep}; border: none; + }} + """ ) - signalBus.subtitle_translation_changed.connect( - self.on_subtitle_translation_changed + # qfluent 全局样式会覆盖嵌在 QTableView qss 里的表头/滚动条规则, + # 必须直接设到子控件上才生效。 + self.table.horizontalHeader().setStyleSheet( + f""" + QHeaderView {{ background: {palette.panel_deep}; border: none; }} + QHeaderView::section {{ + background: {palette.panel_deep}; + color: {palette.muted}; + border: none; + border-bottom: 1px solid {palette.line_soft}; + border-right: 1px solid {palette.line_soft}; + padding-left: 14px; + font-size: 15px; + font-weight: bold; + }} + """ + ) + scrollbar_style = f""" + QScrollBar:vertical {{ + background: transparent; width: 5px; margin: 0; border: none; + }} + QScrollBar::handle:vertical {{ + background: {palette.line}; border-radius: 2px; min-height: 32px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; width: 0; background: transparent; border: none; + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: transparent; + }} + """ + self.table.verticalScrollBar().setStyleSheet(scrollbar_style) + + +# --------------------------------------------------------------------------- +# 右侧:处理设置面板 +# --------------------------------------------------------------------------- + + +class ProcessSidePanel(WorkbenchPanel): + """右侧处理设置:开关卡片 + 取值卡片 + 主操作按钮。""" + + settingsRequested = pyqtSignal() + promptRequested = pyqtSignal() + primaryRequested = pyqtSignal() + cancelRequested = pyqtSignal() + collapseRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent, padded=True) + self.bodyLayout.setSpacing(0) + + self.header = PanelHeader( + tr("subtitle.process_settings"), inline=True, underline=True, parent=self + ) + self.collapseButton = RoundIconButton(AppIcon.RIGHT_ARROW, parent=self) + self.collapseButton.setToolTip(tr("subtitle.tip.collapse_settings")) + self.collapseButton.clicked.connect(self.collapseRequested) + self.header.addRight(self.collapseButton) + self.configButton = RoundIconButton(AppIcon.SETTING, parent=self) + self.configButton.setToolTip(tr("subtitle.btn.open_config")) + self.configButton.clicked.connect(self.settingsRequested) + self.header.addRight(self.configButton) + self.bodyLayout.addWidget(self.header) + + self.errorCard = ErrorCard(parent=self) + self.errorCard.hide() + + self.optimizeSwitch = ToggleSwitch(parent=self) + self.translateSwitch = ToggleSwitch(parent=self) + self.splitSwitch = ToggleSwitch(parent=self) + self.languageSelect = PillSelect(self) + self.layoutSelect = PillSelect(self) + self.promptChip = CompactButton(tr("subtitle.prompt.unset"), None, self) + self.promptChip.clicked.connect(self.promptRequested) + + # 子布局统一间距:隐藏的卡片不再残留 addSpacing 导致间距叠加。 + options = QVBoxLayout() + options.setContentsMargins(0, 18, 0, 0) + options.setSpacing(14) + options.addWidget(self.errorCard) + cards = [ + OptionCard(tr("subtitle.opt.optimize"), self.optimizeSwitch, self), + OptionCard(tr("subtitle.opt.translate"), self.translateSwitch, self), + OptionCard(tr("subtitle.opt.split"), self.splitSwitch, self), + OptionCard(tr("subtitle.opt.language"), self.languageSelect, self), + OptionCard(tr("subtitle.opt.layout"), self.layoutSelect, self), + OptionCard(tr("subtitle.opt.prompt"), self.promptChip, self), + ] + self.languageCard = cards[3] + for card in cards: + options.addWidget(card) + self.bodyLayout.addLayout(options) + + self.bodyLayout.addStretch(1) + self.cancelButton = WorkbenchButton(tr("common.cancel"), AppIcon.CANCEL, parent=self) + self.cancelButton.clicked.connect(self.cancelRequested) + self.cancelButton.hide() + self.bodyLayout.addWidget(self.cancelButton) + self.bodyLayout.addSpacing(10) + self.primaryButton = WorkbenchButton( + tr("subtitle.btn.wait_subtitle"), AppIcon.FILE, primary=False, height=48, parent=self + ) + self.primaryButton.setEnabled(False) + self.primaryButton.clicked.connect(self.primaryRequested) + self.bodyLayout.addWidget(self.primaryButton) + self.syncStyle() + + def setError(self, message: str): + self.errorCard.setText(message) + self.errorCard.setVisible(bool(message)) + + def setPromptState(self, has_prompt: bool): + self.promptChip.textLabel.setText( + tr("subtitle.prompt.set") if has_prompt else tr("subtitle.prompt.unset") ) - # self.subtitle_setting_button.clicked.connect(self.show_subtitle_settings) - # self.video_player_button.clicked.connect(self.show_video_player) - def show_prompt_dialog(self) -> None: - dialog = PromptDialog(self) - if dialog.exec_(): - self.custom_prompt_text = cfg.custom_prompt_text.value - self._update_prompt_button_style() + def setButton(self, text: str, *, icon: AppIcon, primary: bool, enabled: bool): + self.primaryButton.setText(text) + self.primaryButton.setIcon(icon) + self.primaryButton.setPrimary(primary) + self.primaryButton.setEnabled(enabled) - def _update_prompt_button_style(self) -> None: - if self.custom_prompt_text.strip(): - green_icon = FIF.DOCUMENT.colored( - QColor(76, 255, 165), QColor(76, 255, 165) - ) - self.prompt_button.setIcon(green_icon) - else: - self.prompt_button.setIcon(FIF.DOCUMENT) + def syncStyle(self): + super().syncStyle() + if hasattr(self, "errorCard"): + self.errorCard.syncStyle() - def set_task(self, task: SubtitleTask) -> None: - """设置任务并更新UI""" - if hasattr(self, "subtitle_optimization_thread"): - self.subtitle_optimization_thread.stop() # type: ignore - self.start_button.setEnabled(True) - self.task = task - self.subtitle_path = task.subtitle_path - self.update_info(task) - def update_info(self, task: SubtitleTask) -> None: - """更新页面信息""" - if not self.task: - return - original_subtitle_save_path = Path(str(self.task.subtitle_path)) - asr_data = ASRData.from_subtitle_file(str(original_subtitle_save_path)) - self.model._data = asr_data.to_json() - self.model.layoutChanged.emit() - self.status_label.setText(self.tr("已加载文件")) - - def start_subtitle_optimization(self, need_create_task: bool = True) -> None: - if self._is_processing(): - return - if not self.subtitle_path: - InfoBar.warning( - self.tr("警告"), - self.tr("请先加载字幕文件"), - duration=INFOBAR_DURATION_WARNING, - parent=self, - ) - return - self.start_button.setEnabled(False) - self.progress_bar.resume() - self.progress_bar.reset() - self.cancel_button.show() - - if need_create_task: - # 将当前表格状态写回原文件,保留用户的合并/删除/编辑 - if self.model._data: - ASRData.from_json(self.model._data).to_srt(save_path=self.subtitle_path) - self.task = TaskFactory.create_subtitle_task(file_path=self.subtitle_path) - if not self.task: - self.start_button.setEnabled(True) - self.cancel_button.hide() - return - self.subtitle_optimization_thread = SubtitleThread(self.task) - self.subtitle_optimization_thread.finished.connect( - self.on_subtitle_optimization_finished - ) - self.subtitle_optimization_thread.progress.connect( - self.on_subtitle_optimization_progress +# --------------------------------------------------------------------------- +# 文稿提示对话框 +# --------------------------------------------------------------------------- + + +class PromptDialog(AppDialog): + """文稿提示:术语表 / 原文稿 / 修正要求,辅助 LLM 校正与翻译。""" + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(tr("subtitle.opt.prompt"), icon=AppIcon.DOCUMENT, parent=parent, width=560) + self.textEdit = AppTextEdit(parent=self.widget, min_height=360) + self.textEdit.setPlaceholderText(tr("subtitle.prompt.placeholder")) + self.textEdit.setPlainText(cfg.custom_prompt_text.value) + self.bodyLayout.addWidget(self.textEdit) + + self.addFooterStretch() + self.cancelButton = self.addFooterButton(tr("common.cancel")) + self.cancelButton.clicked.connect(lambda: self.done(0)) + self.confirmButton = self.addFooterButton(tr("common.ok"), kind="accent") + self.confirmButton.clicked.connect(self._on_confirm) + + def _on_confirm(self): + cfg.set(cfg.custom_prompt_text, self.textEdit.toPlainText()) + self.done(1) + + +# --------------------------------------------------------------------------- +# 页面 +# --------------------------------------------------------------------------- + + +class SubtitleInterface(QWidget): + """字幕优化与翻译页(两栏审校工作台)。""" + + finished = pyqtSignal(str, str) + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + self.setObjectName("SubtitleInterface") + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.setAcceptDrops(True) + + self.state = PageState.EMPTY + self.task: Optional[SubtitleTask] = None + self.subtitle_path: Optional[str] = None + self._output_path: Optional[str] = None + # FAILED 细分:配置缺失(→打开配置)还是运行期失败(→重试),决定按钮文案=行为 + self._failure_is_config = False + self._translated_count = 0 + self._config_signal_connections: list[tuple[Any, Callable]] = [] + + self.controller = SubtitleProcessController(self) + self.model = SubtitleTableModel() + self._build_ui() + self._connect_signals() + self._load_options_from_config() + self.sideHost.setCollapsed( + bool(cfg.subtitle_panel_collapsed.value), animate=False ) - self.subtitle_optimization_thread.update.connect(self.update_data) - self.subtitle_optimization_thread.update_all.connect(self.update_all) - self.subtitle_optimization_thread.error.connect( - self.on_subtitle_optimization_error + self._apply_state(PageState.EMPTY) + + def _on_panel_collapsed(self, collapsed: bool): + if cfg.subtitle_panel_collapsed.value != collapsed: + cfg.set(cfg.subtitle_panel_collapsed, collapsed) + self._sync_collapsed_controls() + + def _sync_collapsed_controls(self): + """折叠态头部控件:展开按钮始终可达;主按钮空态无意义则隐藏。 + + 运行中且侧栏折叠时,侧栏里的「取消」不可见——把头部主按钮变成「取消」, + 否则用户折叠后就没有任何可取消的入口(对齐转录页 always-visible 取消)。 + """ + collapsed = self.sideHost.isCollapsed() + head = self.tablePanel.headStartButton + self.tablePanel.expandButton.setVisible(collapsed) + if collapsed and self.state == PageState.RUNNING: + head.setVisible(True) + head.setText(tr("common.cancel")) + head.setIcon(AppIcon.CANCEL) + head.setPrimary(False) + head.setEnabled(True) + else: + head.setVisible(collapsed and self.state != PageState.EMPTY) + + # ------------------------------------------------------------------ UI + + def _build_ui(self): + palette = app_palette() + self.setStyleSheet( + f"QWidget#SubtitleInterface {{ background: {palette.bg}; }}" ) - self.subtitle_optimization_thread.set_custom_prompt_text( - self.custom_prompt_text + root = QHBoxLayout(self) + root.setContentsMargins(18, 16, 18, 2) + root.setSpacing(18) + self.tablePanel = SubtitleTablePanel(self.model, self) + root.addWidget(self.tablePanel, 1) + self.sidePanel = ProcessSidePanel(self) + # 弹性右栏(280-330 自适应)+ 可折叠宿主,折叠状态持久化。 + self.sideHost = CollapsibleSideHost(self.sidePanel, 280, 330, self) + root.addWidget(self.sideHost, 1) + + def _connect_signals(self): + self.controller.progressChanged.connect(self._on_progress) + self.controller.rowsUpdated.connect(self._on_rows_updated) + self.controller.allUpdated.connect(self._on_all_updated) + self.controller.completed.connect(self._on_completed) + self.controller.failed.connect(self._on_failed) + self.controller.retranslated.connect(self._on_retranslated) + self.controller.retranslateFailed.connect(self._on_retranslate_failed) + + self.tablePanel.browseRequested.connect(self._browse_file) + self.tablePanel.saveFormatRequested.connect(self._save_as_format) + self.tablePanel.openFolderRequested.connect(self._open_folder) + self.tablePanel.resetRequested.connect(self._reset_to_empty) + self.tablePanel.table.customContextMenuRequested.connect(self._show_context_menu) + self.tablePanel.table.setContextMenuPolicy(Qt.CustomContextMenu) # type: ignore[arg-type] + + self.sidePanel.settingsRequested.connect(self.show_subtitle_settings) + self.sidePanel.collapseRequested.connect( + lambda: self.sideHost.setCollapsed(True) ) - self.subtitle_optimization_thread.start() - InfoBar.info( - self.tr("开始优化"), - self.tr("开始优化字幕"), - duration=INFOBAR_DURATION_INFO, - parent=self, + self.sideHost.collapsedChanged.connect(self._on_panel_collapsed) + self.tablePanel.headStartButton.clicked.connect(self._on_primary_clicked) + self.tablePanel.expandButton.clicked.connect( + lambda: self.sideHost.setCollapsed(False) ) + self.sidePanel.promptRequested.connect(self._show_prompt_dialog) + self.sidePanel.primaryRequested.connect(self._on_primary_clicked) + self.sidePanel.cancelRequested.connect(self._cancel_processing) - def process(self) -> None: - """主处理函数""" - # 检查是否有任务 - self.start_subtitle_optimization(need_create_task=False) - - def on_subtitle_optimization_finished( - self, video_path: str, output_path: str - ) -> None: - self.start_button.setEnabled(True) - self.cancel_button.hide() - self.progress_bar.setValue(100) - if self.task and self.task.need_next_task: - self.finished.emit(video_path, output_path) - InfoBar.success( - self.tr("优化完成"), - self.tr("优化完成字幕..."), - duration=INFOBAR_DURATION_SUCCESS, - position=InfoBarPosition.BOTTOM, - parent=self.parent(), + self.sidePanel.optimizeSwitch.toggled.connect( + lambda checked: self._set_config_bool(cfg.need_optimize, checked) ) - - def on_subtitle_optimization_error(self, error: str) -> None: - self.start_button.setEnabled(True) - self.cancel_button.hide() # 隐藏取消按钮 - self.progress_bar.error() - InfoBar.error( - self.tr("优化失败"), - self.tr(error), - duration=INFOBAR_DURATION_ERROR, - parent=self, + self.sidePanel.translateSwitch.toggled.connect(self._on_translate_toggled) + self.sidePanel.splitSwitch.toggled.connect( + lambda checked: self._set_config_bool(cfg.need_split, checked) ) - - def on_subtitle_optimization_progress(self, value: int, status: str) -> None: - self.progress_bar.setValue(value) - self.status_label.setText(status) - - def update_data(self, data): - self.model.update_data(data) - - def update_all(self, data): - self.model.update_all(data) - - def remove_widget(self) -> None: - """隐藏顶部开始按钮和底部进度条""" - self.start_button.hide() - for i in range(self.bottom_layout.count()): - item = self.bottom_layout.itemAt(i) - if item: - widget = item.widget() - if widget: - widget.hide() - - def on_file_select(self) -> None: - # 构建文件过滤器 - subtitle_formats = " ".join( - f"*.{fmt.value}" for fmt in SupportedSubtitleFormats + self.sidePanel.languageSelect.currentTextChanged.connect(self._on_language_selected) + self.sidePanel.layoutSelect.currentTextChanged.connect(self._on_layout_selected) + + self._connect_config_signal(cfg.need_optimize, self._sync_switches_from_config) + self._connect_config_signal(cfg.need_translate, self._sync_switches_from_config) + self._connect_config_signal(cfg.need_split, self._sync_switches_from_config) + + def _connect_config_signal(self, option, handler: Callable): + option.valueChanged.connect(handler) + self._config_signal_connections.append((option.valueChanged, handler)) + + def _disconnect_config_signals(self): + for signal, handler in self._config_signal_connections: + try: + signal.disconnect(handler) + except (RuntimeError, TypeError): + pass + self._config_signal_connections.clear() + + # ------------------------------------------------------- config <-> UI + + def _load_options_from_config(self): + self.sidePanel.optimizeSwitch.setChecked(bool(cfg.need_optimize.value)) + self.sidePanel.translateSwitch.setChecked(bool(cfg.need_translate.value)) + self.sidePanel.splitSwitch.setChecked(bool(cfg.need_split.value)) + self.sidePanel.languageSelect.setItems( + enum_options(TargetLanguage), enum_label(cfg.target_language.value) ) - filter_str = f"{self.tr('字幕文件')} ({subtitle_formats})" - + self.sidePanel.layoutSelect.setItems( + enum_options(SubtitleLayoutEnum), + enum_label(cfg.subtitle_layout.value), + ) + self.sidePanel.languageCard.setVisible(bool(cfg.need_translate.value)) + self.sidePanel.setPromptState(bool(cfg.custom_prompt_text.value.strip())) + + def _set_config_bool(self, option, checked: bool): + if option.value != checked: + cfg.set(option, checked) + + def _on_translate_toggled(self, checked: bool): + self._set_config_bool(cfg.need_translate, checked) + self.sidePanel.languageCard.setVisible(checked) + + def _sync_switches_from_config(self, _value=None): + self.sidePanel.optimizeSwitch.setChecked(bool(cfg.need_optimize.value)) + self.sidePanel.translateSwitch.setChecked(bool(cfg.need_translate.value)) + self.sidePanel.splitSwitch.setChecked(bool(cfg.need_split.value)) + self.sidePanel.languageCard.setVisible(bool(cfg.need_translate.value)) + + def _on_language_selected(self, language_name: str): + lang = enum_from_label(TargetLanguage, language_name) + if lang is not None and cfg.target_language.value != lang: + cfg.set(cfg.target_language, lang) + + def _on_layout_selected(self, layout_name: str): + layout = enum_from_label(SubtitleLayoutEnum, layout_name) + if layout is not None and cfg.subtitle_layout.value != layout: + cfg.set(cfg.subtitle_layout, layout) + + # --------------------------------------------------------- state machine + + def _apply_state(self, state: PageState, *, error: str = ""): + self.state = state + loaded = state != PageState.EMPTY + count = self.model.rowCount() + + self.tablePanel.stack.setCurrentIndex(1 if loaded else 0) + self.tablePanel.setHeadState(state) + + bar = self.tablePanel.bottomBar + bar.setVisible(state != PageState.EMPTY) + if state == PageState.READY: + bar.showReady(count) + elif state == PageState.FAILED: + bar.showFailed(tr("subtitle.fail.config_missing") if self._failure_is_config else tr("subtitle.fail.process"), error) + elif state == PageState.DONE: + bar.showDone(Path(self._output_path).name if self._output_path else "") + # RUNNING 的底部条由进度回调驱动 + + buttons = { + PageState.EMPTY: (tr("subtitle.btn.wait_subtitle"), AppIcon.FILE, False, False), + PageState.READY: (tr("subtitle.btn.start"), AppIcon.PLAY, True, True), + PageState.RUNNING: (tr("subtitle.btn.processing"), AppIcon.SYNC, False, False), + PageState.DONE: (tr("subtitle.btn.enter_synthesis"), AppIcon.RIGHT_ARROW, True, True), + # 配置缺失→「打开处理配置」(SETTING);运行期失败→「重试」(SYNC),文案与点击行为一致 + PageState.FAILED: ( + (tr("subtitle.btn.open_config"), AppIcon.SETTING, True, True) + if self._failure_is_config + else (tr("common.retry"), AppIcon.SYNC, True, True) + ), + } + text, icon, primary, enabled = buttons[state] + self.sidePanel.setButton(text, icon=icon, primary=primary, enabled=enabled) + head_button = self.tablePanel.headStartButton + head_button.setText(text) + head_button.setIcon(icon) + head_button.setPrimary(primary) + head_button.setEnabled(enabled) + self._sync_collapsed_controls() + self.sidePanel.cancelButton.setVisible(state == PageState.RUNNING) + self.sidePanel.setError(error if state == PageState.FAILED else "") + if state != PageState.RUNNING: + self.model.set_dim_from(None) + + # ------------------------------------------------------------ file flow + + def _browse_file(self): + if self.controller.is_processing(): + self._warn_processing() + return + formats = " ".join(f"*.{fmt}" for fmt in sorted(_SUBTITLE_FORMATS)) file_path, _ = QFileDialog.getOpenFileName( - self, self.tr("选择字幕文件"), "", filter_str + self, tr("subtitle.dialog.choose_file"), "", f"{tr('subtitle.file_filter')} ({formats})" ) if file_path: - self.subtitle_path = file_path self.load_subtitle_file(file_path) - def on_save_format_clicked(self, format: str) -> None: - """处理保存格式的选择""" - if not self.subtitle_path: - InfoBar.warning( - self.tr("警告"), - self.tr("请先加载字幕文件"), - duration=INFOBAR_DURATION_WARNING, - parent=self, + def load_subtitle_file(self, file_path: str): + try: + asr_data = ASRData.from_subtitle_file(file_path) + except Exception as exc: + InfoBar.error( + tr("subtitle.toast.load_failed"), str(exc), duration=INFOBAR_DURATION_ERROR, parent=self ) return - - # 获取保存路径 - default_name = Path(self.subtitle_path).stem - file_path, _ = QFileDialog.getSaveFileName( + self.subtitle_path = file_path + self.task = None + self._output_path = None + self.model.replace_all(asr_data.to_json()) + self.tablePanel.setFile(Path(file_path).name, loaded=True) + self._apply_state(PageState.READY) + + def _reset_to_empty(self): + """清空当前字幕、回到初始空态:报错或完成后可一键重来(处理中先取消再清)。""" + if self.state == PageState.RUNNING: + return + dialog = ConfirmDialog( + tr("subtitle.clear.title"), + tr("subtitle.clear.body"), self, - self.tr("保存字幕文件"), - default_name, # 使用原文件名作为默认名 - f"{self.tr('字幕文件')} (*.{format})", + confirm_text=tr("subtitle.btn.clear"), + danger=True, + icon=AppIcon.DELETE, ) - if not file_path: + if not dialog.exec(): return + self.subtitle_path = None + self.task = None + self._output_path = None + self.model.replace_all({}) + self.tablePanel.setFile(tr("subtitle.no_file"), loaded=False) + self._apply_state(PageState.EMPTY) + + # ------------------------------------------------------- process flow + + def _preflight_error(self) -> Optional[str]: + """开始前校验 LLM 配置;不可用时进入“配置未就绪”状态。""" + task = TaskFactory.create_subtitle_task(file_path=self.subtitle_path or "") + config = task.subtitle_config + if config is None: + return tr("subtitle.error.no_config") + # 校正、智能断句、LLM 翻译三者任一开启都依赖大模型配置。 + needs_llm = ( + bool(cfg.need_optimize.value) + or bool(cfg.need_split.value) + or ( + bool(cfg.need_translate.value) + and cfg.translator_service.value == TranslatorServiceEnum.OPENAI + ) + ) + if needs_llm and not (config.api_key and config.base_url and config.llm_model): + return tr("subtitle.error.need_llm") + return None - try: - # 转换并保存字幕 - asr_data = ASRData.from_json(self.model._data) - layout = cfg.subtitle_layout.value - - if file_path.endswith(".ass"): - style_str = get_subtitle_style(cfg.subtitle_style_name.value) - asr_data.to_ass(style_str, layout, file_path) + def _on_primary_clicked(self): + if self.state in (PageState.READY,): + self._start_processing() + elif self.state == PageState.RUNNING: + # 仅折叠态头部主按钮在运行中会变成「取消」并可点(侧栏主按钮此时禁用) + self._cancel_processing() + elif self.state == PageState.FAILED: + # 文案=行为:配置缺失→打开配置;运行期失败→重试(_start_processing 内部仍会预检) + if self._failure_is_config: + self.show_subtitle_settings() else: - asr_data.save(file_path, layout=layout) - InfoBar.success( - self.tr("保存成功"), - self.tr("字幕已保存至:") + file_path, - duration=INFOBAR_DURATION_SUCCESS, - parent=self, - ) - reveal_in_explorer(file_path) - except Exception as e: - InfoBar.error( - self.tr("保存失败"), - self.tr("保存字幕文件失败: ") + str(e), - duration=INFOBAR_DURATION_ERROR, - parent=self, - ) + self._start_processing() + elif self.state == PageState.DONE: + self._enter_synthesis() - def on_open_folder_clicked(self) -> None: - """打开文件夹按钮点击事件""" - if not self.task: - InfoBar.warning( - self.tr("警告"), - self.tr("请先加载字幕文件"), - duration=INFOBAR_DURATION_WARNING, - parent=self, - ) + def _start_processing(self): + if not self.subtitle_path: return - if not self.task: + if self.controller.is_processing(): + self._warn_processing() return - if self.task.output_path: - output_path = Path(self.task.output_path) - target_dir = str( - output_path.parent - if output_path.exists() - else Path(self.task.subtitle_path).parent - ) - else: - target_dir = str(Path(self.task.subtitle_path).parent) - open_folder(target_dir) - - def load_subtitle_file(self, file_path: str) -> None: - self.subtitle_path = file_path - asr_data = ASRData.from_subtitle_file(file_path) - self.model._data = asr_data.to_json() - self.model.layoutChanged.emit() - self.status_label.setText(self.tr("已加载文件")) - - def dragEnterEvent(self, event: QDragEnterEvent) -> None: - event.accept() if event.mimeData().hasUrls() else event.ignore() - - def dropEvent(self, event: QDropEvent) -> None: - files = [u.toLocalFile() for u in event.mimeData().urls()] - for file_path in files: - if not os.path.isfile(file_path): - continue - - file_ext = os.path.splitext(file_path)[1][1:].lower() + # 把表格当前内容(含用户编辑/合并/删除)写回源文件再构建任务 + if self.model.raw(): + ASRData.from_json(self.model.raw()).to_srt(save_path=self.subtitle_path) + self.task = TaskFactory.create_subtitle_task(file_path=self.subtitle_path) + self._launch_task() + + def _launch_task(self): + assert self.task is not None + error = self._preflight_error() + if error is not None: + self._output_path = None + self._failure_is_config = True # 预检失败=配置缺失 → 按钮显示「打开处理配置」 + self._apply_state(PageState.FAILED, error=error) + return + # start_processing 在控制器忙时返回 False;先启动,忙则直接返回,避免页面进 + # RUNNING/dim 但线程其实在跑上一个任务(流水线 process() 不查 busy 的竞态)。 + if not self.controller.start_processing(self.task, cfg.custom_prompt_text.value): + return + self._translated_count = 0 + self.model.set_dim_from(0) + self._apply_state(PageState.RUNNING) + self.tablePanel.bottomBar.showRunning( + tr("subtitle.status.preparing"), 0, 0, self.model.rowCount() + ) - # 检查文件格式是否支持 - supported_formats = {fmt.value for fmt in SupportedSubtitleFormats} - is_supported = file_ext in supported_formats + def _cancel_processing(self): + self.controller.cancel() + self._apply_state(PageState.READY) - if is_supported: - self.load_subtitle_file(file_path) - InfoBar.success( - self.tr("导入成功"), - self.tr("成功导入") + os.path.basename(file_path), - duration=INFOBAR_DURATION_SUCCESS, - position=InfoBarPosition.BOTTOM, - parent=self, - ) - break - else: - InfoBar.error( - self.tr("格式错误") + file_ext, - self.tr("支持的字幕格式:") + str(supported_formats), - duration=INFOBAR_DURATION_ERROR, - parent=self, - ) - event.accept() + def _on_progress(self, value: int, status: str): + if self.state == PageState.RUNNING: + self.tablePanel.bottomBar.showRunning( + status, value, self._translated_count, self.model.rowCount() + ) - def closeEvent(self, event: QCloseEvent) -> None: - if hasattr(self, "subtitle_optimization_thread"): - self.subtitle_optimization_thread.stop() # type: ignore - super().closeEvent(event) + def _on_rows_updated(self, data: dict): + self.model.merge_translations(data) + keys = list(self.model.raw().keys()) + indexes = [keys.index(key) for key in data if key in self.model.raw()] + if indexes: + self._translated_count = max(self._translated_count, max(indexes) + 1) + self.model.set_dim_from(self._translated_count + 1) + + def _on_all_updated(self, data: dict): + self.model.replace_all(data) + + def _on_completed(self, video_path: str, output_path: str): + # 不从输出文件重载表格:双语 SRT 重新解析会丢失原文/译文映射, + # 处理过程中的增量信号已让模型持有正确数据。 + self._output_path = output_path + if output_path: + self.tablePanel.setFile(Path(output_path).name, loaded=True) + self._apply_state(PageState.DONE) + if self.task and self.task.need_next_task: + self.finished.emit(video_path, output_path) - def show_subtitle_settings(self) -> None: - """显示字幕设置对话框""" - dialog = SubtitleSettingDialog(self.window()) - dialog.exec_() + def _on_failed(self, error: str): + self._failure_is_config = False # 运行期失败 → 按钮显示「重试」 + self._apply_state(PageState.FAILED, error=error) + self._output_path = None - def show_video_player(self) -> None: - """显示视频播放器窗口""" - # 创建视频播放器窗口(延迟导入,因为vlc是可选依赖) - from videocaptioner.ui.components.MyVideoWidget import MyVideoWidget + def _on_retranslated(self, result: dict): + self.model.merge_translations(result) + self._apply_state(PageState.READY) + InfoBar.success( + tr("subtitle.toast.translate_done"), + tr("subtitle.toast.translate_done_body"), + duration=INFOBAR_DURATION_SUCCESS, + parent=self, + ) - self.video_player = MyVideoWidget() - self.video_player.resize(800, 600) + def _on_retranslate_failed(self, error: str): + self._apply_state(PageState.READY) + InfoBar.error( + tr("subtitle.toast.translate_failed"), error, duration=INFOBAR_DURATION_ERROR, parent=self + ) - def signal_update() -> None: - if not self.model._data: - return - ass_style_name = cfg.subtitle_style_name.value - ass_style_path = SUBTITLE_STYLE_PATH / f"{ass_style_name}.txt" - if ass_style_path.exists(): - subtitle_style_srt = ass_style_path.read_text(encoding="utf-8") - else: - subtitle_style_srt = None - temp_srt_path = os.path.join(tempfile.gettempdir(), "temp_subtitle.ass") - asr_data = ASRData.from_json(self.model._data) - asr_data.save( - temp_srt_path, - layout=cfg.subtitle_layout.value, - ass_style=subtitle_style_srt or "", - ) - signalBus.add_subtitle(temp_srt_path) - - # 如果有字幕文件,则添加字幕 - signal_update() - - signalBus.subtitle_layout_changed.connect(signal_update) - self.model.dataChanged.connect(signal_update) - self.model.layoutChanged.connect(signal_update) - - # 如果有关联的视频文件,则自动加载 - # Note: SubtitleTask doesn't have file_path attribute - # if self.task and hasattr(self.task, "file_path") and self.task.file_path: - # self.video_player.setVideo(QUrl.fromLocalFile(self.task.file_path)) - - self.video_player.show() - self.video_player.play() - - def on_subtitle_clicked(self, index: QModelIndex) -> None: - row = index.row() - item = list(self.model._data.values())[row] - start_time = item["start_time"] # 毫秒 - end_time = ( - item["end_time"] - 50 - if item["end_time"] - 50 > start_time - else item["end_time"] + def _enter_synthesis(self): + video = str(self.task.video_path) if self.task and self.task.video_path else "" + if video and self._output_path: + self.finished.emit(video, self._output_path) + return + InfoBar.info( + tr("common.tip"), + tr("subtitle.toast.no_video"), + duration=INFOBAR_DURATION_WARNING, + parent=self, ) - signalBus.play_video_segment(start_time, end_time) - def show_context_menu(self, pos) -> None: - """显示右键菜单""" - menu = RoundMenu(parent=self) + # ------------------------------------------------------- table actions - # 获取选中的行 - indexes = self.subtitle_table.selectedIndexes() - if not indexes: - return + def _selected_rows(self) -> list[int]: + indexes = self.tablePanel.table.selectedIndexes() + return sorted({index.row() for index in indexes}) - # 获取唯一的行号 - rows = sorted(set(index.row() for index in indexes)) + def _show_context_menu(self, pos): + rows = self._selected_rows() if not rows: return - - # 添加菜单项 - merge_action = Action(FIF.LINK, self.tr("合并")) - delete_action = Action(FIF.DELETE, self.tr("删除")) - retranslate_action = Action(FIF.SYNC, self.tr("重新翻译")) - menu.addAction(merge_action) - menu.addAction(delete_action) - menu.addAction(retranslate_action) + menu = RoundMenu(parent=self) + merge_action = Action(FIF.LINK, tr("subtitle.menu.merge")) + delete_action = Action(FIF.DELETE, tr("common.delete")) + retranslate_action = Action(FIF.SYNC, tr("subtitle.menu.retranslate")) merge_action.setShortcut("Ctrl+M") delete_action.setShortcut("Delete") retranslate_action.setShortcut("Ctrl+T") - merge_action.setEnabled(len(rows) > 1) - retranslate_action.setEnabled(cfg.need_translate.value and not self._is_processing()) - - merge_action.triggered.connect(lambda: self.merge_selected_rows(rows)) - delete_action.triggered.connect(lambda: self.delete_selected_rows(rows)) - retranslate_action.triggered.connect(lambda: self.retranslate_selected_rows(rows)) - - # 显示菜单 - menu.exec(self.subtitle_table.viewport().mapToGlobal(pos)) - - def merge_selected_rows(self, rows: List[int]) -> None: - """合并选中的字幕行""" - if not rows or len(rows) < 2: - return - - # 获取选中行的数据 - data = self.model._data - data_list = list(data.values()) - - # 获取第一行和最后一行的时间戳 - first_row = data_list[rows[0]] - last_row = data_list[rows[-1]] - start_time = first_row["start_time"] - end_time = last_row["end_time"] - - # 合并字幕内容 - original_subtitles = [] - translated_subtitles = [] - for row in rows: - item = data_list[row] - original_subtitles.append(item["original_subtitle"]) - translated_subtitles.append(item["translated_subtitle"]) - - merged_original = " ".join(original_subtitles) - merged_translated = " ".join(translated_subtitles) - - # 创建新的合并后的字幕项 - merged_item = { - "start_time": start_time, - "end_time": end_time, - "original_subtitle": merged_original, - "translated_subtitle": merged_translated, - } - - # 获取所有需要保留的键 - keys = list(data.keys()) - preserved_keys = keys[: rows[0]] + keys[rows[-1] + 1 :] - - # 创建新的数据字典 - new_data = {} - for i, key in enumerate(preserved_keys): - if i == rows[0]: - new_key = f"{len(new_data) + 1}" - new_data[new_key] = merged_item - new_key = f"{len(new_data) + 1}" - new_data[new_key] = data[key] - - # 如果合并的是最后几行,需要确保合并项被添加 - if rows[0] >= len(preserved_keys): - new_key = f"{len(new_data) + 1}" - new_data[new_key] = merged_item - - # 更新模型数据 - self.subtitle_table.clearSelection() - self.model.update_all(new_data) - - # 显示成功提示 - InfoBar.success( - self.tr("合并成功"), - self.tr("已成功合并选中的字幕行"), - duration=INFOBAR_DURATION_SUCCESS, - parent=self, + retranslate_action.setEnabled( + bool(cfg.need_translate.value) and not self.controller.is_processing() ) - - def delete_selected_rows(self, rows: List[int]) -> None: - """删除选中的字幕行""" - if not rows: + merge_action.triggered.connect(lambda: self._merge_rows(rows)) + delete_action.triggered.connect(lambda: self._delete_rows(rows)) + retranslate_action.triggered.connect(lambda: self._retranslate_rows(rows)) + menu.addAction(merge_action) + menu.addAction(delete_action) + menu.addAction(retranslate_action) + menu.exec(self.tablePanel.table.viewport().mapToGlobal(pos)) + + def _merge_rows(self, rows: list[int]): + self.tablePanel.table.clearSelection() + self.model.merge_rows(rows) + if self.state in (PageState.READY, PageState.DONE): + self.tablePanel.bottomBar.showReady(self.model.rowCount()) + + def _delete_rows(self, rows: list[int]): + self.tablePanel.table.clearSelection() + self.model.remove_rows(rows) + if self.state in (PageState.READY, PageState.DONE): + self.tablePanel.bottomBar.showReady(self.model.rowCount()) + + def _retranslate_rows(self, rows: list[int]): + if not rows or self.controller.is_processing(): return - - data = self.model._data - keys = list(data.keys()) - rows_set = set(rows) - - new_data = {} - for i, key in enumerate(keys): - if i not in rows_set: - new_key = f"{len(new_data) + 1}" - new_data[new_key] = data[key] - - self.subtitle_table.clearSelection() - self.model.update_all(new_data) - - def _is_processing(self) -> bool: - """是否有任何处理任务正在运行""" - if hasattr(self, "subtitle_optimization_thread") and self.subtitle_optimization_thread.isRunning(): # type: ignore - return True - if hasattr(self, "_retranslate_thread") and self._retranslate_thread.isRunning(): - return True - return False - - def retranslate_selected_rows(self, rows: List[int]) -> None: - """重新翻译选中的字幕行""" - if not rows or not self.model._data: + keys = list(self.model.raw().keys()) + selected = {keys[row]: self.model.raw()[keys[row]] for row in rows} + task = TaskFactory.create_subtitle_task(file_path=self.subtitle_path or "") + if task.subtitle_config is None: return - if self._is_processing(): + file_name = Path(self.subtitle_path).name if self.subtitle_path else "" + self.controller.retranslate(selected, task.subtitle_config, file_name) + self.tablePanel.bottomBar.showRunning( + tr("subtitle.status.retranslating"), 0, 0, len(rows) + ) + + def keyPressEvent(self, event): + rows = self._selected_rows() + ctrl = event.modifiers() == Qt.ControlModifier # type: ignore[attr-defined] + if ctrl and event.key() == Qt.Key_M and len(rows) > 1: # type: ignore[attr-defined] + self._merge_rows(rows) + elif event.key() == Qt.Key_Delete and rows: # type: ignore[attr-defined] + self._delete_rows(rows) + elif ctrl and event.key() == Qt.Key_T and rows: # type: ignore[attr-defined] + if cfg.need_translate.value and not self.controller.is_processing(): + self._retranslate_rows(rows) + else: + super().keyPressEvent(event) return + event.accept() - # 提取选中行数据,保留原始键名(行号字符串) - all_keys = list(self.model._data.keys()) - selected_data = {all_keys[row]: self.model._data[all_keys[row]] for row in rows} + # ------------------------------------------------------- save / folder - # 获取当前翻译配置 - subtitle_task = TaskFactory.create_subtitle_task( - file_path=self.subtitle_path or "" + def _save_as_format(self, fmt: str): + if not self.model.raw(): + self._warn_no_subtitle() + return + default_name = Path(self.subtitle_path).stem if self.subtitle_path else "subtitle" + file_path, _ = QFileDialog.getSaveFileName( + self, tr("subtitle.dialog.save_file"), default_name, f"*.{fmt}" ) - config = subtitle_task.subtitle_config - if not config: + if not file_path: + return + try: + asr_data = ASRData.from_json(self.model.raw()) + layout = cfg.subtitle_layout.value + if file_path.endswith(".ass"): + style = get_subtitle_style(cfg.subtitle_style_name.value) + asr_data.to_ass(style, layout, file_path) + else: + asr_data.save(file_path, layout=layout) + except Exception as exc: + InfoBar.error( + tr("subtitle.toast.save_failed"), str(exc), duration=INFOBAR_DURATION_ERROR, parent=self + ) return - - self.start_button.setEnabled(False) - self.status_label.setText(self.tr("正在重新翻译...")) - self.progress_bar.resume() - self.progress_bar.reset() - - file_name = Path(self.subtitle_path).name if self.subtitle_path else "" - self._retranslate_thread = RetranslateThread(selected_data, config, file_name) - self._retranslate_thread.finished.connect(self._on_retranslate_finished) - self._retranslate_thread.progress.connect(self.on_subtitle_optimization_progress) - self._retranslate_thread.error.connect(self._on_retranslate_error) - self._retranslate_thread.start() - - def _on_retranslate_finished(self, result: dict) -> None: - self.start_button.setEnabled(True) - self.model.update_data(result) - self.progress_bar.setValue(100) - self.status_label.setText(self.tr("重新翻译完成")) InfoBar.success( - self.tr("翻译完成"), - self.tr("已更新选中行的翻译"), + tr("subtitle.toast.save_done"), + tr("subtitle.toast.saved_to") + file_path, duration=INFOBAR_DURATION_SUCCESS, parent=self, ) + reveal_in_explorer(file_path) + + def _open_folder(self): + # 已知具体文件时在文件管理器中直接选中它 + target = None + if self._output_path and Path(self._output_path).exists(): + target = Path(self._output_path) + elif self.subtitle_path: + target = Path(self.subtitle_path) + if target is None: + self._warn_no_subtitle() + return + if target.exists(): + reveal_in_explorer(str(target)) + else: + open_folder(str(target.parent)) - def _on_retranslate_error(self, error: str) -> None: - self.start_button.setEnabled(True) - self.progress_bar.error() - self.status_label.setText(self.tr("重新翻译失败")) - InfoBar.error( - self.tr("翻译失败"), - error, - duration=INFOBAR_DURATION_ERROR, + def _show_prompt_dialog(self): + dialog = PromptDialog(self) + if dialog.exec_(): + self.sidePanel.setPromptState(bool(cfg.custom_prompt_text.value.strip())) + + def show_subtitle_settings(self): + """弹出全局字幕处理配置(翻译与优化)。""" + window = self.window() + if hasattr(window, "openSettingsPage"): + window.openSettingsPage("translate") # type: ignore[attr-defined] + + def _warn_processing(self): + InfoBar.warning( + tr("common.warning"), + tr("subtitle.toast.processing_wait"), + duration=INFOBAR_DURATION_WARNING, parent=self, ) - def keyPressEvent(self, event: QKeyEvent) -> None: - """处理键盘事件""" - if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_M: # type: ignore - indexes = self.subtitle_table.selectedIndexes() - if indexes: - rows = sorted(set(index.row() for index in indexes)) - if len(rows) > 1: - self.merge_selected_rows(rows) - event.accept() - elif event.key() == Qt.Key_Delete: # type: ignore - indexes = self.subtitle_table.selectedIndexes() - if indexes: - rows = sorted(set(index.row() for index in indexes)) - self.delete_selected_rows(rows) - event.accept() - elif event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_T: # type: ignore - if cfg.need_translate.value and not self._is_processing(): - indexes = self.subtitle_table.selectedIndexes() - if indexes: - rows = sorted(set(index.row() for index in indexes)) - self.retranslate_selected_rows(rows) - event.accept() - else: - super().keyPressEvent(event) - - def cancel_optimization(self) -> None: - """取消字幕校正""" - if hasattr(self, "subtitle_optimization_thread"): - self.subtitle_optimization_thread.stop() # type: ignore - self.start_button.setEnabled(True) - self.cancel_button.hide() - self.progress_bar.resume() # 恢复正常状态 - self.progress_bar.setValue(0) - self.status_label.setText(self.tr("已取消校正")) - InfoBar.warning( - self.tr("已取消"), - self.tr("字幕校正已取消"), - duration=INFOBAR_DURATION_WARNING, - parent=self, - ) - - def on_target_language_changed(self, language: str) -> None: - """处理翻译语言变更""" - for lang in TargetLanguage: - if lang.value == language: - self.target_language_button.setText(lang.value) - cfg.set(cfg.target_language, lang) - break - - def on_subtitle_optimization_changed(self, checked: bool) -> None: - """处理字幕优化开关变更""" - cfg.set(cfg.need_optimize, checked) - self.optimize_button.setChecked(checked) - - def on_subtitle_translation_changed(self, checked: bool) -> None: - """处理字幕翻译开关变更""" - cfg.set(cfg.need_translate, checked) - self.translate_button.setChecked(checked) - # 控制翻译语言选择按钮的启用状态 - self.target_language_button.setEnabled(checked) - - def on_subtitle_layout_changed(self, layout: str) -> None: - """处理字幕排布变更""" - layout_enum = SubtitleLayoutEnum(layout) # Convert string to enum - cfg.set(cfg.subtitle_layout, layout_enum) - self.layout_button.setText(layout) - - -class PromptDialog(MessageBoxBase): - def __init__(self, parent: Optional[QWidget] = None): - super().__init__(parent) - self.setup_ui() - self.setWindowTitle(self.tr("文稿提示")) - # 连接按钮点击事件 - self.yesButton.clicked.connect(self.save_prompt) - - def setup_ui(self) -> None: - self.titleLabel = BodyLabel(self.tr("文稿提示"), self) - - # 添加文本编辑框 - self.text_edit = TextEdit(self) - self.text_edit.setPlaceholderText( - self.tr( - "请输入文稿提示(辅助校正字幕和翻译)\n\n" - "支持以下内容:\n" - "1. 术语表 - 专业术语、人名、特定词语的修正对照表\n" - "示例:\n机器学习->Machine Learning\n马斯克->Elon Musk\n打call->应援\n\n" - "2. 原字幕文稿 - 视频的原有文稿或相关内容\n" - "示例: 完整的演讲稿、课程讲义等\n\n" - "3. 修正要求 - 内容相关的具体修正要求\n" - "示例: 统一人称代词、规范专业术语等\n\n" - "注意: 使用小型LLM模型时建议控制文稿在1千字内。对于不同字幕文件,请使用与该字幕相关的文稿提示。" - ) + def _warn_no_subtitle(self): + InfoBar.warning( + tr("common.warning"), + tr("subtitle.toast.load_first"), + duration=INFOBAR_DURATION_WARNING, + parent=self, ) - self.text_edit.setText(cfg.custom_prompt_text.value) - self.text_edit.setMinimumWidth(420) - self.text_edit.setMinimumHeight(380) + # --------------------------------------------------------- external API - # 添加到布局 - self.viewLayout.addWidget(self.titleLabel) - self.viewLayout.addWidget(self.text_edit) - self.viewLayout.setSpacing(10) + def set_task(self, task: SubtitleTask): + """外部(流水线)注入任务:加载字幕进表格,保留任务配置。""" + self.task = task + self.subtitle_path = task.subtitle_path + self._output_path = None + try: + asr_data = ASRData.from_subtitle_file(str(task.subtitle_path)) + self.model.replace_all(asr_data.to_json()) + except Exception as exc: + InfoBar.error( + tr("subtitle.toast.load_failed"), str(exc), duration=INFOBAR_DURATION_ERROR, parent=self + ) + return + self.tablePanel.setFile(Path(str(task.subtitle_path)).name, loaded=True) + self._apply_state(PageState.READY) - # 设置按钮文本 - self.yesButton.setText(self.tr("确定")) - self.cancelButton.setText(self.tr("取消")) + def process(self): + """外部注入任务后直接开始处理(流水线模式)。""" + if self.task is None: + return + self._launch_task() - def get_prompt(self) -> str: - return self.text_edit.toPlainText() + # ----------------------------------------------------------- drag&drop - def save_prompt(self) -> None: - # 在点击确定按钮时保存提示文本到配置 - prompt_text = self.text_edit.toPlainText() - cfg.set(cfg.custom_prompt_text, prompt_text) + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + self.tablePanel.dropZone.setDragActive(True) + event.accept() + else: + event.ignore() + def dragLeaveEvent(self, event): + self.tablePanel.dropZone.setDragActive(False) + super().dragLeaveEvent(event) -if __name__ == "__main__": - QApplication.setHighDpiScaleFactorRoundingPolicy( - Qt.HighDpiScaleFactorRoundingPolicy.PassThrough # type: ignore - ) - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) # type: ignore - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) # type: ignore + def dropEvent(self, event): + self.tablePanel.dropZone.setDragActive(False) + if self.controller.is_processing(): + self._warn_processing() + return + for url in event.mimeData().urls(): + file_path = url.toLocalFile() + if not os.path.isfile(file_path): + continue + suffix = Path(file_path).suffix.lstrip(".").lower() + if suffix in _SUBTITLE_FORMATS: + self.load_subtitle_file(file_path) + return + InfoBar.error( + tr("subtitle.toast.format_error") + suffix, + tr("subtitle.toast.supported_formats") + _FORMATS_PILL_TEXT, + duration=INFOBAR_DURATION_ERROR, + parent=self, + ) - app = QApplication(sys.argv) - window = SubtitleInterface() - window.show() - sys.exit(app.exec_()) + def closeEvent(self, event): + self._disconnect_config_signals() + self.controller.shutdown() + super().closeEvent(event) diff --git a/videocaptioner/ui/view/subtitle_style_interface.py b/videocaptioner/ui/view/subtitle_style_interface.py index e1f18fd5..6a2b49ad 100644 --- a/videocaptioner/ui/view/subtitle_style_interface.py +++ b/videocaptioner/ui/view/subtitle_style_interface.py @@ -1,1253 +1,1208 @@ -import json +# coding:utf-8 +"""字幕样式页(三栏工作台)。 + +左「样式库」(渲染模式分页 + 样式卡)、中「预览」(实时渲染当前样式)、 +右「参数」(分组参数行)。编辑即自动保存到当前用户样式(编辑内置样式时自动 +fork 成用户样式),与合成页共用 subtitle_style_name。 +""" + +from __future__ import annotations + +import time from pathlib import Path from typing import Optional, Tuple -from PIL import ImageFont -from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtCore import Qt, QThread, QTimer, pyqtSignal from PyQt5.QtGui import QColor, QFontDatabase -from PyQt5.QtWidgets import QFileDialog, QHBoxLayout, QVBoxLayout, QWidget -from qfluentwidgets import ( - BodyLabel, - CardWidget, - ImageLabel, - InfoBar, - InfoBarPosition, - LineEdit, - MessageBoxBase, - PushSettingCard, - ScrollArea, - SettingCardGroup, +from PyQt5.QtWidgets import ( + QFileDialog, + QFrame, + QGridLayout, + QHBoxLayout, + QVBoxLayout, + QWidget, ) -from qfluentwidgets import FluentIcon as FIF +from qfluentwidgets import ImageLabel, InfoBar, InfoBarPosition, ScrollArea -from videocaptioner.config import ASSETS_PATH, SUBTITLE_STYLE_PATH +from videocaptioner.config import ASSETS_PATH, USER_SUBTITLE_STYLE_PATH from videocaptioner.core.constant import INFOBAR_DURATION_SUCCESS, INFOBAR_DURATION_WARNING from videocaptioner.core.entities import SubtitleLayoutEnum, SubtitleRenderModeEnum from videocaptioner.core.subtitle import get_builtin_fonts, render_ass_preview, render_preview -from videocaptioner.core.subtitle.style_manager import StyleMode +from videocaptioner.core.subtitle.style_manager import ( + AssSecondaryStyle, + AssSubtitleStyle, + RoundedSubtitleStyle, + StyleSource, + SubtitleRenderer, + SubtitleStylePreset, + delete_user_style, + list_styles, + load_style, + normalize_style_id, + save_user_style, +) from videocaptioner.core.subtitle.styles import RoundedBgStyle from videocaptioner.core.utils.platform_utils import open_folder +from videocaptioner.ui.common.app_icons import AppIcon from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.common.signal_bus import signalBus -from videocaptioner.ui.components.MySettingCard import ( - ColorSettingCard, - ComboBoxSettingCard, - DoubleSpinBoxSettingCard, - SpinBoxSettingCard, +from videocaptioner.ui.common.theme_tokens import app_palette +from videocaptioner.ui.components.app_dialog import AppDialog, ConfirmDialog +from videocaptioner.ui.components.inspector_controls import ( + ColorValueControl, + InspectorGroup, + InspectorRow, + StyleCard, ) +from videocaptioner.ui.components.workbench import ( + AppLineEdit, + CompactButton, + FilterTabs, + PillSelect, + SectionLabel, + StatusPill, + StepperControl, + ToggleSwitch, + WorkbenchButton, + WorkbenchPanel, + apply_font, + draw_rounded_surface, +) +from videocaptioner.ui.i18n import tr -PERVIEW_TEXTS = { - "长文本": ( - "This is a long text for testing subtitle preview, text wrapping, and style settings.", - "这是一段用于测试字幕预览、自动换行以及样式设置的较长文本内容。", - ), - "中文本": ( - "Welcome to apply for the prestigious South China Normal University!", - "欢迎报考百年名校华南师范大学", - ), - "短文本": ("Elementary school students know this", "小学二年级的都知道"), -} - -DEFAULT_BG_LANDSCAPE = { - "path": ASSETS_PATH / "default_bg_landscape.png", - "width": 1280, - "height": 720, -} -DEFAULT_BG_PORTRAIT = { - "path": ASSETS_PATH / "default_bg_portrait.png", - "width": 480, - "height": 852, -} - - -class AssPreviewThread(QThread): - """ASS 样式预览线程""" - - previewReady = pyqtSignal(str) - - def __init__( - self, - preview_text: Tuple[str, Optional[str]], - style_str: str, - bg_image_path: str, - width: Optional[int] = None, - height: Optional[int] = None, - ): - super().__init__() - self.preview_text = preview_text - self.width = width - self.height = height - self.style_str = style_str - self.bg_image_path = bg_image_path - - def run(self): - preview_path = render_ass_preview( - style_str=self.style_str, - preview_text=self.preview_text, - bg_image_path=self.bg_image_path, - width=self.width, - height=self.height, - ) - self.previewReady.emit(preview_path) - - -class RoundedBgPreviewThread(QThread): - """圆角背景预览线程""" - - previewReady = pyqtSignal(str) - - def __init__( - self, - style: RoundedBgStyle, - preview_text: Tuple[str, Optional[str]], - width: Optional[int] = None, - height: Optional[int] = None, - bg_image_path: Optional[str] = None, - ): - super().__init__() - self.primary_text = preview_text[0] - self.secondary_text = preview_text[1] or "" - self.width = width - self.height = height - self.style = style - self.bg_image_path = bg_image_path - - def run(self): - preview_path = render_preview( - primary_text=self.primary_text, - secondary_text=self.secondary_text, - width=self.width, - height=self.height, - style=self.style, - bg_image_path=self.bg_image_path, - ) - self.previewReady.emit(preview_path) - - -class SubtitleStyleInterface(QWidget): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setObjectName("SubtitleStyleInterface") - self.setWindowTitle(self.tr("字幕样式配置")) - self.setAcceptDrops(True) # 启用拖放功能 - - # 创建主布局 - self.hBoxLayout = QHBoxLayout(self) - - # 初始化界面组件 - self._initSettingsArea() - self._initPreviewArea() - self._initSettingCards() - self._initLayout() - self._initStyle() - - # 控制是否触发样式变更回调(加载样式时禁用) - self._loading_style = False - - # 设置初始值,加载样式 - self.__setValues() - - # 连接信号 - self.connectSignals() - - def _initSettingsArea(self): - """初始化左侧设置区域""" - self.settingsScrollArea = ScrollArea() - self.settingsScrollArea.setFixedWidth(350) - self.settingsWidget = QWidget() - self.settingsLayout = QVBoxLayout(self.settingsWidget) - self.settingsScrollArea.setWidget(self.settingsWidget) - self.settingsScrollArea.setWidgetResizable(True) - - # 创建设置组 - 通用 - self.layoutGroup = SettingCardGroup(self.tr("字幕排布"), self.settingsWidget) - - # ASS 样式设置组 - self.assPrimaryGroup = SettingCardGroup( - self.tr("主字幕样式"), self.settingsWidget - ) - self.assSecondaryGroup = SettingCardGroup( - self.tr("副字幕样式"), self.settingsWidget - ) - - # 圆角背景设置组 - self.roundedBgGroup = SettingCardGroup( - self.tr("圆角背景样式"), self.settingsWidget - ) +# 预览示例文本(原文, 译文)的兜底:用户清空自定义时回落到配置里的默认值, +# 直接取 SettingField 默认,避免再硬编码一份导致与 config 漂移。 +PREVIEW_TEXT = ( + cfg.subtitle_preview_source.defaultValue, + cfg.subtitle_preview_target.defaultValue, +) - # 预览设置组 - self.previewGroup = SettingCardGroup(self.tr("预览设置"), self.settingsWidget) - - def _initPreviewArea(self): - """初始化右侧预览区域""" - self.previewCard = CardWidget() - self.previewLayout = QVBoxLayout(self.previewCard) - self.previewLayout.setSpacing(16) - - # 顶部预览区域 - self.previewTopWidget = QWidget() - self.previewTopWidget.setFixedHeight(430) - self.previewTopLayout = QVBoxLayout(self.previewTopWidget) - - self.previewLabel = BodyLabel(self.tr("预览效果")) - self.previewImage = ImageLabel() - self.previewImage.setAlignment(Qt.AlignCenter) # type: ignore - self.previewTopLayout.addWidget(self.previewImage, 0, Qt.AlignCenter) # type: ignore - self.previewTopLayout.setAlignment(Qt.AlignVCenter) # type: ignore - - # 底部控件区域 - self.previewBottomWidget = QWidget() - self.previewBottomLayout = QVBoxLayout(self.previewBottomWidget) - - self.styleNameComboBox = ComboBoxSettingCard( - FIF.VIEW, # type: ignore - self.tr("选择样式"), - self.tr("选择已保存的字幕样式"), - texts=[], # type: ignore - ) +DEFAULT_BG_LANDSCAPE = ASSETS_PATH / "default_bg_landscape.png" +DEFAULT_BG_PORTRAIT = ASSETS_PATH / "default_bg_portrait.png" - self.newStyleButton = PushSettingCard( - self.tr("新建样式"), - FIF.ADD, - self.tr("新建样式"), - self.tr("基于当前样式新建预设"), - ) +_FONT_CHOICES: Optional[list[str]] = None - self.openStyleFolderButton = PushSettingCard( - self.tr("打开样式文件夹"), - FIF.FOLDER, - self.tr("打开样式文件夹"), - self.tr("在文件管理器中打开样式文件夹"), - ) - self.previewBottomLayout.addWidget(self.styleNameComboBox) - self.previewBottomLayout.addWidget(self.newStyleButton) - self.previewBottomLayout.addWidget(self.openStyleFolderButton) - - self.previewLayout.addWidget(self.previewTopWidget) - self.previewLayout.addWidget(self.previewBottomWidget) - self.previewLayout.addStretch(1) - - def _initSettingCards(self): - """初始化所有设置卡片""" - # 渲染模式切换 - self.renderModeCard = ComboBoxSettingCard( - FIF.BRUSH, # type: ignore - self.tr("渲染模式"), - self.tr("选择字幕渲染方式"), - texts=[e.value for e in SubtitleRenderModeEnum], - ) +def _font_choices() -> list[str]: + """字体下拉的候选:内置字体在前(已随程序打包,渲染最稳),其后接系统已装字体。 - # 字幕排布设置 - self.layoutCard = ComboBoxSettingCard( - FIF.ALIGNMENT, # type: ignore - self.tr("字幕排布"), - self.tr("设置主字幕和副字幕的显示方式"), - texts=["译文在上", "原文在上", "仅译文", "仅原文"], - ) + 系统字体枚举略有开销且程序运行期不变,缓存一次即可。 + """ + global _FONT_CHOICES + if _FONT_CHOICES is not None: + return _FONT_CHOICES + names: list[str] = [] + seen: set[str] = set() + for item in get_builtin_fonts(): + name = item["name"] + if name not in seen: + names.append(name) + seen.add(name) + for family in QFontDatabase().families(): + if family not in seen: + names.append(family) + seen.add(family) + _FONT_CHOICES = names + return names - # ASS 模式 - 垂直间距 - self.assVerticalSpacingCard = SpinBoxSettingCard( - FIF.ALIGNMENT, # type: ignore - self.tr("垂直间距"), - self.tr("设置字幕的垂直间距"), - minimum=8, - maximum=10000, - ) - # ASS 模式 - 主字幕样式 - self.assPrimaryFontCard = ComboBoxSettingCard( - FIF.FONT, # type: ignore - self.tr("主字幕字体"), - self.tr("设置主字幕的字体"), - ) +# 对齐取值与显示文案(存 left/center/right,展示居左/居中/居右)。 +def _align_items() -> tuple[tuple[str, str], ...]: + return ( + ("left", tr("substyle.align.left")), + ("center", tr("substyle.align.center")), + ("right", tr("substyle.align.right")), + ) - self.assPrimarySizeCard = SpinBoxSettingCard( - FIF.FONT_SIZE, # type: ignore - self.tr("主字幕字号"), - self.tr("设置主字幕的大小"), - minimum=8, - maximum=1000, - ) +# 样式卡固定宽度(坞横向滚动按此累加内容宽度)。容纳一行「复制/重命名/删除」图标按钮。 +_CARD_WIDTH = 260 - self.assPrimarySpacingCard = DoubleSpinBoxSettingCard( - FIF.ALIGNMENT, # type: ignore - self.tr("主字幕间距"), - self.tr("设置主字幕的字符间距"), - minimum=0.0, - maximum=10.0, - decimals=1, - ) - self.assPrimaryColorCard = ColorSettingCard( - QColor(255, 255, 255), - FIF.PALETTE, # type: ignore - self.tr("主字幕颜色"), - self.tr("设置主字幕的颜色"), - ) +def _rgba_hex_to_qcolor(hex_color: str) -> QColor: + raw = hex_color.lstrip("#") + if len(raw) == 8: + return QColor(int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16), int(raw[6:8], 16)) + if len(raw) == 6: + return QColor(f"#{raw}") + return QColor(25, 25, 25, 200) - self.assPrimaryOutlineColorCard = ColorSettingCard( - QColor(0, 0, 0), - FIF.PALETTE, # type: ignore - self.tr("主字幕边框颜色"), - self.tr("设置主字幕的边框颜色"), - ) - self.assPrimaryOutlineSizeCard = DoubleSpinBoxSettingCard( - FIF.ZOOM, # type: ignore - self.tr("主字幕边框大小"), - self.tr("设置主字幕的边框粗细"), - minimum=0.0, - maximum=10.0, - decimals=1, - ) +def _qcolor_to_rgba_hex(color: QColor) -> str: + return f"#{color.red():02x}{color.green():02x}{color.blue():02x}{color.alpha():02x}" - # ASS 模式 - 副字幕样式 - self.assSecondaryFontCard = ComboBoxSettingCard( - FIF.FONT, # type: ignore - self.tr("副字幕字体"), - self.tr("设置副字幕的字体"), - ) - self.assSecondarySizeCard = SpinBoxSettingCard( - FIF.FONT_SIZE, # type: ignore - self.tr("副字幕字号"), - self.tr("设置副字幕的大小"), - minimum=8, - maximum=1000, - ) +# --------------------------------------------------------------------------- +# 预览线程:渲染异常不得 qFatal(裸抛会 abort 进程),失败打日志不 emit。 +# --------------------------------------------------------------------------- - self.assSecondarySpacingCard = DoubleSpinBoxSettingCard( - FIF.ALIGNMENT, # type: ignore - self.tr("副字幕间距"), - self.tr("设置副字幕的字符间距"), - minimum=0.0, - maximum=50.0, - decimals=1, - ) - self.assSecondaryColorCard = ColorSettingCard( - QColor(255, 255, 255), - FIF.PALETTE, # type: ignore - self.tr("副字幕颜色"), - self.tr("设置副字幕的颜色"), - ) +class AssPreviewThread(QThread): + previewReady = pyqtSignal(str) - self.assSecondaryOutlineColorCard = ColorSettingCard( - QColor(0, 0, 0), - FIF.PALETTE, # type: ignore - self.tr("副字幕边框颜色"), - self.tr("设置副字幕的边框颜色"), - ) + def __init__(self, preview_text: Tuple[str, Optional[str]], style_str: str, bg_image_path: str, line_gap: int = 0): + super().__init__() + self.preview_text = preview_text + self.style_str = style_str + self.bg_image_path = bg_image_path + self.line_gap = line_gap - self.assSecondaryOutlineSizeCard = DoubleSpinBoxSettingCard( - FIF.ZOOM, # type: ignore - self.tr("副字幕边框大小"), - self.tr("设置副字幕的边框粗细"), - minimum=0.0, - maximum=50.0, - decimals=1, - ) + def run(self): + try: + path = render_ass_preview( + style_str=self.style_str, + preview_text=self.preview_text, + line_gap=self.line_gap, + bg_image_path=self.bg_image_path, + ) + except Exception: + import traceback - # 圆角背景样式设置 - self.roundedFontCard = ComboBoxSettingCard( - FIF.FONT, # type: ignore - self.tr("字体"), - self.tr("设置字幕字体"), - ) + traceback.print_exc() + return + self.previewReady.emit(path) - self.roundedFontSizeCard = SpinBoxSettingCard( - FIF.FONT_SIZE, # type: ignore - self.tr("字体大小"), - self.tr("设置字幕字体大小"), - minimum=16, - maximum=120, - ) - self.roundedTextColorCard = ColorSettingCard( - QColor(255, 255, 255), - FIF.PALETTE, # type: ignore - self.tr("文字颜色"), - self.tr("设置字幕文字颜色"), - ) +class RoundedBgPreviewThread(QThread): + previewReady = pyqtSignal(str) - self.roundedBgColorCard = ColorSettingCard( - QColor(25, 25, 25, 200), - FIF.PALETTE, # type: ignore - self.tr("背景颜色"), - self.tr("设置圆角矩形背景颜色"), - enableAlpha=True, - ) + def __init__(self, preview_text: Tuple[str, Optional[str]], style: RoundedBgStyle, bg_image_path: str): + super().__init__() + self.preview_text = preview_text + self.style = style + self.bg_image_path = bg_image_path - self.roundedCornerRadiusCard = SpinBoxSettingCard( - FIF.ZOOM, # type: ignore - self.tr("圆角半径"), - self.tr("设置背景圆角大小"), - minimum=0, - maximum=50, - ) + def run(self): + try: + path = render_preview( + primary_text=self.preview_text[0], + secondary_text=self.preview_text[1] or "", + style=self.style, + bg_image_path=self.bg_image_path, + ) + except Exception: + import traceback - self.roundedPaddingHCard = SpinBoxSettingCard( - FIF.ALIGNMENT, # type: ignore - self.tr("水平内边距"), - self.tr("文字与背景边缘的水平距离"), - minimum=4, - maximum=100, - ) + traceback.print_exc() + return + self.previewReady.emit(path) - self.roundedPaddingVCard = SpinBoxSettingCard( - FIF.ALIGNMENT, # type: ignore - self.tr("垂直内边距"), - self.tr("文字与背景边缘的垂直距离"), - minimum=4, - maximum=50, - ) - self.roundedMarginBottomCard = SpinBoxSettingCard( - FIF.ALIGNMENT, # type: ignore - self.tr("底部边距"), - self.tr("字幕距视频底部的距离"), - minimum=20, - maximum=300, - ) +class _Stage(QFrame): + """预览舞台容器:自绘圆角底,居中放预览图。""" - self.roundedLineSpacingCard = SpinBoxSettingCard( - FIF.ALIGNMENT, # type: ignore - self.tr("行间距"), - self.tr("双语字幕的行间距"), - minimum=0, - maximum=50, - ) + # 预览图适配必须跟舞台几何走:预览图可压缩后,布局重排可能在页面尺寸 + # 不变时改变舞台大小(此时页面 resizeEvent 不触发),漏适配会把图卡在小尺寸。 + resized = pyqtSignal() - self.roundedLetterSpacingCard = SpinBoxSettingCard( - FIF.FONT, # type: ignore - self.tr("字符间距"), - self.tr("每个字符之间的额外间距"), - minimum=0, - maximum=20, - step=1, - ) + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("styleStage") + self.setStyleSheet("QFrame#styleStage { background: transparent; border: none; }") - # 预览设置 - self.previewTextCard = ComboBoxSettingCard( - FIF.MESSAGE, # type: ignore - self.tr("预览文字"), - self.tr("设置预览显示的文字内容"), - texts=list(PERVIEW_TEXTS.keys()), - parent=self.previewGroup, - ) + def resizeEvent(self, event): + super().resizeEvent(event) + self.resized.emit() - self.orientationCard = ComboBoxSettingCard( - FIF.LAYOUT, # type: ignore - self.tr("预览方向"), - self.tr("设置预览图片的显示方向"), - texts=["横屏", "竖屏"], - parent=self.previewGroup, - ) + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.field, palette.line_soft, 14) + super().paintEvent(event) - self.previewImageCard = PushSettingCard( - self.tr("选择图片"), - FIF.PHOTO, - self.tr("预览背景"), - self.tr("选择预览使用的背景图片"), - parent=self.previewGroup, - ) - def _initLayout(self): - """初始化布局""" - # 通用设置 - self.layoutGroup.addSettingCard(self.renderModeCard) - self.layoutGroup.addSettingCard(self.layoutCard) - self.layoutGroup.addSettingCard(self.assVerticalSpacingCard) - - # ASS 样式卡片 - self.assPrimaryGroup.addSettingCard(self.assPrimaryFontCard) - self.assPrimaryGroup.addSettingCard(self.assPrimarySizeCard) - self.assPrimaryGroup.addSettingCard(self.assPrimarySpacingCard) - self.assPrimaryGroup.addSettingCard(self.assPrimaryColorCard) - self.assPrimaryGroup.addSettingCard(self.assPrimaryOutlineColorCard) - self.assPrimaryGroup.addSettingCard(self.assPrimaryOutlineSizeCard) - - self.assSecondaryGroup.addSettingCard(self.assSecondaryFontCard) - self.assSecondaryGroup.addSettingCard(self.assSecondarySizeCard) - self.assSecondaryGroup.addSettingCard(self.assSecondarySpacingCard) - self.assSecondaryGroup.addSettingCard(self.assSecondaryColorCard) - self.assSecondaryGroup.addSettingCard(self.assSecondaryOutlineColorCard) - self.assSecondaryGroup.addSettingCard(self.assSecondaryOutlineSizeCard) - - # 圆角背景卡片 - self.roundedBgGroup.addSettingCard(self.roundedFontCard) - self.roundedBgGroup.addSettingCard(self.roundedFontSizeCard) - self.roundedBgGroup.addSettingCard(self.roundedTextColorCard) - self.roundedBgGroup.addSettingCard(self.roundedBgColorCard) - self.roundedBgGroup.addSettingCard(self.roundedCornerRadiusCard) - self.roundedBgGroup.addSettingCard(self.roundedPaddingHCard) - self.roundedBgGroup.addSettingCard(self.roundedPaddingVCard) - self.roundedBgGroup.addSettingCard(self.roundedMarginBottomCard) - self.roundedBgGroup.addSettingCard(self.roundedLineSpacingCard) - self.roundedBgGroup.addSettingCard(self.roundedLetterSpacingCard) - - # 预览设置 - self.previewGroup.addSettingCard(self.previewTextCard) - self.previewGroup.addSettingCard(self.orientationCard) - self.previewGroup.addSettingCard(self.previewImageCard) - - # 添加组到布局 - self.settingsLayout.addWidget(self.layoutGroup) - self.settingsLayout.addWidget(self.assPrimaryGroup) - self.settingsLayout.addWidget(self.assSecondaryGroup) - self.settingsLayout.addWidget(self.roundedBgGroup) - self.settingsLayout.addWidget(self.previewGroup) - self.settingsLayout.addStretch(1) - - # 添加左右两侧到主布局 - self.hBoxLayout.addWidget(self.settingsScrollArea) - self.hBoxLayout.addWidget(self.previewCard) - - def _initStyle(self): - """初始化样式""" - self.settingsWidget.setObjectName("settingsWidget") - self.setStyleSheet( - """ - SubtitleStyleInterface, #settingsWidget { - background-color: transparent; - } - QScrollArea { - border: none; - background-color: transparent; - } +class SubtitleStyleInterface(QWidget): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setObjectName("SubtitleStyleInterface") + self.setWindowTitle(tr("substyle.title")) + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.setAcceptDrops(True) + + self._loading = False # 程序化更新控件时抑制自动保存 + self._mode_key = self._renderer_key() # 当前渲染模式的唯一真源(不依赖控件状态) + # 每个渲染模式各记住最近选中的样式 id:切到另一模式再切回时据此还原。 + # subtitle_style_name 只存一份,否则切模式往返会把本模式的选择丢回内置默认, + # 下一次编辑又把内置 fork 成新样式,导致样式越积越多。 + self._mode_style: dict[str, str] = { + self._mode_key: normalize_style_id( + cfg.subtitle_style_name.value, self._mode_key + ) + } + self._orientation = "横屏" + self._preview_threads: list[QThread] = [] + self._preview_generation = 0 + self._cards: list[StyleCard] = [] + # 预览防抖(前沿触发):空闲后第一次调用立即渲染(首屏/切换不闪空), + # 紧接着的连拍(连点步进器等)合并到末尾再渲一次,避免 ASS(ffmpeg) 渲染堆积。 + self._last_preview_ts = 0.0 + self._preview_timer = QTimer(self) + self._preview_timer.setSingleShot(True) + self._preview_timer.setInterval(120) + self._preview_timer.timeout.connect(self._render_preview_now) + # 渲染模式专属控件引用(重建参数面板时刷新) + self._ass: dict = {} + self._rounded: dict = {} + + self._build_ui() + self._on_mode_changed(self._renderer_key(), initial=True) + + # ---------------------------------------------------------------- 构建 + + def _build_ui(self): + # 上下左右两栏布局:左上预览(占主空间)、左下样式坞、右侧参数(通栏)。 + # 让预览尽量大,样式库收到底部横向陈列。 + root = QGridLayout(self) + root.setContentsMargins(22, 22, 22, 22) + root.setHorizontalSpacing(18) + root.setVerticalSpacing(18) + preview = self._build_preview() + inspector = self._build_inspector() + dock = self._build_dock() + root.addWidget(preview, 0, 0) + root.addWidget(inspector, 0, 1, 2, 1) + root.addWidget(dock, 1, 0) + root.setColumnStretch(0, 1) + root.setColumnStretch(1, 0) + root.setRowStretch(0, 1) + root.setRowStretch(1, 0) + self._apply_page_style() + + def _build_dock(self) -> QWidget: + """底部样式坞:单行横向陈列所有样式卡,点选即高亮(卡片定高,不重排不闪动)。 + + 坞高 = 头部(58) + 分隔线(1) + 轨道视口(卡片 126 + 上下内边距 24 = 150) = 209, + 刚好容纳一行卡片:横向滚动条是叠加层不占高度,视口与卡片等高则既不截断也不留空白。 """ - ) - - def __setValues(self): - """设置初始值""" - # 设置渲染模式 - self.renderModeCard.comboBox.setCurrentText( - cfg.subtitle_render_mode.value.value - ) - - # 设置字幕排布 - self.layoutCard.comboBox.setCurrentText(cfg.subtitle_layout.value.value) - - # 设置字幕样式 - self.styleNameComboBox.comboBox.setCurrentText(cfg.get(cfg.subtitle_style_name)) - - # 获取字体列表(内置字体 + 系统字体) - builtin_fonts = get_builtin_fonts() - builtin_font_names = [f["name"] for f in builtin_fonts] - - fontDatabase = QFontDatabase() - fontFamilies = fontDatabase.families() - - # 过滤系统字体: - # 1. 排除私有字体(以 . 开头) - # 2. 排除已有的内置字体 - # 3. 只保留 PIL 能实际加载的字体(用于圆角背景渲染) - system_fonts = [] - for font_name in fontFamilies: - if font_name.startswith(".") or font_name in builtin_font_names: - continue - # 测试 PIL 是否能加载此字体 - try: - ImageFont.truetype(font_name, 12) # 测试用小尺寸 - system_fonts.append(font_name) - except (OSError, IOError): - # PIL 无法加载,跳过此字体 - pass - - # 合并字体列表:内置字体在最前面 - all_fonts = builtin_font_names + sorted(system_fonts) - - # ASS 模式字体 - self.assPrimaryFontCard.addItems(all_fonts) - self.assSecondaryFontCard.addItems(all_fonts) - self.assPrimaryFontCard.comboBox.setMaxVisibleItems(12) - self.assSecondaryFontCard.comboBox.setMaxVisibleItems(12) - - # 圆角背景模式字体 - self.roundedFontCard.addItems(all_fonts) - self.roundedFontCard.comboBox.setMaxVisibleItems(12) - - # 设置圆角背景模式的初始值 - self.roundedFontSizeCard.spinBox.setValue(cfg.get(cfg.rounded_bg_font_size)) - self.roundedCornerRadiusCard.spinBox.setValue( - cfg.get(cfg.rounded_bg_corner_radius) - ) - self.roundedPaddingHCard.spinBox.setValue(cfg.get(cfg.rounded_bg_padding_h)) - self.roundedPaddingVCard.spinBox.setValue(cfg.get(cfg.rounded_bg_padding_v)) - self.roundedMarginBottomCard.spinBox.setValue( - cfg.get(cfg.rounded_bg_margin_bottom) - ) - self.roundedLineSpacingCard.spinBox.setValue( - cfg.get(cfg.rounded_bg_line_spacing) - ) - self.roundedLetterSpacingCard.spinBox.setValue( - cfg.get(cfg.rounded_bg_letter_spacing) - ) + panel = WorkbenchPanel(padded=False) + panel.setFixedHeight(209) + layout = panel.bodyLayout + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + head = QHBoxLayout() + head.setContentsMargins(14, 10, 14, 10) + head.setSpacing(12) + is_rounded = self._renderer_key() == "rounded" + self.modeTabs = FilterTabs( + [("ass", tr("substyle.mode.ass")), ("rounded", tr("substyle.mode.rounded"))] + ) + self.modeTabs.setCurrent("rounded" if is_rounded else "ass") + self.modeTabs.changed.connect(self._on_mode_changed) + head.addWidget(self.modeTabs) + head.addStretch(1) + self.countChip = StatusPill("", "neutral") + head.addWidget(self.countChip) + self.newButton = WorkbenchButton(tr("substyle.dock.new"), AppIcon.ADD, primary=True, height=34) + self.newButton.clicked.connect(self._on_new_style) + self.folderButton = WorkbenchButton(tr("substyle.dock.folder"), AppIcon.FOLDER, height=34) + self.folderButton.clicked.connect(lambda: open_folder(str(USER_SUBTITLE_STYLE_PATH))) + head.addWidget(self.newButton) + head.addWidget(self.folderButton) + layout.addLayout(head) + layout.addWidget(self._hline()) + + # 单行横向滚动陈列所有样式 + self.trackScroll = ScrollArea() + self.trackScroll.setWidgetResizable(True) + self.trackScroll.enableTransparentBackground() + self.trackScroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + self.trackBody = QWidget() + self.trackBody.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.trackBody.setStyleSheet("background: transparent;") + self.trackLayout = QHBoxLayout(self.trackBody) + self.trackLayout.setContentsMargins(14, 12, 14, 12) + self.trackLayout.setSpacing(12) + self.trackLayout.addStretch(1) + self.trackScroll.setWidget(self.trackBody) + layout.addWidget(self.trackScroll, 1) + return panel + + def _build_preview(self) -> QWidget: + panel = WorkbenchPanel(padded=False) + panel.setMinimumWidth(380) + layout = panel.bodyLayout + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # 头部与「参数」栏同款(16/14/16/13 内边距、17/880 标题),标题随 HBox 垂直居中, + # 与右侧 32 高按钮对齐——不再借用 PanelHeader(其自带 18px 底边距会顶高标题)。 + toolbar = QHBoxLayout() + toolbar.setContentsMargins(16, 14, 16, 13) + toolbar.setSpacing(10) + self.previewTitle = SectionLabel(tr("substyle.preview.title")) + apply_font(self.previewTitle, 17, 880) + toolbar.addWidget(self.previewTitle) + toolbar.addStretch(1) + self.textButton = CompactButton(tr("substyle.preview.text"), AppIcon.FONT) + self.textButton.clicked.connect(self._edit_preview_text) + self.orientationButton = CompactButton(tr("substyle.preview.landscape"), AppIcon.LAYOUT) + self.orientationButton.clicked.connect(self._toggle_orientation) + self.bgButton = CompactButton(tr("substyle.preview.background"), AppIcon.PHOTO) + self.bgButton.clicked.connect(self._pick_background) + for btn in (self.textButton, self.orientationButton, self.bgButton): + toolbar.addWidget(btn) + layout.addLayout(toolbar) + layout.addWidget(self._hline()) + + body = QVBoxLayout() + body.setContentsMargins(12, 10, 12, 12) + body.setSpacing(0) + self.stage = _Stage() + self.stage.resized.connect(self._fit_preview) + # 预览图不进布局、由 _fit_preview 手动缩放并居中:ImageLabel 靠 setFixedSize + # 撑尺寸(sizeHint 不反映图片),进布局要么把页面最小高度钉死到窗口无法 + # 缩小,要么被布局按 sizeHint 压瘪。 + self.previewImage = ImageLabel(self.stage) + body.addWidget(self.stage, 1) + layout.addLayout(body) + return panel + + def _build_inspector(self) -> QWidget: + panel = WorkbenchPanel(padded=False) + panel.setMinimumWidth(360) + panel.setMaximumWidth(398) + layout = panel.bodyLayout + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # 自定义头:与「样式库」头同款 16px 内边距,让"参数"标题与下面的分组左对齐。 + head = QHBoxLayout() + head.setContentsMargins(16, 14, 16, 13) + title = SectionLabel(tr("substyle.inspector.title")) + apply_font(title, 17, 880) + head.addWidget(title) + head.addStretch(1) + self.autoSavePill = StatusPill(tr("substyle.inspector.autosave"), "ok") + head.addWidget(self.autoSavePill) + layout.addLayout(head) + layout.addWidget(self._hline()) + + scroll = ScrollArea() + scroll.setWidgetResizable(True) + scroll.enableTransparentBackground() + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + self.inspectorBody = QWidget() + self.inspectorBody.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.inspectorBody.setStyleSheet("background: transparent;") + self.inspectorLayout = QVBoxLayout(self.inspectorBody) + self.inspectorLayout.setContentsMargins(16, 16, 16, 16) + self.inspectorLayout.setSpacing(18) + self.inspectorLayout.addStretch(1) + scroll.setWidget(self.inspectorBody) + layout.addWidget(scroll, 1) + return panel + + def _hline(self) -> QFrame: + line = QFrame() + line.setFixedHeight(1) + palette = app_palette() + line.setStyleSheet(f"background: {palette.line_soft}; border: none;") + return line + + # ---------------------------------------------------------------- 渲染模式 + + def _renderer_key(self) -> str: + mode = cfg.subtitle_render_mode.value + return "rounded" if mode == SubtitleRenderModeEnum.ROUNDED_BG else "ass" + + def _on_mode_changed(self, key: str, initial: bool = False): + mode = ( + SubtitleRenderModeEnum.ROUNDED_BG + if key == "rounded" + else SubtitleRenderModeEnum.ASS_STYLE + ) + if not initial: + cfg.set(cfg.subtitle_render_mode, mode) + self._rebuild_inspector(key) + self._refresh_style_list() + + # ---------------------------------------------------------------- 参数面板 + + def _clear_inspector(self): + while self.inspectorLayout.count() > 1: # 保留末尾 stretch + item = self.inspectorLayout.takeAt(0) + widget = item.widget() + if widget is not None: + # 先脱离父级立即从显示移除:deleteLater 是异步的, + # 否则重建时旧控件未销毁会与新控件叠成重影。 + widget.setParent(None) + widget.deleteLater() + + def _stepper(self, value, minimum, maximum, step=1, decimals=0, suffix=""): + ctl = StepperControl(value, minimum, maximum, step, decimals, suffix, width=124) + ctl.valueChanged.connect(self._on_edit) + return ctl + + def _font_select(self) -> PillSelect: + """字体下拉:与「双语顺序」同款胶囊(设计语言一致),内置 + 系统字体, + + 候选很多时菜单自动限高滚动。先填充再接信号,避免初始填充误触自动保存。""" + pill = PillSelect() + pill.setItems(_font_choices()) + pill.currentTextChanged.connect(lambda _t: self._on_edit()) + return pill + + @staticmethod + def _set_font(pill: PillSelect, name: str): + """选中字体;若样式里的字体不在候选中(如来自他机),临时补进列表再选中。""" + if name and name not in pill.items(): + pill.setItems(pill.items() + [name], current=name) + else: + pill.setCurrentText(name) + + def _toggle(self, checked: bool) -> ToggleSwitch: + sw = ToggleSwitch(checked) + sw.toggled.connect(lambda _v: self._on_edit()) + return sw + + def _align_select(self) -> PillSelect: + pill = PillSelect() + pill.setItems([label for _v, label in _align_items()]) + pill.currentTextChanged.connect(lambda _t: self._on_edit()) + return pill + + @staticmethod + def _align_value(pill: PillSelect) -> str: + label = pill.currentText() + return next((v for v, lab in _align_items() if lab == label), "center") + + @staticmethod + def _set_align(pill: PillSelect, value: str): + items = _align_items() + label = next((lab for v, lab in items if v == value), items[1][1]) + pill.setCurrentText(label) + + def _color(self, color: QColor, title: str, alpha: bool = False) -> ColorValueControl: + ctl = ColorValueControl(color, title, alpha=alpha, width=124) + ctl.colorChanged.connect(lambda _c: self._on_edit()) + return ctl + + def _build_layout_group(self) -> InspectorGroup: + group = InspectorGroup(tr("substyle.group.layout")) + self.contentSeg = FilterTabs( + [ + ("bilingual", tr("substyle.content.bilingual")), + ("source", tr("substyle.content.source")), + ("target", tr("substyle.content.target")), + ] + ) + self.contentSeg.changed.connect(self._on_content_changed) + group.addRow(InspectorRow(AppIcon.LANGUAGE, tr("substyle.row.content"), self.contentSeg)) + + self.orderPill = PillSelect() + self.orderPill.setItems([tr("substyle.order.source_top"), tr("substyle.order.target_top")]) + self.orderPill.currentTextChanged.connect(lambda _t: self._on_edit()) + self.orderRow = InspectorRow(AppIcon.ALIGNMENT, tr("substyle.row.order"), self.orderPill) + group.addRow(self.orderRow) + return group + + def _gap_row(self) -> InspectorRow: + """主副间距行(放在「位置」组):ASS 映射上行对话 MarginV;圆角映射两气泡间距。""" + self.gapStepper = self._stepper(10, 0, 80, 1, suffix="px") + self.gapRow = InspectorRow(AppIcon.LAYOUT, tr("substyle.row.gap"), self.gapStepper) + return self.gapRow + + def _rebuild_inspector(self, key: str): + self._mode_key = key + self._clear_inspector() + self._ass, self._rounded = {}, {} + groups: list[InspectorGroup] = [self._build_layout_group()] + + if key == "rounded": + r = self._rounded + bg = InspectorGroup(tr("substyle.group.background")) + r["bg_color"] = self._color(QColor(13, 227, 255, 230), tr("substyle.field.bg_color"), alpha=True) + bg.addRow(InspectorRow(AppIcon.PALETTE, tr("substyle.field.bg_color"), r["bg_color"])) + r["radius"] = self._stepper(14, 0, 60, 1, suffix="px") + bg.addRow(InspectorRow(AppIcon.ZOOM, tr("substyle.field.radius"), r["radius"])) + groups.append(bg) + + text = InspectorGroup(tr("substyle.group.text"), tr("substyle.group.text.sub")) + r["font"] = self._font_select() + text.addRow(InspectorRow(AppIcon.FONT, tr("substyle.field.font"), r["font"])) + r["size"] = self._stepper(34, 8, 160, 1, suffix="px") + text.addRow(InspectorRow(AppIcon.FONT_SIZE, tr("substyle.field.size"), r["size"])) + r["text_color"] = self._color(QColor("#ffffff"), tr("substyle.field.text_color")) + text.addRow(InspectorRow(AppIcon.PALETTE, tr("substyle.field.text_color"), r["text_color"])) + r["letter"] = self._stepper(0, 0, 40, 1, suffix="px") + text.addRow(InspectorRow(AppIcon.FONT, tr("substyle.field.letter_spacing"), r["letter"])) + groups.append(text) + + inner = InspectorGroup(tr("substyle.group.padding")) + r["pad_h"] = self._stepper(28, 0, 120, 1, suffix="px") + inner.addRow(InspectorRow(AppIcon.LAYOUT, tr("substyle.field.pad_h"), r["pad_h"])) + r["pad_v"] = self._stepper(14, 0, 80, 1, suffix="px") + inner.addRow(InspectorRow(AppIcon.LAYOUT, tr("substyle.field.pad_v"), r["pad_v"])) + groups.append(inner) + + position = InspectorGroup(tr("substyle.group.position")) + r["margin"] = self._stepper(60, 0, 400, 2, suffix="px") + position.addRow(InspectorRow(AppIcon.ALIGNMENT, tr("substyle.field.margin_bottom"), r["margin"])) + position.addRow(self._gap_row()) + r["max_width"] = self._stepper(90, 30, 100, 2, suffix="%") + position.addRow(InspectorRow(AppIcon.LAYOUT, tr("substyle.field.max_width"), r["max_width"])) + r["align"] = self._align_select() + position.addRow(InspectorRow(AppIcon.ALIGNMENT, tr("substyle.field.align"), r["align"])) + groups.append(position) + else: + a = self._ass + primary = InspectorGroup(tr("substyle.group.primary"), tr("substyle.content.source")) + a["font"] = self._font_select() + primary.addRow(InspectorRow(AppIcon.FONT, tr("substyle.field.font"), a["font"])) + a["size"] = self._stepper(42, 8, 160, 1, suffix="px") + primary.addRow(InspectorRow(AppIcon.FONT_SIZE, tr("substyle.field.size"), a["size"])) + a["color"] = self._color(QColor("#ffffff"), tr("substyle.field.text_color")) + primary.addRow(InspectorRow(AppIcon.PALETTE, tr("substyle.field.text_color"), a["color"])) + a["outline_color"] = self._color(QColor("#000000"), tr("substyle.field.outline_color")) + primary.addRow(InspectorRow(AppIcon.BRUSH, tr("substyle.field.outline_color"), a["outline_color"])) + a["outline"] = self._stepper(3, 0, 12, 0.5, decimals=1, suffix="px") + primary.addRow(InspectorRow(AppIcon.BRUSH, tr("substyle.field.outline_width"), a["outline"])) + a["spacing"] = self._stepper(0.2, 0, 12, 0.2, decimals=1, suffix="px") + primary.addRow(InspectorRow(AppIcon.FONT, tr("substyle.field.letter_spacing"), a["spacing"])) + a["bold"] = self._toggle(True) + primary.addRow(InspectorRow(AppIcon.FONT, tr("substyle.field.bold"), a["bold"])) + groups.append(primary) + + secondary = InspectorGroup(tr("substyle.group.secondary"), tr("substyle.content.target")) + a["sec_font"] = self._font_select() + secondary.addRow(InspectorRow(AppIcon.FONT, tr("substyle.field.font"), a["sec_font"])) + a["sec_size"] = self._stepper(27, 8, 160, 1, suffix="px") + secondary.addRow(InspectorRow(AppIcon.FONT_SIZE, tr("substyle.field.size"), a["sec_size"])) + a["sec_color"] = self._color(QColor("#ffe36b"), tr("substyle.field.text_color")) + secondary.addRow(InspectorRow(AppIcon.PALETTE, tr("substyle.field.text_color"), a["sec_color"])) + a["sec_outline_color"] = self._color(QColor("#000000"), tr("substyle.field.outline_color")) + secondary.addRow(InspectorRow(AppIcon.BRUSH, tr("substyle.field.outline_color"), a["sec_outline_color"])) + a["sec_outline"] = self._stepper(2, 0, 12, 0.5, decimals=1, suffix="px") + secondary.addRow(InspectorRow(AppIcon.BRUSH, tr("substyle.field.outline_width"), a["sec_outline"])) + a["sec_spacing"] = self._stepper(0.8, 0, 12, 0.2, decimals=1, suffix="px") + secondary.addRow(InspectorRow(AppIcon.FONT, tr("substyle.field.letter_spacing"), a["sec_spacing"])) + a["sec_bold"] = self._toggle(True) + secondary.addRow(InspectorRow(AppIcon.FONT, tr("substyle.field.bold"), a["sec_bold"])) + groups.append(secondary) + + position = InspectorGroup(tr("substyle.group.position")) + a["margin"] = self._stepper(42, 0, 400, 2, suffix="px") + position.addRow(InspectorRow(AppIcon.ALIGNMENT, tr("substyle.field.margin_bottom"), a["margin"])) + position.addRow(self._gap_row()) + a["max_width"] = self._stepper(100, 30, 100, 2, suffix="%") + position.addRow(InspectorRow(AppIcon.LAYOUT, tr("substyle.field.max_width"), a["max_width"])) + a["align"] = self._align_select() + position.addRow(InspectorRow(AppIcon.ALIGNMENT, tr("substyle.field.align"), a["align"])) + groups.append(position) + + for group in groups: + self.inspectorLayout.insertWidget(self.inspectorLayout.count() - 1, group) + + # ---------------------------------------------------------------- 字幕排布 + + def _on_content_changed(self, _key: str): + bilingual = self.contentSeg.current() == "bilingual" + self.orderRow.setVisible(bilingual) + self.gapRow.setVisible(bilingual) # 主副间距只在双语时有意义 + self._on_edit() + + def _current_layout(self) -> SubtitleLayoutEnum: + content = self.contentSeg.current() + if content == "source": + return SubtitleLayoutEnum.ONLY_ORIGINAL + if content == "target": + return SubtitleLayoutEnum.ONLY_TRANSLATE + if self.orderPill.currentText() == tr("substyle.order.target_top"): + return SubtitleLayoutEnum.TRANSLATE_ON_TOP + return SubtitleLayoutEnum.ORIGINAL_ON_TOP + + def _apply_layout_to_controls(self, layout: SubtitleLayoutEnum): + if layout == SubtitleLayoutEnum.ONLY_ORIGINAL: + self.contentSeg.setCurrent("source") + elif layout == SubtitleLayoutEnum.ONLY_TRANSLATE: + self.contentSeg.setCurrent("target") + elif layout == SubtitleLayoutEnum.TRANSLATE_ON_TOP: + self.contentSeg.setCurrent("bilingual") + self.orderPill.setCurrentText(tr("substyle.order.target_top")) + else: + self.contentSeg.setCurrent("bilingual") + self.orderPill.setCurrentText(tr("substyle.order.source_top")) + bilingual = self.contentSeg.current() == "bilingual" + self.orderRow.setVisible(bilingual) + self.gapRow.setVisible(bilingual) + + # ---------------------------------------------------------------- 样式库 + + def _clear_dock_cards(self): + """清空横向轨道(setParent(None) 立即脱离,避免 deleteLater 异步重影)。""" + self._cards = [] + while self.trackLayout.count() > 1: # 保留末尾 stretch + item = self.trackLayout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + + def _set_current_style(self, style_id: str): + """提交当前模式选中的样式:写配置 + 记住该模式的选择(切模式往返可还原)。""" + self._mode_style[self._mode_key] = style_id + cfg.set(cfg.subtitle_style_name, style_id) + + def _refresh_style_list(self): + """重建样式卡(仅在新增/复制/重命名/删除/切换模式时调用,点选切换不重建)。""" + self._clear_dock_cards() + + key = self._mode_key + styles = list_styles(renderer=key) + styles.sort(key=lambda s: (s.id != f"{key}/default", s.source.value, s.short_id)) + self.countChip.setText(tr("substyle.dock.count", n=len(styles))) + + # 优先用本模式记住的选择;它若属于另一模式(normalize 仍带原前缀)则解析不到, + # 回退到该模式首张卡(内置默认),而不会污染另一模式的记忆。 + active_id = self._mode_style.get(key) or normalize_style_id( + cfg.subtitle_style_name.value, key + ) + active_preset = next((s for s in styles if s.id == active_id), None) + if active_preset is None: + active_preset = styles[0] if styles else None + active_norm = active_preset.id if active_preset is not None else "" + + active_card = None + for idx, preset in enumerate(styles): + card = self._make_card(preset) + card.setActive(preset.id == active_norm) + if preset.id == active_norm: + active_card = card + self.trackLayout.insertWidget(idx, card) + self._cards.append(card) + + # 让坞内容宽度等于所有卡片之和,横向才能真正滚动(否则 setWidgetResizable + # 会把内容压成视口宽度,超出的卡片被裁还滚不到)。 + margins = self.trackLayout.contentsMargins() + spacing = self.trackLayout.spacing() + content_w = ( + margins.left() + margins.right() + + len(self._cards) * _CARD_WIDTH + + max(0, len(self._cards) - 1) * spacing + ) + self.trackBody.setMinimumWidth(content_w) + + if active_preset is not None: + self._set_current_style(active_preset.id) + self._load_into_controls(active_preset) + self.update_preview() + self._scroll_card_into_view(active_card) + + def _make_card(self, preset: SubtitleStylePreset) -> StyleCard: + icon = AppIcon.PALETTE if preset.renderer == SubtitleRenderer.ROUNDED else AppIcon.BRUSH + card = StyleCard( + preset.id, + preset.name, + self._swatches(preset), + preset.editable, + icon, + ) + card.setFixedWidth(_CARD_WIDTH) # 统一宽度,可容纳选中态的三个文字动作按钮 + card.clicked.connect(self._select_style) + card.duplicateRequested.connect(self._duplicate_style) + card.renameRequested.connect(self._rename_style) + card.deleteRequested.connect(self._delete_style) + return card + + def _swatches(self, preset: SubtitleStylePreset) -> list[str]: + style = preset.style + if isinstance(style, RoundedSubtitleStyle): + return [style.text_color, _rgba_hex_to_qcolor(style.bg_color).name(QColor.HexRgb)] + if isinstance(style, AssSubtitleStyle): + colors = [style.primary_color, style.outline_color] + if style.secondary and style.secondary.color not in colors: + colors.append(style.secondary.color) + return colors + return [] + + def _select_style(self, style_id: str): + """点选某个样式 → 仅高亮 + 载入参数 + 刷新预览(不重建卡片,避免重排闪动)。""" + key = self._mode_key + if normalize_style_id(style_id, key) == normalize_style_id(cfg.subtitle_style_name.value, key): + return # 已是当前样式 + preset = load_style(style_id, renderer=key) + if preset is None: + return + self._set_current_style(preset.id) + active_card = None + for card in self._cards: + card.setActive(card.style_id == preset.id) + if card.style_id == preset.id: + active_card = card + self._load_into_controls(preset) + self.update_preview() + self._scroll_card_into_view(active_card) + + def _scroll_card_into_view(self, card: Optional[StyleCard]): + """把指定卡片横向滚动到可见处(延后到布局完成,确保滚动范围已就绪)。""" + if card is None: + return - # 设置颜色 - text_color = cfg.get(cfg.rounded_bg_text_color) - self.roundedTextColorCard.setColor(QColor(text_color)) - bg_color = cfg.get(cfg.rounded_bg_color) - self.roundedBgColorCard.setColor(self._parseRgbaHex(bg_color)) - - # 加载样式列表(根据当前模式) - self._refreshStyleList() - - # 根据当前渲染模式显示/隐藏设置组 - self._updateVisibleGroups() - - def connectSignals(self): - """连接所有设置变更的信号到预览更新函数""" - # 渲染模式切换 - self.renderModeCard.currentTextChanged.connect(self.onRenderModeChanged) - - # 字幕排布(通用设置) - self.layoutCard.currentTextChanged.connect(self.updatePreview) - self.layoutCard.currentTextChanged.connect( - lambda: cfg.set( - cfg.subtitle_layout, - SubtitleLayoutEnum(self.layoutCard.comboBox.currentText()), + def _do(c=card): + if c in self._cards: + self.trackScroll.ensureWidgetVisible(c, 24, 0) + + QTimer.singleShot(0, _do) + + # ---------------------------------------------------------------- 控件 ↔ 样式 + + def _load_into_controls(self, preset: SubtitleStylePreset): + self._loading = True + self._apply_layout_to_controls(cfg.subtitle_layout.value) + if isinstance(preset.style, RoundedSubtitleStyle): + s, r = preset.style, self._rounded + self._set_font(r["font"], s.font_name) + r["size"].setValue(s.font_size) + r["text_color"].setColor(QColor(s.text_color)) + r["bg_color"].setColor(_rgba_hex_to_qcolor(s.bg_color)) + r["radius"].setValue(s.corner_radius) + self.gapStepper.setValue(s.line_spacing) + r["letter"].setValue(s.letter_spacing) + r["pad_h"].setValue(s.padding_h) + r["pad_v"].setValue(s.padding_v) + r["margin"].setValue(s.margin_bottom) + r["max_width"].setValue(s.max_width) + self._set_align(r["align"], s.align) + elif isinstance(preset.style, AssSubtitleStyle): + s, a = preset.style, self._ass + self._set_font(a["font"], s.font_name) + a["size"].setValue(s.font_size) + a["color"].setColor(QColor(s.primary_color)) + a["outline_color"].setColor(QColor(s.outline_color)) + a["outline"].setValue(s.outline_width) + a["spacing"].setValue(s.spacing) + a["bold"].setChecked(s.bold) + a["margin"].setValue(s.margin_bottom) + a["max_width"].setValue(s.max_width) + self._set_align(a["align"], s.align) + self.gapStepper.setValue(s.line_gap) + sec = s.secondary + if sec: + self._set_font(a["sec_font"], sec.font_name) + a["sec_size"].setValue(sec.font_size) + a["sec_color"].setColor(QColor(sec.color)) + a["sec_outline_color"].setColor(QColor(sec.outline_color)) + a["sec_outline"].setValue(sec.outline_width) + a["sec_spacing"].setValue(sec.spacing) + a["sec_bold"].setChecked(sec.bold) + else: + self._set_font(a["sec_font"], s.font_name) + a["sec_bold"].setChecked(s.bold) # 无副字幕样式时沿用主字幕加粗 + self._loading = False + + def _preset_from_controls(self, style_id: str, name: Optional[str] = None) -> SubtitleStylePreset: + key = self._mode_key + if key == "rounded": + r = self._rounded + style = RoundedSubtitleStyle( + font_name=r["font"].currentText(), + font_size=int(r["size"].value()), + text_color=r["text_color"].color().name(QColor.HexRgb), + bg_color=_qcolor_to_rgba_hex(r["bg_color"].color()), + corner_radius=int(r["radius"].value()), + padding_h=int(r["pad_h"].value()), + padding_v=int(r["pad_v"].value()), + margin_bottom=int(r["margin"].value()), + line_spacing=int(self.gapStepper.value()), + letter_spacing=int(r["letter"].value()), + max_width=int(r["max_width"].value()), + align=self._align_value(r["align"]), ) + renderer = SubtitleRenderer.ROUNDED + else: + a = self._ass + style = AssSubtitleStyle( + font_name=a["font"].currentText(), + font_size=int(a["size"].value()), + primary_color=a["color"].color().name(QColor.HexRgb), + outline_color=a["outline_color"].color().name(QColor.HexRgb), + outline_width=a["outline"].value(), + bold=a["bold"].isChecked(), + spacing=a["spacing"].value(), + margin_bottom=int(a["margin"].value()), + max_width=int(a["max_width"].value()), + align=self._align_value(a["align"]), + line_gap=int(self.gapStepper.value()), + secondary=AssSecondaryStyle( + font_name=a["sec_font"].currentText(), + font_size=int(a["sec_size"].value()), + color=a["sec_color"].color().name(QColor.HexRgb), + outline_color=a["sec_outline_color"].color().name(QColor.HexRgb), + outline_width=a["sec_outline"].value(), + spacing=a["sec_spacing"].value(), + bold=a["sec_bold"].isChecked(), + ), + ) + renderer = SubtitleRenderer.ASS + return SubtitleStylePreset( + id=style_id, + name=name or style_id.split("/", 1)[-1], + renderer=renderer, + source=StyleSource.USER, + style=style, + ) + + def _new_user_id(self, name: str) -> str: + """从显示名生成唯一的用户样式 id(中文名会被 slug 化,塌成 default 时回退)。""" + key = self._mode_key + base = normalize_style_id(name, key) + if base == f"{key}/default": + base = f"{key}/style" + candidate, index = base, 2 + while load_style(candidate, renderer=key) is not None: + candidate = f"{base}-{index}" + index += 1 + return candidate + + def _on_edit(self): + """任一参数变化:刷新预览 + 自动保存(编辑内置样式时自动 fork)。""" + if self._loading: + return + cfg.set(cfg.subtitle_layout, self._current_layout()) + self.update_preview() + self._auto_save() + + def _auto_save(self): + current_id = normalize_style_id(cfg.subtitle_style_name.value, self._mode_key) + preset = load_style(current_id, renderer=self._mode_key) + if preset is not None and preset.source == StyleSource.BUILTIN: + # 内置只读:编辑即派生出该内置「唯一」的「· 自定义」影子样式。 + # 用确定性 id(不递增 -custom-N):已存在就复用并更新,避免每次 + # 选择被重置回内置后又新建一个,导致样式越积越多。 + fork_id = f"{self._mode_key}/{preset.short_id}-custom" + existing = load_style(fork_id, renderer=self._mode_key) + name = ( + existing.name + if existing is not None and existing.source == StyleSource.USER + else f"{preset.name} · {tr('substyle.custom_suffix')}" + ) + save_user_style(self._preset_from_controls(fork_id, name)) + self._set_current_style(fork_id) + self._refresh_style_list() + return + # 已是用户样式:保留其显示名,只更新参数 + keep_name = preset.name if preset is not None else None + save_user_style(self._preset_from_controls(current_id, keep_name)) + # 颜色可能变化,刷新当前卡的色块 + for card in self._cards: + if card.style_id == current_id: + updated = load_style(current_id, renderer=self._mode_key) + if updated: + card.setSwatches(self._swatches(updated)) + break + + def _unique_user_id(self, preset: SubtitleStylePreset) -> str: + renderer = preset.renderer.value + base = f"{renderer}/{preset.short_id}-custom" + candidate, index = base, 2 + while load_style(candidate, renderer=renderer) is not None: + candidate = f"{base}-{index}" + index += 1 + return candidate + + # ---------------------------------------------------------------- 库动作 + + def _on_new_style(self): + name = self._ask_name(tr("substyle.dialog.new_style")) + if not name: + return + style_id = self._new_user_id(name) + save_user_style(self._preset_from_controls(style_id, name)) + self._set_current_style(style_id) + self._refresh_style_list() + self._toast(tr("substyle.toast.created", name=name)) + + def _duplicate_style(self, style_id: str): + preset = load_style(style_id, renderer=self._mode_key) + if preset is None: + return + new_id = self._unique_user_id(preset) + copy = SubtitleStylePreset( + id=new_id, + name=f"{preset.name} {tr('substyle.copy_suffix')}", + renderer=preset.renderer, + source=StyleSource.USER, + style=preset.style, + ) + save_user_style(copy) + self._set_current_style(new_id) + self._refresh_style_list() + self._toast(tr("substyle.toast.duplicated", name=copy.name)) + + def _rename_style(self, style_id: str): + # 只改显示名,文件 id 保持不变:避免中文名 slug 化撞名、也省去删旧文件。 + preset = load_style(style_id, renderer=self._mode_key) + if preset is None or not preset.editable: + return + name = self._ask_name(tr("substyle.dialog.rename_style"), preset.name) + if not name or name == preset.name: + return + renamed = SubtitleStylePreset( + id=style_id, name=name, renderer=preset.renderer, + source=StyleSource.USER, style=preset.style, ) - # ASS 模式 - 垂直间距 - self.assVerticalSpacingCard.spinBox.valueChanged.connect( - self.onAssSettingChanged - ) + save_user_style(renamed) + self._refresh_style_list() + self._toast(tr("substyle.toast.renamed", name=name)) - # ASS 模式 - 主字幕样式 - self.assPrimaryFontCard.currentTextChanged.connect(self.onAssSettingChanged) - self.assPrimarySizeCard.spinBox.valueChanged.connect(self.onAssSettingChanged) - self.assPrimarySpacingCard.spinBox.valueChanged.connect( - self.onAssSettingChanged - ) - self.assPrimaryColorCard.colorChanged.connect(self.onAssSettingChanged) - self.assPrimaryOutlineColorCard.colorChanged.connect(self.onAssSettingChanged) - self.assPrimaryOutlineSizeCard.spinBox.valueChanged.connect( - self.onAssSettingChanged + def _delete_style(self, style_id: str): + preset = load_style(style_id, renderer=self._mode_key) + if preset is None or not preset.editable: + return + dialog = ConfirmDialog( + tr("substyle.dialog.delete_title"), + tr("substyle.dialog.delete_body", name=preset.name), + self, ) + if not dialog.exec(): + return + delete_user_style(style_id) + self._set_current_style(f"{self._mode_key}/default") + self._refresh_style_list() + self._toast(tr("substyle.toast.deleted")) - # ASS 模式 - 副字幕样式 - self.assSecondaryFontCard.currentTextChanged.connect(self.onAssSettingChanged) - self.assSecondarySizeCard.spinBox.valueChanged.connect(self.onAssSettingChanged) - self.assSecondarySpacingCard.spinBox.valueChanged.connect( - self.onAssSettingChanged - ) - self.assSecondaryColorCard.colorChanged.connect(self.onAssSettingChanged) - self.assSecondaryOutlineColorCard.colorChanged.connect(self.onAssSettingChanged) - self.assSecondaryOutlineSizeCard.spinBox.valueChanged.connect( - self.onAssSettingChanged - ) + # ---------------------------------------------------------------- 预览 - # 圆角背景样式信号 - self.roundedFontCard.currentTextChanged.connect(self.onRoundedBgSettingChanged) - self.roundedFontSizeCard.spinBox.valueChanged.connect( - self.onRoundedBgSettingChanged - ) - self.roundedTextColorCard.colorChanged.connect(self.onRoundedBgSettingChanged) - self.roundedBgColorCard.colorChanged.connect(self.onRoundedBgSettingChanged) - self.roundedCornerRadiusCard.spinBox.valueChanged.connect( - self.onRoundedBgSettingChanged - ) - self.roundedPaddingHCard.spinBox.valueChanged.connect( - self.onRoundedBgSettingChanged - ) - self.roundedPaddingVCard.spinBox.valueChanged.connect( - self.onRoundedBgSettingChanged - ) - self.roundedMarginBottomCard.spinBox.valueChanged.connect( - self.onRoundedBgSettingChanged - ) - self.roundedLineSpacingCard.spinBox.valueChanged.connect( - self.onRoundedBgSettingChanged + def _toggle_orientation(self): + self._orientation = "竖屏" if self._orientation == "横屏" else "横屏" + self.orientationButton.setText( + tr("substyle.preview.portrait") + if self._orientation == "竖屏" + else tr("substyle.preview.landscape") ) - self.roundedLetterSpacingCard.spinBox.valueChanged.connect( - self.onRoundedBgSettingChanged - ) - - # 预览设置(通用设置) - self.previewTextCard.currentTextChanged.connect(self.updatePreview) - self.orientationCard.currentTextChanged.connect(self.onOrientationChanged) - self.previewImageCard.clicked.connect(self.selectPreviewImage) - - # 连接样式切换信号 - self.styleNameComboBox.currentTextChanged.connect(self.loadStyle) - self.newStyleButton.clicked.connect(self.createNewStyle) - self.openStyleFolderButton.clicked.connect(self.on_open_style_folder_clicked) + # 切方向时回退到内置背景(用户自定义背景按固定尺寸渲染不区分横竖) + cfg.set(cfg.subtitle_preview_image, "") + self.update_preview() - # 连接字幕排布信号 - self.layoutCard.comboBox.currentTextChanged.connect( - signalBus.subtitle_layout_changed + def _pick_background(self): + path, _ = QFileDialog.getOpenFileName( + self, tr("substyle.dialog.pick_bg"), "", tr("substyle.filter.image") + " (*.png *.jpg *.jpeg)" ) - signalBus.subtitle_layout_changed.connect(self.on_subtitle_layout_changed) - - # 连接渲染模式信号(从视频合成界面同步) - signalBus.subtitle_render_mode_changed.connect(self.on_render_mode_changed_external) - - def on_open_style_folder_clicked(self): - """打开样式文件夹""" - open_folder(str(SUBTITLE_STYLE_PATH)) - - def on_subtitle_layout_changed(self, layout: str): - layout_enum = SubtitleLayoutEnum(layout) - cfg.subtitle_layout.value = layout_enum - self.layoutCard.setCurrentText(layout) - - def on_render_mode_changed_external(self, mode_text: str): - """处理外部渲染模式变更(从视频合成界面同步)""" - # 避免信号循环:阻断信号后再更新 - self.renderModeCard.comboBox.blockSignals(True) - self.renderModeCard.comboBox.setCurrentText(mode_text) - self.renderModeCard.comboBox.blockSignals(False) - # 手动触发 UI 更新 - self._updateVisibleGroups() - self._refreshStyleList() - self.updatePreview() - - def onRenderModeChanged(self): - """渲染模式切换(本界面触发)""" - mode_text = self.renderModeCard.comboBox.currentText() - mode = SubtitleRenderModeEnum(mode_text) - cfg.set(cfg.subtitle_render_mode, mode) - # 断开自身监听,避免信号回传导致重复执行 - signalBus.subtitle_render_mode_changed.disconnect(self.on_render_mode_changed_external) - signalBus.subtitle_render_mode_changed.emit(mode_text) - signalBus.subtitle_render_mode_changed.connect(self.on_render_mode_changed_external) - self._updateVisibleGroups() - self._refreshStyleList() - self.updatePreview() - - def onRoundedBgSettingChanged(self): - """圆角背景设置变更""" - if self._loading_style: - return + if path: + cfg.set(cfg.subtitle_preview_image, path) + self.update_preview() - # 保存圆角背景配置 - cfg.set(cfg.rounded_bg_font_name, self.roundedFontCard.comboBox.currentText()) - cfg.set(cfg.rounded_bg_font_size, self.roundedFontSizeCard.spinBox.value()) - cfg.set( - cfg.rounded_bg_corner_radius, self.roundedCornerRadiusCard.spinBox.value() - ) - cfg.set(cfg.rounded_bg_padding_h, self.roundedPaddingHCard.spinBox.value()) - cfg.set(cfg.rounded_bg_padding_v, self.roundedPaddingVCard.spinBox.value()) - cfg.set( - cfg.rounded_bg_margin_bottom, self.roundedMarginBottomCard.spinBox.value() - ) - cfg.set( - cfg.rounded_bg_line_spacing, self.roundedLineSpacingCard.spinBox.value() + def _edit_preview_text(self): + """编辑预览示例文字(原文 / 译文),持久化后立即刷新预览。""" + dialog = PreviewTextDialog( + cfg.subtitle_preview_source.value, cfg.subtitle_preview_target.value, self ) - cfg.set( - cfg.rounded_bg_letter_spacing, self.roundedLetterSpacingCard.spinBox.value() - ) - - # 保存颜色 - text_color = self.roundedTextColorCard.colorPicker.color.name() - cfg.set(cfg.rounded_bg_text_color, text_color) - bg_color = self.roundedBgColorCard.colorPicker.color - bg_color_hex = f"#{bg_color.red():02x}{bg_color.green():02x}{bg_color.blue():02x}{bg_color.alpha():02x}" - cfg.set(cfg.rounded_bg_color, bg_color_hex) - - # 自动保存当前样式 - current_style = self.styleNameComboBox.comboBox.currentText() - if current_style: - self.saveStyle(current_style) - - self.updatePreview() - - def _updateVisibleGroups(self): - """根据渲染模式显示/隐藏设置组""" - mode_text = self.renderModeCard.comboBox.currentText() - is_ass_mode = mode_text == SubtitleRenderModeEnum.ASS_STYLE.value - - # ASS 样式设置组 - self.assVerticalSpacingCard.setVisible(is_ass_mode) - self.assPrimaryGroup.setVisible(is_ass_mode) - self.assSecondaryGroup.setVisible(is_ass_mode) - - # 圆角背景设置组 - self.roundedBgGroup.setVisible(not is_ass_mode) - - def _getStyleFileExtension(self) -> str: - """获取当前模式的样式文件扩展名 (统一使用 .json)""" - return ".json" - - def _refreshStyleList(self): - """根据当前渲染模式刷新样式列表""" - is_rounded = self._getCurrentRenderMode() == SubtitleRenderModeEnum.ROUNDED_BG - target_mode = "rounded" if is_rounded else "ass" - - # 阻断信号,避免 addItems/setCurrentText 重复触发 loadStyle - self.styleNameComboBox.comboBox.blockSignals(True) - - # 清空现有列表 - self.styleNameComboBox.comboBox.clear() - - # 获取匹配当前模式的 JSON 样式文件 - from videocaptioner.core.subtitle.style_manager import style_id_from_filename - style_files = [] - for f in sorted(SUBTITLE_STYLE_PATH.glob("*.json")): - try: - data = json.loads(f.read_text(encoding="utf-8")) - if data.get("mode", "ass") == target_mode: - style_id = style_id_from_filename(f.name) - style_files.append(style_id) - except (json.JSONDecodeError, OSError): - pass - - # 确保有默认样式 - if "default" not in style_files: - style_files.insert(0, "default") - self.saveStyle("default") + if dialog.exec(): + cfg.set(cfg.subtitle_preview_source, dialog.sourceEdit.text().strip() or PREVIEW_TEXT[0]) + cfg.set(cfg.subtitle_preview_target, dialog.targetEdit.text().strip() or PREVIEW_TEXT[1]) + self.update_preview() + + def _preview_pair(self) -> Tuple[str, Optional[str]]: + original = cfg.subtitle_preview_source.value or PREVIEW_TEXT[0] + translation = cfg.subtitle_preview_target.value or PREVIEW_TEXT[1] + layout = self._current_layout() + if layout == SubtitleLayoutEnum.ONLY_ORIGINAL: + main, sub = original, None + elif layout == SubtitleLayoutEnum.ONLY_TRANSLATE: + main, sub = translation, None + elif layout == SubtitleLayoutEnum.TRANSLATE_ON_TOP: + main, sub = translation, original else: - style_files.insert(0, style_files.pop(style_files.index("default"))) - - self.styleNameComboBox.comboBox.addItems(style_files) - - # 加载默认样式或配置中保存的样式 - subtitle_style_name = cfg.get(cfg.subtitle_style_name) - if subtitle_style_name in style_files: - self.styleNameComboBox.comboBox.setCurrentText(subtitle_style_name) + main, sub = original, translation + return main, sub + + def _background_path(self) -> str: + user_bg = cfg.subtitle_preview_image.value + if user_bg and Path(user_bg).exists(): + return user_bg + return str(DEFAULT_BG_LANDSCAPE if self._orientation == "横屏" else DEFAULT_BG_PORTRAIT) + + def update_preview(self): + """预览刷新入口:空闲后首次立即渲染,连拍则防抖合并到末尾。""" + if time.monotonic() - self._last_preview_ts > 0.15: + self._preview_timer.stop() + self._render_preview_now() else: - self.styleNameComboBox.comboBox.setCurrentText(style_files[0]) - subtitle_style_name = style_files[0] - - # 恢复信号 - self.styleNameComboBox.comboBox.blockSignals(False) - - # 只调用一次 loadStyle - self.loadStyle(subtitle_style_name) - - def _getCurrentRenderMode(self) -> SubtitleRenderModeEnum: - """获取当前渲染模式""" - mode_text = self.renderModeCard.comboBox.currentText() - return SubtitleRenderModeEnum(mode_text) - - def _parseRgbaHex(self, hex_color: str) -> QColor: - """解析 #RRGGBBAA 格式的颜色""" - hex_color = hex_color.lstrip("#") - if len(hex_color) == 8: - r = int(hex_color[0:2], 16) - g = int(hex_color[2:4], 16) - b = int(hex_color[4:6], 16) - a = int(hex_color[6:8], 16) - return QColor(r, g, b, a) - elif len(hex_color) == 6: - return QColor(f"#{hex_color}") - return QColor(25, 25, 25, 200) # 默认值 - - def onOrientationChanged(self): - """当预览方向改变时调用""" - orientation = self.orientationCard.comboBox.currentText() - preview_image = ( - DEFAULT_BG_LANDSCAPE if orientation == "横屏" else DEFAULT_BG_PORTRAIT - ) - cfg.set(cfg.subtitle_preview_image, str(Path(preview_image["path"]))) - self.updatePreview() + self._preview_timer.start() - def onAssSettingChanged(self): - """ASS 样式设置变更""" - if self._loading_style: + def _render_preview_now(self): + self._last_preview_ts = time.monotonic() + if not (self._ass or self._rounded): return - - self.updatePreview() - current_style = self.styleNameComboBox.comboBox.currentText() - if current_style: - self.saveStyle(current_style) - else: - self.saveStyle("default") - - def selectPreviewImage(self): - """选择预览背景图片""" - file_path, _ = QFileDialog.getOpenFileName( - self, - self.tr("选择背景图片"), - "", - self.tr("图片文件") + " (*.png *.jpg *.jpeg)", - ) - if file_path: - cfg.set(cfg.subtitle_preview_image, file_path) - self.updatePreview() - - def generateAssStyles(self) -> str: - """生成 ASS 样式字符串(固定720P分辨率)""" - style_format = "Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding" - - # 垂直间距 - vertical_spacing = self.assVerticalSpacingCard.spinBox.value() - - # 主字幕样式 - primary_font = self.assPrimaryFontCard.comboBox.currentText() - primary_size = self.assPrimarySizeCard.spinBox.value() - - # 颜色转换为 ASS 格式 (AABBGGRR) - primary_color_hex = self.assPrimaryColorCard.colorPicker.color.name() - primary_outline_hex = self.assPrimaryOutlineColorCard.colorPicker.color.name() - primary_color = f"&H00{primary_color_hex[5:7]}{primary_color_hex[3:5]}{primary_color_hex[1:3]}" - primary_outline_color = f"&H00{primary_outline_hex[5:7]}{primary_outline_hex[3:5]}{primary_outline_hex[1:3]}" - primary_spacing = self.assPrimarySpacingCard.spinBox.value() - primary_outline_size = self.assPrimaryOutlineSizeCard.spinBox.value() - - # 副字幕样式 - secondary_font = self.assSecondaryFontCard.comboBox.currentText() - secondary_size = self.assSecondarySizeCard.spinBox.value() - - secondary_color_hex = self.assSecondaryColorCard.colorPicker.color.name() - secondary_outline_hex = ( - self.assSecondaryOutlineColorCard.colorPicker.color.name() - ) - secondary_color = f"&H00{secondary_color_hex[5:7]}{secondary_color_hex[3:5]}{secondary_color_hex[1:3]}" - secondary_outline_color = f"&H00{secondary_outline_hex[5:7]}{secondary_outline_hex[3:5]}{secondary_outline_hex[1:3]}" - secondary_spacing = self.assSecondarySpacingCard.spinBox.value() - secondary_outline_size = self.assSecondaryOutlineSizeCard.spinBox.value() - - # 生成样式字符串 - primary_style = f"Style: Default,{primary_font},{primary_size},{primary_color},&H000000FF,{primary_outline_color},&H00000000,-1,0,0,0,100,100,{primary_spacing},0,1,{primary_outline_size},0,2,10,10,{vertical_spacing},1,\\q1" - secondary_style = f"Style: Secondary,{secondary_font},{secondary_size},{secondary_color},&H000000FF,{secondary_outline_color},&H00000000,-1,0,0,0,100,100,{secondary_spacing},0,1,{secondary_outline_size},0,2,10,10,{vertical_spacing},1,\\q1" - - return f"[V4+ Styles]\n{style_format}\n{primary_style}\n{secondary_style}" - - def updatePreview(self): - """更新预览图片""" - # 获取预览文本 - main_text, sub_text = PERVIEW_TEXTS[self.previewTextCard.comboBox.currentText()] - - # 字幕布局 - layout = self.layoutCard.comboBox.currentText() - if layout == "译文在上": - main_text, sub_text = sub_text, main_text - elif layout == "原文在上": - main_text, sub_text = main_text, sub_text - elif layout == "仅译文": - main_text, sub_text = sub_text, None - elif layout == "仅原文": - main_text, sub_text = main_text, None - - # 获取预览方向和背景 - orientation = self.orientationCard.comboBox.currentText() - default_preview = ( - DEFAULT_BG_LANDSCAPE if orientation == "横屏" else DEFAULT_BG_PORTRAIT - ) - - # 获取背景图片路径 - user_bg_path = cfg.get(cfg.subtitle_preview_image) - if user_bg_path and Path(user_bg_path).exists(): - path = user_bg_path - else: - path = default_preview["path"] - - # 根据渲染模式创建不同的预览线程(不传入尺寸,由渲染层自动从图片获取) - render_mode = self._getCurrentRenderMode() - - if render_mode == SubtitleRenderModeEnum.ROUNDED_BG: - # 圆角背景模式(样式720P基准,由渲染层自动缩放) - bg_color = self.roundedBgColorCard.colorPicker.color - bg_color_hex = f"#{bg_color.red():02x}{bg_color.green():02x}{bg_color.blue():02x}{bg_color.alpha():02x}" - + main, sub = self._preview_pair() + bg = self._background_path() + if self._mode_key == "rounded": + r = self._rounded style = RoundedBgStyle( - font_name=self.roundedFontCard.comboBox.currentText(), - font_size=self.roundedFontSizeCard.spinBox.value(), - bg_color=bg_color_hex, - text_color=self.roundedTextColorCard.colorPicker.color.name(), - corner_radius=self.roundedCornerRadiusCard.spinBox.value(), - padding_h=self.roundedPaddingHCard.spinBox.value(), - padding_v=self.roundedPaddingVCard.spinBox.value(), - margin_bottom=self.roundedMarginBottomCard.spinBox.value(), - line_spacing=self.roundedLineSpacingCard.spinBox.value(), - letter_spacing=self.roundedLetterSpacingCard.spinBox.value(), - ) - - self.preview_thread = RoundedBgPreviewThread( - preview_text=(main_text, sub_text), - style=style, - bg_image_path=str(path), + font_name=r["font"].currentText(), + font_size=int(r["size"].value()), + bg_color=_qcolor_to_rgba_hex(r["bg_color"].color()), + text_color=r["text_color"].color().name(QColor.HexRgb), + corner_radius=int(r["radius"].value()), + padding_h=int(r["pad_h"].value()), + padding_v=int(r["pad_v"].value()), + margin_bottom=int(r["margin"].value()), + line_spacing=int(self.gapStepper.value()), + letter_spacing=int(r["letter"].value()), + max_width=int(r["max_width"].value()), + align=self._align_value(r["align"]), ) + thread: QThread = RoundedBgPreviewThread((main, sub), style, bg) else: - # ASS 样式模式(样式720P基准,由渲染层自动缩放) - style_str = self.generateAssStyles() - self.preview_thread = AssPreviewThread( - preview_text=(main_text, sub_text), - style_str=style_str, - bg_image_path=str(path), + # 预览与合成共用同一份样式字符串(AssSubtitleStyle.to_ass_string),避免漂移 + ass_style = self._preset_from_controls("ass/preview", "preview").style + thread = AssPreviewThread( + (main, sub), ass_style.to_ass_string(), bg, line_gap=int(self.gapStepper.value()) ) - self.preview_thread.previewReady.connect(self.onPreviewReady) - self.preview_thread.start() - - def onPreviewReady(self, preview_path): - """预览图片生成完成的回调""" - self.previewImage.setImage(preview_path) - self.updatePreviewImage() + self._preview_generation += 1 + generation = self._preview_generation + thread.previewReady.connect( + lambda path, gen=generation: self._on_preview_ready(path) + if gen == self._preview_generation + else None + ) + thread.finished.connect(lambda t=thread: self._preview_threads.remove(t) if t in self._preview_threads else None) + self._preview_threads.append(thread) + thread.start() - def updatePreviewImage(self): - """更新预览图片""" - height = int(self.previewTopWidget.height() * 0.98) - width = int(self.previewTopWidget.width() * 0.98) - self.previewImage.scaledToWidth(width) - if self.previewImage.height() > height: - self.previewImage.scaledToHeight(height) - self.previewImage.setBorderRadius(8, 8, 8, 8) + def _on_preview_ready(self, path: str): + self.previewImage.setImage(path) + img = getattr(self.previewImage, "image", None) + self._preview_native = ( + (img.width(), img.height()) if img is not None and not img.isNull() else None + ) + self._fit_preview() - def resizeEvent(self, event): - super().resizeEvent(event) - self.updatePreviewImage() + def _fit_preview(self): + """等比缩放预览图尽量铺满舞台(仅留薄边),同时适配宽屏/矮屏/全屏。 - def showEvent(self, event): - """窗口显示事件""" - super().showEvent(event) - self.updatePreviewImage() - - def _resolve_style_path(self, style_name: str) -> Path: - """Resolve style name to file path, trying prefixed names.""" - mode = self._getCurrentRenderMode() - prefix = "rounded-" if mode == SubtitleRenderModeEnum.ROUNDED_BG else "ass-" - # Try prefixed first, then exact - prefixed = SUBTITLE_STYLE_PATH / f"{prefix}{style_name}.json" - if prefixed.exists(): - return prefixed - exact = SUBTITLE_STYLE_PATH / f"{style_name}.json" - return exact - - def loadStyle(self, style_name): - """加载指定样式(根据当前渲染模式加载对应格式)""" - style_path = self._resolve_style_path(style_name) - - if not style_path.exists(): + 取「按宽适配」与「按高适配」的较小比例,确保任一维度都不溢出;上限为原生 + 分辨率(默认背景 1280×720),避免放大发糊。宽而矮的屏由高度约束自动收窄、 + 横向居中——所以铺得满又不裁切。 + """ + native = getattr(self, "_preview_native", None) + if not native: return + nw, nh = native + if nw <= 0 or nh <= 0: + return + margin = 20 # 舞台四周留 10px 薄边 + avail_w = self.stage.width() - margin + avail_h = self.stage.height() - margin + if avail_w <= 0 or avail_h <= 0: + return + scale = min(avail_w / nw, avail_h / nh, 1.0) + self.previewImage.scaledToWidth(max(1, int(nw * scale))) + if self.previewImage.height() > avail_h: + self.previewImage.scaledToHeight(avail_h) + self.previewImage.setBorderRadius(12, 12, 12, 12) + # 手动居中(图不在布局里,见构造处) + self.previewImage.move( + (self.stage.width() - self.previewImage.width()) // 2, + (self.stage.height() - self.previewImage.height()) // 2, + ) - self._loading_style = True - - mode = self._getCurrentRenderMode() - if mode == SubtitleRenderModeEnum.ROUNDED_BG: - self._loadRoundedBgStyle(style_path) - else: - self._loadAssStyle(style_path) + # ---------------------------------------------------------------- 杂项 - cfg.set(cfg.subtitle_style_name, style_name) - self._loading_style = False - self.updatePreview() + def _ask_name(self, title: str, initial: str = "") -> str: + dialog = StyleNameDialog(title, initial, self) + if dialog.exec(): + return dialog.nameLineEdit.text().strip() + return "" + def _toast(self, text: str): InfoBar.success( - title=self.tr("成功"), - content=self.tr("已加载样式 ") + style_name, - orient=Qt.Horizontal, # type: ignore - isClosable=True, - position=InfoBarPosition.TOP, - duration=INFOBAR_DURATION_SUCCESS, - parent=self, + "", text, orient=Qt.Horizontal, isClosable=True, # type: ignore[arg-type] + position=InfoBarPosition.TOP, duration=INFOBAR_DURATION_SUCCESS, parent=self, ) - def _loadAssStyle(self, style_path: Path): - """加载 ASS 样式 (.json)""" - from videocaptioner.core.subtitle.style_manager import SubtitleStyle - - style = SubtitleStyle.from_file(style_path) - - # Primary style - self.assPrimaryFontCard.setCurrentText(style.font_name) - self.assPrimarySizeCard.spinBox.setValue(style.font_size) - self.assVerticalSpacingCard.spinBox.setValue(style.margin_bottom) - self.assPrimaryColorCard.setColor(QColor(style.primary_color)) - self.assPrimaryOutlineColorCard.setColor(QColor(style.outline_color)) - self.assPrimarySpacingCard.spinBox.setValue(style.spacing) - self.assPrimaryOutlineSizeCard.spinBox.setValue(style.outline_width) - - # Secondary style - sec = style.secondary - if sec: - self.assSecondaryFontCard.setCurrentText(sec.font_name) - self.assSecondarySizeCard.spinBox.setValue(sec.font_size) - self.assSecondaryColorCard.setColor(QColor(sec.color)) - self.assSecondaryOutlineColorCard.setColor(QColor(sec.outline_color)) - self.assSecondarySpacingCard.spinBox.setValue(sec.spacing) - self.assSecondaryOutlineSizeCard.spinBox.setValue(sec.outline_width) - - def _loadRoundedBgStyle(self, style_path: Path): - """加载圆角背景样式 (.json)""" - with open(style_path, "r", encoding="utf-8") as f: - data = json.load(f) - - if "font_name" in data: - self.roundedFontCard.setCurrentText(data["font_name"]) - if "font_size" in data: - self.roundedFontSizeCard.spinBox.setValue(data["font_size"]) - if "text_color" in data: - self.roundedTextColorCard.setColor(QColor(data["text_color"])) - if "bg_color" in data: - self.roundedBgColorCard.setColor(self._parseRgbaHex(data["bg_color"])) - if "corner_radius" in data: - self.roundedCornerRadiusCard.spinBox.setValue(data["corner_radius"]) - if "padding_h" in data: - self.roundedPaddingHCard.spinBox.setValue(data["padding_h"]) - if "padding_v" in data: - self.roundedPaddingVCard.spinBox.setValue(data["padding_v"]) - if "margin_bottom" in data: - self.roundedMarginBottomCard.spinBox.setValue(data["margin_bottom"]) - if "line_spacing" in data: - self.roundedLineSpacingCard.spinBox.setValue(data["line_spacing"]) - if "letter_spacing" in data: - self.roundedLetterSpacingCard.spinBox.setValue(data["letter_spacing"]) - - def createNewStyle(self): - """创建新样式""" - dialog = StyleNameDialog(self) - if dialog.exec(): - style_name = dialog.nameLineEdit.text().strip() - if not style_name: - return - - # 检查是否已存在同名样式(使用带前缀的实际路径) - if self._resolve_style_path(style_name).exists(): - InfoBar.warning( - title=self.tr("警告"), - content=self.tr("样式 ") + style_name + self.tr(" 已存在"), - orient=Qt.Horizontal, # type: ignore - isClosable=True, - position=InfoBarPosition.TOP, - duration=INFOBAR_DURATION_WARNING, - parent=self, - ) - return - - # 保存新样式 - self.saveStyle(style_name) - - # 更新样式列表并选中新样式 - self.styleNameComboBox.addItem(style_name) - self.styleNameComboBox.comboBox.setCurrentText(style_name) - - InfoBar.success( - title=self.tr("成功"), - content=self.tr("已创建新样式 ") + style_name, - orient=Qt.Horizontal, # type: ignore - isClosable=True, - position=InfoBarPosition.TOP, - duration=INFOBAR_DURATION_SUCCESS, - parent=self, - ) + def _warn(self, text: str): + InfoBar.warning( + "", text, orient=Qt.Horizontal, isClosable=True, # type: ignore[arg-type] + position=InfoBarPosition.TOP, duration=INFOBAR_DURATION_WARNING, parent=self, + ) - def saveStyle(self, style_name): - """保存样式(根据当前渲染模式保存对应格式)""" - SUBTITLE_STYLE_PATH.mkdir(parents=True, exist_ok=True) + def _apply_page_style(self): + palette = app_palette() + self.setStyleSheet( + f"QWidget#SubtitleStyleInterface {{ background: {palette.bg}; }}" + ) - mode = self._getCurrentRenderMode() - prefix = "rounded-" if mode == SubtitleRenderModeEnum.ROUNDED_BG else "ass-" - style_path = SUBTITLE_STYLE_PATH / f"{prefix}{style_name}.json" + def resizeEvent(self, event): + super().resizeEvent(event) + self._fit_preview() - if mode == SubtitleRenderModeEnum.ROUNDED_BG: - self._saveRoundedBgStyle(style_path) - else: - self._saveAssStyle(style_path) - - def _saveAssStyle(self, style_path: Path): - """保存 ASS 样式 (.json)""" - from videocaptioner.core.subtitle.style_manager import ( - SecondaryStyle, - SubtitleStyle, - style_id_from_filename, - ) - style = SubtitleStyle( - name=style_id_from_filename(style_path.name), - mode=StyleMode.ASS, - font_name=self.assPrimaryFontCard.comboBox.currentText(), - font_size=self.assPrimarySizeCard.spinBox.value(), - primary_color=self.assPrimaryColorCard.colorPicker.color.name(), - outline_color=self.assPrimaryOutlineColorCard.colorPicker.color.name(), - outline_width=self.assPrimaryOutlineSizeCard.spinBox.value(), - bold=True, - spacing=self.assPrimarySpacingCard.spinBox.value(), - margin_bottom=self.assVerticalSpacingCard.spinBox.value(), - secondary=SecondaryStyle( - font_name=self.assSecondaryFontCard.comboBox.currentText(), - font_size=self.assSecondarySizeCard.spinBox.value(), - color=self.assSecondaryColorCard.colorPicker.color.name(), - outline_color=self.assSecondaryOutlineColorCard.colorPicker.color.name(), - outline_width=self.assSecondaryOutlineSizeCard.spinBox.value(), - spacing=self.assSecondarySpacingCard.spinBox.value(), - ), - ) - with open(style_path, "w", encoding="utf-8") as f: - json.dump(style.to_json_dict(), f, ensure_ascii=False, indent=2) - - def _saveRoundedBgStyle(self, style_path: Path): - """保存圆角背景样式 (.json)""" - bg_color = self.roundedBgColorCard.colorPicker.color - bg_color_hex = f"#{bg_color.red():02x}{bg_color.green():02x}{bg_color.blue():02x}{bg_color.alpha():02x}" - - from videocaptioner.core.subtitle.style_manager import style_id_from_filename - data = { - "name": style_id_from_filename(style_path.name), - "description": "", - "mode": "rounded", - "font_name": self.roundedFontCard.comboBox.currentText(), - "font_size": self.roundedFontSizeCard.spinBox.value(), - "text_color": self.roundedTextColorCard.colorPicker.color.name(), - "bg_color": bg_color_hex, - "corner_radius": self.roundedCornerRadiusCard.spinBox.value(), - "padding_h": self.roundedPaddingHCard.spinBox.value(), - "padding_v": self.roundedPaddingVCard.spinBox.value(), - "margin_bottom": self.roundedMarginBottomCard.spinBox.value(), - "line_spacing": self.roundedLineSpacingCard.spinBox.value(), - "letter_spacing": self.roundedLetterSpacingCard.spinBox.value(), - } - with open(style_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + def showEvent(self, event): + super().showEvent(event) + self._fit_preview() + # 首次显示时把当前样式滚到可见处(构建时页面还没尺寸,滚动不生效) + active = next((c for c in self._cards if c.isActive()), None) + self._scroll_card_into_view(active) def dragEnterEvent(self, event): - """拖入事件:检查是否为图片文件""" if event.mimeData().hasUrls(): - # 检查是否有图片文件 for url in event.mimeData().urls(): - file_path = url.toLocalFile() - if file_path.lower().endswith((".png", ".jpg", ".jpeg")): + if url.toLocalFile().lower().endswith((".png", ".jpg", ".jpeg")): event.accept() return event.ignore() def dropEvent(self, event): - """放下事件:将图片设置为预览背景""" - files = [u.toLocalFile() for u in event.mimeData().urls()] - for file_path in files: - # 检查是否为图片文件 - if file_path.lower().endswith((".png", ".jpg", ".jpeg")): - # 设置为预览背景 - cfg.set(cfg.subtitle_preview_image, file_path) - # 更新预览 - self.updatePreview() - # 显示成功提示 - InfoBar.success( - title=self.tr("成功"), - content=self.tr("已设置预览背景:") + Path(file_path).name, - orient=Qt.Horizontal, # type: ignore - isClosable=True, - position=InfoBarPosition.TOP, - duration=INFOBAR_DURATION_SUCCESS, - parent=self, - ) - break # 只处理第一个图片文件 - - -class StyleNameDialog(MessageBoxBase): - """样式名称输入对话框""" - - def __init__(self, parent=None): - super().__init__(parent) - self.titleLabel = BodyLabel(self.tr("新建样式"), self) - self.nameLineEdit = LineEdit(self) - - self.nameLineEdit.setPlaceholderText(self.tr("输入样式名称")) + for url in event.mimeData().urls(): + path = url.toLocalFile() + if path.lower().endswith((".png", ".jpg", ".jpeg")): + cfg.set(cfg.subtitle_preview_image, path) + self.update_preview() + self._toast(tr("substyle.toast.bg_set") + Path(path).name) + break + + def closeEvent(self, event): + for thread in list(self._preview_threads): + if thread.isRunning(): + thread.wait(3000) + self._preview_threads.clear() + super().closeEvent(event) + + +class StyleNameDialog(AppDialog): + """样式名称输入弹窗(新建 / 重命名共用)。""" + + def __init__(self, title: Optional[str] = None, initial: str = "", parent=None): + super().__init__(title or tr("substyle.dialog.new_style"), icon=AppIcon.EDIT, parent=parent, width=380) + self.nameLineEdit = AppLineEdit(parent=self.widget) + self.nameLineEdit.setPlaceholderText(tr("substyle.dialog.name_placeholder")) self.nameLineEdit.setClearButtonEnabled(True) - - # 添加控件到布局 - self.viewLayout.addWidget(self.titleLabel) - self.viewLayout.addWidget(self.nameLineEdit) - - # 设置按钮文本 - self.yesButton.setText(self.tr("确定")) - self.cancelButton.setText(self.tr("取消")) - - self.widget.setMinimumWidth(350) - self.yesButton.setDisabled(True) - self.nameLineEdit.textChanged.connect(self._validateInput) - - def _validateInput(self, text): - self.yesButton.setEnabled(bool(text.strip())) + self.nameLineEdit.setText(initial) + self.bodyLayout.addWidget(self.nameLineEdit) + + self.addFooterStretch() + self.cancelButton = self.addFooterButton(tr("common.cancel")) + self.cancelButton.clicked.connect(lambda: self.done(0)) + self.confirmButton = self.addFooterButton(tr("common.ok"), kind="accent") + self.confirmButton.clicked.connect(lambda: self.done(1)) + self.confirmButton.setEnabled(bool(initial.strip())) + self.nameLineEdit.textChanged.connect( + lambda text: self.confirmButton.setEnabled(bool(text.strip())) + ) + + +class PreviewTextDialog(AppDialog): + """编辑预览示例文字(原文 / 译文)。""" + + def __init__(self, source: str = "", target: str = "", parent=None): + super().__init__(tr("substyle.preview.text"), icon=AppIcon.FONT, parent=parent, width=420) + source_label = SectionLabel(tr("substyle.content.source")) + apply_font(source_label, 13, 800) + self.bodyLayout.addWidget(source_label) + self.sourceEdit = AppLineEdit(parent=self.widget) + self.sourceEdit.setPlaceholderText(tr("substyle.preview.source_placeholder")) + self.sourceEdit.setText(source) + self.bodyLayout.addWidget(self.sourceEdit) + + target_label = SectionLabel(tr("substyle.content.target")) + apply_font(target_label, 13, 800) + self.bodyLayout.addWidget(target_label) + self.targetEdit = AppLineEdit(parent=self.widget) + self.targetEdit.setPlaceholderText(tr("substyle.preview.target_placeholder")) + self.targetEdit.setText(target) + self.bodyLayout.addWidget(self.targetEdit) + + self.addFooterStretch() + self.cancelButton = self.addFooterButton(tr("common.cancel")) + self.cancelButton.clicked.connect(lambda: self.done(0)) + self.confirmButton = self.addFooterButton(tr("common.ok"), kind="accent") + self.confirmButton.clicked.connect(lambda: self.done(1)) diff --git a/videocaptioner/ui/view/task_creation_interface.py b/videocaptioner/ui/view/task_creation_interface.py index 610179de..5aedd379 100644 --- a/videocaptioner/ui/view/task_creation_interface.py +++ b/videocaptioner/ui/view/task_creation_interface.py @@ -1,408 +1,1017 @@ # -*- coding: utf-8 -*- +"""任务创建页(首页入口)。 + +布局与状态: +hero 标识 + 输入卡(链接/文件 + 主按钮 + 轻状态行)+ 详情区 +(文件就绪面板 / 下载进度盒 / 错误卡)+ 流程线 + 底部品牌行。 + +输入形态由内容实时派生(_input_kind): + + EMPTY 什么都没填 -> 选择文件 + FILE 本地受支持音视频 -> 开始处理(直接进转录) + URL http(s) 链接 -> 开始处理(先下载,完成自动流转) + INVALID 无法识别 -> 错误卡 + 选择文件 + +下载是页面唯一的长任务,由 DownloadController 持有 +MediaDownloadThread(协作取消);对外契约保持 finished(str, object)。 +""" + +from __future__ import annotations + import os -import sys +from pathlib import Path +from typing import Optional from urllib.parse import urlparse -from PyQt5.QtCore import QStandardPaths, Qt, pyqtSignal +from PyQt5.QtCore import QEvent, QObject, Qt, pyqtSignal from PyQt5.QtGui import QPixmap from PyQt5.QtWidgets import ( - QApplication, + QAction, QFileDialog, + QFrame, QHBoxLayout, QLabel, + QLineEdit, + QStackedWidget, QVBoxLayout, QWidget, ) -from qfluentwidgets import ( - BodyLabel, - FluentIcon, - HyperlinkButton, - InfoBar, - InfoBarPosition, - LineEdit, - ProgressBar, - ToolButton, -) +from qfluentwidgets import InfoBar, InfoBarPosition -from videocaptioner.config import APPDATA_PATH, ASSETS_PATH, VERSION -from videocaptioner.core.constant import ( - INFOBAR_DURATION_ERROR, - INFOBAR_DURATION_INFO, - INFOBAR_DURATION_SUCCESS, - INFOBAR_DURATION_WARNING, -) +from videocaptioner.config import ASSETS_PATH, VERSION +from videocaptioner.core.constant import INFOBAR_DURATION_SUCCESS from videocaptioner.core.entities import ( SupportedAudioFormats, SupportedVideoFormats, + VideoInfo, ) +from videocaptioner.ui.common.app_icons import AppIcon, render_svg_icon from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.components.DonateDialog import DonateDialog -from videocaptioner.ui.thread.video_download_thread import VideoDownloadThread +from videocaptioner.ui.common.theme_tokens import app_palette, is_dark_theme, rgba +from videocaptioner.ui.components.donate_dialog import DonateDialog +from videocaptioner.ui.components.feedback_dialog import FeedbackDialog +from videocaptioner.ui.components.workbench import ( + CompactButton, + ElidedLabel, + ErrorCard, + InfoChip, + PillSelect, + PrimaryIconButton, + ProgressBarLine, + StatusPill, + WorkbenchButton, + apply_font, + draw_rounded_surface, + file_type_icon, + icon_pixmap, +) +from videocaptioner.ui.i18n import tr +from videocaptioner.ui.thread.media_download_thread import MediaDownloadThread +from videocaptioner.ui.thread.video_info_thread import VideoInfoThread from videocaptioner.ui.view.log_window import LogWindow -LOGO_PATH = ASSETS_PATH / "logo.png" +HERO_MARK_PATH = ASSETS_PATH / "hero-logo-mark.svg" + +_MEDIA_EXTENSIONS = {f".{fmt.value}" for fmt in SupportedVideoFormats} | { + f".{fmt.value}" for fmt in SupportedAudioFormats +} + +class InputKind: + EMPTY = "empty" + FILE = "file" + URL = "url" + INVALID = "invalid" + + +def classify_input(text: str) -> str: + """输入内容 -> 形态。本地文件要求扩展名受支持。""" + text = text.strip() + if not text: + return InputKind.EMPTY + if os.path.isfile(text): + return ( + InputKind.FILE + if Path(text).suffix.lower() in _MEDIA_EXTENSIONS + else InputKind.INVALID + ) + try: + result = urlparse(text) + except ValueError: + return InputKind.INVALID + if result.scheme in ("http", "https") and result.netloc: + return InputKind.URL + return InputKind.INVALID -class TaskCreationInterface(QWidget): - """ - 任务创建界面类,用于创建和配置任务。 - """ +class PageState: + INPUT = "input" + PROBING = "probing" # 解析链接信息中(未下载) + CONFIRM = "confirm" # 解析完成,等用户确认清晰度后开始下载 + DOWNLOADING = "downloading" - finished = pyqtSignal(str) # 该信号用于在任务创建完成后通知主窗口 - def __init__(self, parent=None): +# --------------------------------------------------------------------------- +# 线程编排 +# --------------------------------------------------------------------------- + + +class DownloadController(QObject): + """持有下载线程;进度/速度透传,支持协作取消。""" + + progressChanged = pyqtSignal(int, str) + statsChanged = pyqtSignal(str, str) + mediaChanged = pyqtSignal(dict) # 解析出的视频元数据 + probed = pyqtSignal(dict) # 解析完成(含清晰度档位),等待确认 + completed = pyqtSignal(str, object) # (视频路径, 字幕路径或 None) + failed = pyqtSignal(str) + + def __init__(self, parent: Optional[QObject] = None): super().__init__(parent) - self.task = None - self.log_window = None + self._thread: Optional[MediaDownloadThread] = None - self.setObjectName("TaskCreationInterface") - self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore - self.setAcceptDrops(True) + def probe(self, url: str) -> bool: + """只解析视频信息(标题/清晰度/字幕),秒级返回,不下载。""" + return self._launch(url, probe_only=True) + + def start(self, url: str, max_height: Optional[int] = None) -> bool: + return self._launch(url, max_height=max_height) - self.setup_ui() - self.setup_values() - self.setup_signals() - - def setup_ui(self): - self.main_layout = QVBoxLayout(self) - self.main_layout.setObjectName("main_layout") - self.main_layout.setSpacing(50) - self.main_layout.addSpacing(120) - self.setup_logo() - self.setup_search_layout() - self.setup_status_layout() - self.setup_info_label() - - def setup_logo(self): - self.logo_label = QLabel(self) - self.logo_pixmap = QPixmap(str(LOGO_PATH)) - self.logo_pixmap = self.logo_pixmap.scaled( - 150, - 150, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.SmoothTransformation, # type: ignore + def _launch( + self, url: str, probe_only: bool = False, max_height: Optional[int] = None + ) -> bool: + if self.is_running(): + return False + thread = MediaDownloadThread( + url, str(cfg.work_dir.value), probe_only=probe_only, max_height=max_height ) + thread.progress.connect(self.progressChanged) + thread.stats.connect(self.statsChanged) + thread.media.connect(self.mediaChanged) + thread.probed.connect(self.probed) + thread.finished.connect(self.completed) + thread.error.connect(self.failed) + self._thread = thread + thread.start() + return True + + def is_running(self) -> bool: + return self._thread is not None and self._thread.isRunning() + + def cancel(self): + thread, self._thread = self._thread, None + if thread is not None and thread.isRunning(): + signals = ( + thread.progress, thread.stats, thread.media, + thread.probed, thread.finished, thread.error, + ) + for signal in signals: + try: + signal.disconnect() + except TypeError: + pass + thread.stop() + + def shutdown(self): + self.cancel() + + +# --------------------------------------------------------------------------- +# 页面组件 +# --------------------------------------------------------------------------- + + +class InputField(QFrame): + """输入框:58 高 12 圆角,左侧形态图标 + 内嵌输入框。""" - self.logo_label.setPixmap(self.logo_pixmap) - self.logo_label.setAlignment(Qt.AlignCenter) # type: ignore - self.main_layout.addWidget(self.logo_label) - self.main_layout.addSpacing(10) - - def setup_search_layout(self): - self.search_layout = QHBoxLayout() - self.search_layout.setContentsMargins(80, 0, 80, 0) - self.search_input = LineEdit(self) - self.search_input.setPlaceholderText(self.tr("请拖拽文件或输入视频URL")) - self.search_input.setFixedHeight(40) - self.search_input.setClearButtonEnabled(True) - self.search_input.focusOutEvent = lambda e: super( - LineEdit, self.search_input - ).focusOutEvent(e) - self.search_input.paintEvent = lambda e: super( - LineEdit, self.search_input - ).paintEvent(e) - self.search_input.setStyleSheet( - self.search_input.styleSheet() - + """ - QLineEdit { - border-radius: 18px; - padding: 0 20px; - background-color: transparent; - border: 1px solid rgba(255,255, 255, 0.08); - } - QLineEdit:focus[transparent=true] { - border: 1px solid rgba(47,141, 99, 0.48); - } - - """ + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("taskInputField") + self._icon = AppIcon.LINK + self.setFixedHeight(52) + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 0, 12, 0) + layout.setSpacing(12) + self.iconLabel = QLabel(self) + self.iconLabel.setFixedSize(20, 20) + layout.addWidget(self.iconLabel) + self.edit = QLineEdit(self) + self.edit.setObjectName("taskInputEdit") + self.edit.setPlaceholderText(tr("home.input.placeholder")) + # 聚焦反馈由外框点亮主题色承担,关掉 macOS 原生蓝色焦点环 + self.edit.setAttribute(Qt.WA_MacShowFocusRect, False) # type: ignore[attr-defined] + # 自绘清除按钮:Qt 内置清除图标是低分辨率位图,Retina 下发糊 + self.clearAction = QAction(self.edit) + self.clearAction.triggered.connect(self.edit.clear) + self.clearAction.setVisible(False) + self.edit.addAction(self.clearAction, QLineEdit.TrailingPosition) + self.edit.textChanged.connect( + lambda text: self.clearAction.setVisible(bool(text)) ) - self.start_button = ToolButton(FluentIcon.FOLDER, self) - self.start_button.setFixedSize(40, 40) - self.start_button.setStyleSheet( - self.start_button.styleSheet() - + """ - QToolButton { - border-radius: 20px; - background-color: #2F8D63; - } - QToolButton:hover { - background-color: #2E805C; - } - QToolButton:pressed { - background-color: #2E905C; - } - """ + apply_font(self.edit, 16, 760) + # 焦点进出时重绘外框(聚焦点亮主题色描边) + self.edit.installEventFilter(self) + layout.addWidget(self.edit, 1) + self.syncStyle() + + def eventFilter(self, obj, event): + if obj is self.edit and event.type() in (QEvent.FocusIn, QEvent.FocusOut): + self.update() + return super().eventFilter(obj, event) + + def setKindIcon(self, icon: AppIcon): + if icon != self._icon: + self._icon = icon + self.syncStyle() + + def paintEvent(self, event): + palette = app_palette() + focused = self.edit.hasFocus() + draw_rounded_surface( + self, + palette.field, + rgba(palette.accent, 0.62) if focused else palette.line, + 12, ) - self.search_layout.addWidget(self.search_input) - self.search_layout.addWidget(self.start_button) - self.search_layout.setSpacing(10) - self.main_layout.addLayout(self.search_layout) - self.main_layout.addSpacing(100) - - def setup_status_layout(self): - self.status_layout = QVBoxLayout() - self.status_layout.setContentsMargins(50, 0, 30, 5) - self.status_layout.setAlignment(Qt.AlignBottom | Qt.AlignHCenter) # type: ignore - self.status_label = BodyLabel(self.tr("准备就绪"), self) - self.status_label.setStyleSheet("font-size: 14px; color: #888888;") - self.status_layout.addWidget(self.status_label, 0, Qt.AlignCenter) # type: ignore - self.progress_bar = ProgressBar(self) - self.status_label.hide() - self.progress_bar.hide() - self.progress_bar.setFixedWidth(300) - self.status_layout.addWidget(self.progress_bar, 0, Qt.AlignCenter) # type: ignore - - self.main_layout.addStretch(1) - self.main_layout.addLayout(self.status_layout) - - def setup_info_label(self): - # 创建底部容器 - bottom_container = QWidget() - bottom_layout = QHBoxLayout(bottom_container) - bottom_layout.setContentsMargins(0, 0, 0, 0) - - # 创建日志按钮 - self.log_button = HyperlinkButton(url="", text=self.tr("查看日志"), parent=self) - self.log_button.setStyleSheet( - self.log_button.styleSheet() - + """ - QPushButton { - font-size: 12px; - color: #2F8D63; - text-decoration: underline; - } - """ + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.iconLabel.setPixmap(icon_pixmap(self._icon, palette.muted, 20)) + self.clearAction.setIcon(render_svg_icon(AppIcon.CLOSE, palette.muted, 16)) + self.setStyleSheet( + f""" + QFrame#taskInputField {{ background: transparent; border: none; }} + QLineEdit#taskInputEdit {{ + color: {palette.text}; + background: transparent; + border: none; + selection-background-color: {rgba(palette.accent, 0.35)}; + }} + """ ) + self.update() + + +class MediaReadyPanel(QFrame): + """文件就绪面板:文件名 + 路径 + 元信息胶囊 + 已就绪。""" - # 创建捐助按钮 - self.donate_button = HyperlinkButton(url="", text=self.tr("捐助"), parent=self) - self.donate_button.setStyleSheet( - self.donate_button.styleSheet() - + """ - QPushButton { - font-size: 12px; - color: #2F8D63; - text-decoration: underline; - } - """ + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("mediaReadyPanel") + self.setMinimumHeight(112) + layout = QHBoxLayout(self) + layout.setContentsMargins(18, 16, 18, 16) + layout.setSpacing(18) + column = QVBoxLayout() + column.setSpacing(7) + self.nameLabel = ElidedLabel("", self) + self.nameLabel.setObjectName("mediaName") + apply_font(self.nameLabel, 19, 860) + column.addWidget(self.nameLabel) + self.pathLabel = ElidedLabel("", self) + self.pathLabel.setObjectName("mediaPath") + apply_font(self.pathLabel, 13, 720) + column.addWidget(self.pathLabel) + chips = QHBoxLayout() + chips.setSpacing(8) + self.chipRow = chips + chips.addStretch(1) + column.addLayout(chips) + layout.addLayout(column, 1) + self.pill = StatusPill(tr("home.media.ready"), "ok", self) + layout.addWidget(self.pill, 0, Qt.AlignTop) # type: ignore[arg-type] + self.syncStyle() + + def setFile(self, path: str, info: Optional[VideoInfo] = None): + file = Path(path) + self.nameLabel.setText(file.stem) + self.pathLabel.setText(path) + chips = [ + tr("home.media.kind.audio") + if file_type_icon(path) == AppIcon.MUSIC + else tr("home.media.kind.video") + ] + chips.append(file.suffix.lstrip(".").lower()) + if info is not None and info.duration_seconds: + minutes, seconds = divmod(int(info.duration_seconds), 60) + chips.append(f"{minutes:02d}:{seconds:02d}") + if info.width and info.height: + chips.append(f"{info.width}x{info.height}") + if file.exists(): + size = file.stat().st_size + chips.append( + f"{size / 1024 / 1024:.1f} MB" if size >= 1024 * 1024 + else f"{max(1, size // 1024)} KB" + ) + self._set_chips(chips) + + def _set_chips(self, texts: list[str]): + while self.chipRow.count() > 1: # 末尾 stretch 保留 + item = self.chipRow.takeAt(0) + if item.widget() is not None: + item.widget().deleteLater() + for text in texts: + self.chipRow.insertWidget(self.chipRow.count() - 1, InfoChip(text, self)) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 15) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f""" + QFrame#mediaReadyPanel {{ background: transparent; border: none; }} + QLabel#mediaName {{ color: {palette.text}; background: transparent; }} + QLabel#mediaPath {{ color: {palette.muted}; background: transparent; }} + """ ) - # 添加版权信息标签 - self.info_label = BodyLabel( - self.tr(f"©VideoCaptioner {VERSION} • By Weifeng"), self + +class DownloadPanel(QFrame): + """下载进度盒:标题行 + 元信息行 + 进度行,信息分层不堆标签。 + + 视频标题 ⊗ 取消 + YouTube · Anthropic · 01:53 + ▮▮▮▮▮▮▯▯▯▯ 38% 10.9 MB/s · 剩余 01:18 + + 元数据来自 yt-dlp 解析结果,站点缺什么字段就少一段。 + """ + + cancelRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("downloadPanel") + self.setMinimumHeight(106) + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 14, 16, 14) + layout.setSpacing(7) + + head = QHBoxLayout() + head.setSpacing(12) + self.titleLabel = ElidedLabel(tr("home.download.parsing"), self) + self.titleLabel.setObjectName("downloadTitle") + apply_font(self.titleLabel, 15, 850) + head.addWidget(self.titleLabel, 1) + # 下载必须可取消 + self.cancelButton = CompactButton(tr("common.cancel"), AppIcon.CANCEL, self) + self.cancelButton.clicked.connect(self.cancelRequested) + head.addWidget(self.cancelButton) + layout.addLayout(head) + + self.metaLabel = ElidedLabel("", self) + self.metaLabel.setObjectName("downloadMeta") + apply_font(self.metaLabel, 12, 720) + layout.addWidget(self.metaLabel) + layout.addSpacing(4) + + progress_row = QHBoxLayout() + progress_row.setSpacing(12) + self.progressLine = ProgressBarLine(self) + progress_row.addWidget(self.progressLine, 1) + self.percentLabel = QLabel("0%", self) + self.percentLabel.setObjectName("downloadPercent") + self.percentLabel.setMinimumWidth(46) + self.percentLabel.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + apply_font(self.percentLabel, 14, 880) + progress_row.addWidget(self.percentLabel) + self.speedLabel = QLabel("", self) + self.speedLabel.setObjectName("downloadSpeed") + apply_font(self.speedLabel, 12, 740) + self.speedLabel.setMinimumWidth(152) + progress_row.addWidget(self.speedLabel) + layout.addLayout(progress_row) + self.syncStyle() + + def setPreparing(self, url: str): + """解析阶段:标题占位 + 元信息行先放链接域名。""" + self.titleLabel.setText(tr("home.download.parsing")) + try: + from urllib.parse import urlparse + + self.metaLabel.setText(urlparse(url).netloc or "") + except ValueError: + self.metaLabel.setText("") + self.setProgress(0, "") + self.setStats("--", "") + + def setMedia(self, summary: dict): + if summary.get("title"): + self.titleLabel.setText(summary["title"]) + parts = [ + summary[key] for key in ("site", "uploader", "duration") if summary.get(key) + ] + if parts: + self.metaLabel.setText(" · ".join(parts)) + + def setProgress(self, value: int, message: str): + self.progressLine.setValue(value) + self.percentLabel.setText(f"{value}%") + + def setStats(self, speed: str, eta: str): + if not speed or speed == "--": + self.speedLabel.setText("") + return + self.speedLabel.setText(f"{speed} · {eta}" if eta else speed) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 13) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f""" + QFrame#downloadPanel {{ background: transparent; border: none; }} + QLabel#downloadTitle {{ color: {palette.text}; background: transparent; }} + QLabel#downloadMeta {{ color: {palette.muted}; background: transparent; }} + QLabel#downloadPercent {{ color: {palette.warn_fg}; background: transparent; }} + QLabel#downloadSpeed {{ color: {palette.muted}; background: transparent; }} + """ ) - self.info_label.setAlignment(Qt.AlignCenter) # type: ignore - self.info_label.setStyleSheet("font-size: 12px; color: #888888;") - - # 将组件添加到底部布局 - bottom_layout.addStretch() - bottom_layout.addWidget(self.info_label) - bottom_layout.addWidget(self.log_button) - bottom_layout.addWidget(self.donate_button) - bottom_layout.addStretch() - - self.main_layout.addStretch() - self.main_layout.addWidget(bottom_container) - - def setup_signals(self): - self.start_button.clicked.connect(self.on_start_clicked) - self.search_input.textChanged.connect(self.on_search_input_changed) - self.log_button.clicked.connect(self.show_log_window) - self.donate_button.clicked.connect(self.show_donate_dialog) - - def setup_values(self): - self.search_input.setText("") - - def on_start_clicked(self): - if self.start_button._icon == FluentIcon.FOLDER: - desktop_path = QStandardPaths.writableLocation( - QStandardPaths.DesktopLocation - ) - file_dialog = QFileDialog() - # 构建文件过滤器 - video_formats = " ".join(f"*.{fmt.value}" for fmt in SupportedVideoFormats) - audio_formats = " ".join(f"*.{fmt.value}" for fmt in SupportedAudioFormats) - filter_str = f"{self.tr('媒体文件')} ({video_formats} {audio_formats});;{self.tr('视频文件')} ({video_formats});;{self.tr('音频文件')} ({audio_formats})" - file_path, _ = file_dialog.getOpenFileName( - self, self.tr("选择媒体文件"), desktop_path, filter_str - ) - if file_path: - self.search_input.setText(file_path) +class ConfirmPanel(QFrame): + """下载前确认面板:解析结果 + 清晰度选择,确认后才真正下载。 + + 视频标题 ⊗ 取消 + YouTube · Anthropic · 01:53 · 含字幕 + 清晰度 [1080p ▾] [▶ 开始下载] + """ + + startRequested = pyqtSignal(object) # 选中的清晰度上限(int 或 None=最佳) + cancelRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("confirmPanel") + self.setMinimumHeight(112) + self._heights: list[int] = [] + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 14, 16, 14) + layout.setSpacing(7) + + head = QHBoxLayout() + head.setSpacing(12) + self.titleLabel = ElidedLabel("", self) + self.titleLabel.setObjectName("confirmTitle") + apply_font(self.titleLabel, 15, 850) + head.addWidget(self.titleLabel, 1) + self.cancelButton = CompactButton(tr("common.cancel"), AppIcon.CANCEL, self) + self.cancelButton.clicked.connect(self.cancelRequested) + head.addWidget(self.cancelButton) + layout.addLayout(head) + + self.metaLabel = ElidedLabel("", self) + self.metaLabel.setObjectName("confirmMeta") + apply_font(self.metaLabel, 12, 720) + layout.addWidget(self.metaLabel) + layout.addSpacing(2) + + action_row = QHBoxLayout() + action_row.setSpacing(10) + self.qualityLabel = QLabel(tr("home.confirm.quality"), self) + self.qualityLabel.setObjectName("confirmQualityLabel") + apply_font(self.qualityLabel, 13, 780) + action_row.addWidget(self.qualityLabel) + self.qualitySelect = PillSelect(self) + action_row.addWidget(self.qualitySelect) + action_row.addStretch(1) + self.startButton = WorkbenchButton( + tr("home.download.start"), AppIcon.PLAY, primary=True, height=34, parent=self + ) + self.startButton.setMinimumWidth(118) + self.startButton.clicked.connect(self._emit_start) + action_row.addWidget(self.startButton) + layout.addLayout(action_row) + self.syncStyle() + + def setData(self, summary: dict): + self.titleLabel.setText(summary.get("title") or tr("home.confirm.untitled")) + parts = [ + summary[key] for key in ("site", "uploader", "duration") if summary.get(key) + ] + if summary.get("has_subtitle"): + parts.append(tr("home.confirm.has_subtitle")) + self.metaLabel.setText(" · ".join(parts)) + self._heights = list(summary.get("qualities") or []) + labels = [tr("home.confirm.quality.best")] + [f"{h}p" for h in self._heights] + self.qualitySelect.setItems(labels, labels[0]) + # 始终显示清晰度选择(至少「最佳」):没探测到分档时也让用户知道是自动选最佳, + # 而不是只剩一个下载按钮、以为没有清晰度可选。 + self.qualityLabel.setVisible(True) + self.qualitySelect.setVisible(True) + + def selectedHeight(self) -> Optional[int]: + text = self.qualitySelect.currentText() + return int(text[:-1]) if text.endswith("p") else None + + def _emit_start(self): + self.startRequested.emit(self.selectedHeight()) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, rgba(palette.accent, 0.45), 13) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f""" + QFrame#confirmPanel {{ background: transparent; border: none; }} + QLabel#confirmTitle {{ color: {palette.text}; background: transparent; }} + QLabel#confirmMeta {{ color: {palette.muted}; background: transparent; }} + QLabel#confirmQualityLabel {{ color: {palette.muted}; background: transparent; }} + """ + ) + + +class FooterAction(QLabel): + """底部链接动作:hover 点亮主题色。""" + + clicked = pyqtSignal() + + def __init__(self, text: str, parent=None): + super().__init__(text, parent) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + apply_font(self, 12, 780) + self.syncStyle() + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() return + super().mousePressEvent(event) - self.process() + def enterEvent(self, event): + self.syncStyle() + super().enterEvent(event) - def on_search_input_changed(self): - if self.search_input.text(): - self.start_button.setIcon(FluentIcon.PLAY) - else: - self.start_button.setIcon(FluentIcon.FOLDER) + def leaveEvent(self, event): + self.syncStyle() + super().leaveEvent(event) - def dragEnterEvent(self, event): - event.accept() if event.mimeData().hasUrls() else event.ignore() + def syncStyle(self): + palette = app_palette() + color = palette.accent if self.underMouse() else palette.subtle + self.setStyleSheet( + f"color: {color}; background: transparent; border: none; padding: 0 9px;" + ) - def dropEvent(self, event): - files = [u.toLocalFile() for u in event.mimeData().urls()] - for file_path in files: - if not os.path.isfile(file_path): - continue - - file_ext = os.path.splitext(file_path)[1][1:].lower() - - # 检查文件格式是否支持 - supported_formats = {fmt.value for fmt in SupportedVideoFormats} | { - fmt.value for fmt in SupportedAudioFormats - } - is_supported = file_ext in supported_formats - - if is_supported: - self.search_input.setText(file_path) - self.status_label.setText(self.tr("导入成功")) - InfoBar.success( - self.tr("导入成功"), - self.tr("导入媒体文件成功"), - duration=INFOBAR_DURATION_SUCCESS, - parent=self, - ) - break - else: - InfoBar.error( - self.tr("格式错误") + file_ext, - self.tr("不支持该文件格式"), - duration=INFOBAR_DURATION_ERROR, - parent=self, - ) - - def create_task(self): - search_input = self.search_input.text() - if os.path.isfile(search_input): - self._process_file(search_input) - elif self._is_valid_url(search_input): - self._process_url(search_input) - else: - InfoBar.error( - self.tr("错误"), - self.tr("请输入有效的文件路径或视频URL"), - duration=INFOBAR_DURATION_ERROR, - parent=self, - ) - def _is_valid_url(self, url): - try: - result = urlparse(url) - return result.scheme in ("http", "https") and bool(result.netloc) - except ValueError: - return False +# --------------------------------------------------------------------------- +# 页面 +# --------------------------------------------------------------------------- - def _process_file(self, file_path): - self.finished.emit(file_path) - - def _process_url(self, url): - # 检测 cookies.txt 文件 - cookiefile_path = APPDATA_PATH / "cookies.txt" - if not cookiefile_path.exists(): - InfoBar.warning( - self.tr("警告"), - self.tr("建议根据文档配置cookies.txt文件,以可以下载高清视频"), - duration=INFOBAR_DURATION_WARNING, - parent=self, - ) - # 创建视频下载线程 - self.video_download_thread = VideoDownloadThread(url, str(cfg.work_dir.value)) - self.video_download_thread.finished.connect(self.on_video_download_finished) - self.video_download_thread.progress.connect(self.on_create_task_progress) - self.video_download_thread.error.connect(self.on_create_task_error) - self.video_download_thread.start() - - InfoBar.info( - self.tr("开始下载"), - self.tr("开始下载视频..."), - duration=INFOBAR_DURATION_INFO, - parent=self, +class TaskCreationInterface(QWidget): + """任务创建页(首页)。""" + + finished = pyqtSignal(str, object) # (媒体文件路径, 字幕路径或 None) + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + self.setObjectName("TaskCreationInterface") + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.setAcceptDrops(True) + + self.state = PageState.INPUT + self.task = None + self._error: str = "" # 下载失败后的错误卡内容 + self._media_info: Optional[VideoInfo] = None + self._info_thread: Optional[VideoInfoThread] = None + self._log_window: Optional[LogWindow] = None + + self.controller = DownloadController(self) + self._build_ui() + self._connect_signals() + self._refresh() + + # ------------------------------------------------------------------ UI + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(42, 24, 42, 0) + root.setSpacing(0) + root.addStretch(5) + + # hero:标识 + 标题 + hero = QHBoxLayout() + hero.setSpacing(22) + hero.addStretch(1) + self.heroMark = QLabel(self) + self.heroMark.setFixedSize(96, 96) + self.heroMark.setScaledContents(True) + self.heroMark.setPixmap(QPixmap(str(HERO_MARK_PATH))) + hero.addWidget(self.heroMark) + self.heroTitle = QLabel(tr("home.hero.title"), self) + self.heroTitle.setObjectName("heroTitle") + # 跨平台中文字体栈:mac / Windows / Linux 各取系统最佳黑体 + apply_font( + self.heroTitle, + 36, + 900, + families=[ + "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", + "Noto Sans CJK SC", "Source Han Sans SC", "sans-serif", + ], + ) + hero.addWidget(self.heroTitle) + hero.addStretch(1) + root.addLayout(hero) + root.addSpacing(26) + + # 内容列(输入卡 / 详情 / 流程线,宽度收敛到 860) + column_host = QWidget(self) + column = QVBoxLayout(column_host) + column.setContentsMargins(0, 0, 0, 0) + column.setSpacing(16) + column_host.setMaximumWidth(1020) + + self.inputCard = QFrame(self) + self.inputCard.setObjectName("taskInputCard") + card_layout = QVBoxLayout(self.inputCard) + card_layout.setContentsMargins(14, 14, 14, 10) + card_layout.setSpacing(10) + input_row = QHBoxLayout() + input_row.setSpacing(12) + self.inputField = InputField(self.inputCard) + input_row.addWidget(self.inputField, 1) + self.primaryButton = PrimaryIconButton( + AppIcon.FOLDER_ADD, diameter=52, parent=self.inputCard + ) + self.primaryButton.setToolTip(tr("home.btn.browse")) + input_row.addWidget(self.primaryButton) + card_layout.addLayout(input_row) + quick_row = QHBoxLayout() + quick_row.setSpacing(12) + self.quickLabel = QLabel("", self.inputCard) + self.quickLabel.setObjectName("taskQuickLabel") + apply_font(self.quickLabel, 13, 740) + quick_row.addWidget(self.quickLabel) + quick_row.addStretch(1) + self.statusPill = StatusPill(tr("home.status.waiting"), "neutral", self.inputCard) + self.statusPill.setMinimumWidth(66) + quick_row.addWidget(self.statusPill) + card_layout.addLayout(quick_row) + column.addWidget(self.inputCard) + + # 详情区:占位 / 文件就绪 / 下载进度 / 错误条。 + # 每个子页顶对齐(内容多高就显示多高),整个区域固定占位, + # 详情切换时 hero/输入卡位置纹丝不动。 + def top_aligned(widget: QWidget) -> QWidget: + host = QWidget(self) + host_layout = QVBoxLayout(host) + host_layout.setContentsMargins(0, 0, 0, 0) + host_layout.addWidget(widget) + host_layout.addStretch(1) + return host + + self.detailStack = QStackedWidget(self) + self.detailPlaceholder = QWidget(self) + self.detailStack.addWidget(self.detailPlaceholder) + self.mediaPanel = MediaReadyPanel(self) + self.mediaHost = top_aligned(self.mediaPanel) + self.detailStack.addWidget(self.mediaHost) + self.downloadPanel = DownloadPanel(self) + self.downloadHost = top_aligned(self.downloadPanel) + self.detailStack.addWidget(self.downloadHost) + self.confirmPanel = ConfirmPanel(self) + self.confirmHost = top_aligned(self.confirmPanel) + self.detailStack.addWidget(self.confirmHost) + self.errorCard = ErrorCard(parent=self) + self.errorHost = top_aligned(self.errorCard) + self.detailStack.addWidget(self.errorHost) + self.detailStack.setFixedHeight(118) + column.addWidget(self.detailStack) + + center_row = QHBoxLayout() + center_row.addStretch(1) + center_row.addWidget(column_host, 8) + center_row.addStretch(1) + root.addLayout(center_row) + root.addStretch(7) + + # 底部品牌行:贴底、整体居中——程序名 + 版本胶囊 | 查看日志 | 捐助 + self.footerBar = QFrame(self) + self.footerBar.setObjectName("taskFooter") + self.footerBar.setFixedHeight(34) + footer = QHBoxLayout(self.footerBar) + footer.setContentsMargins(0, 0, 0, 0) + footer.setSpacing(10) + footer.addStretch(1) + self.brandLabel = QLabel("VideoCaptioner", self.footerBar) + self.brandLabel.setObjectName("taskBrand") + apply_font(self.brandLabel, 12, 850) + footer.addWidget(self.brandLabel) + self.versionChip = QLabel(f"v{VERSION}", self.footerBar) + self.versionChip.setObjectName("taskVersionChip") + self.versionChip.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + self.versionChip.setFixedHeight(18) + apply_font(self.versionChip, 10, 800) + footer.addWidget(self.versionChip) + footer.addSpacing(14) + self.feedbackAction = FooterAction(tr("feedback.title"), self.footerBar) + self.logAction = FooterAction(tr("home.footer.logs"), self.footerBar) + self.donateAction = FooterAction(tr("home.footer.donate"), self.footerBar) + self._footerDividers = [] + for index, action in enumerate((self.feedbackAction, self.logAction, self.donateAction)): + if index: + divider = QFrame(self.footerBar) + divider.setObjectName("taskFooterDivider") + divider.setFixedSize(1, 11) + footer.addWidget(divider) + self._footerDividers.append(divider) + footer.addWidget(action) + footer.addStretch(1) + root.addWidget(self.footerBar) + self._sync_page_style() + + def _sync_page_style(self): + palette = app_palette() + self.setStyleSheet( + f""" + QWidget#TaskCreationInterface {{ background: {palette.bg}; }} + QLabel#heroTitle {{ color: {palette.text}; background: transparent; }} + QLabel#taskQuickLabel {{ color: {palette.muted}; background: transparent; }} + QLabel#taskBrand {{ color: {rgba(palette.text, 0.64)}; background: transparent; }} + QLabel#taskAuthor {{ color: {rgba(palette.muted, 0.5)}; background: transparent; }} + QFrame#taskFooter {{ background: transparent; border: none; }} + QLabel#taskVersionChip {{ + color: {palette.muted}; + background: {palette.control}; + border: none; + border-radius: 9px; + padding: 0 8px; + }} + QFrame#taskFooterDivider {{ + background: {rgba(palette.muted, 0.22)}; + border: none; + }} + """ + ) + if hasattr(self, "errorCard"): + self.errorCard.syncStyle() + self.inputCard.setStyleSheet( + f""" + QFrame#taskInputCard {{ + background: {rgba("#ffffff", 0.03) if is_dark_theme() else rgba("#000000", 0.02)}; + border: 1px solid {palette.line_soft}; + border-radius: 16px; + }} + """ ) - def on_video_download_finished(self, video_file_path): - """视频下载完成的回调函数""" - if video_file_path: - self.finished.emit(video_file_path) - InfoBar.success( - self.tr("下载成功"), - self.tr("视频下载完成,开始自动处理..."), - duration=INFOBAR_DURATION_SUCCESS, - position=InfoBarPosition.BOTTOM, - parent=self.parent(), - ) - else: - InfoBar.error( - self.tr("错误"), - self.tr("视频下载失败"), - duration=INFOBAR_DURATION_ERROR, - parent=self, - ) + # ------------------------------------------------------------- signals + + def _connect_signals(self): + self.inputField.edit.textChanged.connect(self._on_text_changed) + self.primaryButton.clicked.connect(self._on_primary_clicked) + self.downloadPanel.cancelRequested.connect(self._cancel_download) + self.confirmPanel.startRequested.connect(self._start_download) + self.confirmPanel.cancelRequested.connect(self._cancel_download) + self.controller.probed.connect(self._on_probed) + self.controller.progressChanged.connect(self._on_download_progress) + self.controller.statsChanged.connect(self.downloadPanel.setStats) + self.controller.mediaChanged.connect(self.downloadPanel.setMedia) + self.controller.completed.connect(self._on_download_completed) + self.controller.failed.connect(self._on_download_failed) + self.feedbackAction.clicked.connect(lambda: FeedbackDialog(self).exec()) + self.logAction.clicked.connect(self._show_log_window) + self.donateAction.clicked.connect(lambda: DonateDialog(self).exec_()) + + # --------------------------------------------------------- 输入与动作 + + def _input_text(self) -> str: + return self.inputField.edit.text().strip() + + def _input_kind(self) -> str: + return classify_input(self._input_text()) + + def _on_text_changed(self): + self._error = "" + self._media_info = None + if self.state == PageState.INPUT: + kind = self._input_kind() + if kind == InputKind.FILE: + self._load_media_info(self._input_text()) + self._refresh() + + def _on_primary_clicked(self): + if self.state == PageState.CONFIRM: + self._start_download(self.confirmPanel.selectedHeight()) + return + kind = self._input_kind() + if kind == InputKind.FILE: + self.finished.emit(self._input_text(), None) + return + if kind == InputKind.URL: + self._start_probe() + return + self._browse_file() - def on_create_task_progress(self, value, status): - self.progress_bar.show() - self.status_label.show() - self.progress_bar.setValue(value) - self.status_label.setText(status) - - def on_create_task_error(self, error): - InfoBar.error( - self.tr("错误"), - self.tr(error), - duration=INFOBAR_DURATION_ERROR, - parent=self, + def _browse_file(self): + if self.state == PageState.DOWNLOADING: + return + video_formats = " ".join(f"*.{fmt.value}" for fmt in SupportedVideoFormats) + audio_formats = " ".join(f"*.{fmt.value}" for fmt in SupportedAudioFormats) + file_path, _ = QFileDialog.getOpenFileName( + self, + tr("home.dialog.select_media"), + "", + f"{tr('home.dialog.filter.media')} ({video_formats} {audio_formats});;" + f"{tr('home.dialog.filter.video')} ({video_formats});;" + f"{tr('home.dialog.filter.audio')} ({audio_formats})", ) + if file_path: + self.inputField.edit.setText(file_path) - def set_task(self, task): - self.task = task - self.update_info() + # ------------------------------------------------------------- 下载 - def update_info(self): - if self.task: - self.search_input.setText(self.task.file_path) + def _start_probe(self): + """第一步:只解析视频信息,完成后进入确认面板(不下载)。""" + if not self.controller.probe(self._input_text()): + return + self._error = "" + self.state = PageState.PROBING + self.downloadPanel.setPreparing(self._input_text()) + self._refresh() - def process(self): - search_input = self.search_input.text() + def _on_probed(self, summary: dict): + if self.state != PageState.PROBING: + return + self.state = PageState.CONFIRM + self.confirmPanel.setData(summary) + self._refresh() - if os.path.isfile(search_input): - self._process_file(search_input) - elif self._is_valid_url(search_input): - self._process_url(search_input) - else: - InfoBar.error( - self.tr("错误"), - self.tr("请输入音视频文件路径或URL"), - duration=INFOBAR_DURATION_ERROR, - parent=self, + def _start_download(self, max_height): + """第二步:用户确认清晰度后真正开始下载。""" + if not self.controller.start(self._input_text(), max_height): + return + self._error = "" + self.state = PageState.DOWNLOADING + self.downloadPanel.setPreparing(self._input_text()) + self._refresh() + + def _cancel_download(self): + self.controller.cancel() + self.state = PageState.INPUT + self._refresh() + + def _on_download_progress(self, value: int, message: str): + if self.state == PageState.DOWNLOADING: + self.downloadPanel.setProgress(value, message) + self.statusPill.setState(f"{value}%", "warn") + + def _on_download_completed(self, video_path: str, subtitle_path): + self.state = PageState.INPUT + self.inputField.edit.setText(video_path) + InfoBar.success( + tr("home.download.done.title"), tr("home.download.done.body"), + duration=INFOBAR_DURATION_SUCCESS, + position=InfoBarPosition.TOP, parent=self, + ) + self.finished.emit(video_path, subtitle_path) + + def _on_download_failed(self, message: str): + self.state = PageState.INPUT + self._error = message + self._refresh() + + # --------------------------------------------------------- 媒体元信息 + + def _load_media_info(self, path: str): + if self._info_thread is not None and self._info_thread.isRunning(): + self._info_thread.stop(wait_ms=200) + thread = VideoInfoThread(path) + thread.setParent(self) + thread.finished.connect(self._on_media_info) + thread.error.connect(lambda _msg: None) # 元信息失败不阻断流程 + self._info_thread = thread + thread.start() + + def _on_media_info(self, info: VideoInfo): + self._media_info = info + if self.state == PageState.INPUT and self._input_kind() == InputKind.FILE: + self.mediaPanel.setFile(self._input_text(), info) + + # --------------------------------------------------------- 状态呈现 + + def _refresh(self): + kind = self._input_kind() + + if self.state in (PageState.PROBING, PageState.DOWNLOADING): + probing = self.state == PageState.PROBING + self.inputField.setKindIcon(AppIcon.LINK) + self.inputField.edit.setReadOnly(True) + self.primaryButton.setIcon(AppIcon.SYNC) + self.primaryButton.setToolTip( + tr("home.tip.parsing") if probing else tr("home.tip.downloading") + ) + self.primaryButton.setEnabled(False) + self.quickLabel.setText( + tr("home.quick.parsing") + if probing + else tr("home.quick.downloading") ) + self.statusPill.setState( + tr("home.status.parsing") if probing else "0%", "warn" + ) + self.detailStack.setCurrentWidget(self.downloadHost) + return + + if self.state == PageState.CONFIRM: + self.inputField.setKindIcon(AppIcon.LINK) + self.inputField.edit.setReadOnly(True) + self.primaryButton.setIcon(AppIcon.PLAY) + self.primaryButton.setToolTip(tr("home.download.start")) + self.primaryButton.setEnabled(True) + self.quickLabel.setText(tr("home.quick.confirm")) + self.statusPill.setState(tr("home.status.pending"), "ok") + self.detailStack.setCurrentWidget(self.confirmHost) + return - def show_log_window(self): - """显示日志窗口""" - if self.log_window is None: - self.log_window = LogWindow() - if self.log_window.isHidden(): - self.log_window.show() + self.inputField.edit.setReadOnly(False) + specs = { + InputKind.EMPTY: (AppIcon.LINK, tr("home.btn.browse"), AppIcon.FOLDER_ADD, tr("home.status.waiting"), "neutral"), + InputKind.FILE: (file_type_icon(self._input_text()), tr("home.btn.start"), AppIcon.PLAY, tr("home.status.ready"), "ok"), + InputKind.URL: (AppIcon.LINK, tr("home.btn.start"), AppIcon.PLAY, tr("home.status.ready"), "ok"), + InputKind.INVALID: (AppIcon.FILE, tr("home.btn.browse"), AppIcon.FOLDER_ADD, tr("home.status.invalid"), "fail"), + } + icon, tooltip, button_icon, pill_text, pill_level = specs[kind] + self.inputField.setKindIcon(icon) + self.primaryButton.setIcon(button_icon) + self.primaryButton.setToolTip(tooltip) + self.primaryButton.setEnabled(True) + self.statusPill.setState(pill_text, pill_level) + + quick_texts = { + InputKind.EMPTY: tr("home.quick.empty"), + InputKind.FILE: tr("home.quick.file"), + InputKind.URL: tr("home.quick.url"), + InputKind.INVALID: tr("home.quick.invalid"), + } + self.quickLabel.setText(quick_texts[kind]) + + if self._error: + self.errorCard.setText(self._error) + self.detailStack.setCurrentWidget(self.errorHost) + elif kind == InputKind.FILE: + self.mediaPanel.setFile(self._input_text(), self._media_info) + self.detailStack.setCurrentWidget(self.mediaHost) + elif kind == InputKind.INVALID: + self.errorCard.setText(tr("home.error.invalid_input")) + self.detailStack.setCurrentWidget(self.errorHost) else: - self.log_window.activateWindow() + self.detailStack.setCurrentWidget(self.detailPlaceholder) + + # ------------------------------------------------------------ 外部契约 + + def set_task(self, task): + self.task = task + if task is not None and getattr(task, "file_path", None): + self.inputField.edit.setText(task.file_path) - def show_donate_dialog(self): - """显示捐助窗口""" - donate_dialog = DonateDialog(self) - donate_dialog.exec_() + def process(self): + self._on_primary_clicked() + # ------------------------------------------------------------ 拖放 -if __name__ == "__main__": - QApplication.setHighDpiScaleFactorRoundingPolicy( - Qt.HighDpiScaleFactorRoundingPolicy.PassThrough - ) - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) # type: ignore - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) # type: ignore + def dragEnterEvent(self, event): + if event.mimeData().hasUrls() and self.state == PageState.INPUT: + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + for url in event.mimeData().urls(): + path = url.toLocalFile() + if path and os.path.isfile(path): + if Path(path).suffix.lower() in _MEDIA_EXTENSIONS: + self.inputField.edit.setText(path) + return + self._error = tr("home.error.unsupported_drop") + self._refresh() + + # ------------------------------------------------------------ 其他 + + def _show_log_window(self): + if self._log_window is None: + self._log_window = LogWindow() + if self._log_window.isHidden(): + self._log_window.show() + else: + self._log_window.activateWindow() - app = QApplication(sys.argv) - window = TaskCreationInterface() - window.show() - sys.exit(app.exec_()) + def closeEvent(self, event): + self.controller.shutdown() + if self._info_thread is not None and self._info_thread.isRunning(): + self._info_thread.stop(wait_ms=500) + super().closeEvent(event) diff --git a/videocaptioner/ui/view/transcription_interface.py b/videocaptioner/ui/view/transcription_interface.py index 27a3a857..30fe3798 100644 --- a/videocaptioner/ui/view/transcription_interface.py +++ b/videocaptioner/ui/view/transcription_interface.py @@ -1,584 +1,1681 @@ # -*- coding: utf-8 -*- +"""语音转录页:单文件转录工作台。 + +左侧是预览与结果区,右侧是固定 330px 的参数 / 结果操作栏。 + +页面状态机(PageState): + + EMPTY 未选择文件:左侧为拖放导入区,右侧参数可见但按钮禁用 + READY 文件就绪:当前文件卡片 + “尚未开始转录”占位,可开始 + RUNNING 转录中:进度卡片展示阶段与百分比,按钮禁用 + FAILED 失败恢复:失败原因面板,按钮变“重新转录” + DONE 完成:左侧切换为 SRT 表格或纯文本预览,右侧切换为结果操作 + +线程统一由 TranscriptionController 持有,页面只消费它的信号; +widget 不直接创建线程,也不互相反向引用。 + +对外接口(HomeInterface 依赖,保持兼容): + finished(str, str) 转录产物路径、原媒体路径 + set_task(task) / process() / close() +""" + +from __future__ import annotations -import datetime import os -import sys +from enum import Enum, auto from pathlib import Path -from typing import Optional - -from PyQt5.QtCore import QStandardPaths, Qt, pyqtSignal -from PyQt5.QtGui import QFont, QPixmap +from typing import Callable, Optional + +from PyQt5.QtCore import QObject, QRectF, QStandardPaths, Qt, QUrl, pyqtSignal +from PyQt5.QtGui import ( + QColor, + QDesktopServices, + QPainter, + QPainterPath, + QPen, +) from PyQt5.QtWidgets import ( - QApplication, QFileDialog, + QFrame, QHBoxLayout, QLabel, + QScrollArea, + QStackedWidget, QVBoxLayout, QWidget, ) -from qfluentwidgets import ( - Action, - BodyLabel, - CardWidget, - CommandBar, - FluentIcon, - InfoBar, - InfoBarPosition, - PillPushButton, - PrimaryPushButton, - ProgressRing, - PushButton, - RoundMenu, - TransparentDropDownPushButton, - setFont, -) +from qfluentwidgets import InfoBar -from videocaptioner.config import RESOURCE_PATH from videocaptioner.core.constant import ( INFOBAR_DURATION_ERROR, - INFOBAR_DURATION_SUCCESS, INFOBAR_DURATION_WARNING, ) from videocaptioner.core.entities import ( + FasterWhisperModelEnum, SupportedAudioFormats, SupportedVideoFormats, + TranscribeLanguageEnum, TranscribeModelEnum, + TranscribeOutputFormatEnum, TranscribeTask, VideoInfo, + WhisperModelEnum, ) -from videocaptioner.core.utils.platform_utils import get_available_transcribe_models, open_folder +from videocaptioner.core.utils.platform_utils import ( + get_available_transcribe_models, + open_folder, + reveal_in_explorer, +) +from videocaptioner.ui.common.app_icons import AppIcon from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.common.signal_bus import signalBus -from videocaptioner.ui.components.transcription_setting_card import TranscriptionSettingCard -from videocaptioner.ui.components.TranscriptionSettingDialog import TranscriptionSettingDialog +from videocaptioner.ui.common.enum_labels import enum_from_label, enum_label, enum_options +from videocaptioner.ui.common.model_options import ( + FUN_ASR_MODEL_OPTIONS, + WHISPER_API_MODEL_OPTIONS, +) +from videocaptioner.ui.common.theme_tokens import app_palette, is_dark_theme, rgba +from videocaptioner.ui.components.workbench import ( + AdaptiveTitleLabel, + ClickableFrame, + CollapsibleSideHost, + DropZone, + ErrorCard, + HeaderLinkButton, + InfoChip, + MediaThumb, + OptionCard, + PanelHeader, + PillSelect, + ProgressBarLine, + RoundIconButton, + StatusPill, + ToggleSwitch, + WorkbenchButton, + WorkbenchPanel, + apply_font, + draw_rounded_surface, + icon_pixmap, +) +from videocaptioner.ui.i18n import N_, tr from videocaptioner.ui.task_factory import TaskFactory from videocaptioner.ui.thread.transcript_thread import TranscriptThread from videocaptioner.ui.thread.video_info_thread import VideoInfoThread -DEFAULT_THUMBNAIL_PATH = RESOURCE_PATH / "assets" / "default_thumbnail.jpg" +# 转录服务在页面上的短名(状态胶囊里的写法)。 +_PROVIDER_SHORT = { + TranscribeModelEnum.BIJIAN: "B 接口", + TranscribeModelEnum.JIANYING: "J 接口", + TranscribeModelEnum.BAILIAN_FUN_ASR: "Fun-ASR", + TranscribeModelEnum.WHISPER_API: "Whisper API", + TranscribeModelEnum.FASTER_WHISPER: "FasterWhisper", + TranscribeModelEnum.WHISPER_CPP: "WhisperCpp", +} +_MEDIA_FORMATS = {fmt.value for fmt in SupportedVideoFormats} | { + fmt.value for fmt in SupportedAudioFormats +} -class VideoInfoCard(CardWidget): - finished = pyqtSignal(TranscribeTask) +# 音轨语言代码 -> 选择器里的中文名(信息胶囊保留原始代码)。 +_TRACK_LANGUAGE_NAMES = { + "zh": "中文", "chi": "中文", "zho": "中文", "cmn": "中文", "yue": "粤语", + "en": "英语", "eng": "英语", + "ja": "日语", "jpn": "日语", + "ko": "韩语", "kor": "韩语", + "fr": "法语", "fra": "法语", "fre": "法语", + "de": "德语", "deu": "德语", "ger": "德语", + "es": "西班牙语", "spa": "西班牙语", + "ru": "俄语", "rus": "俄语", +} - def __init__(self, parent: Optional[QWidget] = None): + +def _track_language(language: str) -> str: + """规整音轨语言标签;und/空串视为未知,不展示。""" + code = (language or "").strip().lower() + if code in ("", "und"): + return "" + return _TRACK_LANGUAGE_NAMES.get(code, language) + + +def _track_label(index: int, language: str) -> str: + name = _track_language(language) + return f"音轨 {index + 1}" + (f" · {name}" if name else "") + +_PREVIEW_ROW_LIMIT = 800 + + +class PageState(Enum): + EMPTY = auto() + READY = auto() + RUNNING = auto() + FAILED = auto() + DONE = auto() + + +# --------------------------------------------------------------------------- +# 线程编排 +# --------------------------------------------------------------------------- + + +class TranscriptionController(QObject): + """持有并编排本页的两类后台线程:媒体信息读取、转录执行。 + + 页面只调用 load_media / start_transcription / shutdown, + 其余交互全部通过信号;线程对象不暴露给任何 widget。 + """ + + mediaLoaded = pyqtSignal(VideoInfo) + mediaFailed = pyqtSignal(str) + progressChanged = pyqtSignal(int, str) + transcriptFailed = pyqtSignal(str) + transcriptCompleted = pyqtSignal(TranscribeTask) + + def __init__(self, parent: Optional[QObject] = None): super().__init__(parent) - self.setup_ui() - self.setup_signals() - self.task: Optional[TranscribeTask] = None - self.video_info: Optional[VideoInfo] = None - self.transcription_interface = parent - self.selected_audio_track_index = 0 # 默认选择第一条音轨 - - def setup_ui(self) -> None: - self.setFixedHeight(150) - self.main_layout = QHBoxLayout(self) - self.main_layout.setContentsMargins(20, 15, 20, 15) - self.main_layout.setSpacing(20) - - self.setup_thumbnail() - self.setup_info_layout() - self.setup_button_layout() - - def setup_thumbnail(self) -> None: - default_thumbnail_path = os.path.join(DEFAULT_THUMBNAIL_PATH) - - self.video_thumbnail = QLabel(self) - self.video_thumbnail.setFixedSize(208, 117) - self.video_thumbnail.setStyleSheet("background-color: #1E1F22;") - self.video_thumbnail.setAlignment(Qt.AlignCenter) # type: ignore - pixmap = QPixmap(default_thumbnail_path).scaled( - self.video_thumbnail.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.SmoothTransformation, # type: ignore - ) - self.video_thumbnail.setPixmap(pixmap) - self.main_layout.addWidget(self.video_thumbnail, 0, Qt.AlignLeft) # type: ignore - - def setup_info_layout(self) -> None: - self.info_layout = QVBoxLayout() - self.info_layout.setContentsMargins(3, 8, 3, 8) - self.info_layout.setSpacing(10) - - self.video_title = BodyLabel(self.tr("请拖入音频或视频文件"), self) - self.video_title.setFont(QFont("Microsoft YaHei", 14, QFont.Bold)) - self.video_title.setWordWrap(True) - self.info_layout.addWidget(self.video_title, alignment=Qt.AlignTop) # type: ignore - - self.details_layout = QHBoxLayout() - self.details_layout.setSpacing(15) - - self.resolution_info = self.create_pill_button(self.tr("画质"), 110) - self.file_size_info = self.create_pill_button(self.tr("文件大小"), 110) - self.duration_info = self.create_pill_button(self.tr("时长"), 100) - self.audio_track_button = self.create_pill_button(self.tr("音轨"), 100) - self.audio_track_button.hide() # 默认隐藏,只在多音轨时显示 - - self.progress_ring = ProgressRing(self) - self.progress_ring.setFixedSize(20, 20) - self.progress_ring.setStrokeWidth(4) - self.progress_ring.hide() - - self.details_layout.addWidget(self.resolution_info) - self.details_layout.addWidget(self.file_size_info) - self.details_layout.addWidget(self.duration_info) - self.details_layout.addWidget(self.audio_track_button) - self.details_layout.addWidget(self.progress_ring) - self.details_layout.addStretch(1) - self.info_layout.addLayout(self.details_layout) - self.main_layout.addLayout(self.info_layout) # type: ignore - - def create_pill_button(self, text: str, width: int) -> PillPushButton: - button = PillPushButton(text, self) - button.setCheckable(False) - setFont(button, 11) - # button.setFixedWidth(width) - button.setMinimumWidth(50) - return button + self._info_thread: Optional[VideoInfoThread] = None + self._transcript_thread: Optional[TranscriptThread] = None + + def load_media(self, file_path: str) -> None: + # 重入保护:连拖两个文件(前一个探测慢)时覆盖前先停旧线程, + # 否则旧 running QThread 被 GC 触发 "Destroyed while still running" abort + if self._info_thread is not None and self._info_thread.isRunning(): + self._info_thread.stop(wait_ms=200) + thread = VideoInfoThread(file_path) + thread.setParent(self) + thread.finished.connect(self.mediaLoaded) + thread.error.connect(self.mediaFailed) + self._info_thread = thread + thread.start() + + def start_transcription(self, task: TranscribeTask) -> bool: + if self.is_transcribing(): + return False + thread = TranscriptThread(task) + thread.finished.connect(self.transcriptCompleted) + thread.progress.connect(self.progressChanged) + thread.error.connect(self.transcriptFailed) + self._transcript_thread = thread + thread.start() + return True + + def is_transcribing(self) -> bool: + return self._transcript_thread is not None and self._transcript_thread.isRunning() + + def cancel_transcription(self) -> None: + """用户主动取消:先断开信号防止迟到事件污染 UI,再协作停止。""" + thread = self._transcript_thread + if thread is None: + return + self._transcript_thread = None + if thread.isRunning(): + try: + thread.finished.disconnect() + thread.progress.disconnect() + thread.error.disconnect() + except TypeError: + pass + thread.stop() + + def shutdown(self) -> None: + """页面关闭时调用:协作停止(基类内部超时才强杀)。""" + for thread in (self._info_thread, self._transcript_thread): + if thread is not None: + thread.stop() + + +# --------------------------------------------------------------------------- +# 格式化工具 +# --------------------------------------------------------------------------- + + +def _format_duration(seconds: float) -> str: + total = int(seconds) + hours, rest = divmod(total, 3600) + minutes, secs = divmod(rest, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes:02d}:{secs:02d}" + + +def _format_file_size(file_path: str) -> str: + size = os.path.getsize(file_path) + if size < 1024 * 1024: + return f"{max(1, size // 1024)} KB" + return f"{size / 1024 / 1024:.0f} MB" - def setup_button_layout(self) -> None: - self.button_layout = QVBoxLayout() - self.open_folder_button = PushButton(self.tr("打开文件夹"), self) - self.start_button = PrimaryPushButton(self.tr("开始转录"), self) - self.button_layout.addWidget(self.open_folder_button) - self.button_layout.addWidget(self.start_button) - self.start_button.setDisabled(True) +def _format_clock(ms: int) -> str: + secs, milli = divmod(max(0, int(ms)), 1000) + hours, rest = divmod(secs, 3600) + minutes, sec = divmod(rest, 60) + return f"{hours:02d}:{minutes:02d}:{sec:02d}.{milli:03d}" - button_widget = QWidget() - button_widget.setLayout(self.button_layout) - button_widget.setFixedWidth(130) - self.main_layout.addWidget(button_widget) # type: ignore - def update_info(self, video_info: VideoInfo) -> None: - """更新视频信息显示""" - self.reset_ui() - self.video_info = video_info +def _provider_short(model: TranscribeModelEnum) -> str: + return _PROVIDER_SHORT.get(model, model.value.replace(" ✨", "")) - self.video_title.setText(video_info.file_name.rsplit(".", 1)[0]) - self.resolution_info.setText( - self.tr("画质: ") + f"{video_info.width}x{video_info.height}" + +def _installed_local_models(kind: str) -> list[str]: + """已下载到本地的模型名(与设置页同一口径)。""" + from videocaptioner.config import MODEL_PATH + from videocaptioner.core.download import iter_models, model_install_state + + models_dir = ( + Path(cfg.faster_whisper_model_dir.value or MODEL_PATH) + if kind == "faster-whisper" + else Path(MODEL_PATH) + ) + return [ + spec.name for spec in iter_models(kind) if model_install_state(spec, models_dir) + ] + + +def _local_model_spec(kind: str, configured: str) -> Optional[tuple[list[str], str]]: + """本地引擎只列已下载的模型;一个都没有时隐藏模型行(去设置页下载)。""" + installed = _installed_local_models(kind) + if not installed: + return None + current = configured if configured in installed else installed[0] + return installed, current + + +def _service_model_spec( + service: TranscribeModelEnum, +) -> Optional[tuple[list[str], str]]: + """返回服务的(模型候选列表, 当前模型);没有模型概念的服务返回 None。""" + if service == TranscribeModelEnum.BAILIAN_FUN_ASR: + return list(FUN_ASR_MODEL_OPTIONS), str(cfg.fun_asr_model.value) + if service == TranscribeModelEnum.WHISPER_API: + return list(WHISPER_API_MODEL_OPTIONS), str(cfg.whisper_api_model.value) + if service == TranscribeModelEnum.WHISPER_CPP: + return _local_model_spec( + "whisper-cpp", + getattr(cfg.whisper_model.value, "value", str(cfg.whisper_model.value)), ) - file_size_mb = os.path.getsize(video_info.file_path) / 1024 / 1024 - self.file_size_info.setText(self.tr("大小: ") + f"{file_size_mb:.1f} MB") - duration = datetime.timedelta(seconds=int(video_info.duration_seconds)) - self.duration_info.setText(self.tr("时长: ") + f"{duration}") + if service == TranscribeModelEnum.FASTER_WHISPER: + return _local_model_spec( + "faster-whisper", + getattr( + cfg.faster_whisper_model.value, + "value", + str(cfg.faster_whisper_model.value), + ), + ) + return None # B 接口 / J 接口:仅默认模型 + + +def _is_audio_file(file_path: str) -> bool: + suffix = Path(file_path).suffix.lstrip(".").lower() + return suffix in {fmt.value for fmt in SupportedAudioFormats} + + +def _clear_chip_row(layout: QHBoxLayout) -> None: + """清空胶囊行(保留末尾 stretch)。 + + 必须先 setParent(None) 再 deleteLater:offscreen/截图场景下 DeferredDelete + 事件可能还没处理,残留的旧胶囊会以父控件 (0,0) 为原点悬浮显示。 + """ + while layout.count() > 1: + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.hide() + widget.setParent(None) + widget.deleteLater() + + +def _result_artifact(task: TranscribeTask) -> tuple[Path, bool]: + """根据输出格式推导实际产物路径。返回 (路径, 是否纯文本预览)。""" + base = Path(str(task.output_path)).with_suffix("") + fmt = task.transcribe_config.output_format if task.transcribe_config else None + if fmt == TranscribeOutputFormatEnum.TXT: + return base.with_suffix(".txt"), True + if fmt is None or fmt == TranscribeOutputFormatEnum.ALL: + return base.with_suffix(".srt"), False + return base.with_suffix(f".{fmt.value.lower()}"), False + - # 更新音轨选择按钮 - self.update_audio_tracks(video_info) +# --------------------------------------------------------------------------- +# 左侧:媒体卡片与各状态部件 +# --------------------------------------------------------------------------- - if self.transcription_interface and self.transcription_interface.is_processing: # type: ignore - self.start_button.setEnabled(False) + +class MediaCard(QFrame): + """当前文件卡片:缩略图 + 标题 + 信息胶囊。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("mediaCard") + + layout = QHBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(24) + self.thumb = MediaThumb(self) + self.thumb.setFixedSize(210, 118) + layout.addWidget(self.thumb, 0, Qt.AlignVCenter) # type: ignore[arg-type] + + column = QVBoxLayout() + column.setSpacing(12) + self.titleLabel = AdaptiveTitleLabel(24, 860, 19, self) + self.titleLabel.setObjectName("mediaTitle") + column.addWidget(self.titleLabel) + self.chipsRow = QHBoxLayout() + self.chipsRow.setSpacing(9) + self.chipsRow.addStretch(1) + column.addLayout(self.chipsRow) + layout.addLayout(column, 1) + self.syncStyle() + + def setThumbSize(self, width: int, height: int): + self.thumb.setFixedSize(width, height) + + def setTitle(self, title: str): + self.titleLabel.setFullText(title) + + def setChips(self, texts: list[str]): + _clear_chip_row(self.chipsRow) + for index, text in enumerate(texts): + self.chipsRow.insertWidget(index, InfoChip(text, self)) + + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.panel, palette.line, 16) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.update() + self.setStyleSheet( + f""" + QFrame#mediaCard {{ background: transparent; border: none; }} + QLabel#mediaTitle {{ + color: {palette.text}; + background: transparent; + border: none; + }} + """ + ) + + +class PendingResultArea(QFrame): + """“尚未开始转录”占位。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("pendingResult") + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.addStretch(1) + self.iconBox = QFrame(self) + self.iconBox.setObjectName("pendingIcon") + self.iconBox.setFixedSize(54, 54) + icon_layout = QVBoxLayout(self.iconBox) + icon_layout.setContentsMargins(0, 0, 0, 0) + self.iconLabel = QLabel(self.iconBox) + self.iconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + icon_layout.addWidget(self.iconLabel) + layout.addWidget(self.iconBox, 0, Qt.AlignHCenter) # type: ignore[arg-type] + layout.addSpacing(12) + self.textLabel = QLabel(tr("transcribe.pending.not_started"), self) + self.textLabel.setObjectName("pendingText") + self.textLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(self.textLabel, 18, 820) + layout.addWidget(self.textLabel) + layout.addStretch(1) + self.syncStyle() + + def paintEvent(self, event): + from videocaptioner.ui.components.workbench import to_qcolor + + dark = is_dark_theme() + surface = app_palette().card_surface + dashed = rgba("#cbd4d0", 0.22) if dark else rgba("#000000", 0.18) + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + path = QPainterPath() + path.addRoundedRect(rect, 14, 14) + painter.fillPath(path, to_qcolor(surface)) + pen = QPen(to_qcolor(dashed), 1, Qt.DashLine) + painter.setPen(pen) + painter.drawPath(path) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.update() + self.iconLabel.setPixmap(icon_pixmap(AppIcon.DOCUMENT, palette.muted, 28)) + self.setStyleSheet( + f""" + QFrame#pendingResult {{ + background: transparent; + border: none; + }} + QFrame#pendingIcon {{ + background: {palette.control}; + border: 1px solid {rgba("#ffffff", 0.04)}; + border-radius: 14px; + }} + QLabel#pendingText {{ color: {palette.muted}; background: transparent; }} + """ + ) + +class ProgressCard(QFrame): + """转录进度卡:百分比 + 进度条 + 三个阶段行。""" + + _STAGES = ( + N_("transcribe.stage.read_audio"), + N_("transcribe.stage.recognize"), + N_("transcribe.stage.write_subtitle"), + ) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("progressCard") + layout = QVBoxLayout(self) + layout.setContentsMargins(22, 22, 22, 22) + layout.setSpacing(18) + + head = QHBoxLayout() + self.phaseLabel = QLabel(tr("transcribe.progress.phase"), self) + self.phaseLabel.setObjectName("progressPhase") + apply_font(self.phaseLabel, 17, 820) + head.addWidget(self.phaseLabel) + head.addStretch(1) + self.percentLabel = QLabel("0%", self) + self.percentLabel.setObjectName("progressPercent") + self.percentLabel.setMinimumWidth(56) + self.percentLabel.setAlignment(Qt.AlignRight | Qt.AlignVCenter) # type: ignore[arg-type] + apply_font(self.percentLabel, 17, 820) + head.addWidget(self.percentLabel) + layout.addLayout(head) + + self.bar = ProgressBarLine(self) + layout.addWidget(self.bar) + + self.stagePills: list[StatusPill] = [] + for index, name in enumerate(self._STAGES): + row_frame = QFrame(self) + row_frame.setMinimumHeight(42) + row = QHBoxLayout(row_frame) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(12) + dot = QLabel(str(index + 1), row_frame) + dot.setObjectName("stageDot") + dot.setFixedSize(24, 24) + dot.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + apply_font(dot, 13, 850) + row.addWidget(dot) + label = QLabel(tr(name), row_frame) + label.setObjectName("stageName") + apply_font(label, 14, 740) + row.addWidget(label, 1) + pill = StatusPill(tr("transcribe.stage.waiting"), "neutral", row_frame) + self.stagePills.append(pill) + row.addWidget(pill) + layout.addWidget(row_frame) + self.syncStyle() + + def setPhase(self, message: str): + """标题展示线程上报的真实阶段消息,空串时回落到默认文案。""" + self.phaseLabel.setText(message or tr("transcribe.progress.phase")) + + def setProgress(self, value: int): + value = max(0, min(100, value)) + self.percentLabel.setText(f"{value}%") + self.bar.setValue(value) + # 阶段映射:<20 在读音频;20-97 在识别;>=98 在写字幕文件。 + running = N_("transcribe.stage.running") + waiting = N_("transcribe.stage.waiting") + done = N_("transcribe.stage.done") + if value < 20: + states = [(running, "warn"), (waiting, "neutral"), (waiting, "neutral")] + elif value < 98: + states = [(done, "ok"), (running, "warn"), (waiting, "neutral")] else: - self.start_button.setEnabled(True) - self.update_thumbnail(video_info.thumbnail_path) - - def update_audio_tracks(self, video_info: VideoInfo) -> None: - """更新音轨选择按钮""" - audio_streams = video_info.audio_streams - - if len(audio_streams) > 1: - # 多音轨,显示选择按钮,默认选择第一条音轨(数组索引0) - self.selected_audio_track_index = 0 - self.update_audio_track_button_text(audio_streams, 0) - - # 创建下拉菜单 - menu = RoundMenu(parent=self) - for i, stream in enumerate(audio_streams): - lang = stream.language - - # 构建菜单项文本(使用序号 i+1) - text = self.tr("音轨") + str(i + 1) - if lang: - text += f" ({lang})" - - action = Action(text) - action.triggered.connect( - lambda checked, array_idx=i, streams=audio_streams: self.on_audio_track_selected( - array_idx, streams - ) - ) - menu.addAction(action) + states = [(done, "ok"), (done, "ok"), (running, "warn")] + for pill, (key, level) in zip(self.stagePills, states): + pill.setState(tr(key), level) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line, 14) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.update() + self.setStyleSheet( + f""" + QFrame#progressCard {{ + background: transparent; + border: none; + }} + QLabel#progressPhase, QLabel#progressPercent {{ + color: {palette.text}; background: transparent; + }} + QLabel#stageDot {{ + color: {palette.accent}; + background: {palette.accent_soft}; + border-radius: 12px; + }} + QLabel#stageName {{ color: {palette.muted}; background: transparent; }} + """ + ) - # 绑定菜单到按钮 - self.audio_track_button.clicked.connect( - lambda: menu.exec( - self.audio_track_button.mapToGlobal( - self.audio_track_button.rect().bottomLeft() - ) + +# --------------------------------------------------------------------------- +# 左侧:结果预览 +# --------------------------------------------------------------------------- + + +class SubtitlePreviewPanel(WorkbenchPanel): + """SRT 结果表格:开始时间 / 结束时间 / 字幕内容。""" + + replaceRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent, padded=False) + self.header = PanelHeader(tr("transcribe.preview.title"), inline=False, parent=self) + self.pill = StatusPill("", "ok", self) + self.header.addRight(self.pill) + # 完成态也要能直接导入下一个文件。 + self.replaceLink = HeaderLinkButton(tr("transcribe.action.replace_file"), AppIcon.FOLDER_ADD, self) + self.replaceLink.clicked.connect(self.replaceRequested) + self.header.addRight(self.replaceLink) + self.bodyLayout.addWidget(self.header) + + self.theadFrame = QFrame(self) + self.theadFrame.setObjectName("subtitleThead") + self.theadFrame.setFixedHeight(46) + thead_layout = QHBoxLayout(self.theadFrame) + thead_layout.setContentsMargins(0, 0, 0, 0) + thead_layout.setSpacing(0) + self.theadLabels = [] + for text, width in ((tr("transcribe.col.start"), 150), (tr("transcribe.col.end"), 150), (tr("transcribe.col.content"), 0)): + label = QLabel(text, self.theadFrame) + label.setObjectName("subtitleTheadCell") + label.setContentsMargins(16, 0, 16, 0) + apply_font(label, 14, 800) + if width: + label.setFixedWidth(width) + thead_layout.addWidget(label) + else: + thead_layout.addWidget(label, 1) + self.theadLabels.append(label) + self.bodyLayout.addWidget(self.theadFrame) + + self.scrollArea = QScrollArea(self) + self.scrollArea.setWidgetResizable(True) + self.scrollArea.setFrameShape(QFrame.NoFrame) + self.scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + self.rowsHost = QWidget(self) + self.rowsLayout = QVBoxLayout(self.rowsHost) + self.rowsLayout.setContentsMargins(0, 0, 0, 0) + self.rowsLayout.setSpacing(0) + self.rowsLayout.addStretch(1) + self.scrollArea.setWidget(self.rowsHost) + self.bodyLayout.addWidget(self.scrollArea, 1) + self.rows: list[QFrame] = [] + self._active_index = -1 + self.syncStyle() + + def setSegments(self, segments: list[tuple[int, int, str]]): + for row in self.rows: + row.deleteLater() + self.rows.clear() + self._active_index = -1 + self.pill.setText(tr("transcribe.preview.count", n=len(segments))) + for index, (start_ms, end_ms, text) in enumerate(segments[:_PREVIEW_ROW_LIMIT]): + row = self._make_row(index, _format_clock(start_ms), _format_clock(end_ms), text) + self.rows.append(row) + self.rowsLayout.insertWidget(index, row) + self._sync_rows_style() + + def _make_row(self, index: int, start: str, end: str, text: str) -> QFrame: + row = QFrame(self.rowsHost) + row.setObjectName("subtitleRow") + row.setMinimumHeight(56) + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + for value, width in ((start, 150), (end, 150), (text, 0)): + cell = QLabel(value, row) + cell.setObjectName("subtitleCell") + cell.setContentsMargins(16, 0, 16, 0) + apply_font(cell, 14, 650) + if width: + cell.setFixedWidth(width) + layout.addWidget(cell) + else: + layout.addWidget(cell, 1) + + def _select(event, target=index): + self.setActiveRow(target) + + row.mousePressEvent = _select # type: ignore[assignment] + return row + + def setActiveRow(self, index: int): + self._active_index = index + self._sync_rows_style() + + def _sync_rows_style(self): + palette = app_palette() + for index, row in enumerate(self.rows): + if index == self._active_index: + row.setStyleSheet( + f""" + QFrame#subtitleRow {{ + background: {rgba(palette.accent, 0.075)}; + border: none; + border-top: 1px solid {palette.line_soft}; + border-left: 3px solid {palette.accent}; + }} + QLabel#subtitleCell {{ color: {palette.text}; background: transparent; }} + """ ) - ) - self.audio_track_button.show() - else: - self.audio_track_button.hide() - self.selected_audio_track_index = 0 - - def update_audio_track_button_text( - self, audio_streams: list, array_index: int - ) -> None: - """更新音轨按钮显示文本 - - Args: - audio_streams: 音轨列表 - array_index: 数组索引(0, 1, 2...) - """ - if array_index < len(audio_streams): - stream = audio_streams[array_index] - lang = stream.language - text = f"{self.tr('音轨')} {array_index + 1}" - if lang: - text += f" ({lang})" - self.audio_track_button.setText(text) - - def on_audio_track_selected(self, array_index: int, audio_streams: list) -> None: - """音轨选择事件处理 - - Args: - array_index: 数组索引(0, 1, 2...),用于 UI 显示和 ffmpeg -map 0:a:N - audio_streams: 音轨列表 - """ - self.selected_audio_track_index = array_index # 保存数组索引,传给 ffmpeg - self.update_audio_track_button_text(audio_streams, array_index) - - def update_thumbnail(self, thumbnail_path): - """更新视频缩略图""" - if not Path(thumbnail_path).exists(): - thumbnail_path = RESOURCE_PATH / "assets" / "audio-thumbnail.png" - - pixmap = QPixmap(str(thumbnail_path)).scaled( - self.video_thumbnail.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.SmoothTransformation, # type: ignore - ) - self.video_thumbnail.setPixmap(pixmap) - - def setup_signals(self) -> None: - self.start_button.clicked.connect(self.on_start_button_clicked) - self.open_folder_button.clicked.connect(self.on_open_folder_clicked) - - def on_start_button_clicked(self): - """开始转录按钮点击事件""" - self.progress_ring.setValue(0) - self.progress_ring.show() - self.start_button.setDisabled(True) - self.start_transcription() - - def on_open_folder_clicked(self): - """打开文件夹按钮点击事件""" - if self.task and self.task.output_path: - original_subtitle_save_path = Path(str(self.task.output_path)) - target_dir = str( - original_subtitle_save_path.parent - if original_subtitle_save_path.exists() - else Path(str(self.task.file_path)).parent - ) - open_folder(target_dir) - else: - InfoBar.warning( - self.tr("警告"), - self.tr("没有可用的字幕文件夹"), - duration=INFOBAR_DURATION_WARNING, - parent=self, - ) + else: + row.setStyleSheet( + f""" + QFrame#subtitleRow {{ + background: transparent; + border: none; + border-top: 1px solid {palette.line_soft}; + }} + QLabel#subtitleCell {{ color: {palette.muted}; background: transparent; }} + """ + ) + + def syncStyle(self): + super().syncStyle() + palette = app_palette() + self.theadFrame.setStyleSheet( + f""" + QFrame#subtitleThead {{ + background: {palette.panel_deep}; + border: none; + }} + QLabel#subtitleTheadCell {{ + color: {palette.muted}; + background: transparent; + border-right: 1px solid {palette.line_soft}; + }} + """ + ) + self.scrollArea.setStyleSheet( + f""" + QScrollArea {{ background: transparent; border: none; }} + QScrollArea > QWidget > QWidget {{ background: transparent; }} + QScrollBar:vertical {{ + background: transparent; + width: 5px; + margin: 0; + }} + QScrollBar::handle:vertical {{ + background: {palette.line}; + border-radius: 2px; + min-height: 32px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; background: transparent; + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: transparent; + }} + """ + ) + + +class TextPreviewPanel(WorkbenchPanel): + """纯文本结果预览。""" + + replaceRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent, padded=False) + self.header = PanelHeader(tr("transcribe.text_preview.title"), inline=False, parent=self) + self.pill = StatusPill(tr("transcribe.text_preview.done"), "ok", self) + self.header.addRight(self.pill) + self.replaceLink = HeaderLinkButton(tr("transcribe.action.replace_file"), AppIcon.FOLDER_ADD, self) + self.replaceLink.clicked.connect(self.replaceRequested) + self.header.addRight(self.replaceLink) + self.bodyLayout.addWidget(self.header) + body = QWidget(self) + body_layout = QVBoxLayout(body) + body_layout.setContentsMargins(24, 24, 24, 24) + self.textLabel = QLabel(body) + self.textLabel.setObjectName("textPreview") + self.textLabel.setWordWrap(True) + self.textLabel.setAlignment(Qt.AlignTop | Qt.AlignLeft) # type: ignore[arg-type] + body_layout.addWidget(self.textLabel, 1) + self.bodyLayout.addWidget(body, 1) + self.syncStyle() + + def setContent(self, text: str): + palette = app_palette() + lines = [line for line in text.splitlines() if line.strip()] + html = "
".join(lines) + self.textLabel.setText( + f'
{html}
' + ) + + +# --------------------------------------------------------------------------- +# 右侧:参数面板与结果操作面板 +# --------------------------------------------------------------------------- + + +class ParamsPanel(WorkbenchPanel): + """右侧参数面板:服务 / 模型 / 语言 / 音轨 / 输出 / 词时间戳 + 开始按钮。 + + 「服务」选转录提供商;「模型」选该服务下的具体模型, + 没有模型概念的服务(B 接口 / J 接口)会隐藏模型行。 + 选项卡片与字幕处理页保持同一套 OptionCard / PillSelect 组件。 + """ + + startRequested = pyqtSignal() + settingsRequested = pyqtSignal() + collapseRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent, padded=True) + self.header = PanelHeader( + tr("transcribe.params.title"), inline=True, underline=True, parent=self + ) + self.collapseButton = RoundIconButton(AppIcon.RIGHT_ARROW, parent=self) + self.collapseButton.setToolTip(tr("transcribe.params.collapse_tip")) + self.collapseButton.clicked.connect(self.collapseRequested) + self.header.addRight(self.collapseButton) + # 仅失败态展示状态胶囊(“未连通”);提供商已由服务卡片表达。 + self.statusPill = StatusPill("", "fail", self) + self.statusPill.hide() + self.header.addRight(self.statusPill) + self.configButton = RoundIconButton(AppIcon.SETTING, parent=self) + self.configButton.setToolTip(tr("transcribe.config.open_tip")) + self.configButton.clicked.connect(self.settingsRequested) + self.header.addRight(self.configButton) + self.bodyLayout.addWidget(self.header) + + self.serviceSelect = PillSelect(self) + self.modelSelect = PillSelect(self) + self.languageSelect = PillSelect(self) + self.trackSelect = PillSelect(self) + self.outputSelect = PillSelect(self) + self.wordSwitch = ToggleSwitch(True, self) + + self.serviceRow = OptionCard(tr("transcribe.opt.service"), self.serviceSelect, self) + self.modelRow = OptionCard(tr("transcribe.opt.model"), self.modelSelect, self) + self.languageRow = OptionCard(tr("transcribe.opt.language"), self.languageSelect, self) + self.trackRow = OptionCard(tr("transcribe.opt.track"), self.trackSelect, self) + self.outputRow = OptionCard(tr("transcribe.opt.output"), self.outputSelect, self) + self.wordRow = OptionCard(tr("transcribe.opt.word_timestamp"), self.wordSwitch, self) + # 子布局统一间距:模型/音轨行按需隐藏时不会残留多余间距。 + options = QVBoxLayout() + options.setContentsMargins(0, 18, 0, 0) + options.setSpacing(14) + for row in ( + self.serviceRow, + self.modelRow, + self.languageRow, + self.trackRow, + self.outputRow, + self.wordRow, + ): + options.addWidget(row) + self.bodyLayout.addLayout(options) + self.trackRow.hide() + + self.bodyLayout.addStretch(1) + # 底部主按钮 48 高,与字幕处理 / 视频合成页右栏主按钮一致 + self.startButton = WorkbenchButton( + tr("transcribe.btn.waiting_file"), AppIcon.PLAY, primary=False, height=48, parent=self + ) + self.startButton.setEnabled(False) + self.startButton.clicked.connect(self.startRequested) + self.bodyLayout.addSpacing(10) + self.bodyLayout.addWidget(self.startButton) + + def setButtonState(self, text: str, *, icon: AppIcon, primary: bool, enabled: bool): + self.startButton.setText(text) + self.startButton.setIcon(icon) + self.startButton.setPrimary(primary) + self.startButton.setEnabled(enabled) + + +class _ResultFileCard(ClickableFrame): + """结果文件卡片:自绘圆角(QSS 圆角无抗锯齿)。""" - def start_transcription(self, need_create_task=True): - """开始转录过程""" - self.transcription_interface.is_processing = True # type: ignore - self.start_button.setEnabled(False) + def paintEvent(self, event): + palette = app_palette() + draw_rounded_surface(self, palette.panel_deep, palette.line_soft, 14) + super().paintEvent(event) - if need_create_task: - self.task = TaskFactory.create_transcribe_task(self.video_info.file_path) - if not self.task: +class ResultTextLink(QFrame): + """结果区的文字链接:重新转录。""" + + clicked = pyqtSignal() + + def __init__(self, text: str, parent=None): + super().__init__(parent) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + layout = QHBoxLayout(self) + layout.setContentsMargins(2, 0, 2, 5) + layout.setSpacing(7) + self.iconLabel = QLabel(self) + layout.addWidget(self.iconLabel) + self.textLabel = QLabel(text, self) + apply_font(self.textLabel, 16, 820) + layout.addWidget(self.textLabel) + self.syncStyle() + + def syncStyle(self): + palette = app_palette() + self.iconLabel.setPixmap(icon_pixmap(AppIcon.SYNC, palette.accent_text, 17)) + self.textLabel.setStyleSheet( + f"color: {palette.accent_text}; background: transparent; border: none;" + ) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() return + super().mousePressEvent(event) + + def paintEvent(self, event): + super().paintEvent(event) + palette = app_palette() + painter = QPainter(self) + accent = QColor(palette.accent) + accent.setAlphaF(0.75) + painter.fillRect(QRectF(0, self.height() - 1, self.width(), 1), accent) + + +class ResultActionsPanel(WorkbenchPanel): + """右侧结果操作面板:缩略图、结果文件、操作按钮。""" + + openSubtitleRequested = pyqtSignal() + openFolderRequested = pyqtSignal() + openFileRequested = pyqtSignal() + retryRequested = pyqtSignal() + settingsRequested = pyqtSignal() + collapseRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent, padded=True) + self.header = PanelHeader( + tr("transcribe.result.title"), inline=True, underline=True, parent=self + ) + self.collapseButton = RoundIconButton(AppIcon.RIGHT_ARROW, parent=self) + self.collapseButton.setToolTip(tr("transcribe.result.collapse_tip")) + self.collapseButton.clicked.connect(self.collapseRequested) + self.header.addRight(self.collapseButton) + self.statusPill = StatusPill(tr("transcribe.status.done"), "ok", self) + self.header.addRight(self.statusPill) + self.configButton = RoundIconButton(AppIcon.SETTING, parent=self) + self.configButton.setToolTip(tr("transcribe.config.open_tip")) + self.configButton.clicked.connect(self.settingsRequested) + self.header.addRight(self.configButton) + self.bodyLayout.addWidget(self.header) + + self.thumb = MediaThumb(self) + self.thumb.setFixedHeight(132) + self.bodyLayout.addWidget(self.thumb) + self.bodyLayout.addSpacing(16) + + self.titleLabel = AdaptiveTitleLabel(18, 860, 15, self) + self.titleLabel.setObjectName("resultTitle") + self.bodyLayout.addWidget(self.titleLabel) + self.bodyLayout.addSpacing(12) + + self.chipsRow = QHBoxLayout() + self.chipsRow.setSpacing(9) + self.chipsRow.addStretch(1) + self.bodyLayout.addLayout(self.chipsRow) + self.bodyLayout.addSpacing(16) + + self.fileCard = _ResultFileCard(self) + self.fileCard.setObjectName("resultFileCard") + self.fileCard.setToolTip(tr("transcribe.result.open_file_tip")) + self.fileCard.clicked.connect(self.openFileRequested) + file_layout = QVBoxLayout(self.fileCard) + file_layout.setContentsMargins(22, 22, 22, 22) + file_layout.setSpacing(8) + self.fileCardTitle = QLabel(tr("transcribe.result.file"), self.fileCard) + self.fileCardTitle.setObjectName("resultFileTitle") + apply_font(self.fileCardTitle, 19, 840) + file_layout.addWidget(self.fileCardTitle) + self.pathLabel = QLabel(self.fileCard) + self.pathLabel.setObjectName("resultFilePath") + apply_font(self.pathLabel, 14, 720) + file_layout.addWidget(self.pathLabel) + self.bodyLayout.addWidget(self.fileCard) + self.bodyLayout.addSpacing(16) + + self.openSubtitleButton = WorkbenchButton( + tr("transcribe.btn.open_subtitle"), AppIcon.RIGHT_ARROW, primary=True, height=48, parent=self + ) + self.openSubtitleButton.clicked.connect(self.openSubtitleRequested) + self.bodyLayout.addWidget(self.openSubtitleButton) + self.bodyLayout.addSpacing(12) + self.openFolderButton = WorkbenchButton( + tr("common.open_folder"), AppIcon.FOLDER, primary=False, parent=self + ) + self.openFolderButton.clicked.connect(self.openFolderRequested) + self.bodyLayout.addWidget(self.openFolderButton) + self.bodyLayout.addSpacing(16) + self.retryLink = ResultTextLink(tr("transcribe.btn.retranscribe"), self) + self.retryLink.clicked.connect(self.retryRequested) + retry_row = QHBoxLayout() + retry_row.addStretch(1) + retry_row.addWidget(self.retryLink) + retry_row.addStretch(1) + self.bodyLayout.addLayout(retry_row) + self.bodyLayout.addStretch(1) + self.syncStyle() + + def setResult(self, *, title: str, chips: list[str], file_name: str): + self.titleLabel.setFullText(title) + _clear_chip_row(self.chipsRow) + for index, text in enumerate(chips): + self.chipsRow.insertWidget(index, InfoChip(text, self)) + path_metrics = self.pathLabel.fontMetrics() + self.pathLabel.setText( + path_metrics.elidedText(file_name, Qt.ElideMiddle, 238) # type: ignore[arg-type] + ) + self.pathLabel.setToolTip(file_name) + + def syncStyle(self): + super().syncStyle() + palette = app_palette() + self.fileCard.setStyleSheet( + f""" + QFrame#resultFileCard {{ + background: {palette.panel_deep}; + border: 1px solid {palette.line_soft}; + border-radius: 14px; + }} + QLabel#resultFileTitle {{ color: {palette.text}; background: transparent; }} + QLabel#resultFilePath {{ color: {palette.subtle}; background: transparent; }} + """ + ) + self.titleLabel.setStyleSheet( + f"color: {palette.text}; background: transparent; border: none;" + ) - # 将选中的音轨索引作为临时属性传递给 task - self.task.selected_audio_track_index = self.selected_audio_track_index # type: ignore - - self.transcript_thread = TranscriptThread(self.task) - self.transcript_thread.finished.connect(self.on_transcript_finished) - self.transcript_thread.progress.connect(self.on_transcript_progress) - self.transcript_thread.error.connect(self.on_transcript_error) - self.transcript_thread.start() - - def on_transcript_progress(self, value, message): - """更新转录进度""" - self.start_button.setText(message) - self.progress_ring.setValue(value) - - def on_transcript_error(self, error): - """处理转录错误""" - self.transcription_interface.is_processing = False # type: ignore - self.start_button.setEnabled(True) - self.start_button.setText(self.tr("重新转录")) - self.progress_ring.hide() - InfoBar.error( - self.tr("转录失败"), - self.tr(error), - duration=INFOBAR_DURATION_ERROR, - parent=self.parent().parent(), - ) - - def on_transcript_finished(self, task): - """转录完成处理""" - self.start_button.setEnabled(True) - self.start_button.setText(self.tr("转录完成")) - self.progress_ring.hide() - self.finished.emit(task) - - def reset_ui(self): - """重置UI状态""" - self.start_button.setDisabled(False) - self.start_button.setText(self.tr("开始转录")) - self.progress_ring.setValue(0) - self.progress_ring.hide() - - def set_task(self, task): - """设置任务并更新UI""" - self.task = task - self.reset_ui() - def stop(self): - if hasattr(self, "transcript_thread"): - self.transcript_thread.terminate() +# --------------------------------------------------------------------------- +# 页面 +# --------------------------------------------------------------------------- class TranscriptionInterface(QWidget): - """转录界面类,用于显示视频信息和转录进度""" + """语音转录页(单文件工作台)。""" finished = pyqtSignal(str, str) def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) - self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore + self.setObjectName("TranscriptionInterface") + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] self.setAcceptDrops(True) - self.task: Optional[TranscribeTask] = None - self.is_processing: bool = False - - self._init_ui() - self._setup_signals() - self._set_value() - - def _init_ui(self) -> None: - """初始化UI""" - self.main_layout = QVBoxLayout(self) - self.main_layout.setObjectName("main_layout") - self.main_layout.setSpacing(20) - - # 添加命令栏 - self._setup_command_bar() - - self.video_info_card = VideoInfoCard(self) - self.main_layout.addWidget(self.video_info_card) - - # 添加转录设置卡片 - self.transcription_setting_card = TranscriptionSettingCard(self) - self.main_layout.addWidget(self.transcription_setting_card) - - def _setup_command_bar(self): - """设置命令栏""" - self.command_bar = CommandBar(self) - self.command_bar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) # type: ignore - self.command_bar.setFixedHeight(40) - - # 添加打开文件按钮 - self.open_file_action = Action(FluentIcon.FOLDER, self.tr("打开文件")) - self.open_file_action.triggered.connect(self._on_file_select) - self.command_bar.addAction(self.open_file_action) - - self.command_bar.addSeparator() - - # 添加转录模型选择按钮 - self.model_button = TransparentDropDownPushButton( - self.tr("转录模型"), self, FluentIcon.MICROPHONE - ) - self.model_button.setFixedHeight(34) - self.model_button.setMinimumWidth(180) - - self.model_menu = RoundMenu(parent=self) - # 只显示当前平台可用的模型(macOS 上不显示 FasterWhisper) - available_models = get_available_transcribe_models() - for model in available_models: - if ( - model == TranscribeModelEnum.WHISPER_API - or model == TranscribeModelEnum.BIJIAN - or model == TranscribeModelEnum.JIANYING - ): - self.model_menu.addActions( - [ - Action(FluentIcon.GLOBE, model.value), - ] - ) - else: - self.model_menu.addActions( - [ - Action(FluentIcon.ROBOT, model.value), - ] - ) - self.model_button.setMenu(self.model_menu) - self.command_bar.addWidget(self.model_button) - - self.command_bar.addSeparator() - # 添加输出设置按钮 - self.command_bar.addAction( - Action(FluentIcon.SETTING, "", triggered=self._show_output_settings) + self.state = PageState.EMPTY + self.task: Optional[TranscribeTask] = None + self.media_info: Optional[VideoInfo] = None + self._media_path: Optional[str] = None + self._result_path: Optional[Path] = None + self._config_signal_connections: list[tuple] = [] + + self.controller = TranscriptionController(self) + self._build_ui() + self._connect_signals() + self._load_params_from_config() + self.sideHost.setCollapsed( + bool(cfg.transcribe_panel_collapsed.value), animate=False ) + self._apply_state(PageState.EMPTY) - self.main_layout.addWidget(self.command_bar) + def _make_compact_start(self, parent) -> WorkbenchButton: + """折叠态下左侧头部的主操作按钮(与右栏主按钮状态同步)。""" + button = WorkbenchButton( + tr("transcribe.btn.start"), AppIcon.PLAY, primary=True, height=32, parent=parent + ) + button.setMinimumWidth(104) + button.hide() + button.clicked.connect(self._on_compact_primary) + return button - def _setup_signals(self) -> None: - """设置信号连接""" - self.video_info_card.finished.connect(self._on_transcript_finished) + def _make_expand_button(self, parent) -> RoundIconButton: + """折叠态下左侧头部的“展开右栏”入口(位置固定,不随动画漂移)。""" + button = RoundIconButton(AppIcon.LAYOUT, diameter=32, parent=parent) + button.setToolTip(tr("transcribe.params.expand_tip")) + button.hide() + button.clicked.connect(lambda: self.sideHost.setCollapsed(False)) + return button - # 设置模型选择菜单的信号连接 - for action in self.model_menu.actions(): - action.triggered.connect( - lambda checked, text=action.text(): self.on_transcription_model_changed( - text - ) + def _compact_start_buttons(self) -> list[WorkbenchButton]: + return [ + self.emptyCompactStart, + self.fileCompactStart, + self.previewCompactStart, + self.textCompactStart, + ] + + def _expand_buttons(self) -> list[RoundIconButton]: + return [ + self.emptyExpand, + self.fileExpand, + self.previewExpand, + self.textExpand, + ] + + def _on_compact_primary(self): + if self.state == PageState.DONE: + self._on_open_subtitle() + else: + self._on_start_clicked() + + def _on_panel_collapsed(self, collapsed: bool): + if cfg.transcribe_panel_collapsed.value != collapsed: + cfg.set(cfg.transcribe_panel_collapsed, collapsed) + self._sync_compact_start() + + def _sync_compact_start(self): + """折叠态主按钮与当前状态同步(DONE 态为进入字幕优化)。""" + if self.state == PageState.DONE: + text, icon, primary, enabled = ( + tr("transcribe.btn.open_subtitle"), AppIcon.RIGHT_ARROW, True, True, ) - - # 全局信号连接 - signalBus.transcription_model_changed.connect( - self.on_transcription_model_changed + else: + specs = { + PageState.EMPTY: (tr("transcribe.btn.waiting_file"), AppIcon.PLAY, False, False), + PageState.READY: (tr("transcribe.btn.start"), AppIcon.PLAY, True, True), + PageState.RUNNING: (tr("transcribe.btn.running"), AppIcon.SYNC, False, False), + PageState.FAILED: (tr("transcribe.btn.retranscribe"), AppIcon.PLAY, True, True), + } + text, icon, primary, enabled = specs[self.state] + collapsed = self.sideHost.isCollapsed() + show_start = collapsed and self.state != PageState.EMPTY + for button in self._compact_start_buttons(): + button.setText(text) + button.setIcon(icon) + button.setPrimary(primary) + button.setEnabled(enabled) + button.setVisible(show_start) + for button in self._expand_buttons(): + button.setVisible(collapsed) + + # ------------------------------------------------------------------ UI + + def _build_ui(self): + palette = app_palette() + self.setStyleSheet( + f"QWidget#TranscriptionInterface {{ background: {palette.bg}; }}" ) + root = QHBoxLayout(self) + root.setContentsMargins(18, 16, 18, 2) + root.setSpacing(18) + + # 左列:空态 / 当前文件 / SRT 结果 / 纯文本结果 + self.leftStack = QStackedWidget(self) + root.addWidget(self.leftStack, 1) + + # 空态外壳与字幕/合成页同构:56 高标题栏(带分隔线)+ 16 边距拖放区。 + self.emptyPanel = WorkbenchPanel(self, padded=False) + empty_header = PanelHeader( + tr("transcribe.empty.title"), inline=False, parent=self.emptyPanel + ) + self.emptyCompactStart = self._make_compact_start(self.emptyPanel) + empty_header.addRight(self.emptyCompactStart) + self.emptyExpand = self._make_expand_button(self.emptyPanel) + empty_header.addRight(self.emptyExpand) + self.emptyPanel.bodyLayout.addWidget(empty_header) + self.dropZone = DropZone( + icon=AppIcon.VIDEO, + title=tr("transcribe.drop.title"), + pick_text=tr("transcribe.drop.pick"), + pick_icon=AppIcon.FOLDER_ADD, + formats_line="mp4 / mov / mkv / mp3 / wav / m4a", + parent=self.emptyPanel, + ) + drop_host = QWidget(self.emptyPanel) + drop_layout = QVBoxLayout(drop_host) + drop_layout.setContentsMargins(16, 16, 16, 16) + drop_layout.addWidget(self.dropZone) + self.emptyPanel.bodyLayout.addWidget(drop_host, 1) + self.leftStack.addWidget(self.emptyPanel) + + self.filePanel = WorkbenchPanel(self, padded=False) + self.fileHeader = PanelHeader(tr("transcribe.file.title"), inline=False, parent=self.filePanel) + self.filePill = StatusPill(tr("transcribe.status.pending"), "neutral", self.filePanel) + self.fileHeader.addRight(self.filePill) + self.replaceLink = HeaderLinkButton(tr("transcribe.action.replace_file"), AppIcon.FOLDER_ADD, self.filePanel) + self.fileHeader.addRight(self.replaceLink) + # 转录中必须可以取消。 + self.cancelLink = HeaderLinkButton(tr("transcribe.action.cancel"), AppIcon.CANCEL, self.filePanel) + self.fileHeader.addRight(self.cancelLink) + self.cancelLink.hide() + self.fileCompactStart = self._make_compact_start(self.filePanel) + self.fileHeader.addRight(self.fileCompactStart) + self.fileExpand = self._make_expand_button(self.filePanel) + self.fileHeader.addRight(self.fileExpand) + self.filePanel.bodyLayout.addWidget(self.fileHeader) + file_body = QWidget(self.filePanel) + file_body_layout = QVBoxLayout(file_body) + file_body_layout.setContentsMargins(22, 22, 22, 22) + file_body_layout.setSpacing(18) + self.mediaCard = MediaCard(file_body) + file_body_layout.addWidget(self.mediaCard) + self.pendingArea = PendingResultArea(file_body) + file_body_layout.addWidget(self.pendingArea, 1) + self.progressCard = ProgressCard(file_body) + file_body_layout.addWidget(self.progressCard) + self.errorBanner = ErrorCard(title=tr("transcribe.error.title"), parent=file_body) + file_body_layout.addWidget(self.errorBanner) + file_body_layout.addStretch(0) + # 初始即隐藏:QStackedWidget 的最小尺寸取所有页面之和, + # 三个互斥区块同时可见会把整页最小高度撑得过高。 + self.progressCard.hide() + self.errorBanner.hide() + self.filePanel.bodyLayout.addWidget(file_body, 1) + self.leftStack.addWidget(self.filePanel) + + self.subtitlePreview = SubtitlePreviewPanel(self) + self.previewCompactStart = self._make_compact_start(self.subtitlePreview) + self.subtitlePreview.header.addRight(self.previewCompactStart) + self.previewExpand = self._make_expand_button(self.subtitlePreview) + self.subtitlePreview.header.addRight(self.previewExpand) + self.leftStack.addWidget(self.subtitlePreview) + self.textPreview = TextPreviewPanel(self) + self.textCompactStart = self._make_compact_start(self.textPreview) + self.textPreview.header.addRight(self.textCompactStart) + self.textExpand = self._make_expand_button(self.textPreview) + self.textPreview.header.addRight(self.textExpand) + self.leftStack.addWidget(self.textPreview) + + # 右列:参数 / 结果操作 + self.rightStack = QStackedWidget(self) + # 弹性右栏(280-330 自适应)+ 可折叠宿主,折叠状态持久化。 + self.sideHost = CollapsibleSideHost(self.rightStack, 280, 330, self) + root.addWidget(self.sideHost, 1) + self.paramsPanel = ParamsPanel(self) + self.rightStack.addWidget(self.paramsPanel) + self.resultPanel = ResultActionsPanel(self) + self.rightStack.addWidget(self.resultPanel) + + # ------------------------------------------------------------- signals + + def _connect_signals(self): + self.controller.mediaLoaded.connect(self._on_media_loaded) + self.controller.mediaFailed.connect(self._on_media_failed) + self.controller.progressChanged.connect(self._on_progress) + self.controller.transcriptFailed.connect(self._on_transcript_failed) + self.controller.transcriptCompleted.connect(self._on_transcript_completed) + + self.dropZone.browseRequested.connect(self._browse_file) + self.replaceLink.clicked.connect(self._browse_file) + self.cancelLink.clicked.connect(self._cancel_transcription) + self.subtitlePreview.replaceRequested.connect(self._browse_file) + self.textPreview.replaceRequested.connect(self._browse_file) + self.resultPanel.openFileRequested.connect(self._on_open_result_file) + self.paramsPanel.startRequested.connect(self._on_start_clicked) + self.paramsPanel.settingsRequested.connect(self._open_transcribe_settings) + self.paramsPanel.collapseRequested.connect( + lambda: self.sideHost.setCollapsed(True) + ) + self.resultPanel.collapseRequested.connect( + lambda: self.sideHost.setCollapsed(True) + ) + self.sideHost.collapsedChanged.connect(self._on_panel_collapsed) + self.resultPanel.settingsRequested.connect(self._open_transcribe_settings) + self.resultPanel.openSubtitleRequested.connect(self._on_open_subtitle) + self.resultPanel.openFolderRequested.connect(self._on_open_folder) + self.resultPanel.retryRequested.connect(self._restart_transcription) + + self.paramsPanel.serviceSelect.currentTextChanged.connect(self._on_service_selected) + self.paramsPanel.modelSelect.currentTextChanged.connect(self._on_model_selected) + self.paramsPanel.languageSelect.currentTextChanged.connect(self._on_language_selected) + self.paramsPanel.outputSelect.currentTextChanged.connect(self._on_output_selected) + self.paramsPanel.wordSwitch.toggled.connect(self._on_word_timestamp_toggled) + + self._connect_config_signal(cfg.transcribe_model, self._on_service_config_changed) + + def _connect_config_signal(self, option, handler: Callable): + option.valueChanged.connect(handler) + self._config_signal_connections.append((option.valueChanged, handler)) + + def _disconnect_config_signals(self): + for signal, handler in self._config_signal_connections: + try: + signal.disconnect(handler) + except (RuntimeError, TypeError): + pass + self._config_signal_connections.clear() + + # ------------------------------------------------------- config <-> UI + + def _load_params_from_config(self): + services = [_provider_short(model) for model in get_available_transcribe_models()] + self.paramsPanel.serviceSelect.setItems( + services, _provider_short(cfg.transcribe_model.value) + ) + self.paramsPanel.languageSelect.setItems( + enum_options(TranscribeLanguageEnum), + enum_label(cfg.transcribe_language.value), + ) + self.paramsPanel.outputSelect.setItems( + enum_options(TranscribeOutputFormatEnum), + enum_label(cfg.transcribe_output_format.value), + ) + self.paramsPanel.wordSwitch.setChecked(bool(cfg.transcribe_word_timestamp.value)) + self._refresh_service_row() + + def _refresh_service_row(self): + """服务变化后刷新模型行(无模型概念的服务隐藏模型行)。""" + service = cfg.transcribe_model.value + spec = _service_model_spec(service) + if spec is None: + self.paramsPanel.modelRow.hide() + return + options, current = spec + if current and current not in options: + options = [current, *options] + self.paramsPanel.modelRow.show() + self.paramsPanel.modelSelect.setItems(options, current or options[0]) - def _show_output_settings(self): - """显示转录设置对话框""" - dialog = TranscriptionSettingDialog(self.window()) - dialog.exec_() - - def _set_value(self) -> None: - """设置转录模型""" - model_name = cfg.get(cfg.transcribe_model).value - # self.model_button.setText(self.tr(model_name)) - self.on_transcription_model_changed(model_name) - - def on_transcription_model_changed(self, model_name: str): - """处理转录模型改变""" - self.model_button.setText(self.tr(model_name)) - self.transcription_setting_card.on_model_changed(model_name) + def _on_service_selected(self, short_name: str): for model in TranscribeModelEnum: - if model.value == model_name: - cfg.set(cfg.transcribe_model, model) + if _provider_short(model) == short_name: + if cfg.transcribe_model.value != model: + cfg.set(cfg.transcribe_model, model) break + self._refresh_service_row() + + def _on_service_config_changed(self, model: TranscribeModelEnum): + self.paramsPanel.serviceSelect.setCurrentText(_provider_short(model)) + self._refresh_service_row() + + def _on_model_selected(self, model_name: str): + """把模型行的选择写回当前服务对应的配置字段。""" + service = cfg.transcribe_model.value + if service == TranscribeModelEnum.BAILIAN_FUN_ASR: + if cfg.fun_asr_model.value != model_name: + cfg.set(cfg.fun_asr_model, model_name) + elif service == TranscribeModelEnum.WHISPER_API: + if cfg.whisper_api_model.value != model_name: + cfg.set(cfg.whisper_api_model, model_name) + elif service == TranscribeModelEnum.WHISPER_CPP: + for option in WhisperModelEnum: + if option.value == model_name: + if cfg.whisper_model.value != option: + cfg.set(cfg.whisper_model, option) + break + elif service == TranscribeModelEnum.FASTER_WHISPER: + for option in FasterWhisperModelEnum: + if option.value == model_name: + if cfg.faster_whisper_model.value != option: + cfg.set(cfg.faster_whisper_model, option) + break + + def _on_language_selected(self, language_name: str): + language = enum_from_label(TranscribeLanguageEnum, language_name) + if language is not None and cfg.transcribe_language.value != language: + cfg.set(cfg.transcribe_language, language) + + def _on_output_selected(self, format_name: str): + fmt = enum_from_label(TranscribeOutputFormatEnum, format_name) + if fmt is not None and cfg.transcribe_output_format.value != fmt: + cfg.set(cfg.transcribe_output_format, fmt) + + def _on_word_timestamp_toggled(self, checked: bool): + if cfg.transcribe_word_timestamp.value != checked: + cfg.set(cfg.transcribe_word_timestamp, checked) + + # --------------------------------------------------------- state machine + + def _apply_state(self, state: PageState, *, preview_text: bool = False): + self.state = state + if state == PageState.EMPTY: + self.leftStack.setCurrentWidget(self.emptyPanel) + elif state == PageState.DONE: + self.leftStack.setCurrentWidget( + self.textPreview if preview_text else self.subtitlePreview + ) + else: + self.leftStack.setCurrentWidget(self.filePanel) - def _on_transcript_finished(self, task: TranscribeTask): - """转录完成处理""" - self.is_processing = False - if task.need_next_task: - self.finished.emit(task.output_path, task.file_path) + self.rightStack.setCurrentWidget( + self.resultPanel if state == PageState.DONE else self.paramsPanel + ) - InfoBar.success( - self.tr("转录完成"), - self.tr("开始字幕优化..."), - duration=INFOBAR_DURATION_SUCCESS, - position=InfoBarPosition.BOTTOM, - parent=self.parent(), - ) + if state in (PageState.READY, PageState.RUNNING, PageState.FAILED): + pills = { + PageState.READY: (tr("transcribe.status.pending"), "neutral"), + PageState.RUNNING: (tr("transcribe.status.running"), "warn"), + PageState.FAILED: (tr("transcribe.status.failed"), "fail"), + } + self.filePill.setState(*pills[state]) + self.replaceLink.setVisible(state != PageState.RUNNING) + self.cancelLink.setVisible(state == PageState.RUNNING) + self.pendingArea.setVisible(state == PageState.READY) + self.progressCard.setVisible(state == PageState.RUNNING) + self.errorBanner.setVisible(state == PageState.FAILED) + # 就绪态用带分隔线的标题栏 + 190px 缩略图; + # 运行/失败态用无分隔线标题 + 210px 缩略图和运行上下文胶囊。 + self.fileHeader.setInline(state != PageState.READY) + if state == PageState.READY: + self.mediaCard.setThumbSize(190, 106) + if self.media_info is not None: + self.mediaCard.setChips( + self._media_chips( + self.media_info, _is_audio_file(self.media_info.file_path) + ) + ) + else: + self.mediaCard.setThumbSize(210, 118) + self.mediaCard.setChips(self._run_chips()) + + buttons = { + PageState.EMPTY: (tr("transcribe.btn.waiting_file"), AppIcon.PLAY, False, False), + PageState.READY: (tr("transcribe.btn.start"), AppIcon.PLAY, True, True), + PageState.RUNNING: (tr("transcribe.btn.running"), AppIcon.SYNC, False, False), + PageState.FAILED: (tr("transcribe.btn.retranscribe"), AppIcon.PLAY, True, True), + PageState.DONE: (tr("transcribe.btn.start"), AppIcon.PLAY, True, True), + } + text, icon, primary, enabled = buttons[state] + self.paramsPanel.setButtonState(text, icon=icon, primary=primary, enabled=enabled) + + if state == PageState.FAILED: + self.paramsPanel.statusPill.setState(tr("transcribe.status.unreachable"), "fail") + self.paramsPanel.statusPill.show() + else: + self.paramsPanel.statusPill.hide() + self._refresh_service_row() + + # 音轨行只在文件就绪可调(运行/失败状态不显示)。 + self.paramsPanel.trackRow.setVisible( + state == PageState.READY and self.media_info is not None + ) + self._sync_compact_start() - def _on_file_select(self): - """文件选择处理""" - desktop_path = QStandardPaths.writableLocation(QStandardPaths.DesktopLocation) - file_dialog = QFileDialog() + # ----------------------------------------------------------- media flow + def _browse_file(self): + if self.controller.is_transcribing(): + self._warn_processing() + return + desktop = QStandardPaths.writableLocation(QStandardPaths.DesktopLocation) video_formats = " ".join(f"*.{fmt.value}" for fmt in SupportedVideoFormats) audio_formats = " ".join(f"*.{fmt.value}" for fmt in SupportedAudioFormats) - filter_str = f"{self.tr('媒体文件')} ({video_formats} {audio_formats});;{self.tr('视频文件')} ({video_formats});;{self.tr('音频文件')} ({audio_formats})" - - file_path, _ = file_dialog.getOpenFileName( - self, self.tr("选择媒体文件"), desktop_path, filter_str + file_path, _ = QFileDialog.getOpenFileName( + self, + tr("transcribe.dialog.pick_media"), + desktop, + f"{tr('transcribe.dialog.media_filter')} ({video_formats} {audio_formats})", ) if file_path: - self.update_info(file_path) - - def update_info(self, file_path): - """设置UI""" - self.video_info_thread = VideoInfoThread(file_path) - self.video_info_thread.finished.connect(self.video_info_card.update_info) - self.video_info_thread.error.connect(self._on_video_info_error) - self.video_info_thread.start() - - def _on_video_info_error(self, error_msg): - """处理视频信息提取错误""" - self.is_processing = False + self.load_media(file_path) + + def load_media(self, file_path: str): + """加载媒体文件:先占位显示文件名,信息线程完成后填充详情。""" + self._media_path = file_path + self.media_info = None + self.task = None + self.mediaCard.setTitle(Path(file_path).name) + self.mediaCard.setChips([]) + self.mediaCard.thumb.setMedia(None, _is_audio_file(file_path)) + self._apply_state(PageState.READY) + self.controller.load_media(file_path) + + def _on_media_loaded(self, info: VideoInfo): + if self._media_path and info.file_path != self._media_path: + return # 过期线程的结果,忽略 + self.media_info = info + is_audio = _is_audio_file(info.file_path) + self.mediaCard.setTitle(info.file_name) + self.mediaCard.setChips(self._media_chips(info, is_audio)) + self.mediaCard.thumb.setMedia(info.thumbnail_path, is_audio) + tracks = [ + _track_label(index, stream.language) + for index, stream in enumerate(info.audio_streams) + ] or [tr("transcribe.track.first")] + self.paramsPanel.trackSelect.setItems(tracks, tracks[0]) + if self.state == PageState.READY: + self._apply_state(PageState.READY) + elif self.state == PageState.DONE: + # 带下载字幕的任务会“直通完成”,结果先于封面解析出来—— + # 封面到了再回填右栏缩略图 + self.resultPanel.thumb.setMedia(info.thumbnail_path, is_audio) + + def _media_chips(self, info: VideoInfo, is_audio: bool) -> list[str]: + """就绪态胶囊:媒体本身的事实(分辨率 / 时长 / 大小 / 音轨)。""" + chips = [] + if not is_audio and info.width and info.height: + chips.append(f"{info.width} × {info.height}") + if info.duration_seconds: + chips.append(_format_duration(info.duration_seconds)) + if is_audio: + chips.append(_format_file_size(info.file_path)) + if info.audio_streams and not is_audio: + stream = info.audio_streams[0] + chip = tr("transcribe.track.first") + if _track_language(stream.language): + chip += f" ({stream.language})" + chips.append(chip) + return chips + + def _run_chips(self) -> list[str]: + """运行 / 失败态胶囊:本次转录的上下文(时长 / 大小 / 服务 / 格式)。""" + chips = [] + info = self.media_info + is_audio = _is_audio_file(self._media_path) if self._media_path else False + if info and info.duration_seconds: + chips.append(_format_duration(info.duration_seconds)) + if is_audio and info: + chips.append(_format_file_size(info.file_path)) + chips.append(_provider_short(cfg.transcribe_model.value)) + if not is_audio: + chips.append(enum_label(cfg.transcribe_output_format.value)) + return chips + + def _on_media_failed(self, message: str): InfoBar.error( - self.tr("错误"), - self.tr(error_msg), - duration=INFOBAR_DURATION_ERROR, + tr("common.error"), message, duration=INFOBAR_DURATION_ERROR, parent=self + ) + if self.state == PageState.READY and self.media_info is None: + self._apply_state(PageState.EMPTY) + + # ------------------------------------------------------ transcribe flow + + def _on_start_clicked(self): + if self.state in (PageState.READY, PageState.FAILED, PageState.DONE): + self._restart_transcription() + + def _restart_transcription(self): + """从就绪 / 失败 / 完成状态发起(重新)转录,任务按当前参数重建。""" + if not self._media_path: + return + if self.controller.is_transcribing(): + self._warn_processing() + return + self.task = TaskFactory.create_transcribe_task(self._media_path) + if self.task.transcribe_config is not None: + self.task.transcribe_config.need_word_time_stamp = bool( + cfg.transcribe_word_timestamp.value + ) + self._launch_task() + + def _launch_task(self): + assert self.task is not None + track_items = self.paramsPanel.trackSelect.items() + if track_items: + current = self.paramsPanel.trackSelect.currentText() + self.task.selected_audio_track_index = max(0, track_items.index(current)) + self.progressCard.setProgress(0) + # start_transcription 在控制器忙时返回 False;只有真正启动才进 RUNNING,否则 + # 页面显示 RUNNING 但线程在跑上一个任务(A 转录中从任务页投 B 的竞态)。 + if self.controller.start_transcription(self.task): + self._apply_state(PageState.RUNNING) + + def _on_progress(self, value: int, message: str): + self.progressCard.setProgress(value) + self.progressCard.setPhase(message) + + def _cancel_transcription(self): + """取消进行中的转录,回到文件就绪状态(文件保留)。""" + self.controller.cancel_transcription() + self.progressCard.setProgress(0) + self.progressCard.setPhase("") + self._apply_state(PageState.READY) + + def _on_open_result_file(self): + if self._result_path and self._result_path.exists(): + QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._result_path))) + + def _on_transcript_failed(self, message: str): + self.errorBanner.setText(message) + self._apply_state(PageState.FAILED) + + def _on_transcript_completed(self, task: TranscribeTask): + self.task = task + artifact, is_text = _result_artifact(task) + self._result_path = artifact + self._show_result(task, artifact, is_text) + if task.need_next_task and task.output_path: + self.finished.emit(task.output_path, task.file_path) + + def _show_result(self, task: TranscribeTask, artifact: Path, is_text: bool): + if is_text: + content = artifact.read_text(encoding="utf-8") if artifact.exists() else "" + self.textPreview.setContent(content) + else: + self.subtitlePreview.setSegments(self._load_segments(artifact)) + + info = self.media_info + is_audio = _is_audio_file(str(task.file_path)) + chips = [_provider_short(cfg.transcribe_model.value)] + if info and info.duration_seconds: + chips.append(_format_duration(info.duration_seconds)) + chips.append(tr("transcribe.format.txt") if is_text else artifact.suffix.lstrip(".").upper()) + self.resultPanel.thumb.setMedia( + info.thumbnail_path if info else None, is_audio + ) + self.resultPanel.setResult( + title=Path(str(task.file_path)).stem, + chips=chips, + file_name=artifact.name, + ) + self._apply_state(PageState.DONE, preview_text=is_text) + + def _load_segments(self, artifact: Path) -> list[tuple[int, int, str]]: + from videocaptioner.core.asr.asr_data import ASRData + + if not artifact.exists(): + return [] + try: + asr_data = ASRData.from_subtitle_file(str(artifact)) + except Exception: + return [] + return [(seg.start_time, seg.end_time, seg.text) for seg in asr_data.segments] + + # ------------------------------------------------------- result actions + + def _on_open_subtitle(self): + srt_path = Path(str(self.task.output_path)).with_suffix(".srt") if self.task else None + if self.task and srt_path and srt_path.exists(): + self.finished.emit(str(srt_path), str(self.task.file_path)) + return + if self._result_path and self._result_path.suffix != ".txt" and self._result_path.exists(): + self.finished.emit(str(self._result_path), str(self.task.file_path)) + return + InfoBar.warning( + tr("common.tip"), + tr("transcribe.toast.no_subtitle"), + duration=INFOBAR_DURATION_WARNING, parent=self, ) - def set_task(self, task: TranscribeTask) -> None: - """设置任务并更新UI""" + def _on_open_folder(self): + # 已知具体文件时在文件管理器中直接选中它 + target = self._result_path if self._result_path and self._result_path.exists() else None + if target is None and self._media_path: + target = Path(self._media_path) + if target is None: + return + if target.exists(): + reveal_in_explorer(str(target)) + else: + open_folder(str(target.parent)) + + def _open_transcribe_settings(self): + window = self.window() + if hasattr(window, "openSettingsPage"): + window.openSettingsPage("transcribe") # type: ignore[attr-defined] + + def _warn_processing(self): + InfoBar.warning( + tr("common.warning"), + tr("transcribe.toast.processing"), + duration=INFOBAR_DURATION_WARNING, + parent=self, + ) + + # ------------------------------------------------------- external API + + def set_task(self, task: TranscribeTask): + """外部(任务创建流程)注入任务:加载媒体信息并保留任务配置。""" self.task = task - self.video_info_card.set_task(self.task) - self.update_info(self.task.file_path) + self._media_path = str(task.file_path) + self.media_info = None + self.mediaCard.setTitle(Path(self._media_path).name) + self.mediaCard.setChips([]) + self.mediaCard.thumb.setMedia(None, _is_audio_file(self._media_path)) + self._apply_state(PageState.READY) + self.controller.load_media(self._media_path) def process(self): - """主处理函数""" - self.is_processing = True - self.video_info_card.start_transcription(need_create_task=False) + """外部注入任务后直接开始转录(流水线模式)。""" + if self.task is None: + return + self._launch_task() + + # ----------------------------------------------------------- drag&drop def dragEnterEvent(self, event): - """拖拽进入事件处理""" - event.accept() if event.mimeData().hasUrls() else event.ignore() + if event.mimeData().hasUrls(): + self.dropZone.setDragActive(True) + event.accept() + else: + event.ignore() + + def dragLeaveEvent(self, event): + self.dropZone.setDragActive(False) + super().dragLeaveEvent(event) def dropEvent(self, event): - """拖拽放下事件处理""" - if self.is_processing: - InfoBar.warning( - self.tr("警告"), - self.tr("正在处理中,请等待当前任务完成"), - duration=INFOBAR_DURATION_WARNING, - parent=self, - ) + self.dropZone.setDragActive(False) + if self.controller.is_transcribing(): + self._warn_processing() return - - files = [u.toLocalFile() for u in event.mimeData().urls()] - for file_path in files: + for url in event.mimeData().urls(): + file_path = url.toLocalFile() if not os.path.isfile(file_path): continue - - file_ext = os.path.splitext(file_path)[1][1:].lower() - - # 检查文件格式是否支持 - supported_formats = {fmt.value for fmt in SupportedVideoFormats} | { - fmt.value for fmt in SupportedAudioFormats - } - is_supported = file_ext in supported_formats - - if is_supported: - self.update_info(file_path) - InfoBar.success( - self.tr("导入成功"), - self.tr("开始语音转文字"), - duration=INFOBAR_DURATION_SUCCESS, - parent=self, - ) - break - else: - InfoBar.error( - self.tr("格式错误") + file_ext, - self.tr("请拖入音频或视频文件"), - duration=INFOBAR_DURATION_ERROR, - parent=self, - ) + suffix = Path(file_path).suffix.lstrip(".").lower() + if suffix in _MEDIA_FORMATS: + self.load_media(file_path) + return + InfoBar.error( + tr("transcribe.toast.bad_format", suffix=suffix), + tr("transcribe.toast.drop_media"), + duration=INFOBAR_DURATION_ERROR, + parent=self, + ) def closeEvent(self, event): - self.video_info_card.stop() + self._disconnect_config_signals() + self.controller.shutdown() super().closeEvent(event) - - -if __name__ == "__main__": - QApplication.setHighDpiScaleFactorRoundingPolicy( - Qt.HighDpiScaleFactorRoundingPolicy.PassThrough - ) - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) # type: ignore - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) # type: ignore - - app = QApplication(sys.argv) - window = TranscriptionInterface() - window.show() - sys.exit(app.exec_()) diff --git a/videocaptioner/ui/view/video_synthesis_interface.py b/videocaptioner/ui/view/video_synthesis_interface.py index f50776ff..68d4b1ec 100644 --- a/videocaptioner/ui/view/video_synthesis_interface.py +++ b/videocaptioner/ui/view/video_synthesis_interface.py @@ -1,601 +1,1558 @@ # -*- coding: utf-8 -*- +"""字幕视频合成页:组合开关工作台。 + +左侧是输入文件面板(拖放 / 文件清单 / 生成计划 / 结果文件), +右侧是可折叠的「本次生成」栏(输出内容开关 + 参数 + 主按钮)。 + +输出由两个开关组合驱动: + + 字幕视频(cfg.need_video) 需要 字幕 + 视频 + 配音音轨(cfg.dubbing_enabled) 仅需字幕,视频可选(选了则额外产出配音视频) + +生命周期状态(PageState)只有 IDLE / RUNNING / DONE; +IDLE 下的具体呈现(空态 / 缺文件 / 配置缺失 / 可生成)由 _evaluate() +根据开关与输入实时计算,避免组合爆炸。 + +线程统一由 SynthesisController 持有(配音 -> 合成 链式编排在控制器内, +进度按 0-55 / 55-100 映射);对外接口与其它工作台页一致: + finished() / set_task(task) / process() / close() +""" + +from __future__ import annotations import os -import sys +import shutil +from dataclasses import dataclass, field +from enum import Enum, auto from pathlib import Path - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QDropEvent -from PyQt5.QtWidgets import QApplication, QFileDialog, QHBoxLayout, QVBoxLayout, QWidget -from qfluentwidgets import ( - Action, - BodyLabel, - CardWidget, - CommandBar, - InfoBar, - InfoBarPosition, - LineEdit, - PrimaryPushButton, - ProgressBar, - PushButton, - RoundMenu, - ToolTipFilter, - ToolTipPosition, - TransparentDropDownPushButton, +from typing import Callable, Optional + +from PyQt5.QtCore import QObject, Qt, pyqtSignal +from PyQt5.QtWidgets import ( + QFileDialog, + QFrame, + QHBoxLayout, + QLabel, + QScrollArea, + QStackedWidget, + QVBoxLayout, + QWidget, ) -from qfluentwidgets import FluentIcon as FIF -from videocaptioner.core.constant import ( - INFOBAR_DURATION_ERROR, - INFOBAR_DURATION_SUCCESS, - INFOBAR_DURATION_WARNING, -) +from videocaptioner.core.application import output_paths +from videocaptioner.core.dubbing import get_dubbing_preset from videocaptioner.core.entities import ( + DubbingTask, SubtitleRenderModeEnum, SupportedSubtitleFormats, SupportedVideoFormats, SynthesisTask, + VideoInfo, VideoQualityEnum, ) -from videocaptioner.core.utils.platform_utils import open_folder +from videocaptioner.core.subtitle.ass_renderer import ffmpeg_supports_ass_filter +from videocaptioner.core.utils.platform_utils import open_folder, reveal_in_explorer +from videocaptioner.ui.common.app_icons import AppIcon from videocaptioner.ui.common.config import cfg -from videocaptioner.ui.common.signal_bus import signalBus +from videocaptioner.ui.common.dubbing_options import get_provider_voices +from videocaptioner.ui.common.enum_labels import enum_from_label, enum_label, enum_options +from videocaptioner.ui.common.theme_tokens import app_palette, rgba +from videocaptioner.ui.components.workbench import ( + CollapsibleSideHost, + CompactButton, + DropZone, + ElidedLabel, + ErrorCard, + HeaderLinkButton, + IconBox, + MediaThumb, + OptionCard, + PanelHeader, + PillSelect, + ProgressBarLine, + RoundIconButton, + SectionLabel, + StatusPill, + ToggleCard, + ToggleSwitch, + WorkbenchButton, + WorkbenchPanel, + apply_font, + draw_rounded_surface, + file_type_icon, + icon_pixmap, +) +from videocaptioner.ui.i18n import tr from videocaptioner.ui.task_factory import TaskFactory +from videocaptioner.ui.thread.dubbing_thread import DubbingThread +from videocaptioner.ui.thread.video_info_thread import VideoInfoThread from videocaptioner.ui.thread.video_synthesis_thread import VideoSynthesisThread +_SUBTITLE_FORMATS = {fmt.value for fmt in SupportedSubtitleFormats} +_VIDEO_FORMATS = {fmt.value for fmt in SupportedVideoFormats} + +def TEXT_TRACK_LABELS() -> dict[str, str]: + return { + "auto": tr("synth.text_track.auto"), + "first": tr("synth.text_track.first"), + "second": tr("synth.text_track.second"), + } + + +def _voice_labels() -> dict[str, str]: + """当前配音提供商的可选音色(preset -> 标题),与配音页提供商选择联动。""" + return { + voice.preset: voice.title + for voice in get_provider_voices(cfg.dubbing_provider.value) + } + + +def TIMING_LABELS() -> dict[str, str]: + return { + "natural": tr("synth.timing.natural"), + "balanced": tr("synth.timing.balanced"), + "strict": tr("synth.timing.strict"), + } + + +def AUDIO_MODE_LABELS() -> dict[str, str]: + return { + "replace": tr("synth.audio_mode.replace"), + "mix": tr("synth.audio_mode.mix"), + "duck": tr("synth.audio_mode.duck"), + } + + +def SUBTITLE_MODE_LABELS() -> dict[bool, str]: + return {False: tr("synth.subtitle_mode.hard"), True: tr("synth.subtitle_mode.soft")} + + +class PageState(Enum): + IDLE = auto() + RUNNING = auto() + DONE = auto() + + +@dataclass +class Readiness: + """IDLE 状态的呈现计算结果。""" + + view: str # "empty" / "files" + title: str + bottom: str + pill: tuple[str, str] + primary: tuple[str, AppIcon, bool] # 文案 / 图标 / 是否可点 + blocker: str = "" # 右栏错误卡文案(缺 Key / 缺 FFmpeg 等) + plan: list[str] = field(default_factory=list) # 生成计划步骤标题 + + +# --------------------------------------------------------------------------- +# 线程编排 +# --------------------------------------------------------------------------- + + +class SynthesisController(QObject): + """编排配音 / 合成线程;全流程时在控制器内链式执行。 + + 进度映射:全流程 配音 0-55、合成 55-100;单流程 0-100。 + """ + + progressChanged = pyqtSignal(int, str) + completed = pyqtSignal(list) # [(标签, 路径)] + failed = pyqtSignal(str) + + def __init__(self, parent: Optional[QObject] = None): + super().__init__(parent) + self._dubbing_thread: Optional[DubbingThread] = None + self._synthesis_thread: Optional[VideoSynthesisThread] = None + self._chained_synthesis: Optional[SynthesisTask] = None + self._results: list[tuple[str, str]] = [] + self._cancelled = False + + # ----- 启动 ----- + + def start_synthesis_only(self, task: SynthesisTask) -> bool: + if self.is_running(): + return False + self._results = [] + self._chained_synthesis = None + self._cancelled = False + self._start_synthesis(task, offset=0) + return True + + def start_dubbing( + self, task: DubbingTask, chained_synthesis: Optional[SynthesisTask] = None + ) -> bool: + """配音;chained_synthesis 不为空时配音完成后接续视频合成。""" + if self.is_running(): + return False + self._results = [] + self._chained_synthesis = chained_synthesis + self._cancelled = False + thread = DubbingThread(task) + thread.finished.connect(self._on_dubbing_finished) + thread.progress.connect(self._on_dubbing_progress) + thread.error.connect(self.failed) + self._dubbing_thread = thread + thread.start() + return True + + # ----- 内部链路 ----- + + def _start_synthesis(self, task: SynthesisTask, offset: int): + thread = VideoSynthesisThread(task) + thread.finished.connect(self._on_synthesis_finished) + scale = (100 - offset) / 100 + thread.progress.connect( + lambda value, message: self.progressChanged.emit( + int(offset + value * scale), message + ) + ) + thread.error.connect(self.failed) + self._synthesis_thread = thread + thread.start() + + def _on_dubbing_progress(self, value: int, message: str): + if self._chained_synthesis is not None: + value = int(value * 0.55) + self.progressChanged.emit(value, message) + + def _on_dubbing_finished(self, task: DubbingTask): + # 取消后已投递的 queued finished 信号仍会到达,丢弃以免启动已取消的链式合成。 + if self._cancelled: + return + if task.output_audio_path: + self._results.append((tr("synth.result.dubbed_audio"), task.output_audio_path)) + if self._chained_synthesis is not None: + if not task.output_video_path: + self.failed.emit(tr("synth.error.dubbed_video_path_empty")) + return + synthesis = self._chained_synthesis + self._chained_synthesis = None + synthesis.video_path = task.output_video_path + self._start_synthesis(synthesis, offset=55) + return + if task.output_video_path: + self._results.append((tr("synth.result.dubbed_video"), task.output_video_path)) + self.completed.emit(list(self._results)) + + def _on_synthesis_finished(self, task: SynthesisTask): + if self._cancelled: + return + # 链式模式的中间配音视频在任务目录里,随任务目录由页面统一清理。 + if task.output_path: + self._results.insert(0, (tr("synth.result.subtitled_video"), task.output_path)) + self.completed.emit(list(self._results)) + + # ----- 控制 ----- + + def is_running(self) -> bool: + return any( + thread is not None and thread.isRunning() + for thread in (self._dubbing_thread, self._synthesis_thread) + ) + + def cancel(self): + self._cancelled = True + self._chained_synthesis = None + for attr in ("_dubbing_thread", "_synthesis_thread"): + thread = getattr(self, attr) + setattr(self, attr, None) + if thread is not None and thread.isRunning(): + try: + thread.finished.disconnect() + thread.progress.disconnect() + thread.error.disconnect() + except TypeError: + pass + thread.stop() + + def shutdown(self): + for thread in (self._dubbing_thread, self._synthesis_thread): + if thread is not None: + thread.stop() + + +# --------------------------------------------------------------------------- +# 左侧:文件行 / 计划行 / 结果行 / 底部状态条 +# --------------------------------------------------------------------------- + + +class FileStateRow(QFrame): + """输入文件行:名称 + 说明 + 状态胶囊;缺失时虚线边。""" + + clicked = pyqtSignal() + + def __init__(self, label: str, icon: AppIcon = AppIcon.FILE, parent=None): + super().__init__(parent) + self.setObjectName("fileStateRow") + self._missing = True + self._icon = icon + self.setMinimumHeight(56) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 10, 14, 10) + layout.setSpacing(12) + self.iconLabel = QLabel(self) + self.iconLabel.setFixedSize(18, 18) + layout.addWidget(self.iconLabel) + self.nameLabel = QLabel(label, self) + self.nameLabel.setObjectName("fileRowName") + self.nameLabel.setFixedWidth(76) + apply_font(self.nameLabel, 14, 850) + layout.addWidget(self.nameLabel) + self.detailLabel = ElidedLabel("", self) + self.detailLabel.setObjectName("fileRowDetail") + apply_font(self.detailLabel, 14, 720) + layout.addWidget(self.detailLabel, 1) + self.pill = StatusPill("", "warn", self) + layout.addWidget(self.pill) + self.syncStyle() + + def setName(self, name: str): + self.nameLabel.setText(name) + + def setState(self, detail: str, pill_text: str, level: str, missing: bool): + self.detailLabel.setText(detail) + self.pill.setState(pill_text, level) + if missing != self._missing: + self._missing = missing + self.update() + self.syncStyle() + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: # type: ignore[attr-defined] + self.clicked.emit() + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + from PyQt5.QtCore import QRectF + from PyQt5.QtGui import QPainter, QPainterPath, QPen + + from videocaptioner.ui.components.workbench import to_qcolor + + palette = app_palette() + surface = palette.card_surface + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + path = QPainterPath() + path.addRoundedRect(rect, 12, 12) + painter.fillPath(path, to_qcolor(surface)) + pen = QPen(to_qcolor(palette.line_soft), 1) + if self._missing: + pen.setStyle(Qt.DashLine) # type: ignore[attr-defined] + painter.setPen(pen) + painter.drawPath(path) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + detail_color = palette.subtle if self._missing else palette.muted + self.iconLabel.setPixmap( + icon_pixmap(self._icon, palette.subtle if self._missing else palette.muted, 18) + ) + self.setStyleSheet( + f""" + QFrame#fileStateRow {{ background: transparent; border: none; }} + QLabel#fileRowName {{ color: {palette.text}; background: transparent; }} + QLabel#fileRowDetail {{ color: {detail_color}; background: transparent; }} + """ + ) + + +class PlanStepRow(QFrame): + """生成计划行:图标 + 步骤名 + 状态胶囊。""" + + def __init__(self, icon: AppIcon, title: str, parent=None): + super().__init__(parent) + self.setObjectName("planStepRow") + self._icon = icon + self.setMinimumHeight(56) + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 10, 14, 10) + layout.setSpacing(12) + self.iconBox = IconBox(self._icon, self, size=32, tone="accent") + layout.addWidget(self.iconBox) + self.titleLabel = QLabel(title, self) + self.titleLabel.setObjectName("planTitle") + apply_font(self.titleLabel, 15, 820) + layout.addWidget(self.titleLabel, 1) + self.pill = StatusPill(tr("synth.plan.pending"), "neutral", self) + layout.addWidget(self.pill) + self.syncStyle() + + def setTitle(self, title: str): + self.titleLabel.setText(title) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.iconBox.syncStyle() + self.setStyleSheet( + f""" + QFrame#planStepRow {{ background: transparent; border: none; }} + QLabel#planTitle {{ color: {palette.text}; background: transparent; }} + """ + ) + + +class SynthesisBottomBar(QFrame): + """底部状态条:提示文案 +(运行中)进度条 + 状态胶囊。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("synthesisBottomBar") + self.setFixedHeight(40) + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 0, 14, 0) + layout.setSpacing(14) + # 消息文本用弹性省略吸收长度变化,进度条与胶囊右锚定; + # 百分比胶囊固定最小宽("100%"),位数变化不再让整行抖动 + self.messageLabel = ElidedLabel("", self) + self.messageLabel.setObjectName("bottomMessage") + apply_font(self.messageLabel, 14, 730) + layout.addWidget(self.messageLabel, 1) + self.progressLine = ProgressBarLine(self) + self.progressLine.setFixedWidth(320) + self.progressLine.hide() + layout.addWidget(self.progressLine) + self.pill = StatusPill("", "warn", self) + self.pill.setMinimumWidth(66) + layout.addWidget(self.pill) + self.syncStyle() + + def setState(self, message: str, pill_text: str, level: str, progress: int = -1): + self.messageLabel.setText(message) + self.pill.setState(pill_text, level) + self.progressLine.setVisible(progress >= 0) + if progress >= 0: + self.progressLine.setValue(progress) + + def syncStyle(self): + palette = app_palette() + self.setStyleSheet( + f""" + QFrame#synthesisBottomBar {{ + background: transparent; + border: none; + border-top: 1px solid {palette.line_soft}; + }} + QLabel#bottomMessage {{ color: {palette.muted}; background: transparent; }} + """ + ) + + +class ResultFileRow(QFrame): + """结果文件行:名称 + 元信息 + 完成胶囊,点击打开。""" + + clicked = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("resultFileRow") + self._path = "" + self.setMinimumHeight(58) + self.setCursor(Qt.PointingHandCursor) # type: ignore[arg-type] + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 10, 14, 10) + layout.setSpacing(14) + self._icon = AppIcon.FILE + self.iconBox = QFrame(self) + self.iconBox.setObjectName("resultIconBox") + self.iconBox.setFixedSize(34, 34) + icon_layout = QVBoxLayout(self.iconBox) + icon_layout.setContentsMargins(0, 0, 0, 0) + self.iconLabel = QLabel(self.iconBox) + self.iconLabel.setAlignment(Qt.AlignCenter) # type: ignore[arg-type] + icon_layout.addWidget(self.iconLabel) + layout.addWidget(self.iconBox) + column = QVBoxLayout() + column.setSpacing(4) + self.nameLabel = ElidedLabel("", self) + self.nameLabel.setObjectName("resultRowName") + apply_font(self.nameLabel, 15, 840) + column.addWidget(self.nameLabel) + self.metaLabel = QLabel(self) + self.metaLabel.setObjectName("resultRowMeta") + apply_font(self.metaLabel, 13, 760) + column.addWidget(self.metaLabel) + layout.addLayout(column, 1) + self.pill = StatusPill(tr("synth.status.done"), "ok", self) + layout.addWidget(self.pill) + self.syncStyle() + + def setResult(self, label: str, path: str): + self._path = path + file = Path(path) + self._icon = file_type_icon(path) + self.syncStyle() + self.nameLabel.setText(file.name) + suffix = file.suffix.lstrip(".").upper() + size = "" + if file.exists(): + size = f" · {max(1, file.stat().st_size // 1024)} KB" + if file.stat().st_size >= 1024 * 1024: + size = f" · {file.stat().st_size / 1024 / 1024:.1f} MB" + self.metaLabel.setText(f"{label} · {suffix}{size}") + self.setToolTip(path) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton and self._path: # type: ignore[attr-defined] + self.clicked.emit(self._path) + event.accept() + return + super().mousePressEvent(event) + + def paintEvent(self, event): + palette = app_palette() + surface = palette.card_surface + draw_rounded_surface(self, surface, palette.line_soft, 12) + super().paintEvent(event) + + def syncStyle(self): + palette = app_palette() + self.iconLabel.setPixmap(icon_pixmap(self._icon, palette.muted, 17)) + self.setStyleSheet( + f""" + QFrame#resultFileRow {{ background: transparent; border: none; }} + QFrame#resultIconBox {{ + background: {palette.control}; + border: none; + border-radius: 10px; + }} + QLabel#resultRowName {{ color: {palette.text}; background: transparent; }} + QLabel#resultRowMeta {{ color: {palette.subtle}; background: transparent; }} + """ + ) + + +def _section_label(text: str, parent=None) -> QLabel: + return SectionLabel(text, parent) + + +# --------------------------------------------------------------------------- +# 右侧:本次生成面板 +# --------------------------------------------------------------------------- + + +class GeneratePanel(WorkbenchPanel): + """右侧「本次生成」:输出内容开关 + 字幕/配音参数 + 主按钮。""" + + settingsRequested = pyqtSignal() + collapseRequested = pyqtSignal() + voiceLibraryRequested = pyqtSignal() + styleLibraryRequested = pyqtSignal() + primaryRequested = pyqtSignal() + cancelRequested = pyqtSignal() + openFolderRequested = pyqtSignal() + + def __init__(self, parent=None): + # 自管内边距:滚动区横向通到面板边,滚动条落在右侧留白带里, + # 不再贴着参数卡;头部 / 参数卡 / 底部按钮仍按 22 对齐。 + super().__init__(parent, padded=False) + self.bodyLayout.setContentsMargins(0, 22, 0, 22) + self.bodyLayout.setSpacing(0) + + self.header = PanelHeader( + tr("synth.panel.title"), inline=True, underline=True, parent=self + ) + # 直径与转录/字幕页右栏头部按钮一致(默认 34) + self.collapseButton = RoundIconButton(AppIcon.RIGHT_ARROW, parent=self) + self.collapseButton.setToolTip(tr("synth.tip.collapse_panel")) + self.collapseButton.clicked.connect(self.collapseRequested) + self.header.addRight(self.collapseButton) + self.configButton = RoundIconButton(AppIcon.SETTING, parent=self) + self.configButton.setToolTip(tr("synth.tip.open_config")) + self.configButton.clicked.connect(self.settingsRequested) + self.header.addRight(self.configButton) + head_wrap = QVBoxLayout() + head_wrap.setContentsMargins(22, 0, 22, 0) + head_wrap.addWidget(self.header) + self.bodyLayout.addLayout(head_wrap) + + body = QVBoxLayout() + # 顶部 18 / 卡片间距 14:与转录、字幕处理页右栏参数区一致; + # 右 12 + 滚动条列 10 = 22,参数卡与头部 / 底部按钮对齐; + # 底部留白:滚动到底时最后一张参数卡不贴视口边(否则像被截断) + body.setContentsMargins(22, 18, 12, 12) + body.setSpacing(14) + + body.addWidget(_section_label(tr("synth.section.output"), self)) + self.subtitleCard = ToggleCard( + tr("synth.output.subtitle_video"), tr("synth.output.subtitle_video_desc"), parent=self + ) + body.addWidget(self.subtitleCard) + self.dubbingCard = ToggleCard( + tr("synth.output.dubbing"), tr("synth.output.dubbing_desc"), parent=self + ) + body.addWidget(self.dubbingCard) + + self.errorCard = ErrorCard(parent=self) + self.errorCard.hide() + body.addWidget(self.errorCard) + + # 字幕视频参数 + self.subtitleSection = QWidget(self) + subtitle_layout = QVBoxLayout(self.subtitleSection) + subtitle_layout.setContentsMargins(0, 6, 0, 0) + subtitle_layout.setSpacing(14) + subtitle_layout.addWidget(_section_label(tr("synth.section.subtitle_params"), self)) + self.subtitleModeSelect = PillSelect(self) + subtitle_layout.addWidget(OptionCard(tr("synth.opt.subtitle_mode"), self.subtitleModeSelect, self)) + self.styleSwitch = ToggleSwitch(parent=self) + self.styleCard = OptionCard(tr("synth.opt.subtitle_style"), self.styleSwitch, self) + subtitle_layout.addWidget(self.styleCard) + self.styleCard.hide() + self.renderModeSelect = PillSelect(self) + self.renderModeCard = OptionCard(tr("synth.opt.render_mode"), self.renderModeSelect, self) + subtitle_layout.addWidget(self.renderModeCard) + style_control = QWidget(self) + style_row = QHBoxLayout(style_control) + style_row.setContentsMargins(0, 0, 0, 0) + style_row.setSpacing(8) + self.stylePageLink = HeaderLinkButton(tr("synth.link.style_page"), AppIcon.SUBTITLE, self) + self.stylePageLink.clicked.connect(self.styleLibraryRequested) + style_row.addWidget(self.stylePageLink) + style_row.addStretch(1) + self.stylePageCard = OptionCard(tr("synth.opt.style"), style_control, self) + subtitle_layout.addWidget(self.stylePageCard) + self.qualitySelect = PillSelect(self) + self.qualityCard = OptionCard(tr("synth.opt.video_quality"), self.qualitySelect, self) + subtitle_layout.addWidget(self.qualityCard) + body.addWidget(self.subtitleSection) + + # 配音参数 + self.dubbingSection = QWidget(self) + dubbing_layout = QVBoxLayout(self.dubbingSection) + dubbing_layout.setContentsMargins(0, 6, 0, 0) + dubbing_layout.setSpacing(14) + dubbing_layout.addWidget(_section_label(tr("synth.section.dubbing_params"), self)) + voice_control = QWidget(self) + voice_row = QHBoxLayout(voice_control) + voice_row.setContentsMargins(0, 0, 0, 0) + voice_row.setSpacing(8) + self.voiceSelect = PillSelect(self) + voice_row.addWidget(self.voiceSelect) + self.voiceLibraryLink = HeaderLinkButton(tr("synth.link.voice_library"), AppIcon.MUSIC, self) + self.voiceLibraryLink.clicked.connect(self.voiceLibraryRequested) + voice_row.addWidget(self.voiceLibraryLink) + dubbing_layout.addWidget(OptionCard(tr("synth.opt.voice"), voice_control, self)) + self.textTrackSelect = PillSelect(self) + dubbing_layout.addWidget(OptionCard(tr("synth.opt.text_track"), self.textTrackSelect, self)) + self.timingSelect = PillSelect(self) + self.timingCard = OptionCard(tr("synth.opt.timing"), self.timingSelect, self) + dubbing_layout.addWidget(self.timingCard) + self.audioModeSelect = PillSelect(self) + self.audioModeCard = OptionCard(tr("synth.opt.audio_mode"), self.audioModeSelect, self) + dubbing_layout.addWidget(self.audioModeCard) + body.addWidget(self.dubbingSection) + body.addStretch(1) + + # 参数区可滚动:两组输出全开时参数较多,矮窗口下不挤掉主按钮 + body_host = QWidget(self) + body_host.setLayout(body) + self.bodyScroll = QScrollArea(self) + self.bodyScroll.setWidgetResizable(True) + self.bodyScroll.setFrameShape(QFrame.NoFrame) + self.bodyScroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # type: ignore[arg-type] + self.bodyScroll.setWidget(body_host) + self.bodyLayout.addWidget(self.bodyScroll, 1) + + # 底部:分隔线 + 操作按钮,左右 22 与头部对齐 + bottom = QVBoxLayout() + bottom.setContentsMargins(22, 0, 22, 0) + bottom.setSpacing(0) + # 滚动区与按钮区之间留间隔 + 分隔线(仅参数溢出可滚时出现), + # 否则滚动中被视口裁切的参数卡看起来像被按钮盖住 + bottom.addSpacing(10) + self.scrollDivider = QFrame(self) + self.scrollDivider.setObjectName("scrollDivider") + self.scrollDivider.setFixedHeight(1) + self.scrollDivider.hide() + self.bodyScroll.verticalScrollBar().rangeChanged.connect( + lambda _min, max_value: self.scrollDivider.setVisible(max_value > 0) + ) + bottom.addWidget(self.scrollDivider) + bottom.addSpacing(10) + + self.cancelButton = WorkbenchButton(tr("common.cancel"), AppIcon.CANCEL, parent=self) + self.cancelButton.clicked.connect(self.cancelRequested) + self.cancelButton.hide() + bottom.addWidget(self.cancelButton) + bottom.addSpacing(10) + self.openFolderButton = WorkbenchButton( + tr("common.open_folder"), AppIcon.FOLDER, parent=self + ) + self.openFolderButton.clicked.connect(self.openFolderRequested) + self.openFolderButton.hide() + bottom.addWidget(self.openFolderButton) + bottom.addSpacing(10) + self.primaryButton = WorkbenchButton( + tr("synth.btn.wait_files"), AppIcon.FILE, primary=False, height=48, parent=self + ) + self.primaryButton.setEnabled(False) + self.primaryButton.clicked.connect(self.primaryRequested) + bottom.addWidget(self.primaryButton) + self.bodyLayout.addLayout(bottom) + self.syncStyle() + + def setError(self, message: str): + self.errorCard.setText(message) + self.errorCard.setVisible(bool(message)) + + def setButton(self, text: str, *, icon: AppIcon, primary: bool, enabled: bool): + self.primaryButton.setText(text) + self.primaryButton.setIcon(icon) + self.primaryButton.setPrimary(primary) + self.primaryButton.setEnabled(enabled) + + def syncStyle(self): + super().syncStyle() + palette = app_palette() + if hasattr(self, "errorCard"): + self.errorCard.syncStyle() + if hasattr(self, "scrollDivider"): + self.scrollDivider.setStyleSheet( + f"background: {palette.line_soft}; border: none;" + ) + if hasattr(self, "bodyScroll"): + # 滚动条规则必须并在 QScrollArea 自己的样式表里:macOS 上只给 + # QScrollBar 子控件设样式时仍走 transient 浮层模式(不占布局空间), + # 滚动条会盖在参数卡右缘上;并入后强制占位模式,对齐才成立。 + self.bodyScroll.setStyleSheet( + f""" + QScrollArea {{ background: transparent; border: none; }} + QScrollBar:vertical {{ + background: transparent; width: 10px; margin: 2px 3px; + }} + QScrollBar::handle:vertical {{ + background: {rgba(palette.muted, 0.32)}; + border-radius: 2px; min-height: 28px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: transparent; + }} + """ + ) + self.bodyScroll.widget().setStyleSheet("background: transparent;") + + +# --------------------------------------------------------------------------- +# 页面 +# --------------------------------------------------------------------------- + class VideoSynthesisInterface(QWidget): + """字幕视频合成页(组合开关工作台)。""" + finished = pyqtSignal() - def __init__(self, parent=None): + def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setObjectName("VideoSynthesisInterface") - self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore - self.setAcceptDrops(True) # 启用拖放功能 - self.setup_ui() - self.setup_style() - self.set_value() - self.setup_signals() - self.task = None - - self.installEventFilter(ToolTipFilter(self, 100, ToolTipPosition.BOTTOM)) - - def setup_ui(self): - self.main_layout = QVBoxLayout(self) - self.main_layout.setSpacing(20) - - # 创建顶部布局 - top_layout = QHBoxLayout() - - # 添加顶部命令栏 - self.command_bar = CommandBar(self) - self.command_bar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) # type: ignore - top_layout.addWidget(self.command_bar, 1) # 设置stretch为1,使其尽可能占用空间 - - # 设置命令栏 - self._setup_command_bar() - - # 添加开始合成按钮到水平布局 - self.synthesize_button = PrimaryPushButton( - self.tr("开始合成"), self, icon=FIF.PLAY - ) - self.synthesize_button.setFixedHeight(34) - top_layout.addWidget(self.synthesize_button) - - self.main_layout.addLayout(top_layout) - - # 配置卡片 - self.config_card = CardWidget(self) - self.config_layout = QVBoxLayout(self.config_card) - self.config_layout.setContentsMargins(20, 20, 20, 20) - self.config_layout.setSpacing(20) - - # 字幕文件选择 - self.subtitle_layout = QHBoxLayout() - self.subtitle_layout.setSpacing(15) - self.subtitle_label = BodyLabel(self.tr("字幕文件"), self) - self.subtitle_input = LineEdit(self) - self.subtitle_input.setPlaceholderText(self.tr("选择或者拖拽字幕文件")) - self.subtitle_input.setAcceptDrops(True) # 启用拖放 - self.subtitle_button = PushButton(self.tr("浏览")) - self.subtitle_layout.addWidget(self.subtitle_label) - self.subtitle_layout.addWidget(self.subtitle_input) - self.subtitle_layout.addWidget(self.subtitle_button) - self.config_layout.addLayout(self.subtitle_layout) - - # 视频文件选择 - self.video_layout = QHBoxLayout() - self.video_layout.setSpacing(15) - self.video_label = BodyLabel(self.tr("视频文件"), self) - self.video_input = LineEdit(self) - self.video_input.setPlaceholderText(self.tr("选择或者拖拽视频文件")) - self.video_input.setAcceptDrops(True) # 启用拖放 - self.video_button = PushButton(self.tr("浏览")) - self.video_layout.addWidget(self.video_label) - self.video_layout.addWidget(self.video_input) - self.video_layout.addWidget(self.video_button) - self.config_layout.addLayout(self.video_layout) - - self.main_layout.addWidget(self.config_card) - - self.main_layout.addStretch(1) - - # 底部进度条和状态信息 - self.bottom_layout = QHBoxLayout() - self.progress_bar = ProgressBar(self) - self.status_label = BodyLabel(self.tr("就绪"), self) - self.status_label.setMinimumWidth(100) # 设置最小宽度 - self.status_label.setAlignment(Qt.AlignCenter) # type: ignore # 设置文本居中对齐 - self.bottom_layout.addWidget(self.progress_bar, 1) # 进度条使用剩余空间 - self.bottom_layout.addWidget(self.status_label) # 状态标签使用固定宽度 - self.main_layout.addLayout(self.bottom_layout) - - def _setup_command_bar(self): - """设置顶部命令栏""" - # 添加软字幕选项 - self.soft_subtitle_action = Action( - FIF.FONT, - self.tr("软字幕"), - triggered=self.on_soft_subtitle_action_triggered, - checkable=True, - ) - self.soft_subtitle_action.setToolTip(self.tr("使用软字幕嵌入视频")) - self.command_bar.addAction(self.soft_subtitle_action) - - # 添加分隔符 - self.command_bar.addSeparator() - - # 添加使用样式开关 - self.use_style_action = Action( - FIF.PALETTE, - self.tr("使用样式"), - triggered=self.on_use_style_action_triggered, - checkable=True, - ) - self.use_style_action.setToolTip(self.tr("启用字幕样式渲染")) - self.command_bar.addAction(self.use_style_action) - - self.command_bar.addSeparator() - - # 添加渲染模式下拉按钮 - self.render_mode_button = TransparentDropDownPushButton( - self.tr("渲染模式"), self, FIF.FONT_SIZE - ) - self.render_mode_button.setFixedHeight(34) - self.render_mode_button.setMinimumWidth(140) - self.render_mode_menu = RoundMenu(parent=self) - for mode in SubtitleRenderModeEnum: - action = Action(text=mode.value) - action.triggered.connect( - lambda checked, m=mode.value: self.on_render_mode_changed(m) + self.setAttribute(Qt.WA_StyledBackground, True) # type: ignore[arg-type] + self.setAcceptDrops(True) + + self.state = PageState.IDLE + self.subtitle_path: Optional[str] = None + self.video_path: Optional[str] = None + self.task: Optional[SynthesisTask] = None + self._results: list[tuple[str, str]] = [] + self._active_task_dir: Optional[str] = None + self._pipeline_task_dir: Optional[str] = None + self._config_signal_connections: list[tuple] = [] + + self.controller = SynthesisController(self) + self._info_thread: Optional[VideoInfoThread] = None + self._build_ui() + self._connect_signals() + self._load_options_from_config() + self._refresh() + if cfg.synthesis_panel_collapsed.value: + self.sideHost.setCollapsed(True, animate=False) + self._sync_collapsed_controls() + + # ------------------------------------------------------------------ UI + + def _build_ui(self): + palette = app_palette() + self.setStyleSheet( + f"QWidget#VideoSynthesisInterface {{ background: {palette.bg}; }}" + ) + root = QHBoxLayout(self) + root.setContentsMargins(18, 16, 18, 2) + root.setSpacing(18) + + # 左:输入 / 计划 / 结果面板 + self.workspace = WorkbenchPanel(self, padded=False) + self.header = PanelHeader(tr("synth.title.input"), inline=False, parent=self.workspace) + self.subtitleButton = CompactButton(tr("synth.btn.pick_subtitle"), AppIcon.FOLDER_ADD, self) + self.header.addRight(self.subtitleButton) + self.videoButton = CompactButton(tr("synth.btn.pick_video"), AppIcon.FOLDER_ADD, self) + self.header.addRight(self.videoButton) + self.headStartButton = WorkbenchButton( + tr("synth.btn.generate_full"), AppIcon.PLAY, primary=True, height=32, parent=self + ) + self.headStartButton.setMinimumWidth(104) + self.headStartButton.hide() + self.header.addRight(self.headStartButton) + self.expandButton = RoundIconButton(AppIcon.LAYOUT, diameter=32, parent=self) + self.expandButton.setToolTip(tr("synth.tip.expand_panel")) + self.expandButton.hide() + self.header.addRight(self.expandButton) + self.workspace.bodyLayout.addWidget(self.header) + + self.stack = QStackedWidget(self) + + # 空态拖放 + self.dropZone = DropZone( + icon=AppIcon.VIDEO, + title=tr("synth.drop.title"), + pick_text=tr("synth.drop.pick"), + pick_icon=AppIcon.FOLDER_ADD, + formats_line=tr("synth.drop.formats"), + parent=self, + ) + drop_host = QWidget(self) + drop_layout = QVBoxLayout(drop_host) + drop_layout.setContentsMargins(16, 16, 16, 16) + drop_layout.addWidget(self.dropZone) + self.stack.addWidget(drop_host) + + # 文件清单 + 生成计划 + inputs_host = QWidget(self) + inputs_layout = QVBoxLayout(inputs_host) + inputs_layout.setContentsMargins(16, 16, 16, 16) + inputs_layout.setSpacing(10) + self.subtitleRow = FileStateRow(tr("synth.file.subtitle"), AppIcon.SUBTITLE, self) + inputs_layout.addWidget(self.subtitleRow) + self.videoRow = FileStateRow(tr("synth.file.video"), AppIcon.VIDEO, self) + inputs_layout.addWidget(self.videoRow) + inputs_layout.addSpacing(8) + self.planSteps: list[PlanStepRow] = [ + PlanStepRow(AppIcon.VOLUME, tr("synth.plan.dubbing"), self), + PlanStepRow(AppIcon.SUBTITLE, tr("synth.plan.synthesize"), self), + PlanStepRow(AppIcon.FOLDER, tr("synth.plan.save"), self), + ] + for step in self.planSteps: + inputs_layout.addWidget(step) + inputs_layout.addStretch(1) + self.stack.addWidget(inputs_host) + + # 结果视图 + done_host = QWidget(self) + done_layout = QVBoxLayout(done_host) + done_layout.setContentsMargins(16, 16, 16, 16) + done_layout.setSpacing(12) + # contain:结果预览要完整看到成片画面,不能像小卡片那样铺满裁切 + self.resultThumb = MediaThumb(self, fit="contain") + self.resultThumb.setMinimumHeight(240) + done_layout.addWidget(self.resultThumb, 1) + self.resultRows = [ResultFileRow(self), ResultFileRow(self)] + for row in self.resultRows: + row.hide() + done_layout.addWidget(row) + self.stack.addWidget(done_host) + + self.workspace.bodyLayout.addWidget(self.stack, 1) + self.bottomBar = SynthesisBottomBar(self) + self.workspace.bodyLayout.addWidget(self.bottomBar) + root.addWidget(self.workspace, 1) + + # 右:本次生成(可折叠) + self.generatePanel = GeneratePanel(self) + self.sideHost = CollapsibleSideHost(self.generatePanel, parent=self) + root.addWidget(self.sideHost, 1) + + # ------------------------------------------------------------- signals + + def _connect_signals(self): + self.controller.progressChanged.connect(self._on_progress) + self.controller.completed.connect(self._on_completed) + self.controller.failed.connect(self._on_failed) + + self.dropZone.browseRequested.connect(self._browse_any) + self.subtitleButton.clicked.connect(self._browse_subtitle) + self.videoButton.clicked.connect(self._browse_video) + self.subtitleRow.clicked.connect(self._browse_subtitle) + self.videoRow.clicked.connect(self._browse_video) + self.headStartButton.clicked.connect(self._on_primary) + self.expandButton.clicked.connect(lambda: self.sideHost.setCollapsed(False)) + + panel = self.generatePanel + panel.primaryRequested.connect(self._on_primary) + panel.cancelRequested.connect(self._cancel) + panel.openFolderRequested.connect(self._open_result_folder) + panel.settingsRequested.connect(self._open_synthesis_settings) + panel.voiceLibraryRequested.connect(self._open_voice_library) + panel.styleLibraryRequested.connect(self._open_subtitle_style_library) + panel.collapseRequested.connect(lambda: self.sideHost.setCollapsed(True)) + self.sideHost.collapsedChanged.connect(self._on_panel_collapsed) + for row in self.resultRows: + row.clicked.connect(self._open_file) + + panel.subtitleCard.toggled.connect( + lambda checked: self._set_config_bool(cfg.need_video, checked) + ) + panel.dubbingCard.toggled.connect( + lambda checked: self._set_config_bool(cfg.dubbing_enabled, checked) + ) + panel.subtitleModeSelect.currentTextChanged.connect(self._on_subtitle_mode) + panel.renderModeSelect.currentTextChanged.connect(self._on_render_mode) + panel.qualitySelect.currentTextChanged.connect(self._on_quality) + panel.voiceSelect.currentTextChanged.connect(self._on_voice) + panel.textTrackSelect.currentTextChanged.connect( + lambda text: self._set_label_config(cfg.dubbing_text_track, TEXT_TRACK_LABELS(), text) + ) + panel.timingSelect.currentTextChanged.connect( + lambda text: self._set_label_config(cfg.dubbing_timing, TIMING_LABELS(), text) + ) + panel.audioModeSelect.currentTextChanged.connect( + lambda text: self._set_label_config(cfg.dubbing_audio_mode, AUDIO_MODE_LABELS(), text) + ) + + self._connect_config_signal(cfg.need_video, self._on_outputs_changed) + self._connect_config_signal(cfg.dubbing_enabled, self._on_outputs_changed) + + def _connect_config_signal(self, option, handler: Callable): + option.valueChanged.connect(handler) + self._config_signal_connections.append((option.valueChanged, handler)) + + def _disconnect_config_signals(self): + for signal, handler in self._config_signal_connections: + try: + signal.disconnect(handler) + except (RuntimeError, TypeError): + pass + self._config_signal_connections.clear() + + # ------------------------------------------------------- config <-> UI + + def _load_options_from_config(self): + panel = self.generatePanel + panel.subtitleCard.setChecked(bool(cfg.need_video.value)) + panel.dubbingCard.setChecked(bool(cfg.dubbing_enabled.value)) + subtitle_mode_labels = SUBTITLE_MODE_LABELS() + panel.subtitleModeSelect.setItems( + list(subtitle_mode_labels.values()), + subtitle_mode_labels[bool(cfg.soft_subtitle.value)], + ) + panel.renderModeSelect.setItems( + enum_options(SubtitleRenderModeEnum), + enum_label(cfg.subtitle_render_mode.value), + ) + panel.qualitySelect.setItems( + enum_options(VideoQualityEnum), + enum_label(cfg.video_quality.value), + ) + self._sync_voice_options() + text_track_labels = TEXT_TRACK_LABELS() + panel.textTrackSelect.setItems( + list(text_track_labels.values()), + text_track_labels.get(cfg.dubbing_text_track.value, text_track_labels["auto"]), + ) + timing_labels = TIMING_LABELS() + panel.timingSelect.setItems( + list(timing_labels.values()), + timing_labels.get(cfg.dubbing_timing.value, timing_labels["balanced"]), + ) + audio_mode_labels = AUDIO_MODE_LABELS() + panel.audioModeSelect.setItems( + list(audio_mode_labels.values()), + audio_mode_labels.get(cfg.dubbing_audio_mode.value, audio_mode_labels["replace"]), + ) + + def showEvent(self, event): + super().showEvent(event) + # 从配音页回来时提供商 / 音色可能已变,重新同步音色选项 + self._sync_voice_options() + + def _set_config_bool(self, option, checked: bool): + if option.value != checked: + cfg.set(option, checked) + + @staticmethod + def _set_label_config(option, labels: dict, label: str): + for key, value in labels.items(): + if value == label: + if option.value != key: + cfg.set(option, key) + return + + def _on_outputs_changed(self, _value=None): + self.generatePanel.subtitleCard.setChecked(bool(cfg.need_video.value)) + self.generatePanel.dubbingCard.setChecked(bool(cfg.dubbing_enabled.value)) + if self.state == PageState.DONE: + # 完成后调整输出选择即视为准备下一次生成 + self.state = PageState.IDLE + self._refresh() + + def _on_subtitle_mode(self, label: str): + soft = label == SUBTITLE_MODE_LABELS()[True] + self._set_config_bool(cfg.soft_subtitle, soft) + self._refresh_param_locks() + + def _on_render_mode(self, label: str): + mode = enum_from_label(SubtitleRenderModeEnum, label) + if mode is not None and cfg.subtitle_render_mode.value != mode: + cfg.set(cfg.subtitle_render_mode, mode) + + def _on_quality(self, label: str): + quality = enum_from_label(VideoQualityEnum, label) + if quality is not None and cfg.video_quality.value != quality: + cfg.set(cfg.video_quality, quality) + + def _sync_voice_options(self): + labels = _voice_labels() + current = labels.get(cfg.dubbing_preset.value) or next( + iter(labels.values()), "" + ) + self.generatePanel.voiceSelect.setItems(list(labels.values()), current) + + def _on_voice(self, label: str): + for preset, title in _voice_labels().items(): + if title == label: + if cfg.dubbing_preset.value != preset: + cfg.set(cfg.dubbing_preset, preset) + option = get_dubbing_preset(preset) + if option is not None: + cfg.set(cfg.dubbing_voice, option.voice) + self._refresh() + return + + def _refresh_param_locks(self): + """渲染模式仅硬字幕可调;软字幕由播放器决定样式,整行连同样式入口一起隐藏。""" + hard_subtitle = not cfg.soft_subtitle.value + self.generatePanel.renderModeSelect.setEnabled(hard_subtitle) + self.generatePanel.renderModeCard.setVisible(hard_subtitle) + self.generatePanel.stylePageCard.setVisible(hard_subtitle) + + # --------------------------------------------------------- state engine + + def _evaluate(self) -> Readiness: + """根据开关与输入计算 IDLE 的呈现。""" + add_subtitle = bool(cfg.need_video.value) + add_dubbing = bool(cfg.dubbing_enabled.value) + has_subtitle = bool(self.subtitle_path) + has_video = bool(self.video_path) + + plan = [] + if add_dubbing: + plan.append(tr("synth.plan.dubbing")) + if add_subtitle: + plan.append(tr("synth.plan.synthesize")) + plan.append(tr("synth.plan.save")) + + if not add_subtitle and not add_dubbing: + return Readiness( + view="files" if has_subtitle else "empty", + title=tr("synth.title.input"), + bottom=tr("synth.hint.pick_output"), + pill=(tr("synth.pill.no_output"), "warn"), + primary=(tr("synth.btn.pick_output"), AppIcon.SETTING, False), ) - self.render_mode_menu.addAction(action) - self.render_mode_button.setMenu(self.render_mode_menu) - self.command_bar.addWidget(self.render_mode_button) - - self.command_bar.addSeparator() - - # 添加视频质量选择下拉按钮 - self.video_quality_button = TransparentDropDownPushButton( - self.tr("视频质量"), self, FIF.SPEED_HIGH - ) - self.video_quality_button.setFixedHeight(34) - self.video_quality_button.setMinimumWidth(125) - self.video_quality_menu = RoundMenu(parent=self) - for quality in VideoQualityEnum: - action = Action(text=quality.value) - action.triggered.connect( - lambda checked, q=quality.value: self.on_video_quality_action_changed(q) + if not has_subtitle: + need_both = add_subtitle + return Readiness( + view="empty", + title=tr("synth.title.input"), + bottom=tr("synth.hint.need_subtitle_and_video") + if need_both + else tr("synth.hint.need_subtitle"), + pill=(tr("synth.btn.wait_files"), "warn"), + primary=(tr("synth.btn.wait_files"), AppIcon.FILE, False), ) - self.video_quality_menu.addAction(action) - self.video_quality_button.setMenu(self.video_quality_menu) - self.command_bar.addWidget(self.video_quality_button) - - # 添加分隔符 - self.command_bar.addSeparator() - - # 添加是否合成视频选项 - self.need_video_action = Action( - FIF.VIDEO, - self.tr("合成视频"), - triggered=self.on_need_video_action_triggered, - checkable=True, - ) - self.need_video_action.setToolTip(self.tr("是否生成新的视频文件")) - self.command_bar.addAction(self.need_video_action) - - self.command_bar.addSeparator() - - # 添加打开文件夹按钮 - folder_action = Action(FIF.FOLDER, "", triggered=self.open_video_folder) - folder_action.setToolTip(self.tr("打开输出文件夹")) - self.command_bar.addAction(folder_action) - - def setup_style(self): - self.subtitle_input.focusOutEvent = lambda e: super( - LineEdit, self.subtitle_input - ).focusOutEvent(e) - self.subtitle_input.paintEvent = lambda e: super( - LineEdit, self.subtitle_input - ).paintEvent(e) - self.subtitle_input.setStyleSheet( - self.subtitle_input.styleSheet() - + """ - QLineEdit { - border-radius: 15px; - padding: 0 20px; - background-color: transparent; - border: 1px solid rgba(255,255, 255, 0.08); - } - QLineEdit:focus[transparent=true] { - border: 1px solid rgba(47,141, 99, 0.48); - } - """ - ) - - self.video_input.focusOutEvent = lambda e: super( - LineEdit, self.video_input - ).focusOutEvent(e) - self.video_input.paintEvent = lambda e: super( - LineEdit, self.video_input - ).paintEvent(e) - self.video_input.setStyleSheet( - self.video_input.styleSheet() - + """ - QLineEdit { - border-radius: 15px; - padding: 0 20px; - background-color: transparent; - border: 1px solid rgba(255,255, 255, 0.08); - } - QLineEdit:focus[transparent=true] { - border: 1px solid rgba(47,141, 99, 0.48); - } - """ - ) - - def setup_signals(self): - # 文件选择相关信号 - self.subtitle_button.clicked.connect(self.choose_subtitle_file) - self.video_button.clicked.connect(self.choose_video_file) - - # 合成和文件夹相关信号 - self.synthesize_button.clicked.connect( - lambda: self.start_video_synthesis(need_create_task=True) - ) - - # 全局 signalBus - signalBus.soft_subtitle_changed.connect(self.on_soft_subtitle_changed) - signalBus.need_video_changed.connect(self.on_need_video_changed) - signalBus.video_quality_changed.connect(self.on_video_quality_changed) - signalBus.use_subtitle_style_changed.connect(self.on_use_style_changed) - signalBus.subtitle_render_mode_changed.connect(self.on_render_mode_changed_external) - - def set_value(self): - """设置初始值""" - self.soft_subtitle_action.setChecked(cfg.soft_subtitle.value) - self.need_video_action.setChecked(cfg.need_video.value) - self.video_quality_button.setText(cfg.video_quality.value.value) - - # 设置样式相关初始值 - self.use_style_action.setChecked(cfg.use_subtitle_style.value) - self.render_mode_button.setText(cfg.subtitle_render_mode.value.value) - self._update_synthesis_controls_state() - - def on_soft_subtitle_action_triggered(self, checked: bool): - """处理软字幕按钮点击(更新配置+显示InfoBar)""" - cfg.set(cfg.soft_subtitle, checked) - - # 显示说明信息 - if checked: - # 开启软字幕时自动关闭使用样式 - if self.use_style_action.isChecked(): - self.use_style_action.setChecked(False) - cfg.set(cfg.use_subtitle_style, False) - self._update_style_controls_state() - InfoBar.info( - self.tr("开启软字幕"), - self.tr("字幕作为独立轨道嵌入视频,不包含字幕样式"), - duration=3000, - position=InfoBarPosition.BOTTOM, - parent=self, + if add_subtitle and not has_video: + return Readiness( + view="files", + title=tr("synth.title.input"), + bottom=tr("synth.hint.need_video"), + pill=(tr("synth.pill.missing_video"), "warn"), + primary=(tr("synth.btn.wait_video"), AppIcon.VIDEO, False), + plan=plan, ) - else: - InfoBar.info( - self.tr("开启硬烧录字幕"), - self.tr("字幕直接烧录到视频画面中,包含字幕样式"), - duration=3000, - position=InfoBarPosition.BOTTOM, - parent=self, + + blocker = self._preflight_blocker(add_dubbing) + if blocker: + return Readiness( + view="files", + title=tr("synth.title.config_check"), + bottom=blocker[0], + pill=blocker[1], + primary=(self._primary_text(add_subtitle, add_dubbing), AppIcon.PLAY, False), + blocker=blocker[2], + plan=plan, ) - def on_soft_subtitle_changed(self, checked: bool): - """处理外部软字幕配置变更(仅更新UI状态)""" - self.soft_subtitle_action.setChecked(checked) - - def on_need_video_action_triggered(self, checked: bool): - """处理视频合成按钮点击(更新配置+显示InfoBar)""" - cfg.set(cfg.need_video, checked) - self._update_synthesis_controls_state() - - # 显示说明信息 - if checked: - InfoBar.info( - self.tr("开启视频合成"), - self.tr("将进行视频与字幕的合成操作"), - duration=3000, - position=InfoBarPosition.BOTTOM, - parent=self, + bottoms = { + (True, True): tr("synth.hint.dub_then_synthesize"), + (True, False): tr("synth.hint.synthesize_only"), + (False, True): tr("synth.hint.dub_only"), + } + return Readiness( + view="files", + title=tr("synth.title.confirm"), + bottom=bottoms[(add_subtitle, add_dubbing)], + pill=(tr("synth.pill.ready"), "ok"), + primary=(self._primary_text(add_subtitle, add_dubbing), AppIcon.PLAY, True), + plan=plan, + ) + + def _primary_text(self, add_subtitle: bool, add_dubbing: bool) -> str: + if add_subtitle and add_dubbing: + return tr("synth.btn.generate_full") + if add_dubbing: + return tr("synth.btn.generate_audio") + return tr("synth.btn.generate_subtitle_video") + + def _preflight_blocker(self, add_dubbing: bool) -> Optional[tuple]: + """返回 (底部文案, (胶囊文案, 等级), 错误卡文案);通过返回 None。""" + if not shutil.which("ffmpeg"): + return ( + tr("synth.blocker.ffmpeg_missing"), + (tr("synth.pill.missing_ffmpeg"), "fail"), + tr("synth.blocker.ffmpeg_missing_detail"), ) - else: - InfoBar.info( - self.tr("关闭视频合成"), - self.tr("仅生成字幕文件,不生成新的视频文件"), - duration=3000, - position=InfoBarPosition.BOTTOM, - parent=self, + if add_dubbing and not shutil.which("ffprobe"): + # pydub 读 mp3 段(Edge 默认输出)经 ffprobe,缺了会在任务中途裸崩 + return ( + tr("synth.blocker.ffprobe_missing"), + (tr("synth.pill.missing_ffprobe"), "fail"), + tr("synth.blocker.ffprobe_missing_detail"), ) + if add_dubbing: + provider = cfg.dubbing_provider.value + if provider != "edge" and not cfg.dubbing_api_key.value.strip(): + return ( + tr("synth.blocker.key_required"), + (tr("synth.pill.missing_key"), "fail"), + tr("synth.blocker.key_required_detail"), + ) + return None - def on_need_video_changed(self, checked: bool): - """处理外部视频合成配置变更(仅更新UI状态)""" - self.need_video_action.setChecked(checked) - self._update_synthesis_controls_state() - - def on_video_quality_action_changed(self, quality_text: str): - """处理质量选择""" - # 根据文本找到对应的枚举 - quality_enum = None - for e in VideoQualityEnum: - if e.value == quality_text: - quality_enum = e - break - - if quality_enum is None: + def _refresh(self): + """IDLE 状态的统一刷新入口。""" + if self.state != PageState.IDLE: return - - cfg.set(cfg.video_quality, quality_enum) - self.video_quality_button.setText(quality_text) - - def on_video_quality_changed(self, quality_text: str): - """处理外部质量配置变更(仅更新UI状态)""" - self.video_quality_button.setText(quality_text) - - def on_use_style_action_triggered(self, checked: bool): - """处理使用样式开关点击""" - cfg.set(cfg.use_subtitle_style, checked) - self._update_style_controls_state() - - if checked: - # 启用样式时自动关闭软字幕 - if self.soft_subtitle_action.isChecked(): - self.soft_subtitle_action.setChecked(False) - cfg.set(cfg.soft_subtitle, False) - InfoBar.info( - self.tr("启用字幕样式"), - self.tr("已自动切换为硬字幕渲染"), - duration=3000, - position=InfoBarPosition.BOTTOM, - parent=self, + readiness = self._evaluate() + self.header.setTitle(readiness.title) + self.stack.setCurrentIndex(0 if readiness.view == "empty" else 1) + if readiness.view == "files": + self._refresh_file_rows() + self._refresh_plan(readiness.plan) + # 空态与转录/字幕页一致:不显示底部状态条(拖放区已说明一切) + self.bottomBar.setVisible(readiness.view != "empty") + self.bottomBar.setState(readiness.bottom, *readiness.pill) + text, icon, enabled = readiness.primary + self.generatePanel.setButton(text, icon=icon, primary=enabled, enabled=enabled) + self.generatePanel.setError(readiness.blocker) + self.generatePanel.cancelButton.hide() + self.generatePanel.openFolderButton.hide() + self.subtitleButton.show() + self.videoButton.show() + self.videoButton.textLabel.setText( + tr("synth.btn.optional_video") + if cfg.dubbing_enabled.value and not cfg.need_video.value + else tr("synth.btn.pick_video") + ) + # 参数组只跟输出开关走:开了哪个输出就显示哪组全部参数, + # 不再按文件就绪度/双开收敛隐藏(参数区可滚动,不怕长)。 + # 例外:配音配置缺失(缺 Key)时配音组只留音色行,先解决配置。 + add_subtitle = bool(cfg.need_video.value) + add_dubbing = bool(cfg.dubbing_enabled.value) + blocked = bool(readiness.blocker) + panel = self.generatePanel + panel.subtitleSection.setVisible(add_subtitle) + panel.dubbingSection.setVisible(add_dubbing) + panel.styleCard.hide() + panel.qualityCard.setVisible(add_subtitle) + show_full_dubbing = add_dubbing and not blocked + panel.textTrackSelect.parentWidget().setVisible(show_full_dubbing) + panel.timingCard.setVisible(show_full_dubbing) + panel.audioModeCard.setVisible(show_full_dubbing) + self._refresh_param_locks() + self._sync_collapsed_controls() + + def _refresh_file_rows(self): + add_subtitle = bool(cfg.need_video.value) + if self.subtitle_path: + self.subtitleRow.setState( + Path(self.subtitle_path).name, tr("synth.status.ready"), "ok", missing=False ) else: - InfoBar.info( - self.tr("关闭字幕样式"), - self.tr("将使用默认字幕渲染"), - duration=3000, - position=InfoBarPosition.BOTTOM, - parent=self, - ) - - def on_use_style_changed(self, checked: bool): - """处理外部使用样式配置变更(仅更新 UI)""" - self.use_style_action.setChecked(checked) - self._update_style_controls_state() - - def on_render_mode_changed(self, mode_text: str): - """处理渲染模式选择(本界面触发)""" - mode_enum = None - for e in SubtitleRenderModeEnum: - if e.value == mode_text: - mode_enum = e - break - if mode_enum: - cfg.set(cfg.subtitle_render_mode, mode_enum) - self.render_mode_button.setText(mode_text) - signalBus.subtitle_render_mode_changed.emit(mode_text) - - def on_render_mode_changed_external(self, mode_text: str): - """处理外部渲染模式变更(仅更新 UI)""" - self.render_mode_button.setText(mode_text) - - def _update_synthesis_controls_state(self): - """更新所有合成相关控件的启用/禁用状态""" - need_video = self.need_video_action.isChecked() - - # 合成视频关闭时,禁用所有相关选项 - self.soft_subtitle_action.setEnabled(need_video) - self.use_style_action.setEnabled(need_video) - self.video_quality_button.setEnabled(need_video) - - # 渲染模式按钮需要同时满足:合成视频开启 且 使用样式开启 - self._update_style_controls_state() - - def _update_style_controls_state(self): - """更新样式相关控件的启用/禁用状态""" - need_video = self.need_video_action.isChecked() - use_style = self.use_style_action.isChecked() - # 渲染模式按钮:需要合成视频开启 且 使用样式开启 - self.render_mode_button.setEnabled(need_video and use_style) - - def choose_subtitle_file(self): - # 构建文件过滤器 - subtitle_formats = " ".join( - f"*.{fmt.value}" for fmt in SupportedSubtitleFormats - ) - filter_str = f"{self.tr('字幕文件')} ({subtitle_formats})" - - file_path, _ = QFileDialog.getOpenFileName( - self, self.tr("选择字幕文件"), "", filter_str - ) - if file_path: - self.subtitle_input.setText(file_path) - - def choose_video_file(self): - # 构建文件过滤器 - video_formats = " ".join(f"*.{fmt.value}" for fmt in SupportedVideoFormats) - filter_str = f"{self.tr('视频文件')} ({video_formats})" - - file_path, _ = QFileDialog.getOpenFileName( - self, self.tr("选择视频文件"), "", filter_str - ) - if file_path: - self.video_input.setText(file_path) - - def create_task(self): - subtitle_file = self.subtitle_input.text() - video_file = self.video_input.text() - if not subtitle_file or not video_file: - InfoBar.error( - self.tr("错误"), - self.tr("请选择字幕文件和视频文件"), - duration=INFOBAR_DURATION_ERROR, - position=InfoBarPosition.TOP, - parent=self, + self.subtitleRow.setState( + tr("synth.row.subtitle_required"), + tr("synth.status.missing"), + "warn", + missing=True, ) - return None - return TaskFactory.create_synthesis_task(video_file, subtitle_file) - - def set_task(self, task: SynthesisTask): - self.task = task - self.update_info() - - def update_info(self): - if self.task: - self.video_input.setText(self.task.video_path) - self.subtitle_input.setText(self.task.subtitle_path) - - def start_video_synthesis(self, need_create_task=True): - self.synthesize_button.setEnabled(False) - self.progress_bar.resume() - self.progress_bar.reset() - if need_create_task: - self.task = self.create_task() - - if self.task: - self.video_synthesis_thread = VideoSynthesisThread(self.task) - self.video_synthesis_thread.finished.connect( - self.on_video_synthesis_finished + self.videoRow.setName( + tr("synth.file.video") if add_subtitle else tr("synth.file.reference_video") + ) + if self.video_path: + self.videoRow.setState( + Path(self.video_path).name, tr("synth.status.ready"), "ok", missing=False ) - self.video_synthesis_thread.progress.connect( - self.on_video_synthesis_progress + elif add_subtitle: + self.videoRow.setState( + tr("synth.row.video_required"), + tr("synth.status.missing"), + "warn", + missing=True, ) - self.video_synthesis_thread.error.connect(self.on_video_synthesis_error) - self.video_synthesis_thread.start() else: - self.synthesize_button.setEnabled(True) + self.videoRow.setState( + tr("synth.row.video_optional"), + tr("synth.status.optional"), + "neutral", + missing=True, + ) - def process(self): - self.start_video_synthesis(need_create_task=False) - - def on_video_synthesis_finished(self, task): - self.synthesize_button.setEnabled(True) - self.progress_bar.setValue(100) - self.open_video_folder() - InfoBar.success( - self.tr("成功"), - self.tr("视频合成已完成"), - duration=INFOBAR_DURATION_SUCCESS, - position=InfoBarPosition.TOP, - parent=self, + def _refresh_plan(self, plan: list[str], statuses: Optional[list[tuple]] = None): + """生成计划行:默认全部待生成;运行中由 statuses 指定。""" + for index, step in enumerate(self.planSteps): + if index < len(plan): + step.setTitle(plan[index]) + step.show() + if statuses and index < len(statuses): + step.pill.setState(*statuses[index]) + else: + step.pill.setState(tr("synth.plan.pending"), "neutral") + else: + step.hide() + + def _sync_collapsed_controls(self): + collapsed = self.sideHost.isCollapsed() + self.expandButton.setVisible(collapsed) + enabled = ( + self.state == PageState.IDLE and self.generatePanel.primaryButton.isEnabled() ) + button = self.headStartButton + button.setText(self.generatePanel.primaryButton.text()) + button.setPrimary(enabled) + button.setEnabled(enabled) + button.setVisible(collapsed and self.state == PageState.IDLE and enabled) - def on_video_synthesis_progress(self, progress, message): - self.progress_bar.setValue(progress) - self.status_label.setText(message) + def _on_panel_collapsed(self, collapsed: bool): + if cfg.synthesis_panel_collapsed.value != collapsed: + cfg.set(cfg.synthesis_panel_collapsed, collapsed) + self._sync_collapsed_controls() - def on_video_synthesis_error(self, error): - self.synthesize_button.setEnabled(True) - self.progress_bar.error() - InfoBar.error( - self.tr("错误"), - str(error), - duration=INFOBAR_DURATION_ERROR, - position=InfoBarPosition.TOP, - parent=self, + # ------------------------------------------------------------ file flow + + def _browse_subtitle(self): + if self.controller.is_running(): + return + formats = " ".join(f"*.{fmt}" for fmt in sorted(_SUBTITLE_FORMATS)) + path, _ = QFileDialog.getOpenFileName( + self, tr("synth.dialog.pick_subtitle"), "", f"{tr('synth.file.subtitle')} ({formats})" ) + if path: + self.set_subtitle_file(path) - def open_video_folder(self): - if self.task and self.task.output_path: - file_path = Path(self.task.output_path) - target_dir = str( - file_path.parent - if file_path.exists() - else ( - Path(str(self.task.video_path)).parent - if self.task.video_path - else file_path.parent - ) + def _browse_video(self): + if self.controller.is_running(): + return + formats = " ".join(f"*.{fmt}" for fmt in sorted(_VIDEO_FORMATS)) + path, _ = QFileDialog.getOpenFileName( + self, tr("synth.dialog.pick_video"), "", f"{tr('synth.file.video')} ({formats})" + ) + if path: + self.set_video_file(path) + + def _browse_any(self): + formats = " ".join( + f"*.{fmt}" for fmt in sorted(_SUBTITLE_FORMATS | _VIDEO_FORMATS) + ) + paths, _ = QFileDialog.getOpenFileNames( + self, tr("synth.dialog.pick_media"), "", f"{tr('synth.file.media')} ({formats})" + ) + for path in paths: + self._dispatch_file(path) + + def _dispatch_file(self, path: str) -> bool: + suffix = Path(path).suffix.lstrip(".").lower() + if suffix in _SUBTITLE_FORMATS: + self.set_subtitle_file(path) + return True + if suffix in _VIDEO_FORMATS: + self.set_video_file(path) + return True + return False + + def set_subtitle_file(self, path: str): + self.subtitle_path = path + if self.state == PageState.DONE: + self.state = PageState.IDLE + self._refresh() + + def set_video_file(self, path: str): + self.video_path = path + if self.state == PageState.DONE: + self.state = PageState.IDLE + self._refresh() + + # ------------------------------------------------------------- run flow + + def _on_primary(self): + if self.state == PageState.DONE: + self.state = PageState.IDLE # 重新生成 + if self.state != PageState.IDLE: + return + readiness = self._evaluate() + if not readiness.primary[2]: + self._refresh() + return + add_subtitle = bool(cfg.need_video.value) + add_dubbing = bool(cfg.dubbing_enabled.value) + + # ASS 滤镜检查放在点击时做(探测有进程开销,不适合放实时 evaluate)。 + if ( + add_subtitle + and not cfg.soft_subtitle.value + and cfg.subtitle_render_mode.value == SubtitleRenderModeEnum.ASS_STYLE + and not ffmpeg_supports_ass_filter() + ): + self.generatePanel.setError( + tr("synth.error.ass_unsupported") + ) + return + + # 首页流水线注入的任务目录只消费一次(含转录/字幕中间产物,收尾一并清理) + pipeline_task_dir, self._pipeline_task_dir = self._pipeline_task_dir, None + + if add_dubbing and add_subtitle: + # 配音+字幕链式:共享一个任务目录,配音视频是其中的中间产物。 + task_dir = pipeline_task_dir or TaskFactory.new_task_dir(self.video_path, "synthesis") + temp_video = str( + Path(task_dir) / output_paths.DUBBING_DIR / f"dubbed{Path(self.video_path).suffix}" ) - # Cross-platform folder opening - open_folder(target_dir) + synthesis = TaskFactory.create_synthesis_task( + self.video_path, self.subtitle_path, task_dir=task_dir, dubbed=True + ) + dubbing = TaskFactory.create_dubbing_task( + self.video_path, + self.subtitle_path, + output_video_path=temp_video, + task_dir=task_dir, + ) + self._active_task_dir = task_dir + started = self.controller.start_dubbing(dubbing, chained_synthesis=synthesis) + elif add_dubbing: + dubbing = TaskFactory.create_dubbing_task( + self.video_path or "", + self.subtitle_path, + task_dir=pipeline_task_dir, + ) + self._active_task_dir = dubbing.task_dir + started = self.controller.start_dubbing(dubbing) else: - InfoBar.warning( - self.tr("警告"), - self.tr("没有可用的视频文件夹"), - duration=INFOBAR_DURATION_WARNING, - position=InfoBarPosition.TOP, - parent=self, + self.task = TaskFactory.create_synthesis_task( + self.video_path, self.subtitle_path, task_dir=pipeline_task_dir ) - - def dragEnterEvent(self, event): - """拖拽进入事件处理""" - event.accept() if event.mimeData().hasUrls() else event.ignore() - - def dropEvent(self, event: QDropEvent): - """拖拽放下事件处理""" - files = [u.toLocalFile() for u in event.mimeData().urls()] - for file_path in files: - if not os.path.isfile(file_path): - continue - - file_ext = os.path.splitext(file_path)[1][1:].lower() - - # 检查文件格式是否支持 - if file_ext in {fmt.value for fmt in SupportedSubtitleFormats}: - self.subtitle_input.setText(file_path) - InfoBar.success( - self.tr("导入成功"), - self.tr("字幕文件已放入输入框"), - duration=INFOBAR_DURATION_SUCCESS, - parent=self, - ) - break - elif file_ext in {fmt.value for fmt in SupportedVideoFormats}: - self.video_input.setText(file_path) - InfoBar.success( - self.tr("导入成功"), - self.tr("视频文件已输入框"), - duration=INFOBAR_DURATION_SUCCESS, - parent=self, - ) - break + self._active_task_dir = pipeline_task_dir + started = self.controller.start_synthesis_only(self.task) + if started: + self._enter_running() + + def _enter_running(self): + self.state = PageState.RUNNING + self.header.setTitle(tr("synth.title.running")) + self.stack.setCurrentIndex(1) + self.bottomBar.show() + self._refresh_file_rows() + plan = [] + if cfg.dubbing_enabled.value: + plan.append(tr("synth.plan.dubbing")) + if cfg.need_video.value: + plan.append(tr("synth.plan.synthesize")) + plan.append(tr("synth.plan.collect")) + self._refresh_plan(plan, [(tr("synth.status.waiting"), "neutral")] * len(plan)) + self.bottomBar.setState( + tr("synth.status.generating"), tr("synth.status.processing"), "warn", progress=0 + ) + self.generatePanel.setButton( + tr("synth.status.processing"), icon=AppIcon.SYNC, primary=False, enabled=False + ) + self.generatePanel.setError("") + self.generatePanel.cancelButton.show() + self.generatePanel.openFolderButton.hide() + self.subtitleButton.hide() + self.videoButton.hide() + self._sync_collapsed_controls() + + def _on_progress(self, value: int, message: str): + if self.state != PageState.RUNNING: + return + self.bottomBar.setState( + message or tr("synth.status.generating"), + f"{value}%", + "warn", + progress=value, + ) + # 计划行状态跟随整体进度(全流程 0-55 配音 / 55-100 合成)。 + dubbing_on = bool(cfg.dubbing_enabled.value) + subtitle_on = bool(cfg.need_video.value) + if dubbing_on and subtitle_on: + if value < 55: + statuses = [(f"{int(value / 55 * 100)}%", "warn"), (tr("synth.status.waiting"), "neutral")] else: - InfoBar.error( - self.tr("格式错误") + file_ext, - self.tr("请拖入视频或者字幕文件"), - duration=INFOBAR_DURATION_ERROR, - parent=self, - ) + statuses = [ + (tr("synth.status.done"), "ok"), + (f"{int((value - 55) / 45 * 100)}%", "warn"), + ] + statuses.append((tr("synth.status.waiting"), "neutral")) + else: + statuses = [(f"{value}%", "warn"), (tr("synth.status.waiting"), "neutral")] + for step, status in zip([s for s in self.planSteps if s.isVisible()], statuses): + step.pill.setState(*status) + + def _cancel(self): + self.controller.cancel() + # 取消的运行是废弃物,任务目录直接清掉(controller.cancel 已等线程退出)。 + output_paths.cleanup_task_dir(self._active_task_dir, keep=False) + self._active_task_dir = None + self.state = PageState.IDLE + self._refresh() + + def _on_failed(self, error: str): + # 失败保留任务目录供排查,只复位引用。 + self._active_task_dir = None + self.state = PageState.IDLE + self._refresh() + self.generatePanel.setError(error) + self.bottomBar.setState(tr("synth.status.failed"), tr("synth.pill.failed"), "fail") + + def _on_completed(self, results: list): + output_paths.cleanup_task_dir( + self._active_task_dir, keep=bool(cfg.keep_intermediates.value) + ) + self._active_task_dir = None + self.state = PageState.DONE + self._results = results + self.header.setTitle(tr("synth.title.results")) + self.stack.setCurrentIndex(2) + for row, result in zip(self.resultRows, results[: len(self.resultRows)]): + row.setResult(*result) + row.show() + for row in self.resultRows[len(results):]: + row.hide() + + # 有视频产物时异步读取封面帧做预览 + video_result = next( + (path for _, path in results if Path(path).suffix.lower() == ".mp4"), None + ) + self.resultThumb.setVisible(video_result is not None) + if video_result: + self._info_thread = VideoInfoThread(video_result) + self._info_thread.finished.connect(self._on_result_info) + self._info_thread.start() + + self.bottomBar.setState(tr("synth.status.completed"), tr("synth.pill.completed"), "ok") + self.generatePanel.setButton( + tr("synth.btn.regenerate"), icon=AppIcon.SYNC, primary=True, enabled=True + ) + self.generatePanel.cancelButton.hide() + self.generatePanel.openFolderButton.show() + self.subtitleButton.show() + self.videoButton.show() + self._sync_collapsed_controls() + self._open_result_folder() + + def _on_result_info(self, info: VideoInfo): + self.resultThumb.setMedia(info.thumbnail_path, is_audio=False) + + # ------------------------------------------------------------- actions + + def _open_result_folder(self): + # 已知具体文件时在文件管理器中直接选中它 + target = None + if self._results: + target = Path(self._results[0][1]) + elif self.video_path: + target = Path(self.video_path) + elif self.subtitle_path: + target = Path(self.subtitle_path) + if target is None: + return + if target.exists(): + reveal_in_explorer(str(target)) + else: + open_folder(str(target.parent)) + + def _open_file(self, path: str): + from PyQt5.QtCore import QUrl + from PyQt5.QtGui import QDesktopServices + + if Path(path).exists(): + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + + def _open_synthesis_settings(self): + window = self.window() + if hasattr(window, "openSettingsPage"): + window.openSettingsPage("subtitle") # type: ignore[attr-defined] + + def _open_voice_library(self): + window = self.window() + dubbing = getattr(window, "dubbingInterface", None) + if dubbing is not None and hasattr(window, "switchTo"): + window.switchTo(dubbing) # type: ignore[attr-defined] + def _open_subtitle_style_library(self): + window = self.window() + style_page = getattr(window, "subtitleStyleInterface", None) + if style_page is not None and hasattr(window, "switchTo"): + window.switchTo(style_page) # type: ignore[attr-defined] -if __name__ == "__main__": - QApplication.setHighDpiScaleFactorRoundingPolicy( - Qt.HighDpiScaleFactorRoundingPolicy.PassThrough - ) - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) # type: ignore - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) # type: ignore + # --------------------------------------------------------- external API - app = QApplication(sys.argv) - window = VideoSynthesisInterface() - window.resize(600, 400) # 设置窗口大小 - window.show() - sys.exit(app.exec_()) + def set_task(self, task: SynthesisTask): + """外部(流水线)注入任务:填充输入文件与待清理的任务目录。""" + self.task = task + self.subtitle_path = str(task.subtitle_path) if task.subtitle_path else None + self.video_path = str(task.video_path) if task.video_path else None + self._pipeline_task_dir = task.task_dir + self.state = PageState.IDLE + self._refresh() + + def process(self): + """外部注入任务后直接开始(流水线模式)。""" + self._on_primary() + + # ----------------------------------------------------------- drag&drop + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + self.dropZone.setDragActive(True) + event.accept() + else: + event.ignore() + + def dragLeaveEvent(self, event): + self.dropZone.setDragActive(False) + super().dragLeaveEvent(event) + + def dropEvent(self, event): + self.dropZone.setDragActive(False) + if self.controller.is_running(): + return + for url in event.mimeData().urls(): + path = url.toLocalFile() + if os.path.isfile(path): + self._dispatch_file(path) + + def closeEvent(self, event): + self._disconnect_config_signals() + self.controller.shutdown() + if self._info_thread is not None: + self._info_thread.stop() + super().closeEvent(event)