[
  {
    "path": ".github/ISSUE_TEMPLATE/01_bug.yaml",
    "content": "name: 错误 | Bug\ndescription: 反馈程序出现的错误 | Report bugs\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        感谢您报告问题！请提供以下信息帮助我更好地解决问题。\n        \n        Thank you for reporting the issue! Using English or Chinese.\n\n  - type: textarea\n    id: description\n    attributes:\n      label: 问题描述 | Problem Description\n      description: |\n        描述您遇到的问题，如果能提供一个复现步骤将帮我更好定位修复问题。(例如:错误字幕内容、或者视频链接、或者具体报错)\n        \n        Please describe in detail the problem you encountered.\n    validations:\n      required: true\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: 日志信息（可选）| Logs (Optional)\n      description: |\n        （可选）如果你在生成字幕视频过程遇到了错误，请打开根目录下的 AppData/logs/app.log 文件，根据日志的时间复制最近一次运行错误的日志信息并填写。这样可以更好帮助我排查。\n        \n        (Optional) Please open the AppData/logs/app.log file in the root directory and copy the log information from the most recent run error.\n      render: shell\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/02_request.yaml",
    "content": "name: 功能请求 | Feature Request\ndescription: 提出增加新功能的请求 | Create the request for a new feature\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ✨ 感谢您提出功能建议！请描述您希望的新功能，对于有用可行的建议我会努力实现的。\n        \n        🌟 Thank you for your feature suggestion! Please describe the new feature you expect. Using English or Chinese.\n  - type: textarea\n    id: feature\n    attributes:\n      label: 💡 预期的功能 | Expected Feature\n      description: |\n        请详细描述您期望添加的功能，包括使用场景和希望达到的效果。\n        \n        Please describe in detail the feature you want to add, including usage scenarios and desired effects.\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/03_question.yaml",
    "content": "name: 问题咨询 Question\ndescription: 向作者咨询软件使用或配置相关的问题 | Consult about software usage or configuration\n\nlabels: [\"question\"]\n\nbody:\n  - type: textarea\n    id: problem\n    attributes:\n      label: 🤔 问题描述 Problem Description\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/workflows/claude-code-review.yml",
    "content": "name: Claude Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize]\n    # Optional: Only run on specific file changes\n    # paths:\n    #   - \"src/**/*.ts\"\n    #   - \"src/**/*.tsx\"\n    #   - \"src/**/*.js\"\n    #   - \"src/**/*.jsx\"\n\njobs:\n  claude-review:\n    # Optional: Filter by PR author\n    # if: |\n    #   github.event.pull_request.user.login == 'external-contributor' ||\n    #   github.event.pull_request.user.login == 'new-developer' ||\n    #   github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'\n    \n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: read\n      issues: read\n      id-token: write\n    \n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n\n      - name: Run Claude Code Review\n        id: claude-review\n        uses: anthropics/claude-code-action@beta\n        with:\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n\n          # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)\n          # model: \"claude-opus-4-1-20250805\"\n\n          # Direct prompt for automated review (no @claude mention needed)\n          direct_prompt: |\n            Please review this pull request and provide feedback on:\n            - Code quality and best practices\n            - Potential bugs or issues\n            - Performance considerations\n            - Security concerns\n            - Test coverage\n            \n            Be constructive and helpful in your feedback.\n\n          # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR\n          # use_sticky_comment: true\n          \n          # Optional: Customize review based on file types\n          # direct_prompt: |\n          #   Review this PR focusing on:\n          #   - For TypeScript files: Type safety and proper interface usage\n          #   - For API endpoints: Security, input validation, and error handling\n          #   - For React components: Performance, accessibility, and best practices\n          #   - For tests: Coverage, edge cases, and test quality\n          \n          # Optional: Different prompts for different authors\n          # direct_prompt: |\n          #   ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && \n          #   'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||\n          #   'Please provide a thorough code review focusing on our coding standards and best practices.' }}\n          \n          # Optional: Add specific tools for running tests or linting\n          # allowed_tools: \"Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)\"\n          \n          # Optional: Skip review for certain conditions\n          # if: |\n          #   !contains(github.event.pull_request.title, '[skip-review]') &&\n          #   !contains(github.event.pull_request.title, '[WIP]')\n\n"
  },
  {
    "path": ".github/workflows/claude.yml",
    "content": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issues:\n    types: [opened, assigned]\n  pull_request_review:\n    types: [submitted]\n\njobs:\n  claude:\n    if: |\n      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||\n      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: read\n      issues: read\n      id-token: write\n      actions: read # Required for Claude to read CI results on PRs\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n\n      - name: Run Claude Code\n        id: claude\n        uses: anthropics/claude-code-action@beta\n        with:\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n\n          # This is an optional setting that allows Claude to read CI results on PRs\n          additional_permissions: |\n            actions: read\n          \n          # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)\n          # model: \"claude-opus-4-1-20250805\"\n          \n          # Optional: Customize the trigger phrase (default: @claude)\n          # trigger_phrase: \"/claude\"\n          \n          # Optional: Trigger when specific user is assigned to an issue\n          # assignee_trigger: \"claude-bot\"\n          \n          # Optional: Allow Claude to run specific commands\n          # allowed_tools: \"Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)\"\n          \n          # Optional: Add custom instructions for Claude to customize its behavior for your project\n          # custom_instructions: |\n          #   Follow our coding standards\n          #   Ensure all new code has tests\n          #   Use TypeScript for new files\n          \n          # Optional: Custom environment variables for Claude\n          # claude_env: |\n          #   NODE_ENV: test\n\n"
  },
  {
    "path": ".github/workflows/deploy-docs.yml",
    "content": "name: Deploy Documentation\n\non:\n  push:\n    branches:\n      - master\n      - main\n      - dev\n    paths:\n      - \"docs/**\"\n      - \".github/workflows/deploy-docs.yml\"\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: pages\n  cancel-in-progress: false\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n\n      - name: Install dependencies\n        run: npm ci\n        working-directory: docs\n\n      - name: Build documentation\n        run: npm run docs:build\n        working-directory: docs\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v3\n        with:\n          path: docs/.vitepress/dist\n\n  deploy:\n    if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main'\n    needs: build\n    runs-on: ubuntu-latest\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".gitignore",
    "content": "# win 二进制文件资源目录\n/resource/bin/\n!/resource/bin/bin_environment.txt\n\n# 开发环境\n.idea/\n*.pyc\n*/__pycache__/\n*.env\n*.env.local\n*.env.*.local\n.env.test\n**/.env\n**/.env.local\nvenv/\n.venv/\n\n# 系统文件\n.DS_Store\n\n# 测试和脚本\n/test/\n/release/\n/my_content/\n\n# 媒体文件\n*.srt\n*.mp4\n*.exe\n\n# 应用数据\n/AppData/\n**/settings.json\n!**/settings.json.example\n/output/\n/work-dir/\n.vscode/\n.claude/\n\n# 敏感文件\ncookies.txt\n**/cookies.txt\n*.key\n*.pem\n*.p12\n*.pfx\n*secret*\n*credential*\n\n# 测试相关\n.pytest_cache/\n.coverage\nhtmlcov/\n*.log\n\n# 项目文档\nCLAUDE.md\n\n# Node.js 和 VitePress\nnode_modules/\ndocs/.vitepress/cache/\ndocs/.vitepress/dist/\n/package-lock.json\n!docs/package-lock.json"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# 更新日志\n\n## 2025.02.07\n\n### Bug 修复与其他改进\n\n- 修复谷歌翻译语言不正确的问题。\n- 修部微软翻译不准确的问题。\n- 修复运行设备不选择cuda时显示报 winError的错误\n- 修复合成失败的问题\n- 修复ass单语字幕没有内容的问题\n\n## 2024.2.06\n\n### 核心功能增强\n\n- 完整重构代码架构，优化整体性能\n- 字幕优化与翻译功能模块分离，提供更灵活的处理选项\n- 新增批量处理功能：支持批量字幕、批量转录、批量字幕视频合成\n- 全面优化 UI 界面与交互细节\n\n### AI 模型与翻译升级\n\n- 扩展 LLM 支持：新增 SiliconCloud、DeepSeek、Ollama、Gemini、ChatGLM 等模型\n- 集成多种翻译服务：DeepLx、Bing、Google、LLM\n- 新增 faster-whisper-large-v3-turbo 模型支持\n- 新增多种 VAD（语音活动检测）方法\n- 支持自定义反思翻译开关\n- 字幕断句支持语义/句子两种模式\n- 字幕断句、优化、翻译提示词的优化\n- 字幕、转录缓存机制的优化\n- 优化中文字幕自动换行功能\n- 新增竖屏字幕样式\n- 改进字幕时间轴切换机制，消除闪烁问题\n\n### Bug 修复与其他改进\n\n- 修复 Whisper API 无法使用问题\n- 新增多种字幕视频格式支持\n- 修复部分情况转录错误的问题\n- 优化视频工作目录结构\n- 新增日志查看功能\n- 新增泰语、德语等语言的字幕优化\n- 修复诸多Bug...\n\n## 2024.12.07\n\n- 新增 Faster-whisper 支持，音频转字幕质量更优\n- 支持Vad语音断点检测，大大减少幻觉现象\n- 支持人声音分离，分离视频背景噪音\n- 支持关闭视频合成\n- 新增字幕最大长度设置\n- 新增字幕末尾标点去除设置\n- 优化和翻译的提示词优化\n- 优化LLM字幕断句错误的情况\n- 修复音频转换格式不一致问题\n\n## 2024.11.23\n\n- 新增 Whisper-v3 模型支持，大幅提升语音识别准确率\n- 优化字幕断句算法，提供更自然的阅读体验\n- 修复检测模型可用性时的稳定性问题\n\n## 2024.11.20\n\n- 支持自定义调节字幕位置和样式\n- 新增字幕优化和翻译过程的实时日志查看\n- 修复使用 API 时的自动翻译问题\n- 优化视频工作目录结构,提升文件管理效率\n\n## 2024.11.17\n\n- 支持双语/单语字幕灵活导出\n- 新增文稿匹配提示对齐功能\n- 修复字幕导入时的稳定性问题\n- 修复非中文路径下载模型的兼容性问题\n\n## 2024.11.13\n\n- 新增 Whisper API 调用支持\n- 支持导入 cookie.txt 下载各大视频平台资源\n- 字幕文件名自动与视频保持一致\n- 软件主页新增运行日志实时查看\n- 统一和完善软件内部功能\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    VideoCaptioner - A desktop application for video subtitle processing based on LLM.\n    Copyright (C) 2025  Weifeng\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    VideoCaptioner  Copyright (C) 2025  Weifeng\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <img src=\"./legacy-docs/images/logo.png\"alt=\"VideoCaptioner Logo\" width=\"100\">\n  <p>卡卡字幕助手</p>\n  <h1>VideoCaptioner</h1>\n  <p>一款基于大语言模型(LLM)的视频字幕处理助手，支持语音识别、字幕断句、优化、翻译全流程处理</p>\n\n简体中文 / [正體中文](./legacy-docs/README_TW.md) / [English](./legacy-docs/README_EN.md) / [日本語](./legacy-docs/README_JA.md)\n\n📚 **[在线文档](https://weifeng2333.github.io/VideoCaptioner/)** | 🚀 **[快速开始](https://weifeng2333.github.io/VideoCaptioner/guide/getting-started)** | ⚙️ **[配置指南](https://weifeng2333.github.io/VideoCaptioner/config/llm)**\n\n</div>\n\n## 项目介绍\n\n卡卡字幕助手（VideoCaptioner）操作简单且无需高配置，支持 API 和本地离线两种方式进行语音识别，利用大语言模型进行字幕智能断句、校正、翻译，字幕视频全流程一键处理。为视频配上效果惊艳的字幕。\n\n- 支持词级时间戳与 VAD 语音活动检测，识别准确率高\n- 基于 LLM 的语义理解，自动将逐字字幕重组为自然流畅的句子段落\n- 结合上下文的 AI 翻译，支持反思优化机制，译文地道专业\n- 支持批量视频字幕合成，提升处理效率\n- 直观的字幕编辑查看界面，支持实时预览和快捷编辑\n\n## 界面预览\n\n<div align=\"center\">\n  <img src=\"https://h1.appinn.me/file/1731487405884_main.png\" alt=\"软件界面预览\" width=\"90%\" style=\"border-radius: 5px;\">\n</div>\n\n![页面预览](https://h1.appinn.me/file/1731487410170_preview1.png)\n![页面预览](https://h1.appinn.me/file/1731487410832_preview2.png)\n\n## 测试\n\n全流程处理一个14分钟1080P的 [B站英文 TED 视频](https://www.bilibili.com/video/BV1jT411X7Dz)，调用本地 Whisper 模型进行语音识别，使用 `gpt-5-mini` 模型优化和翻译为中文，总共消耗时间约 **4 分钟**。\n\n近后台计算，模型优化和翻译消耗费用不足 ￥0.01（以OpenAI官方价格为计算）\n\n具体字幕和视频合成的效果的测试结果图片，请参考 [TED视频测试](./legacy-docs/test.md)\n\n## 快速开始\n\n### Windows 用户\n\n#### 方式一：使用打包程序（推荐）\n\n软件较为轻量，打包大小不足 60M,已集成所有必要环境，下载后可直接运行。\n\n1. 从 [Release](https://github.com/WEIFENG2333/VideoCaptioner/releases) 页面下载最新版本的可执行程序。或者：[蓝奏盘下载](https://wwwm.lanzoue.com/ii14G2pdsbej)\n\n2. 打开安装包进行安装\n\n3. LLM API 配置，（用于字幕断句、校正），可使用[本项目的中转站](https://api.videocaptioner.cn)\n\n4. 翻译配置，选择是否启用翻译，翻译服务（默认使用微软翻译，质量一般，推荐配置自己的 API KEY 使用大模型翻译）\n\n5. 语音识别配置（默认使用B接口网络调用语音识别服务，中英以外的语言请使用本地转录）\n\n### macOS 用户\n\n#### 一键安装运行（推荐）\n\n```bash\n# 方式一：直接运行（自动安装 uv、克隆项目、安装相关依赖）\ncurl -fsSL https://raw.githubusercontent.com/WEIFENG2333/VideoCaptioner/main/scripts/run.sh | bash\n\n# 方式二：先克隆再运行\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\n./scripts/run.sh\n```\n\n脚本会自动：\n\n1. 安装 [uv](https://docs.astral.sh/uv/) 包管理器（如果未安装）\n2. 克隆项目到 `~/VideoCaptioner`（如果不在项目目录中运行）\n3. 安装所有 Python 依赖\n4. 启动应用\n\n<details>\n<summary>手动安装步骤</summary>\n\n#### 1. 安装 uv 包管理器\n\n```bash\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n```\n\n#### 2. 安装系统依赖（macOS）\n\n```bash\nbrew install ffmpeg\n```\n\n#### 3. 克隆并运行\n\n```bash\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\nuv sync          # 安装依赖\nuv run python main.py  # 运行\n```\n\n</details>\n\n### 开发者指南\n\n```bash\n# 安装依赖（包括开发依赖）\nuv sync\n\n# 运行应用\nuv run python main.py\n\n# 类型检查\nuv run pyright\n\n# 代码检查\nuv run ruff check .\n```\n\n## 基本配置\n\n### 1. LLM API 配置说明\n\nLLM 大模型是用来字幕段句、字幕优化、以及字幕翻译（如果选择了LLM 大模型翻译）。\n\n| 配置项         | 说明                                                                                                                                              |\n| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| SiliconCloud   | [SiliconCloud 官网](https://cloud.siliconflow.cn/i/onCHcaDx)配置方法请参考[配置文档](https://weifeng2333.github.io/VideoCaptioner/config/llm)<br>该并发较低，建议把线程设置为5以下。 |\n| DeepSeek       | [DeepSeek 官网](https://platform.deepseek.com)，建议使用 `deepseek-v3` 模型，<br>官方网站最近服务好像并不太稳定。                                 |\n| OpenAI兼容接口 | 如果有其他服务商的API，可直接在软件中填写。base_url 和api_key [VideoCaptioner API](https://api.videocaptioner.cn)                                 |\n\n注：如果用的 API 服务商不支持高并发，请在软件设置中将“线程数”调低，避免请求错误。\n\n---\n\n如果希望高并发，或者希望在在软件内使用使用 OpenAI 或者 Claude 等优质大模型进行字幕校正和翻译。\n\n可使用本项目的✨LLM API中转站✨： [https://api.videocaptioner.cn](https://api.videocaptioner.cn)\n\n其支持高并发，性价比极高，且有国内外大量模型可挑选。\n\n注册获取key之后，设置中按照下面配置：\n\nBaseURL: `https://api.videocaptioner.cn/v1`\n\nAPI-key: `个人中心-API 令牌页面自行获取。`\n\n💡 模型选择建议 (本人在各质量层级中精选出的高性价比模型)：\n\n- 高质量之选： `gemini-3-pro`、`claude-sonnet-4-5-20250929` (耗费比例：3)\n\n- 较高质量之选： `gpt-5-2025-08-07`、 `claude-haiku-4-5-20251001` (耗费比例：1.2)\n\n- 中质量之选： `gpt-5-mini`、`gemini-3-flash` (耗费比例：0.3)\n\n本站支持超高并发，软件中线程数直接拉满即可~ 处理速度非常快~\n\n更详细的API配置教程：[中转站配置](https://weifeng2333.github.io/VideoCaptioner/config/llm)\n\n---\n\n## 2. 翻译配置\n\n| 配置项         | 说明                                                                                                                          |\n| -------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| LLM 大模型翻译 | 🌟 翻译质量最好的选择。使用 AI 大模型进行翻译,能更好理解上下文,翻译更自然。需要在设置中配置 LLM API(比如 OpenAI、DeepSeek 等) |\n| 微软翻译       | 使用微软的翻译服务, 速度非常快                                                                                                |\n| 谷歌翻译       | 谷歌的翻译服务,速度快,但需要能访问谷歌的网络环境                                                                              |\n\n推荐使用 `LLM 大模型翻译` ，翻译质量最好。\n\n### 3. 语音识别接口说明\n\n| 接口名称         | 支持语言                                           | 运行方式 | 说明                                                                                                              |\n| ---------------- | -------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |\n| B接口            | 仅支持中文、英文                                   | 在线     | 免费、速度较快                                                                                                    |\n| J接口            | 仅支持中文、英文                                   | 在线     | 免费、速度较快                                                                                                    |\n| WhisperCpp       | 中文、日语、韩语、英文等 99 种语言，外语效果较好   | 本地     | （实际使用不稳定）需要下载转录模型<br>中文建议medium以上模型<br>英文等使用较小模型即可达到不错效果。              |\n| fasterWhisper 👍 | 中文、英文等多99种语言，外语效果优秀，时间轴更准确 | 本地     | （🌟推荐🌟）需要下载程序和转录模型<br>支持CUDA,速度更快，转录准确。<br>超级准确的时间戳字幕。<br>仅支持 window |\n\n### 4. 本地 Whisper 语音识别模型\n\nWhisper 版本有 WhisperCpp 和 fasterWhisper（推荐） 两种，后者效果更好，都需要自行在软件内下载模型。\n\n| 模型        | 磁盘空间 | 内存占用 | 说明                                |\n| ----------- | -------- | -------- | ----------------------------------- |\n| Tiny        | 75 MiB   | ~273 MB  | 转录很一般，仅用于测试              |\n| Small       | 466 MiB  | ~852 MB  | 英文识别效果已经不错                |\n| Medium      | 1.5 GiB  | ~2.1 GB  | 中文识别建议至少使用此版本          |\n| Large-v2 👍 | 2.9 GiB  | ~3.9 GB  | 效果好，配置允许情况推荐使用        |\n| Large-v3    | 2.9 GiB  | ~3.9 GB  | 社区反馈可能会出现幻觉/字幕重复问题 |\n\n推荐模型: `Large-v2` 稳定且质量较好。\n\n\n### 5. 文稿匹配\n\n- 在\"字幕优化与翻译\"页面，包含\"文稿匹配\"选项，支持以下**一种或者多种**内容，辅助校正字幕和翻译:\n\n| 类型       | 说明                                 | 填写示例                                                                                                                                                |\n| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 术语表     | 专业术语、人名、特定词语的修正对照表 | 机器学习->Machine Learning<br>马斯克->Elon Musk<br>打call -> 应援<br>图灵斑图<br>公交车悖论                                                             |\n| 原字幕文稿 | 视频的原有文稿或相关内容             | 完整的演讲稿、课程讲义等                                                                                                                                |\n| 修正要求   | 内容相关的具体修正要求               | 统一人称代词、规范专业术语等<br>填写**内容相关**的要求即可，[示例参考](https://github.com/WEIFENG2333/VideoCaptioner/issues/59#issuecomment-2495849752) |\n\n- 如果需要文稿进行字幕优化辅助，全流程处理时，先填写文稿信息，再进行开始任务处理\n- 注意: 使用上下文参数量不高的小型LLM模型时，建议控制文稿内容在1千字内，如果使用上下文较大的模型，则可以适当增加文稿内容。\n\n无特殊需求，可不填写。\n\n### 6. Cookie 配置说明\n\n如果使用URL下载功能时，如果遇到以下情况:\n\n1. 下载视频网站需要登录信息才可以下载；\n2. 只能下载较低分辨率的视频；\n3. 网络条件较差时需要验证；\n\n- 请参考 [Cookie 配置说明](https://weifeng2333.github.io/VideoCaptioner/guide/cookies-config) 获取Cookie信息，并将cookies.txt文件放置到软件安装目录的 `AppData` 目录下，即可正常下载高质量视频。\n\n## 软件流程介绍\n\n程序简单的处理流程如下:\n\n```\n语音识别转录 -> 字幕断句(可选) -> 字幕优化翻译(可选) -> 字幕视频合成\n```\n\n## 软件主要功能\n\n软件利用大语言模型(LLM)在理解上下文方面的优势，对语音识别生成的字幕进一步处理。有效修正错别字、统一专业术语，让字幕内容更加准确连贯，为用户带来出色的观看体验！\n\n#### 1. 多平台视频下载与处理\n\n- 支持国内外主流视频平台（B站、Youtube、小红书、TikTok、X、西瓜视频、抖音等）\n- 自动提取视频原有字幕处理\n\n#### 2. 专业的语音识别引擎\n\n- 提供多种接口在线识别，效果媲美剪映（免费、高速）\n- 支持本地Whisper模型（保护隐私、可离线）\n\n#### 3. 字幕智能纠错\n\n- 自动优化专业术语、代码片段和数学公式格式\n- 上下文进行断句优化，提升阅读体验\n- 支持文稿提示，使用原有文稿或者相关提示优化字幕断句\n\n#### 4. 高质量字幕翻译\n\n- 结合上下文的智能翻译，确保译文兼顾全文\n- 通过Prompt指导大模型反思翻译，提升翻译质量\n- 使用序列模糊匹配算法、保证时间轴完全一致\n\n#### 5. 字幕样式调整\n\n- 丰富的字幕样式模板（科普风、新闻风、番剧风等等）\n- 多种格式字幕视频（SRT、ASS、VTT、TXT）\n\n针对小白用户，对一些软件内的选项说明：\n\n#### 1. 语音转录页面\n\n- `VAD过滤`：开启后，VAD（语音活动检测）将过滤无人声的语音片段，从而减少幻觉现象。建议保持默认开启状态。如果不懂，其他VAD选项建议直接保持默认即可。\n\n- `音频分离`：开启后，使用MDX-Net进行降噪处理，能够有效分离人声和背景音乐，从而提升音频质量。建议只在嘈杂的视频中开启。\n\n#### 2. 字幕优化与翻译页面\n\n- `智能断句`：开启后，全流程处理时生成字级时间戳，然后通过LLM大模型进行断句，从而在视频有更完美的观看体验。有按照句子断句和按照语义断句两种模式。可根据自己的需求配置。\n\n- `字幕校正`：开启后，会通过LLM大模型对字幕内容进行校正(如：英文单词大小写、标点符号、错别字、数学公式和代码的格式等)，提升字幕的质量。\n\n- `反思翻译`：开启后，会通过LLM大模型进行反思翻译，提升翻译的质量。相应的会增加请求的时间和消耗的Token。(选项在 设置页-LLM大模型翻译-反思翻译 中开启。)\n\n- `文稿提示`：填写后，这部分也将作为提示词发送给大模型，辅助字幕优化和翻译。\n\n#### 3. 字幕视频合成页面\n\n- `视频合成`：开启后，会根据合成字幕视频；关闭将跳过视频合成的流程。\n\n- `软字幕`：开启后，字幕不会烧录到视频中，处理速度极快。但是软字幕需要一些播放器（如PotPlayer）支持才可以进行显示播放。而且软字幕的样式不是软件内调整的字幕样式，而是播放器默认的白色样式。\n\n项目主要目录结构说明如下：\n\n```\nVideoCaptioner/\n├── app/                        # 应用源代码目录\n│   ├── common/                 # 公共模块（配置、信号总线）\n│   ├── components/             # UI 组件\n│   ├── core/                   # 核心业务逻辑（ASR、翻译、优化等）\n│   ├── thread/                 # 异步线程\n│   └── view/                   # 界面视图\n├── resource/                   # 资源文件目录\n│   ├── assets/                 # 图标、Logo 等\n│   ├── bin/                    # 二进制程序（FFmpeg、Whisper 等）\n│   ├── fonts/                  # 字体文件\n│   ├── subtitle_style/         # 字幕样式模板\n│   └── translations/           # 多语言翻译文件\n├── work-dir/                   # 工作目录（处理完成的视频和字幕）\n├── AppData/                    # 应用数据目录\n│   ├── cache/                  # 缓存目录（转录、LLM 请求）\n│   ├── models/                 # Whisper 模型文件\n│   ├── logs/                   # 日志文件\n│   └── settings.json           # 用户设置\n├── scripts/                    # 安装和运行脚本\n├── main.py                     # 程序入口\n└── pyproject.toml              # 项目配置和依赖\n```\n\n## 📝 说明\n\n1. 字幕断句的质量对观看体验至关重要。软件能将逐字字幕智能重组为符合自然语言习惯的段落，并与视频画面完美同步。\n\n2. 在处理过程中，仅向大语言模型发送文本内容，不包含时间轴信息，这大大降低了处理开销。\n\n3. 在翻译环节，我们采用吴恩达提出的\"翻译-反思-翻译\"方法论。这种迭代优化的方式确保了翻译的准确性。\n\n4. 填入 YouTube 链接时进行处理时，会自动下载视频的字幕，从而省去转录步骤，极大地节省操作时间。\n\n## 🤝 贡献指南\n\n项目在不断完善中，如果在使用过程遇到的Bug，欢迎提交 [Issue](https://github.com/WEIFENG2333/VideoCaptioner/issues) 和 Pull Request 帮助改进项目。\n\n## 📝 更新日志\n\n查看完整的更新历史，请访问 [CHANGELOG.md](./CHANGELOG.md)\n\n## 💖 支持作者\n\n如果觉得项目对你有帮助，可以给项目点个Star！\n\n<details>\n<summary>捐助支持</summary>\n<div align=\"center\">\n  <img src=\"./legacy-docs/images/alipay.jpg\" alt=\"支付宝二维码\" width=\"30%\">\n  <img src=\"./legacy-docs/images/wechat.jpg\" alt=\"微信二维码\" width=\"30%\">\n</div>\n</details>\n\n## ⭐ Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=WEIFENG2333/VideoCaptioner&type=Date)](https://star-history.com/#WEIFENG2333/VideoCaptioner&Date)\n"
  },
  {
    "path": "app/__init__.py",
    "content": ""
  },
  {
    "path": "app/common/config.py",
    "content": "# coding:utf-8\nfrom enum import Enum\n\nfrom PyQt5.QtCore import QLocale\nfrom PyQt5.QtGui import QColor\nfrom qfluentwidgets import (\n    BoolValidator,\n    ConfigItem,\n    ConfigSerializer,\n    EnumSerializer,\n    FolderValidator,\n    OptionsConfigItem,\n    OptionsValidator,\n    QConfig,\n    RangeConfigItem,\n    RangeValidator,\n    Theme,\n    qconfig,\n)\n\nfrom app.config import SETTINGS_PATH, WORK_PATH\nfrom app.core.utils.platform_utils import get_available_transcribe_models\n\nfrom ..core.entities import (\n    FasterWhisperModelEnum,\n    LLMServiceEnum,\n    SubtitleLayoutEnum,\n    SubtitleRenderModeEnum,\n    TranscribeLanguageEnum,\n    TranscribeModelEnum,\n    TranscribeOutputFormatEnum,\n    TranslatorServiceEnum,\n    VadMethodEnum,\n    VideoQualityEnum,\n    WhisperModelEnum,\n)\nfrom ..core.translate.types import TargetLanguage\n\n\nclass Language(Enum):\n    \"\"\"软件语言\"\"\"\n\n    CHINESE_SIMPLIFIED = QLocale(QLocale.Chinese, QLocale.China)\n    CHINESE_TRADITIONAL = QLocale(QLocale.Chinese, QLocale.HongKong)\n    ENGLISH = QLocale(QLocale.English)\n    AUTO = QLocale()\n\n\nclass LanguageSerializer(ConfigSerializer):\n    \"\"\"Language serializer\"\"\"\n\n    def serialize(self, language):\n        return language.value.name() if language != Language.AUTO else \"Auto\"\n\n    def deserialize(self, value: str):\n        return Language(QLocale(value)) if value != \"Auto\" else Language.AUTO\n\n\nclass PlatformAwareTranscribeModelValidator(OptionsValidator):\n    \"\"\"平台相关的转录模型验证器，在 macOS 上自动过滤掉 FasterWhisper\"\"\"\n\n    def __init__(self):\n        # 不调用父类的 __init__，因为我们要自定义 options\n        self._options = get_available_transcribe_models()\n\n    @property\n    def options(self):\n        return self._options\n\n    def validate(self, value):\n        return value in self._options\n\n    def correct(self, value):\n        return value if self.validate(value) else self._options[0]\n\n\nclass Config(QConfig):\n    \"\"\"应用配置\"\"\"\n\n    # LLM配置\n    llm_service = OptionsConfigItem(\n        \"LLM\",\n        \"LLMService\",\n        LLMServiceEnum.OPENAI,\n        OptionsValidator(LLMServiceEnum),\n        EnumSerializer(LLMServiceEnum),\n    )\n\n    openai_model = ConfigItem(\"LLM\", \"OpenAI_Model\", \"gpt-4o-mini\")\n    openai_api_key = ConfigItem(\"LLM\", \"OpenAI_API_Key\", \"\")\n    openai_api_base = ConfigItem(\"LLM\", \"OpenAI_API_Base\", \"https://api.openai.com/v1\")\n\n    silicon_cloud_model = ConfigItem(\"LLM\", \"SiliconCloud_Model\", \"gpt-4o-mini\")\n    silicon_cloud_api_key = ConfigItem(\"LLM\", \"SiliconCloud_API_Key\", \"\")\n    silicon_cloud_api_base = ConfigItem(\n        \"LLM\", \"SiliconCloud_API_Base\", \"https://api.siliconflow.cn/v1\"\n    )\n\n    deepseek_model = ConfigItem(\"LLM\", \"DeepSeek_Model\", \"deepseek-chat\")\n    deepseek_api_key = ConfigItem(\"LLM\", \"DeepSeek_API_Key\", \"\")\n    deepseek_api_base = ConfigItem(\n        \"LLM\", \"DeepSeek_API_Base\", \"https://api.deepseek.com/v1\"\n    )\n\n    ollama_model = ConfigItem(\"LLM\", \"Ollama_Model\", \"llama2\")\n    ollama_api_key = ConfigItem(\"LLM\", \"Ollama_API_Key\", \"ollama\")\n    ollama_api_base = ConfigItem(\"LLM\", \"Ollama_API_Base\", \"http://localhost:11434/v1\")\n\n    lm_studio_model = ConfigItem(\"LLM\", \"LmStudio_Model\", \"qwen2.5:7b\")\n    lm_studio_api_key = ConfigItem(\"LLM\", \"LmStudio_API_Key\", \"lmstudio\")\n    lm_studio_api_base = ConfigItem(\n        \"LLM\", \"LmStudio_API_Base\", \"http://localhost:1234/v1\"\n    )\n\n    gemini_model = ConfigItem(\"LLM\", \"Gemini_Model\", \"gemini-pro\")\n    gemini_api_key = ConfigItem(\"LLM\", \"Gemini_API_Key\", \"\")\n    gemini_api_base = ConfigItem(\n        \"LLM\",\n        \"Gemini_API_Base\",\n        \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n    )\n\n    chatglm_model = ConfigItem(\"LLM\", \"ChatGLM_Model\", \"glm-4\")\n    chatglm_api_key = ConfigItem(\"LLM\", \"ChatGLM_API_Key\", \"\")\n    chatglm_api_base = ConfigItem(\n        \"LLM\", \"ChatGLM_API_Base\", \"https://open.bigmodel.cn/api/paas/v4\"\n    )\n\n    # ------------------- 翻译配置 -------------------\n    translator_service = OptionsConfigItem(\n        \"Translate\",\n        \"TranslatorServiceEnum\",\n        TranslatorServiceEnum.BING,\n        OptionsValidator(TranslatorServiceEnum),\n        EnumSerializer(TranslatorServiceEnum),\n    )\n    need_reflect_translate = ConfigItem(\n        \"Translate\", \"NeedReflectTranslate\", False, BoolValidator()\n    )\n    deeplx_endpoint = ConfigItem(\"Translate\", \"DeeplxEndpoint\", \"\")\n    batch_size = RangeConfigItem(\"Translate\", \"BatchSize\", 10, RangeValidator(5, 50))\n    thread_num = RangeConfigItem(\"Translate\", \"ThreadNum\", 10, RangeValidator(1, 50))\n\n    # ------------------- 转录配置 -------------------\n    transcribe_model = OptionsConfigItem(\n        \"Transcribe\",\n        \"TranscribeModel\",\n        TranscribeModelEnum.BIJIAN,\n        PlatformAwareTranscribeModelValidator(),\n        EnumSerializer(TranscribeModelEnum),\n    )\n    transcribe_output_format = OptionsConfigItem(\n        \"Transcribe\",\n        \"OutputFormat\",\n        TranscribeOutputFormatEnum.SRT,\n        OptionsValidator(TranscribeOutputFormatEnum),\n        EnumSerializer(TranscribeOutputFormatEnum),\n    )\n    transcribe_language = OptionsConfigItem(\n        \"Transcribe\",\n        \"TranscribeLanguage\",\n        TranscribeLanguageEnum.AUTO,\n        OptionsValidator(TranscribeLanguageEnum),\n        EnumSerializer(TranscribeLanguageEnum),\n    )\n\n    # ------------------- Whisper Cpp 配置 -------------------\n    whisper_model = OptionsConfigItem(\n        \"Whisper\",\n        \"WhisperModel\",\n        WhisperModelEnum.TINY,\n        OptionsValidator(WhisperModelEnum),\n        EnumSerializer(WhisperModelEnum),\n    )\n\n    # ------------------- Faster Whisper 配置 -------------------\n    faster_whisper_program = ConfigItem(\n        \"FasterWhisper\",\n        \"Program\",\n        \"faster-whisper-xxl.exe\",\n    )\n    faster_whisper_model = OptionsConfigItem(\n        \"FasterWhisper\",\n        \"Model\",\n        FasterWhisperModelEnum.TINY,\n        OptionsValidator(FasterWhisperModelEnum),\n        EnumSerializer(FasterWhisperModelEnum),\n    )\n    faster_whisper_model_dir = ConfigItem(\"FasterWhisper\", \"ModelDir\", \"\")\n    faster_whisper_device = OptionsConfigItem(\n        \"FasterWhisper\", \"Device\", \"cuda\", OptionsValidator([\"cuda\", \"cpu\"])\n    )\n    # VAD 参数\n    faster_whisper_vad_filter = ConfigItem(\n        \"FasterWhisper\", \"VadFilter\", True, BoolValidator()\n    )\n    faster_whisper_vad_threshold = RangeConfigItem(\n        \"FasterWhisper\", \"VadThreshold\", 0.4, RangeValidator(0, 1)\n    )\n    faster_whisper_vad_method = OptionsConfigItem(\n        \"FasterWhisper\",\n        \"VadMethod\",\n        VadMethodEnum.SILERO_V4,\n        OptionsValidator(VadMethodEnum),\n        EnumSerializer(VadMethodEnum),\n    )\n    # 人声提取\n    faster_whisper_ff_mdx_kim2 = ConfigItem(\n        \"FasterWhisper\", \"FfMdxKim2\", False, BoolValidator()\n    )\n    # 文本处理参数\n    faster_whisper_one_word = ConfigItem(\n        \"FasterWhisper\", \"OneWord\", True, BoolValidator()\n    )\n    # 提示词\n    faster_whisper_prompt = ConfigItem(\"FasterWhisper\", \"Prompt\", \"\")\n\n    # ------------------- Whisper API 配置 -------------------\n    whisper_api_base = ConfigItem(\"WhisperAPI\", \"WhisperApiBase\", \"\")\n    whisper_api_key = ConfigItem(\"WhisperAPI\", \"WhisperApiKey\", \"\")\n    whisper_api_model = OptionsConfigItem(\"WhisperAPI\", \"WhisperApiModel\", \"\")\n    whisper_api_prompt = ConfigItem(\"WhisperAPI\", \"WhisperApiPrompt\", \"\")\n\n    # ------------------- 字幕配置 -------------------\n    need_optimize = ConfigItem(\"Subtitle\", \"NeedOptimize\", False, BoolValidator())\n    need_translate = ConfigItem(\"Subtitle\", \"NeedTranslate\", False, BoolValidator())\n    need_split = ConfigItem(\"Subtitle\", \"NeedSplit\", False, BoolValidator())\n    target_language = OptionsConfigItem(\n        \"Subtitle\",\n        \"TargetLanguage\",\n        TargetLanguage.SIMPLIFIED_CHINESE,\n        OptionsValidator(TargetLanguage),\n        EnumSerializer(TargetLanguage),\n    )\n    max_word_count_cjk = ConfigItem(\n        \"Subtitle\", \"MaxWordCountCJK\", 28, RangeValidator(8, 100)\n    )\n    max_word_count_english = ConfigItem(\n        \"Subtitle\", \"MaxWordCountEnglish\", 20, RangeValidator(8, 100)\n    )\n    custom_prompt_text = ConfigItem(\"Subtitle\", \"CustomPromptText\", \"\")\n\n    # ------------------- 字幕合成配置 -------------------\n    soft_subtitle = ConfigItem(\"Video\", \"SoftSubtitle\", False, BoolValidator())\n    need_video = ConfigItem(\"Video\", \"NeedVideo\", True, BoolValidator())\n    video_quality = OptionsConfigItem(\n        \"Video\",\n        \"VideoQuality\",\n        VideoQualityEnum.MEDIUM,\n        OptionsValidator(VideoQualityEnum),\n        EnumSerializer(VideoQualityEnum),\n    )\n    use_subtitle_style = ConfigItem(\"Video\", \"UseSubtitleStyle\", False, BoolValidator())\n\n    # ------------------- 字幕样式配置 -------------------\n    subtitle_style_name = ConfigItem(\"SubtitleStyle\", \"StyleName\", \"default\")\n    subtitle_layout = OptionsConfigItem(\n        \"SubtitleStyle\",\n        \"Layout\",\n        SubtitleLayoutEnum.TRANSLATE_ON_TOP,\n        OptionsValidator(SubtitleLayoutEnum),\n        EnumSerializer(SubtitleLayoutEnum),\n    )\n    subtitle_preview_image = ConfigItem(\"SubtitleStyle\", \"PreviewImage\", \"\")\n\n    # 字幕渲染模式\n    subtitle_render_mode = OptionsConfigItem(\n        \"SubtitleStyle\",\n        \"RenderMode\",\n        SubtitleRenderModeEnum.ROUNDED_BG,\n        OptionsValidator(SubtitleRenderModeEnum),\n        EnumSerializer(SubtitleRenderModeEnum),\n    )\n\n    # 圆角背景模式配置\n    rounded_bg_font_name = ConfigItem(\"RoundedBgStyle\", \"FontName\", \"LXGW WenKai\")\n    rounded_bg_font_size = RangeConfigItem(\n        \"RoundedBgStyle\", \"FontSize\", 52, RangeValidator(16, 120)\n    )\n    # 背景色：深灰半透明 (R=25, G=25, B=25, A=200)\n    rounded_bg_color = ConfigItem(\"RoundedBgStyle\", \"BgColor\", \"#191919C8\")\n    rounded_bg_text_color = ConfigItem(\"RoundedBgStyle\", \"TextColor\", \"#FFFFFF\")\n    rounded_bg_corner_radius = RangeConfigItem(\n        \"RoundedBgStyle\", \"CornerRadius\", 12, RangeValidator(0, 50)\n    )\n    rounded_bg_padding_h = RangeConfigItem(\n        \"RoundedBgStyle\", \"PaddingH\", 28, RangeValidator(4, 100)\n    )\n    rounded_bg_padding_v = RangeConfigItem(\n        \"RoundedBgStyle\", \"PaddingV\", 14, RangeValidator(4, 50)\n    )\n    rounded_bg_margin_bottom = RangeConfigItem(\n        \"RoundedBgStyle\", \"MarginBottom\", 60, RangeValidator(20, 300)\n    )\n    rounded_bg_line_spacing = RangeConfigItem(\n        \"RoundedBgStyle\", \"LineSpacing\", 10, RangeValidator(0, 50)\n    )\n    rounded_bg_letter_spacing = RangeConfigItem(\n        \"RoundedBgStyle\", \"LetterSpacing\", 0, RangeValidator(0, 20)\n    )\n\n    # ------------------- 保存配置 -------------------\n    work_dir = ConfigItem(\"Save\", \"Work_Dir\", WORK_PATH, FolderValidator())\n\n    # ------------------- 软件页面配置 -------------------\n    micaEnabled = ConfigItem(\"MainWindow\", \"MicaEnabled\", False, BoolValidator())\n    dpiScale = OptionsConfigItem(\n        \"MainWindow\",\n        \"DpiScale\",\n        \"Auto\",\n        OptionsValidator([1, 1.25, 1.5, 1.75, 2, \"Auto\"]),\n        restart=True,\n    )\n    language = OptionsConfigItem(\n        \"MainWindow\",\n        \"Language\",\n        Language.AUTO,\n        OptionsValidator(Language),\n        LanguageSerializer(),\n        restart=True,\n    )\n\n    # ------------------- 更新配置 -------------------\n    checkUpdateAtStartUp = ConfigItem(\n        \"Update\", \"CheckUpdateAtStartUp\", True, BoolValidator()\n    )\n\n    # ------------------- 缓存配置 -------------------\n    cache_enabled = ConfigItem(\"Cache\", \"CacheEnabled\", True, BoolValidator())\n\n\ncfg = Config()\ncfg.themeMode.value = Theme.DARK\ncfg.themeColor.value = QColor(\"#ff28f08b\")\nqconfig.load(SETTINGS_PATH, cfg)\n"
  },
  {
    "path": "app/common/signal_bus.py",
    "content": "from PyQt5.QtCore import QObject, QUrl, pyqtSignal\n\n\nclass SignalBus(QObject):\n    # 字幕排布信号\n    subtitle_layout_changed = pyqtSignal(str)\n    # 字幕优化信号\n    subtitle_optimization_changed = pyqtSignal(bool)\n    # 字幕翻译信号\n    subtitle_translation_changed = pyqtSignal(bool)\n    # 翻译语言\n    target_language_changed = pyqtSignal(str)\n    # 转录模型\n    transcription_model_changed = pyqtSignal(str)\n    # 软字幕信号\n    soft_subtitle_changed = pyqtSignal(bool)\n    # 视频合成信号\n    need_video_changed = pyqtSignal(bool)\n    # 视频质量信号\n    video_quality_changed = pyqtSignal(str)\n    # 使用样式信号\n    use_subtitle_style_changed = pyqtSignal(bool)\n    # 渲染模式变更信号\n    subtitle_render_mode_changed = pyqtSignal(str)\n\n    # 新增视频控制相关信号\n    video_play = pyqtSignal()  # 播放信号\n    video_pause = pyqtSignal()  # 暂停信号\n    video_stop = pyqtSignal()  # 停止信号\n    video_source_changed = pyqtSignal(QUrl)  # 视频源改变信号\n    video_segment_play = pyqtSignal(int, int)  # 播放片段信号，参数为开始和结束时间(ms)\n    video_subtitle_added = pyqtSignal(str)  # 添加字幕文件信号\n\n    # 新增视频控制相关方法\n    def play_video(self):\n        \"\"\"触发视频播放\"\"\"\n        self.video_play.emit()\n\n    def pause_video(self):\n        \"\"\"触发视频暂停\"\"\"\n        self.video_pause.emit()\n\n    def stop_video(self):\n        \"\"\"触发视频停止\"\"\"\n        self.video_stop.emit()\n\n    def set_video_source(self, url: QUrl):\n        \"\"\"设置视频源\n\n        Args:\n            url: 视频文件的URL\n        \"\"\"\n        self.video_source_changed.emit(url)\n\n    def play_video_segment(self, start_time: int, end_time: int):\n        \"\"\"播放指定时间段的视频\n\n        Args:\n            start_time: 开始时间(毫秒)\n            end_time: 结束时间(毫秒)\n        \"\"\"\n        self.video_segment_play.emit(start_time, end_time)\n\n    def add_subtitle(self, subtitle_file: str):\n        \"\"\"添加字幕文件\n\n        Args:\n            subtitle_file: 字幕文件路径\n        \"\"\"\n        self.video_subtitle_added.emit(subtitle_file)\n\n\nsignalBus = SignalBus()\n"
  },
  {
    "path": "app/components/DonateDialog.py",
    "content": "import os\n\nfrom PyQt5.QtCore import Qt\nfrom PyQt5.QtGui import QPixmap\nfrom PyQt5.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout\nfrom qfluentwidgets import BodyLabel, MessageBoxBase\n\nfrom app.config import ASSETS_PATH\n\n\nclass DonateDialog(MessageBoxBase):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        # 定义二维码路径\n        self.WECHAT_QR_PATH = os.path.join(ASSETS_PATH, \"donate_green.jpg\")\n        self.ALIPAY_QR_PATH = os.path.join(ASSETS_PATH, \"donate_blue.jpg\")\n\n        self.setup_ui()\n        self.setWindowTitle(self.tr(\"支持作者\"))\n\n    def setup_ui(self):\n        # 创建标题标签\n        self.titleLabel = BodyLabel(self.tr(\"感谢支持\"), self)\n\n        # 创建说明文本\n        self.descLabel = BodyLabel(\n            self.tr(\n                \"目前本人精力有限，您的支持让我有动力继续折腾这个项目！\\n感谢您对开源事业的热爱与支持！\"\n            ),\n            self,\n        )\n        self.descLabel.setAlignment(Qt.AlignCenter)  # type: ignore\n\n        # 创建水平布局放置两个二维码\n        self.qrLayout = QHBoxLayout()\n\n        # 创建支付宝二维码标签\n        self.alipayContainer = QVBoxLayout()\n        self.alipayQR = QLabel()\n        self.alipayQR.setPixmap(\n            QPixmap(self.ALIPAY_QR_PATH).scaled(\n                300,\n                300,\n                Qt.AspectRatioMode.KeepAspectRatio,\n                Qt.SmoothTransformation,  # type: ignore\n            )\n        )\n        self.alipayLabel = BodyLabel(self.tr(\"支付宝\"))\n        self.alipayLabel.setAlignment(Qt.AlignCenter)  # type: ignore\n        self.alipayContainer.addWidget(self.alipayQR, alignment=Qt.AlignCenter)  # type: ignore\n        self.alipayContainer.addWidget(self.alipayLabel)\n\n        # 创建微信二维码标签\n        self.wechatContainer = QVBoxLayout()\n        self.wechatQR = QLabel()\n        self.wechatQR.setPixmap(\n            QPixmap(self.WECHAT_QR_PATH).scaled(\n                300,\n                300,\n                Qt.AspectRatioMode.KeepAspectRatio,\n                Qt.SmoothTransformation,  # type: ignore\n            )\n        )\n        self.wechatLabel = BodyLabel(self.tr(\"微信\"))\n        self.wechatLabel.setAlignment(Qt.AlignCenter)  # type: ignore\n        self.wechatContainer.addWidget(self.wechatQR, alignment=Qt.AlignCenter)  # type: ignore\n        self.wechatContainer.addWidget(self.wechatLabel)\n\n        # 将二维码添加到水平布局\n        self.qrLayout.addLayout(self.alipayContainer)\n        self.qrLayout.addLayout(self.wechatContainer)\n\n        self.viewLayout.setSpacing(30)\n        # 添加到主布局\n        self.viewLayout.addWidget(self.titleLabel)\n        self.viewLayout.addWidget(self.descLabel)\n        # 添加垂直间距\n        self.viewLayout.addLayout(self.qrLayout)\n\n        # 设置对话框最小宽度\n        self.widget.setMinimumWidth(800)\n        # 设置对话框最小高度\n        self.widget.setMinimumHeight(500)\n\n        # 隐藏是按钮，只显示取消按钮\n        self.yesButton.hide()\n        self.cancelButton.setText(self.tr(\"关闭\"))\n"
  },
  {
    "path": "app/components/EditComboBoxSettingCard.py",
    "content": "from typing import List, Optional, Union\n\nfrom PyQt5.QtCore import Qt, pyqtSignal\nfrom PyQt5.QtGui import QIcon\nfrom PyQt5.QtWidgets import QCompleter\nfrom qfluentwidgets import EditableComboBox, SettingCard\nfrom qfluentwidgets.common.config import ConfigItem, qconfig\n\n\nclass EditComboBoxSettingCard(SettingCard):\n    \"\"\"可编辑的下拉框设置卡片\"\"\"\n\n    currentTextChanged = pyqtSignal(str)\n\n    def __init__(\n        self,\n        configItem: ConfigItem,\n        icon: Union[str, QIcon],\n        title: str,\n        content: Optional[str] = None,\n        items: Optional[List[str]] = None,\n        parent=None,\n    ):\n        super().__init__(icon, title, content, parent)\n\n        self.configItem = configItem\n        self.items = items or []\n\n        # 创建可编辑的组合框\n        self.comboBox = EditableComboBox(self)\n        for item in self.items:\n            self.comboBox.addItem(item)\n\n        # 设置搜索功能\n        self._setupCompleter()\n\n        # 设置布局\n        self.hBoxLayout.addWidget(self.comboBox, 1, Qt.AlignRight)  # type: ignore\n        self.hBoxLayout.addSpacing(16)\n\n        # 设置最小宽度\n        self.comboBox.setMinimumWidth(280)\n\n        # 设置初始值\n        self.setValue(qconfig.get(configItem))\n\n        # 连接信号\n        self.comboBox.currentTextChanged.connect(self.__onTextChanged)\n        configItem.valueChanged.connect(self.setValue)\n\n    def _setupCompleter(self):\n        \"\"\"设置搜索自动完成功能\"\"\"\n        if not self.items:\n            return\n\n        completer = QCompleter(self.items, self)\n        completer.setCaseSensitivity(Qt.CaseInsensitive)  # type: ignore # 不区分大小写\n        completer.setFilterMode(Qt.MatchContains)  # type: ignore # 包含匹配\n        self.comboBox.setCompleter(completer)\n\n    def __onTextChanged(self, text: str):\n        \"\"\"当文本改变时触发\"\"\"\n        self.setValue(text)\n        self.currentTextChanged.emit(text)\n\n    def setValue(self, value: str):\n        \"\"\"设置值\"\"\"\n        qconfig.set(self.configItem, value)\n        self.comboBox.setText(value)\n\n    def addItems(self, items: List[str]):\n        \"\"\"添加选项\"\"\"\n        for item in items:\n            self.comboBox.addItem(item)\n        self.items.extend(items)\n        self._setupCompleter()\n\n    def setItems(self, items: List[str]):\n        \"\"\"重新设置选项列表\"\"\"\n        self.comboBox.clear()\n        self.items = items\n        for item in items:\n            self.comboBox.addItem(item)\n        self._setupCompleter()\n"
  },
  {
    "path": "app/components/FasterWhisperSettingWidget.py",
    "content": "import os\nimport subprocess\nfrom pathlib import Path\n\nfrom PyQt5.QtCore import Qt, QThread, pyqtSignal\nfrom PyQt5.QtGui import QShowEvent\nfrom PyQt5.QtWidgets import (\n    QHBoxLayout,\n    QHeaderView,\n    QTableWidgetItem,\n    QVBoxLayout,\n    QWidget,\n)\nfrom qfluentwidgets import (\n    BodyLabel,\n    ComboBox,\n    ComboBoxSettingCard,\n    HyperlinkButton,\n    HyperlinkCard,\n    InfoBar,\n    InfoBarPosition,\n    MessageBoxBase,\n    ProgressBar,\n    PushButton,\n    SettingCardGroup,\n    SingleDirectionScrollArea,\n    SubtitleLabel,\n    SwitchSettingCard,\n    TableItemDelegate,\n    TableWidget,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.components.LineEditSettingCard import LineEditSettingCard\nfrom app.components.SpinBoxSettingCard import DoubleSpinBoxSettingCard\nfrom app.config import BIN_PATH, MODEL_PATH\nfrom app.core.entities import (\n    FasterWhisperModelEnum,\n    TranscribeLanguageEnum,\n    VadMethodEnum,\n)\nfrom app.core.utils.platform_utils import open_folder\nfrom app.thread.file_download_thread import FileDownloadThread\nfrom app.thread.modelscope_download_thread import ModelscopeDownloadThread\n\n# 在文件开头添加常量定义\nFASTER_WHISPER_PROGRAMS = [\n    {\n        \"label\": \"GPU（cuda） + CPU 版本\",\n        \"value\": \"faster-whisper-gpu.7z\",\n        \"type\": \"GPU\",\n        \"size\": \"1.35 GB\",\n        \"downloadLink\": \"https://modelscope.cn/models/bkfengg/whisper-cpp/resolve/master/Faster-Whisper-XXL_r245.2_windows.7z\",\n    },\n    {\n        \"label\": \"CPU版本\",\n        \"value\": \"faster-whisper.exe\",\n        \"type\": \"CPU\",\n        \"size\": \"78.7 MB\",\n        \"downloadLink\": \"https://modelscope.cn/models/bkfengg/whisper-cpp/resolve/master/whisper-faster.exe\",\n    },\n]\n\nFASTER_WHISPER_MODELS = [\n    {\n        \"label\": \"Tiny\",\n        \"value\": \"faster-whisper-tiny\",\n        \"size\": \"77824\",\n        \"downloadLink\": \"https://huggingface.co/Systran/faster-whisper-tiny\",\n        \"modelScopeLink\": \"pengzhendong/faster-whisper-tiny\",\n    },\n    {\n        \"label\": \"Base\",\n        \"value\": \"faster-whisper-base\",\n        \"size\": \"148480\",\n        \"downloadLink\": \"https://huggingface.co/Systran/faster-whisper-base\",\n        \"modelScopeLink\": \"pengzhendong/faster-whisper-base\",\n    },\n    {\n        \"label\": \"Small\",\n        \"value\": \"faster-whisper-small\",\n        \"size\": \"495616\",\n        \"downloadLink\": \"https://huggingface.co/Systran/faster-whisper-small\",\n        \"modelScopeLink\": \"pengzhendong/faster-whisper-small\",\n    },\n    {\n        \"label\": \"Medium\",\n        \"value\": \"faster-whisper-medium\",\n        \"size\": \"1572864\",\n        \"downloadLink\": \"https://huggingface.co/Systran/faster-whisper-medium\",\n        \"modelScopeLink\": \"pengzhendong/faster-whisper-medium\",\n    },\n    {\n        \"label\": \"Large-v1\",\n        \"value\": \"faster-whisper-large-v1\",\n        \"size\": \"3145728\",\n        \"downloadLink\": \"https://huggingface.co/Systran/faster-whisper-large-v1\",\n        \"modelScopeLink\": \"pengzhendong/faster-whisper-large-v1\",\n    },\n    {\n        \"label\": \"Large-v2\",\n        \"value\": \"faster-whisper-large-v2\",\n        \"size\": \"3145728\",\n        \"downloadLink\": \"https://huggingface.co/Systran/faster-whisper-large-v2\",\n        \"modelScopeLink\": \"pengzhendong/faster-whisper-large-v2\",\n    },\n    {\n        \"label\": \"Large-v3\",\n        \"value\": \"faster-whisper-large-v3\",\n        \"size\": \"3145728\",\n        \"downloadLink\": \"https://huggingface.co/Systran/faster-whisper-large-v3\",\n        \"modelScopeLink\": \"pengzhendong/faster-whisper-large-v3\",\n    },\n    {\n        \"label\": \"Large-v3-turbo\",\n        \"value\": \"faster-whisper-large-v3-turbo\",\n        \"size\": \"1720320\",\n        \"downloadLink\": \"https://huggingface.co/Systran/faster-whisper-large-v3-turbo\",\n        \"modelScopeLink\": \"pengzhendong/faster-whisper-large-v3-turbo\",\n    },\n]\n\n\n# 在类外添加这个工具函数\ndef check_faster_whisper_exists() -> tuple[bool, list[str]]:\n    \"\"\"检查 faster-whisper 程序是否存在\n\n    检查以下两种情况:\n    1. bin目录下是否有 faster-whisper.exe\n    2. bin目录下是否有 Faster-Whisper-XXL/faster-whisper-xxl.exe\n\n    Returns:\n        tuple[bool, list[str]]: (是否存在程序, 已安装的版本列表)\n    \"\"\"\n    bin_path = Path(BIN_PATH)\n    installed_versions = []\n\n    # 检查 faster-whisper.exe(CPU版本)\n    if (bin_path / \"faster-whisper.exe\").exists():\n        installed_versions.append(\"CPU\")\n\n    # 检查 Faster-Whisper-XXL/faster-whisper-xxl.exe(GPU版本)\n    xxl_path = bin_path / \"Faster-Whisper-XXL\" / \"faster-whisper-xxl.exe\"\n    if xxl_path.exists():\n        installed_versions.extend([\"GPU\", \"CPU\"])\n    installed_versions = list(set(installed_versions))\n\n    return bool(installed_versions), installed_versions\n\n\n# 添加新的解压线程类\nclass UnzipThread(QThread):\n    \"\"\"7z解压线程\"\"\"\n\n    finished = pyqtSignal()  # 解压完成信号\n    error = pyqtSignal(str)  # 解压错误信号\n\n    def __init__(self, zip_file, extract_path):\n        super().__init__()\n        self.zip_file = zip_file\n        self.extract_path = extract_path\n\n    def run(self):\n        try:\n            subprocess.run(\n                [\"7z\", \"x\", self.zip_file, f\"-o{self.extract_path}\", \"-y\"],\n                check=True,\n                creationflags=subprocess.CREATE_NO_WINDOW if os.name == \"nt\" else 0,\n            )\n            # 删除压缩包\n            os.remove(self.zip_file)\n            self.finished.emit()\n        except subprocess.CalledProcessError as e:\n            self.error.emit(f\"解压失败: {str(e)}\")\n        except Exception as e:\n            self.error.emit(str(e))\n\n\nclass FasterWhisperDownloadDialog(MessageBoxBase):\n    \"\"\"Faster Whisper 下载对话框\"\"\"\n\n    # 添加类变量跟踪下载状态\n    is_downloading = False\n\n    def __init__(self, parent=None, setting_widget=None):\n        super().__init__(parent)\n        self.widget.setMinimumWidth(600)\n        self.program_download_thread = None\n        self.model_download_thread = None\n        self._setup_ui()\n        self._connect_signals()\n        self.setting_widget = setting_widget\n\n    def _setup_ui(self):\n        \"\"\"设置UI\"\"\"\n        layout = QVBoxLayout()\n        self._setup_program_section(layout)\n        layout.addSpacing(20)\n        self._setup_model_section(layout)\n        self._setup_progress_section(layout)\n\n        self.viewLayout.addLayout(layout)\n        self.cancelButton.setText(self.tr(\"关闭\"))\n        self.yesButton.hide()\n\n    def _setup_program_section(self, layout):\n        \"\"\"设置程序下载部分UI\"\"\"\n        # 标题和按钮的水平布局\n        title_layout = QHBoxLayout()\n\n        # 标题\n        faster_whisper_title = SubtitleLabel(self.tr(\"Faster Whisper 下载\"), self)\n        title_layout.addWidget(faster_whisper_title)\n\n        # 添加打开文件夹按钮\n        open_folder_btn = HyperlinkButton(\"\", self.tr(\"打开程序文件夹\"), parent=self)\n        open_folder_btn.setIcon(FIF.FOLDER)\n        open_folder_btn.clicked.connect(self._open_program_folder)\n        title_layout.addStretch()\n        title_layout.addWidget(open_folder_btn)\n\n        layout.addLayout(title_layout)\n        layout.addSpacing(8)\n\n        # 检查已安装的版本\n        has_program, installed_versions = check_faster_whisper_exists()\n\n        if has_program:\n            # 显示已安装版本\n            versions_text = \" + \".join(installed_versions)\n            program_status = BodyLabel(self.tr(f\"已安装版本: {versions_text}\"), self)\n            program_status.setStyleSheet(\"color: green\")\n            layout.addWidget(program_status)\n\n            # 添加说明标签\n            if len(installed_versions) == 1:\n                desc_label = BodyLabel(self.tr(\"您可以继续下载其他版本:\"), self)\n                layout.addWidget(desc_label)\n        else:\n            desc_label = BodyLabel(self.tr(\"未下载Faster Whisper 程序\"), self)\n            layout.addWidget(desc_label)\n\n        # 下载控件\n        program_layout = QHBoxLayout()\n        self.program_combo = ComboBox(self)\n        self.program_combo.setFixedWidth(300)\n        self.program_combo.hide()\n\n        # 只显示未安装的版本\n        for program in FASTER_WHISPER_PROGRAMS:\n            version_type = program[\"type\"]\n            if version_type not in installed_versions:\n                self.program_combo.addItem(f\"{program['label']} ({program['size']})\")\n\n        # 如果还有可下载的版本，显示下载控件\n        if self.program_combo.count() > 0:\n            self.program_combo.show()\n            self.program_download_btn = PushButton(self.tr(\"下载程序\"), self)\n            self.program_download_btn.clicked.connect(self._start_download)\n            program_layout.addWidget(self.program_combo)\n            program_layout.addWidget(self.program_download_btn)\n            program_layout.addStretch()\n            layout.addLayout(program_layout)\n\n    def _setup_model_section(self, layout):\n        \"\"\"设置模型下载部分UI\"\"\"\n        # 标题和按钮的水平布局\n        title_layout = QHBoxLayout()\n\n        # 标题\n        model_title = SubtitleLabel(self.tr(\"模型下载\"), self)\n        title_layout.addWidget(model_title)\n\n        # 添加打开文件夹按钮\n        open_folder_btn = HyperlinkButton(\"\", self.tr(\"打开模型文件夹\"), parent=self)\n        open_folder_btn.setIcon(FIF.FOLDER)\n        open_folder_btn.clicked.connect(self._open_model_folder)\n        title_layout.addStretch()\n        title_layout.addWidget(open_folder_btn)\n\n        layout.addLayout(title_layout)\n        layout.addSpacing(8)\n\n        # 模型表格\n        self.model_table = self._create_model_table()\n        self._populate_model_table()\n        layout.addWidget(self.model_table)\n\n    def _create_model_table(self):\n        \"\"\"创建模型表格\"\"\"\n        table = TableWidget(self)\n        table.setEditTriggers(TableWidget.NoEditTriggers)\n        table.setSelectionMode(TableWidget.NoSelection)\n        table.setColumnCount(4)\n        table.setHorizontalHeaderLabels(\n            [self.tr(\"模型名称\"), self.tr(\"大小\"), self.tr(\"状态\"), self.tr(\"操作\")]\n        )\n\n        # 设置表格样式\n        table.setBorderVisible(True)\n        table.setBorderRadius(8)\n        table.setItemDelegate(TableItemDelegate(table))\n\n        # 设置列宽\n        header = table.horizontalHeader()\n        header.setSectionResizeMode(0, QHeaderView.Stretch)\n        header.setSectionResizeMode(1, QHeaderView.Fixed)\n        header.setSectionResizeMode(2, QHeaderView.Fixed)\n        header.setSectionResizeMode(3, QHeaderView.Fixed)\n\n        table.setColumnWidth(1, 100)\n        table.setColumnWidth(2, 80)\n        table.setColumnWidth(3, 150)\n\n        # 设置行高\n        row_height = 45\n        table.verticalHeader().setDefaultSectionSize(row_height)\n\n        # 设置表格高度\n        header_height = 20\n        max_visible_rows = 6\n        table_height = row_height * max_visible_rows + header_height + 15\n        table.setFixedHeight(table_height)\n\n        return table\n\n    def _setup_progress_section(self, layout):\n        \"\"\"设置进度显示部分UI\"\"\"\n        self.progress_bar = ProgressBar(self)\n        self.progress_label = BodyLabel(\"\", self)\n        self.progress_bar.hide()\n        self.progress_label.hide()\n\n        layout.addWidget(self.progress_bar)\n        layout.addWidget(self.progress_label)\n\n    def _populate_model_table(self):\n        \"\"\"填充模型表格数据\"\"\"\n        self.model_table.setRowCount(len(FASTER_WHISPER_MODELS))\n        for i, model in enumerate(FASTER_WHISPER_MODELS):\n            self._add_model_row(i, model)\n\n    def _add_model_row(self, row, model):\n        \"\"\"添加模型表格行\"\"\"\n        # 模型名称\n        name_item = QTableWidgetItem(model[\"label\"])\n        name_item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n        self.model_table.setItem(row, 0, name_item)\n\n        # 大小\n        size_item = QTableWidgetItem(f\"{int(model['size']) / 1024:.1f} MB\")\n        size_item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n        self.model_table.setItem(row, 1, size_item)\n\n        # 状态 - 检查model.bin文件是否存在\n        model_path = os.path.join(MODEL_PATH, model[\"value\"])\n        model_bin_path = os.path.join(model_path, \"model.bin\")\n        is_downloaded = os.path.exists(model_bin_path)\n\n        status_item = QTableWidgetItem(\n            self.tr(\"已下载\") if is_downloaded else self.tr(\"未下载\")\n        )\n        if is_downloaded:\n            status_item.setForeground(Qt.green)  # type: ignore\n        status_item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n        self.model_table.setItem(row, 2, status_item)\n\n        # 下载按钮\n        button_container = QWidget()\n        button_layout = QHBoxLayout(button_container)\n        button_layout.setContentsMargins(4, 4, 4, 4)\n\n        download_btn = HyperlinkButton(\n            \"\",\n            self.tr(\"重新下载\") if is_downloaded else self.tr(\"下载\"),\n            parent=self,\n        )\n        download_btn.setIcon(FIF.DOWNLOAD)\n        download_btn.clicked.connect(lambda checked, r=row: self._download_model(r))\n\n        button_layout.addStretch()\n        button_layout.addWidget(download_btn)\n        button_layout.addStretch()\n        self.model_table.setCellWidget(row, 3, button_container)\n\n    def _connect_signals(self):\n        \"\"\"连接信号\"\"\"\n        self.rejected.connect(self._on_dialog_reject)\n\n    def _start_download(self):\n        \"\"\"开始下载\"\"\"\n        if FasterWhisperDownloadDialog.is_downloading:\n            InfoBar.warning(\n                self.tr(\"下载进行中\"),\n                self.tr(\"请等待当前下载任务完成\"),\n                duration=3000,\n                parent=self,\n            )\n            return\n\n        FasterWhisperDownloadDialog.is_downloading = True\n        # 禁用所有下载按钮\n        self._set_all_download_buttons_enabled(False)\n\n        # 获取选中的文本\n        selected_text = self.program_combo.currentText()\n\n        # 从显示文本中提取程序标签\n        selected_label = selected_text.split(\" (\")[0]\n\n        # 根据标签找到对应的程序配置\n        program = next(\n            (p for p in FASTER_WHISPER_PROGRAMS if p[\"label\"] == selected_label), None\n        )\n\n        if not program:\n            InfoBar.error(\n                self.tr(\"下载错误\"),\n                self.tr(\"未找到对应的程序配置\"),\n                duration=3000,\n                parent=self,\n            )\n            FasterWhisperDownloadDialog.is_downloading = False\n            self._set_all_download_buttons_enabled(True)\n            return\n\n        # 确保 BIN_PATH 目录存在\n        os.makedirs(BIN_PATH, exist_ok=True)\n\n        self.progress_bar.show()\n        self.progress_label.show()\n        self.program_download_btn.setEnabled(False)\n        self.program_combo.setEnabled(False)\n\n        # 直接下载到bin目录\n        save_path = os.path.join(BIN_PATH, program[\"value\"])\n\n        self.program_download_thread = FileDownloadThread(\n            program[\"downloadLink\"], save_path\n        )\n        self.program_download_thread.progress.connect(\n            self._on_program_download_progress\n        )\n        self.program_download_thread.finished.connect(\n            lambda: self._on_program_download_finished(save_path)\n        )\n        self.program_download_thread.error.connect(self._on_program_download_error)\n        self.program_download_thread.start()\n\n    def _on_program_download_progress(self, value, status_msg):\n        \"\"\"更新程序下载进度\"\"\"\n        self.progress_bar.setValue(int(value))\n        self.progress_label.setText(status_msg)\n\n    def _on_program_download_finished(self, save_path):\n        \"\"\"程序下载完成处理\"\"\"\n        try:\n            # 检查是否是 CPU 版本的直接下载\n            if save_path.endswith(\".exe\"):\n                # 如果是exe文件,重命名为faster-whisper.exe\n                os.rename(save_path, os.path.join(BIN_PATH, \"faster-whisper.exe\"))\n                self._finish_program_installation()\n            else:\n                # GPU 版本需要解压\n                self.progress_label.setText(self.tr(\"正在解压文件...\"))\n\n                # 创建并启动解压线程\n                self.unzip_thread = UnzipThread(save_path, BIN_PATH)\n                self.unzip_thread.finished.connect(self._finish_program_installation)\n                self.unzip_thread.error.connect(self._on_unzip_error)\n                self.unzip_thread.start()\n                return  # 提前返回,等待解压完成\n\n        except Exception as e:\n            InfoBar.error(self.tr(\"安装失败\"), str(e), duration=3000, parent=self)\n            self._cleanup_installation()\n\n    def _on_program_download_error(self, error):\n        \"\"\"程序下载错误处理\"\"\"\n        InfoBar.error(self.tr(\"下载失败\"), error, duration=3000, parent=self)\n        FasterWhisperDownloadDialog.is_downloading = False\n        self._set_all_download_buttons_enabled(True)\n        self.program_download_btn.setEnabled(True)\n        self.program_combo.setEnabled(True)\n        self.progress_bar.hide()\n        self.progress_label.hide()\n\n    def _on_dialog_reject(self):\n        \"\"\"对话框关闭处理\"\"\"\n        if self.program_download_thread and self.program_download_thread.isRunning():\n            self.program_download_thread.stop()\n        if self.model_download_thread and self.model_download_thread.isRunning():\n            self.model_download_thread.terminate()\n        FasterWhisperDownloadDialog.is_downloading = False\n        self.reject()\n\n    def closeEvent(self, event):\n        \"\"\"窗口关闭事件处理\"\"\"\n        self._on_dialog_reject()\n        super().closeEvent(event)\n\n    def _download_model(self, row):\n        \"\"\"下载选中的模型\"\"\"\n        if FasterWhisperDownloadDialog.is_downloading:\n            InfoBar.warning(\n                self.tr(\"下载进行中\"),\n                self.tr(\"请等待当前下载任务完成\"),\n                duration=3000,\n                parent=self,\n            )\n            return\n\n        FasterWhisperDownloadDialog.is_downloading = True\n        self._set_all_download_buttons_enabled(False)\n\n        model = FASTER_WHISPER_MODELS[row]\n        self.progress_bar.show()\n        self.progress_label.show()\n        self.progress_label.setText(self.tr(f\"正在下载 {model['label']} 模型...\"))\n\n        # 禁用当前行的下载按钮\n        button_container = self.model_table.cellWidget(row, 3)\n        download_btn = button_container.findChild(HyperlinkButton)\n        if download_btn:\n            download_btn.setEnabled(False)\n\n        # 创建并启动下载线程，保存到类属性\n        self.model_download_thread = ModelscopeDownloadThread(\n            model[\"modelScopeLink\"], os.path.join(MODEL_PATH, model[\"value\"])\n        )\n\n        def _on_model_download_progress(value, msg):\n            self.progress_bar.setValue(value)\n            self.progress_label.setText(msg)\n\n        def _on_model_download_finished():\n            FasterWhisperDownloadDialog.is_downloading = False\n            self._set_all_download_buttons_enabled(True)\n            # 更新状态\n            status_item = QTableWidgetItem(self.tr(\"已下载\"))\n            status_item.setForeground(Qt.green)  # type: ignore\n            status_item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n            self.model_table.setItem(row, 2, status_item)\n\n            # 更新下载按钮文本\n            if download_btn:\n                download_btn.setText(self.tr(\"重新下载\"))\n                download_btn.setEnabled(True)\n\n            model = FASTER_WHISPER_MODELS[row]\n\n            # 更新主设置对话框的模型选择\n            if self.setting_widget:\n                # 保存当前值并清空\n                current_value = cfg.faster_whisper_model.value\n                combo = self.setting_widget.model_card.comboBox\n                combo.clear()\n\n                # 找出已下载的模型\n                available = []\n                model_map = {\n                    m[\"label\"].lower(): m[\"value\"] for m in FASTER_WHISPER_MODELS\n                }\n                for enum_val in FasterWhisperModelEnum:\n                    if enum_val.value in model_map:\n                        if (MODEL_PATH / model_map[enum_val.value]).exists():\n                            available.append(enum_val)\n\n                # 重建下拉框\n                self.setting_widget.model_card.optionToText = {\n                    e: e.value for e in available\n                }\n                for enum_val in available:\n                    combo.addItem(enum_val.value, userData=enum_val)\n\n                # 恢复选择\n                if current_value in available:\n                    combo.setCurrentText(current_value.value)\n                elif combo.count() > 0:\n                    combo.setCurrentIndex(0)\n\n            InfoBar.success(\n                self.tr(\"下载成功\"),\n                self.tr(f\"{model['label']} 模型已下载完成\"),\n                duration=3000,\n                parent=self,\n            )\n            self.progress_bar.hide()\n            self.progress_label.hide()\n\n        def _on_model_download_error(error):\n            FasterWhisperDownloadDialog.is_downloading = False\n            self._set_all_download_buttons_enabled(True)\n            if download_btn:\n                download_btn.setEnabled(True)\n\n            InfoBar.error(self.tr(\"下载失败\"), str(error), duration=3000, parent=self)\n            self.progress_bar.hide()\n            self.progress_label.hide()\n\n        self.model_download_thread.progress.connect(_on_model_download_progress)\n        self.model_download_thread.finished.connect(_on_model_download_finished)\n        self.model_download_thread.error.connect(_on_model_download_error)\n        self.model_download_thread.start()\n\n    def _set_all_download_buttons_enabled(self, enabled: bool):\n        \"\"\"设置所有下载按钮的启用状态\"\"\"\n        # 设置程序下载按钮\n        if hasattr(self, \"program_download_btn\"):\n            self.program_download_btn.setEnabled(enabled)\n            self.program_combo.setEnabled(enabled)\n\n        # 设置所有模型下载按钮\n        for row in range(self.model_table.rowCount()):\n            button_container = self.model_table.cellWidget(row, 3)\n            if button_container:\n                download_btn = button_container.findChild(HyperlinkButton)\n                if download_btn:\n                    download_btn.setEnabled(enabled)\n\n    def _open_model_folder(self):\n        \"\"\"打开模型文件夹\"\"\"\n        if os.path.exists(MODEL_PATH):\n            # 根据操作系统打开文件夹\n            open_folder(str(MODEL_PATH))\n\n    def _open_program_folder(self):\n        \"\"\"打开程序文件夹\"\"\"\n        if os.path.exists(BIN_PATH):\n            # 根据操作系统打开文件夹\n            open_folder(str(BIN_PATH))\n\n    def _finish_program_installation(self):\n        \"\"\"完成程序安装\"\"\"\n        InfoBar.success(\n            self.tr(\"安装完成\"),\n            self.tr(\"Faster Whisper 程序已安装成功\"),\n            duration=3000,\n            parent=self,\n        )\n        self.accept()\n        self._cleanup_installation()\n\n    def _on_unzip_error(self, error_msg):\n        \"\"\"处理解压错误\"\"\"\n        InfoBar.error(self.tr(\"安装失败\"), error_msg, duration=3000, parent=self)\n        self._cleanup_installation()\n\n    def _cleanup_installation(self):\n        \"\"\"清理安装状态\"\"\"\n        FasterWhisperDownloadDialog.is_downloading = False\n        self._set_all_download_buttons_enabled(True)\n        self.progress_bar.hide()\n        self.progress_label.hide()\n\n\nclass FasterWhisperSettingWidget(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setup_ui()\n        self._connect_signals()\n\n    def showEvent(self, a0: QShowEvent) -> None:\n        super().showEvent(a0)\n        # 检查Faster Whisper模型是否存在\n        is_faster_whisper_exists, _ = check_faster_whisper_exists()\n        if not is_faster_whisper_exists:\n            self.show_error_info(self.tr(\"Faster Whisper程序不存在，请先下载程序\"))\n            self._show_model_manager()\n        return\n\n    def setup_ui(self):\n        self.main_layout = QVBoxLayout(self)\n\n        # 创建单向滚动区域和容器\n        self.scrollArea = SingleDirectionScrollArea(orient=Qt.Vertical, parent=self)  # type: ignore\n        self.scrollArea.setStyleSheet(\n            \"QScrollArea{background: transparent; border: none}\"\n        )\n\n        self.container = QWidget(self)\n        self.container.setStyleSheet(\"QWidget{background: transparent}\")\n        self.containerLayout = QVBoxLayout(self.container)\n\n        self.setting_group = SettingCardGroup(\n            self.tr(\"Faster Whisper 设置\"), self\n        )\n\n        # 模型选择\n        self.model_card = ComboBoxSettingCard(\n            cfg.faster_whisper_model,\n            FIF.ROBOT,\n            self.tr(\"模型\"),\n            self.tr(\"选择 Faster Whisper 模型\"),\n            [model.value for model in FasterWhisperModelEnum],\n            self.setting_group,\n        )\n\n        # 检查未下载的模型并从下拉框中移除\n        for i in range(self.model_card.comboBox.count() - 1, -1, -1):\n            model_text = self.model_card.comboBox.itemText(i).lower()\n            model_config = next(\n                (\n                    model\n                    for model in FASTER_WHISPER_MODELS\n                    if model[\"label\"].lower() == model_text\n                ),\n                None,\n            )\n            if model_config:\n                model_path = Path(MODEL_PATH) / model_config[\"value\"]\n                model_bin_path = model_path / \"model.bin\"\n                if model_bin_path.exists():\n                    continue\n            self.model_card.comboBox.removeItem(i)\n\n        # 创建管理模型卡片\n        self.manage_model_card = HyperlinkCard(\n            \"\",  # 无链接\n            self.tr(\"管理模型\"),\n            FIF.DOWNLOAD,  # 使用下载图标\n            self.tr(\"模型管理\"),\n            self.tr(\"下载或更新 Faster Whisper 模型\"),\n            self.setting_group,  # 添加到设置组\n        )\n\n        # 语言选择\n        self.language_card = ComboBoxSettingCard(\n            cfg.transcribe_language,\n            FIF.LANGUAGE,\n            self.tr(\"源语言\"),\n            self.tr(\"音视频中说话的语言，默认根据前30秒自动识别\"),\n            [lang.value for lang in TranscribeLanguageEnum],\n            self.setting_group,\n        )\n        self.language_card.comboBox.setMaxVisibleItems(6)\n\n        # 设备选择\n        self.device_card = ComboBoxSettingCard(\n            cfg.faster_whisper_device,\n            FIF.IOT,\n            self.tr(\"运行设备\"),\n            self.tr(\"模型运行设备\"),\n            [\"cuda\", \"cpu\"],\n            self.setting_group,\n        )\n        # _, available_devices = check_faster_whisper_exists()\n        # if \"GPU\" not in available_devices:\n        #     self.device_card.comboBox.removeItem(0)\n\n        # VAD设置组\n        self.vad_group = SettingCardGroup(self.tr(\"VAD设置\"), self)\n\n        # VAD过滤开关\n        self.vad_filter_card = SwitchSettingCard(\n            FIF.CHECKBOX,\n            self.tr(\"VAD过滤\"),\n            self.tr(\"过滤无人声语音片断，减少幻觉\"),\n            cfg.faster_whisper_vad_filter,\n            self.vad_group,\n        )\n\n        # VAD阈值\n        self.vad_threshold_card = DoubleSpinBoxSettingCard(\n            cfg.faster_whisper_vad_threshold,\n            FIF.VOLUME,  # type: ignore\n            self.tr(\"VAD阈值\"),\n            self.tr(\"语音概率阈值，高于此值视为语音\"),\n            minimum=0.00,\n            maximum=1.00,\n            decimals=2,\n            step=0.05,\n        )\n\n        # VAD方法\n        self.vad_method_card = ComboBoxSettingCard(\n            cfg.faster_whisper_vad_method,\n            FIF.MUSIC,\n            self.tr(\"VAD方法\"),\n            self.tr(\"选择VAD检测方法\"),\n            [method.value for method in VadMethodEnum],\n            self.vad_group,\n        )\n\n        # 其他设置组\n        self.other_group = SettingCardGroup(self.tr(\"其他设置\"), self)\n\n        # 音频降噪\n        self.ff_mdx_kim2_card = SwitchSettingCard(\n            FIF.MUSIC,\n            self.tr(\"人声分离\"),\n            self.tr(\"处理前使用MDX-Net降噪，分离人声和背景音乐\"),\n            cfg.faster_whisper_ff_mdx_kim2,\n            self.other_group,\n        )\n\n        # 单词时间戳\n        self.one_word_card = SwitchSettingCard(\n            FIF.UNIT,\n            self.tr(\"单字时间戳\"),\n            self.tr(\"开启生成单字级时间戳；关闭后使用原始分段断句\"),\n            cfg.faster_whisper_one_word,\n            self.other_group,\n        )\n\n        # 提示词\n        self.prompt_card = LineEditSettingCard(\n            cfg.faster_whisper_prompt,\n            FIF.CHAT,\n            self.tr(\"提示词\"),\n            self.tr(\"可选的提示词,默认空\"),\n            \"\",\n            self.other_group,\n        )\n\n        # 添加模型设置组的卡片\n        self.setting_group.addSettingCard(self.model_card)\n        self.setting_group.addSettingCard(self.manage_model_card)\n        self.setting_group.addSettingCard(self.device_card)\n        self.setting_group.addSettingCard(self.language_card)\n\n        # 添加VAD设置组的卡片\n        self.vad_group.addSettingCard(self.vad_filter_card)\n        self.vad_group.addSettingCard(self.vad_threshold_card)\n        self.vad_group.addSettingCard(self.vad_method_card)\n\n        # 添加其他设置的卡片\n        self.other_group.addSettingCard(self.ff_mdx_kim2_card)\n        self.other_group.addSettingCard(self.one_word_card)\n        self.other_group.addSettingCard(self.prompt_card)\n\n        # 将所有设置组添加到容器布局\n        self.containerLayout.addWidget(self.setting_group)\n        self.containerLayout.addWidget(self.vad_group)\n        self.containerLayout.addWidget(self.other_group)\n        self.containerLayout.addStretch(1)\n\n        # 设置组件最小宽度\n        self.model_card.comboBox.setMinimumWidth(200)\n        self.device_card.comboBox.setMinimumWidth(200)\n        self.language_card.comboBox.setMinimumWidth(200)\n        self.vad_method_card.comboBox.setMinimumWidth(200)\n        self.prompt_card.lineEdit.setMinimumWidth(200)\n\n        # 设置滚动区域\n        self.scrollArea.setWidget(self.container)\n        self.scrollArea.setWidgetResizable(True)\n\n        # 将滚动区域添加到主布局\n        self.main_layout.addWidget(self.scrollArea)\n\n    def _connect_signals(self):\n        \"\"\"连接信号\"\"\"\n        self.manage_model_card.linkButton.clicked.connect(self._show_model_manager)\n        self.vad_filter_card.checkedChanged.connect(self._on_vad_filter_changed)\n\n    def _on_vad_filter_changed(self, checked: bool):\n        \"\"\"VAD过滤开关状态改变时的处理\"\"\"\n        self.vad_threshold_card.setEnabled(checked)\n        self.vad_method_card.setEnabled(checked)\n\n    def _show_model_manager(self):\n        \"\"\"显示模型管理对话框\"\"\"\n        dialog = FasterWhisperDownloadDialog(self.window(), self)\n        dialog.exec_()\n\n    def show_error_info(self, error_msg):\n        \"\"\"显示错误信息\"\"\"\n        InfoBar.error(\n            title=self.tr(\"错误\"),\n            content=error_msg,\n            parent=self.window(),\n            duration=5000,\n            position=InfoBarPosition.BOTTOM,\n        )\n\n    def check_faster_whisper_model(self):\n        \"\"\"检查选定的Faster Whisper模型是否存在\n\n        Returns:\n            bool: 如果模型存在且配置正确返回True，否则返回False\n        \"\"\"\n        # 检查程序是否存在\n        has_program, _ = check_faster_whisper_exists()\n        if not has_program:\n            self.show_error_info(self.tr(\"Faster Whisper程序不存在，请先下载程序\"))\n            return False\n\n        model_value = cfg.faster_whisper_model.value.value\n        # 检查模型配置是否存在\n        model_config = next(\n            (\n                m\n                for m in FASTER_WHISPER_MODELS\n                if m[\"label\"].lower() == model_value.lower()\n            ),\n            None,\n        )\n        if not model_config:\n            self.show_error_info(self.tr(\"模型配置不存在\"))\n            return False\n\n        model_path = MODEL_PATH / model_config[\"value\"]\n        model_files = model_path / \"model.bin\"\n        # 检查模型文件是否存在\n        if not model_path.exists() and not model_files.exists():\n            self.show_error_info(self.tr(\"模型文件不存在: \") + model_value)\n            return False\n        return True\n"
  },
  {
    "path": "app/components/LanguageSettingDialog.py",
    "content": "from PyQt5.QtWidgets import QVBoxLayout\nfrom qfluentwidgets import (\n    ComboBox,\n    InfoBar,\n    InfoBarPosition,\n    MessageBoxBase,\n    SettingCard,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.core.entities import (\n    TranscribeLanguageEnum,\n    TranscribeModelEnum,\n    get_asr_language_capability,\n)\n\n\nclass LanguageSettingDialog(MessageBoxBase):\n    \"\"\"语言设置对话框\"\"\"\n\n    def __init__(self, model: TranscribeModelEnum, parent=None):\n        self.model = model\n        super().__init__(parent)\n        self.widget.setMinimumWidth(500)\n        self._setup_ui()\n        self._connect_signals()\n\n    def _get_available_languages(self) -> list[str]:\n        \"\"\"获取当前模型支持的语言列表\"\"\"\n        capability = get_asr_language_capability(self.model)\n        languages = [lang.value for lang in capability.supported_languages]\n        if capability.supports_auto:\n            languages.insert(0, TranscribeLanguageEnum.AUTO.value)\n        return languages\n\n    def _setup_ui(self):\n        \"\"\"设置UI\"\"\"\n        self.yesButton.setText(self.tr(\"确定\"))\n        self.cancelButton.setText(self.tr(\"取消\"))\n\n        # 主布局\n        layout = QVBoxLayout()\n\n        # 使用自定义 SettingCard 代替 ComboBoxSettingCard（因为需要动态选项）\n        self.language_card = SettingCard(\n            FIF.LANGUAGE,\n            self.tr(\"源语言\"),\n            self.tr(\"音视频中说话的语言，默认根据前30秒自动识别\"),\n            self,\n        )\n\n        # 创建 ComboBox\n        self.language_combo = ComboBox(self)\n        available_languages = self._get_available_languages()\n        self.language_combo.addItems(available_languages)\n        self.language_combo.setMaxVisibleItems(6)\n        self.language_combo.setMinimumWidth(160)\n\n        # 设置当前值\n        current_lang = cfg.transcribe_language.value\n        if current_lang.value in available_languages:\n            self.language_combo.setCurrentText(current_lang.value)\n        elif available_languages:\n            # 当前选择的语言不在可选列表中，选择第一个\n            self.language_combo.setCurrentIndex(0)\n\n        # 添加 ComboBox 到卡片\n        self.language_card.hBoxLayout.addWidget(self.language_combo)\n        self.language_card.hBoxLayout.addSpacing(16)\n\n        layout.addWidget(self.language_card)\n        layout.addStretch(1)\n\n        self.viewLayout.addLayout(layout)\n\n    def _connect_signals(self):\n        \"\"\"连接信号\"\"\"\n        self.yesButton.clicked.connect(self.__onYesButtonClicked)\n\n    def __onYesButtonClicked(self):\n        # 保存选中的语言到配置\n        selected_text = self.language_combo.currentText()\n        for lang in TranscribeLanguageEnum:\n            if lang.value == selected_text:\n                cfg.set(cfg.transcribe_language, lang)\n                break\n\n        self.accept()\n        InfoBar.success(\n            self.tr(\"设置已保存\"),\n            self.tr(\"语言设置已更新\"),\n            duration=3000,\n            parent=self.window(),\n            position=InfoBarPosition.BOTTOM,\n        )\n        if cfg.transcribe_language.value == TranscribeLanguageEnum.JAPANESE:\n            InfoBar.warning(\n                self.tr(\"请注意身体！！\"),\n                self.tr(\"小心肝儿,注意身体哦~\"),\n                duration=2000,\n                parent=self.window(),\n                position=InfoBarPosition.BOTTOM,\n            )\n"
  },
  {
    "path": "app/components/LineEditSettingCard.py",
    "content": "from typing import Optional\n\nfrom PyQt5.QtCore import Qt, pyqtSignal\nfrom qfluentwidgets import LineEdit, SettingCard\nfrom qfluentwidgets.common.config import ConfigItem, qconfig\n\n\nclass LineEditSettingCard(SettingCard):\n    \"\"\"行输入卡片\"\"\"\n\n    textChanged = pyqtSignal(str)\n\n    def __init__(\n        self,\n        configItem: ConfigItem,\n        icon,\n        title: str,\n        content: Optional[str] = None,\n        placeholder: str = \"\",\n        parent=None,\n    ):\n        super().__init__(icon, title, content, parent)\n\n        self.configItem = configItem\n\n        self.lineEdit = LineEdit(self)\n        self.lineEdit.setPlaceholderText(placeholder)\n        self.hBoxLayout.addWidget(self.lineEdit, 1, Qt.AlignRight)  # type: ignore\n        self.hBoxLayout.addSpacing(16)\n\n        self.lineEdit.setMinimumWidth(280)\n\n        self.setValue(qconfig.get(configItem))\n\n        self.lineEdit.textChanged.connect(self.__onTextChanged)\n        configItem.valueChanged.connect(self.setValue)\n\n    def __onTextChanged(self, text: str):\n        self.setValue(text)\n        self.textChanged.emit(text)\n\n    def setValue(self, value: str):\n        qconfig.set(self.configItem, value)\n        self.lineEdit.setText(value)\n"
  },
  {
    "path": "app/components/MySettingCard.py",
    "content": "# coding:utf-8\nfrom typing import List, Optional, Union\n\nfrom PyQt5.QtCore import Qt, pyqtSignal\nfrom PyQt5.QtGui import QColor, QIcon, QPainter\nfrom PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QToolButton, QVBoxLayout\nfrom qfluentwidgets import ColorDialog, ComboBox, CompactDoubleSpinBox, CompactSpinBox\nfrom qfluentwidgets.common.config import isDarkTheme\nfrom qfluentwidgets.common.icon import FluentIconBase, drawIcon\nfrom qfluentwidgets.common.style_sheet import FluentStyleSheet\nfrom qfluentwidgets.components.widgets.icon_widget import IconWidget\n\n\nclass SettingIconWidget(IconWidget):\n    def paintEvent(self, e):\n        painter = QPainter(self)\n\n        if not self.isEnabled():\n            painter.setOpacity(0.36)\n\n        painter.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)\n        drawIcon(self._icon, painter, self.rect())\n\n\nclass SettingCard(QFrame):\n    \"\"\"Setting card\"\"\"\n\n    def __init__(\n        self, icon: Union[str, QIcon, FluentIconBase], title, content=None, parent=None\n    ):\n        \"\"\"\n        Parameters\n        ----------\n        icon: str | QIcon | FluentIconBase\n            the icon to be drawn\n\n        title: str\n            the title of card\n\n        content: str\n            the content of card\n\n        parent: QWidget\n            parent widget\n        \"\"\"\n        super().__init__(parent=parent)\n        self.iconLabel = SettingIconWidget(icon, self)\n        self.titleLabel = QLabel(title, self)\n        self.contentLabel = QLabel(content or \"\", self)\n        self.hBoxLayout = QHBoxLayout(self)\n        self.vBoxLayout = QVBoxLayout()\n\n        if not content:\n            self.contentLabel.hide()\n\n        self.setFixedHeight(70 if content else 50)\n        self.iconLabel.setFixedSize(16, 16)\n\n        # initialize layout\n        self.hBoxLayout.setSpacing(0)\n        self.hBoxLayout.setContentsMargins(16, 0, 0, 0)\n        self.hBoxLayout.setAlignment(Qt.AlignVCenter)  # type: ignore\n        self.vBoxLayout.setSpacing(0)\n        self.vBoxLayout.setContentsMargins(0, 0, 0, 0)\n        self.vBoxLayout.setAlignment(Qt.AlignVCenter)  # type: ignore\n\n        self.hBoxLayout.addWidget(self.iconLabel, 0, Qt.AlignLeft)  # type: ignore\n        self.hBoxLayout.addSpacing(16)\n\n        self.hBoxLayout.addLayout(self.vBoxLayout)\n        self.vBoxLayout.addWidget(self.titleLabel, 0, Qt.AlignLeft)  # type: ignore\n        self.vBoxLayout.addWidget(self.contentLabel, 0, Qt.AlignLeft)  # type: ignore\n\n        self.hBoxLayout.addSpacing(16)\n        self.hBoxLayout.addStretch(1)\n\n        self.contentLabel.setObjectName(\"contentLabel\")\n        FluentStyleSheet.SETTING_CARD.apply(self)\n\n    def setTitle(self, title: str):\n        \"\"\"set the title of card\"\"\"\n        self.titleLabel.setText(title)\n\n    def setContent(self, content: str):\n        \"\"\"set the content of card\"\"\"\n        self.contentLabel.setText(content)\n        self.contentLabel.setVisible(bool(content))\n\n    def setValue(self, value):\n        \"\"\"set the value of config item\"\"\"\n        pass\n\n    def setIconSize(self, width: int, height: int):\n        \"\"\"set the icon fixed size\"\"\"\n        self.iconLabel.setFixedSize(width, height)\n\n    def paintEvent(self, e):\n        painter = QPainter(self)\n        painter.setRenderHints(QPainter.Antialiasing)\n\n        if isDarkTheme():\n            painter.setBrush(QColor(255, 255, 255, 13))\n            painter.setPen(QColor(0, 0, 0, 50))\n        else:\n            painter.setBrush(QColor(255, 255, 255, 170))\n            painter.setPen(QColor(0, 0, 0, 19))\n\n        painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 6, 6)\n\n\nclass DoubleSpinBoxSettingCard(SettingCard):\n    \"\"\"小数输入设置卡片\"\"\"\n\n    valueChanged = pyqtSignal(float)\n\n    def __init__(\n        self,\n        icon: Union[str, QIcon, FluentIconBase],\n        title: str,\n        content: Optional[str] = None,\n        minimum: float = 0.0,\n        maximum: float = 100.0,\n        decimals: int = 1,\n        parent=None,\n    ):\n        super().__init__(icon, title, content, parent)\n\n        # 创建CompactDoubleSpinBox\n        self.spinBox = CompactDoubleSpinBox(self)\n        self.spinBox.setRange(minimum, maximum)\n        self.spinBox.setDecimals(decimals)\n        self.spinBox.setMinimumWidth(60)\n        self.spinBox.setSingleStep(0.2)  # 设置步长为0.1\n\n        # 添加到布局\n        self.hBoxLayout.addWidget(self.spinBox, 0, Qt.AlignRight)  # type: ignore\n        self.hBoxLayout.addSpacing(8)\n\n        # 设置初始值和连接信号\n        self.spinBox.valueChanged.connect(self.__onValueChanged)\n\n    def __onValueChanged(self, value: float):\n        \"\"\"数值改变时的槽函数\"\"\"\n        self.setValue(value)\n        self.valueChanged.emit(value)\n\n    def setValue(self, value: float):\n        \"\"\"设置数值\"\"\"\n        self.spinBox.setValue(value)\n\n\nclass SpinBoxSettingCard(SettingCard):\n    \"\"\"数值输入设置卡片\"\"\"\n\n    valueChanged = pyqtSignal(int)\n\n    def __init__(\n        self,\n        icon: Union[str, QIcon],\n        title: str,\n        content: Optional[str] = None,\n        minimum: int = 0,\n        maximum: int = 100,\n        step: int = 2,\n        parent=None,\n    ):\n        super().__init__(icon, title, content, parent)\n\n        # 创建SpinBox\n        self.spinBox = CompactSpinBox(self)\n        self.spinBox.setRange(minimum, maximum)\n        self.spinBox.setMinimumWidth(60)\n        self.spinBox.setSingleStep(step)\n\n        # 添加到布局\n        self.hBoxLayout.addWidget(self.spinBox, 0, Qt.AlignRight)  # type: ignore\n        self.hBoxLayout.addSpacing(8)\n\n        # 设置初始值和连接信号\n        self.spinBox.valueChanged.connect(self.__onValueChanged)\n\n    def __onValueChanged(self, value: int):\n        \"\"\"数值改变时的槽函数\"\"\"\n        self.setValue(value)\n        self.valueChanged.emit(value)\n\n    def setValue(self, value: int):\n        \"\"\"设置数值\"\"\"\n        self.spinBox.setValue(value)\n\n\nclass ComboBoxSettingCard(SettingCard):\n    \"\"\"下拉框设置卡片\"\"\"\n\n    currentTextChanged = pyqtSignal(str)\n    currentIndexChanged = pyqtSignal(int)\n\n    def __init__(\n        self,\n        icon: Union[str, QIcon],\n        title: str,\n        content: Optional[str] = None,\n        texts: Optional[List[str]] = None,\n        parent=None,\n    ):\n        super().__init__(icon, title, content, parent)\n\n        # 创建ComboBox\n        self.comboBox = ComboBox(self)\n        self.hBoxLayout.addWidget(self.comboBox, 0, Qt.AlignRight)  # type: ignore\n        self.hBoxLayout.addSpacing(16)\n\n        # 添加选项\n        if texts:\n            for text in texts:\n                self.comboBox.addItem(text)\n\n        # 连接信号\n        self.comboBox.currentTextChanged.connect(self.__onCurrentTextChanged)\n        self.comboBox.currentIndexChanged.connect(self.__onCurrentIndexChanged)\n\n    def __onCurrentTextChanged(self, text: str):\n        \"\"\"当前文本改变时的槽函数\"\"\"\n        self.currentTextChanged.emit(text)\n\n    def __onCurrentIndexChanged(self, index: int):\n        \"\"\"当前索引改变时的槽函数\"\"\"\n        self.currentIndexChanged.emit(index)\n\n    def setCurrentText(self, text: str):\n        \"\"\"设置当前文本\"\"\"\n        self.comboBox.setCurrentText(text)\n\n    def setCurrentIndex(self, index: int):\n        \"\"\"设置当前索引\"\"\"\n        self.comboBox.setCurrentIndex(index)\n\n    def addItem(self, text: str):\n        \"\"\"添加选项\"\"\"\n        self.comboBox.addItem(text)\n\n    def addItems(self, texts: List[str]):\n        \"\"\"添加多个选项\"\"\"\n        self.comboBox.addItems(texts)\n\n    def clear(self):\n        \"\"\"清空所有选项\"\"\"\n        self.comboBox.clear()\n\n\nclass ColorSettingCard(SettingCard):\n    \"\"\"带颜色选择器的设置卡片\"\"\"\n\n    colorChanged = pyqtSignal(QColor)\n\n    def __init__(\n        self,\n        color: QColor,\n        icon: Union[str, QIcon, FluentIconBase],\n        title: str,\n        content: Optional[str] = None,\n        parent=None,\n        enableAlpha=False,\n    ):\n        \"\"\"\n        参数\n        ----------\n        color: QColor\n            初始颜色\n\n        icon: str | QIcon | FluentIconBase\n            要绘制的图标\n\n        title: str\n            卡片标题\n\n        content: str\n            卡片内容\n\n        parent: QWidget\n            父组件\n\n        enableAlpha: bool\n            是否启用透明通道\n        \"\"\"\n        super().__init__(icon, title, content, parent)\n        self.colorPicker = ColorPickerButton(color, title, self, enableAlpha)\n        self.colorPicker.setFixedWidth(60)\n        self.hBoxLayout.addWidget(self.colorPicker, 0, Qt.AlignRight)  # type: ignore\n        self.hBoxLayout.addSpacing(16)\n        self.colorPicker.colorChanged.connect(self.__onColorChanged)\n\n    def __onColorChanged(self, color: QColor):\n        \"\"\"颜色改变时的槽函数\"\"\"\n        self.colorChanged.emit(color)\n\n    def setColor(self, color: QColor):\n        \"\"\"设置颜色\"\"\"\n        self.colorPicker.setColor(color)\n\n\nclass ColorPickerButton(QToolButton):\n    \"\"\"Color picker button\"\"\"\n\n    colorChanged = pyqtSignal(QColor)\n\n    def __init__(self, color: QColor, title: str, parent=None, enableAlpha=False):\n        super().__init__(parent=parent)\n        self.title = title\n        self.enableAlpha = enableAlpha\n        self.setFixedSize(96, 32)\n        self.setAttribute(Qt.WA_TranslucentBackground)  # type: ignore\n\n        self.setColor(color)\n        self.setCursor(Qt.PointingHandCursor)  # type: ignore\n        self.clicked.connect(self.__showColorDialog)\n\n    def __showColorDialog(self):\n        \"\"\"show color dialog\"\"\"\n        w = ColorDialog(\n            self.color, self.tr(\"Choose \") + self.title, self.window(), self.enableAlpha\n        )\n        w.colorChanged.connect(self.__onColorChanged)\n        w.exec()\n\n    def __onColorChanged(self, color):\n        \"\"\"color changed slot\"\"\"\n        self.setColor(color)\n        self.colorChanged.emit(color)\n\n    def setColor(self, color):\n        \"\"\"set color\"\"\"\n        self.color = QColor(color)\n        self.update()\n\n    def paintEvent(self, e):\n        painter = QPainter(self)\n        painter.setRenderHints(QPainter.Antialiasing)\n        pc = QColor(255, 255, 255, 10) if isDarkTheme() else QColor(234, 234, 234)\n        painter.setPen(pc)\n\n        color = QColor(self.color)\n        if not self.enableAlpha:\n            color.setAlpha(255)\n\n        painter.setBrush(color)\n        painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 5, 5)\n"
  },
  {
    "path": "app/components/MyVideoWidget.py",
    "content": "# coding:utf-8\nimport sys\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import Optional\n\nimport vlc  # type: ignore\nfrom PyQt5.QtCore import QObject, Qt, QTimer, QUrl, pyqtSignal\nfrom PyQt5.QtGui import QIcon\nfrom PyQt5.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget\n\n# from qfluentwidgets.multimedia.media_player import MediaPlayer, MediaPlayerBase\nfrom qfluentwidgets.common.icon import FluentIcon\nfrom qfluentwidgets.common.style_sheet import FluentStyleSheet\nfrom qfluentwidgets.components.widgets.label import CaptionLabel\nfrom qfluentwidgets.multimedia.media_play_bar import (\n    MediaPlayBarBase,\n    MediaPlayBarButton,\n)\n\nfrom app.common.signal_bus import signalBus\nfrom app.config import RESOURCE_PATH\n\n\nclass MediaStatus(Enum):\n    NoMedia = 0\n    LoadingMedia = 1\n    LoadedMedia = 2\n    BufferingMedia = 3\n    BufferedMedia = 4\n    EndOfMedia = 5\n    InvalidMedia = 6\n    UnknownMediaStatus = 7\n\n\nclass PlaybackState(Enum):\n    StoppedState = 0\n    PlayingState = 1\n    PausedState = 2\n\n\nclass MediaPlayerBase(QObject):\n    \"\"\"Media player base class\"\"\"\n\n    mediaStatusChanged = pyqtSignal(MediaStatus)\n    playbackRateChanged = pyqtSignal(float)\n    positionChanged = pyqtSignal(int)\n    durationChanged = pyqtSignal(int)\n    sourceChanged = pyqtSignal(QUrl)\n    volumeChanged = pyqtSignal(int)\n    mutedChanged = pyqtSignal(bool)\n\n    def __init__(self, parent=None):\n        super().__init__(parent=parent)\n\n    def isPlaying(self):\n        \"\"\"Whether the media is playing\"\"\"\n        raise NotImplementedError\n\n    def mediaStatus(self) -> MediaStatus:\n        \"\"\"Return the status of the current media stream\"\"\"\n        raise NotImplementedError\n\n    def playbackState(self) -> PlaybackState:\n        \"\"\"Return the playback status of the current media stream\"\"\"\n        raise NotImplementedError\n\n    def duration(self):\n        \"\"\"Returns the duration of the current media in ms\"\"\"\n        raise NotImplementedError\n\n    def position(self):\n        \"\"\"Returns the current position inside the media being played back in ms\"\"\"\n        raise NotImplementedError\n\n    def volume(self):\n        \"\"\"Return the volume of player\"\"\"\n        raise NotImplementedError\n\n    def source(self) -> QUrl:\n        \"\"\"Return the active media source being used\"\"\"\n        raise NotImplementedError\n\n    def pause(self):\n        \"\"\"Pause playing the current source\"\"\"\n        raise NotImplementedError\n\n    def play(self):\n        \"\"\"Start or resume playing the current source\"\"\"\n        raise NotImplementedError\n\n    def stop(self):\n        \"\"\"Stop playing, and reset the play position to the beginning\"\"\"\n        raise NotImplementedError\n\n    def playbackRate(self) -> float:\n        \"\"\"Return the playback rate of the current media\"\"\"\n        raise NotImplementedError\n\n    def setPosition(self, position: int):\n        \"\"\"Sets the position of media in ms\"\"\"\n        raise NotImplementedError\n\n    def setSource(self, media: QUrl):\n        \"\"\"Sets the current source\"\"\"\n        raise NotImplementedError\n\n    def setPlaybackRate(self, rate: float):\n        \"\"\"Sets the playback rate of player\"\"\"\n        raise NotImplementedError\n\n    def setVolume(self, volume: int):\n        \"\"\"Sets the volume of player\"\"\"\n        raise NotImplementedError\n\n    def setMuted(self, isMuted: bool):\n        raise NotImplementedError\n\n    def videoOutput(self) -> QObject:\n        \"\"\"Return the video output to be used by the media player\"\"\"\n        raise NotImplementedError\n\n    def setVideoOutput(self, output: QObject) -> None:\n        \"\"\"Sets the video output to be used by the media player\"\"\"\n        raise NotImplementedError\n\n\nclass MediaPlayer(MediaPlayerBase):\n    def __init__(self, parent=None):\n        # 确保在主线程中初始化\n        if parent:\n            super().__init__(parent)\n        else:\n            super().__init__()\n\n        # 修改 VLC 参数以减少警告\n        vlc_args = [\n            \"--no-xlib\",\n            \"--quiet\",\n        ]\n\n        # 在主线程中创建 VLC 实例\n        self.moveToThread(QApplication.instance().thread())\n        self.instance = vlc.Instance(vlc_args)\n        self._player = self.instance.media_player_new()\n        self._media = None\n        self._source = None\n        self._playback_rate = 1.0\n\n        # 创建定时器用于更新状态\n        self._update_timer = QTimer(self)\n        self._update_timer.setInterval(100)  # 100ms更新一次\n        self._update_timer.timeout.connect(self._on_timer_update)\n        self._update_timer.start()\n\n        # 保存上一次的状态，用于检测变化\n        self._last_position = 0\n        self._last_duration = 0\n        self._last_volume = 100\n\n    def _on_timer_update(self):\n        \"\"\"定时更新状态并发送信号\"\"\"\n        if self._player:\n            # 更新位置\n            position = self._player.get_time()\n            if position != self._last_position:\n                self._last_position = position\n                self.positionChanged.emit(position)\n\n            # 更新时长\n            duration = self._player.get_length()\n            if duration != self._last_duration:\n                self._last_duration = duration\n                self.durationChanged.emit(duration)\n\n            # 更新音量\n            volume = self._player.audio_get_volume()\n            if volume != self._last_volume:\n                self._last_volume = volume\n                self.volumeChanged.emit(volume)\n\n    def isPlaying(self):\n        return bool(self._player and self._player.is_playing())\n\n    def mediaStatus(self) -> MediaStatus:\n        if not self._player:\n            return MediaStatus.NoMedia\n\n        state = self._player.get_state()\n        if state == vlc.State.NothingSpecial:\n            return MediaStatus.NoMedia\n        elif state == vlc.State.Opening:\n            return MediaStatus.LoadingMedia\n        elif state == vlc.State.Playing:\n            return MediaStatus.BufferedMedia\n        elif state == vlc.State.Paused:\n            return MediaStatus.BufferedMedia\n        elif state == vlc.State.Stopped:\n            return MediaStatus.LoadedMedia\n        elif state == vlc.State.Ended:\n            return MediaStatus.EndOfMedia\n        elif state == vlc.State.Error:\n            return MediaStatus.InvalidMedia\n        return MediaStatus.UnknownMediaStatus\n\n    def playbackState(self) -> PlaybackState:\n        if not self._player:\n            return PlaybackState.StoppedState\n\n        if self._player.is_playing():\n            return PlaybackState.PlayingState\n        elif self._player.get_state() == vlc.State.Paused:\n            return PlaybackState.PausedState\n        return PlaybackState.StoppedState\n\n    def duration(self):\n        return self._player.get_length() if self._player else 0\n\n    def position(self):\n        return self._player.get_time() if self._player else 0\n\n    def volume(self):\n        return self._player.audio_get_volume() if self._player else 0\n\n    def source(self) -> QUrl:\n        return self._source\n\n    def get_subtitle(self):\n        \"\"\"获取当前使用的字幕文件路径\n\n        Returns:\n            str: 当前字幕文件路径，如果没有字幕则返回 None\n        \"\"\"\n        if not self._player:\n            return None\n\n        try:\n            # 获取当前字幕轨道ID\n            current_spu = self._player.video_get_spu()\n            if current_spu <= 0:  # 0 表示禁用字幕，-1 表示错误\n                return None\n\n            # 获取字幕轨道描述信息\n            spu_description = self._player.video_get_spu_description()\n            if not spu_description:\n                return None\n\n            # 遍历查找当前使用的字幕轨道\n            for spu in spu_description:\n                if spu[0] == current_spu:\n                    # 返回字幕文件路径\n                    return spu[1].decode(\"utf-8\")\n\n            return None\n        except Exception:\n            return None\n\n    def pause(self):\n        self._player.pause()\n\n    def play(self):\n        self._player.play()\n\n    def stop(self):\n        self._player.stop()\n\n    def playbackRate(self) -> float:\n        return self._playback_rate\n\n    def setPosition(self, position: int):\n        if self._player:\n            self._player.set_time(position)\n            self.positionChanged.emit(position)\n\n    def setSource(self, media: QUrl):\n        \"\"\"设置媒体源时重置状态\"\"\"\n        path = media.toLocalFile() or media.toString()\n        self._media = self.instance.media_new(path)\n        self._player.set_media(self._media)\n        self._source = media\n        self.sourceChanged.emit(media)\n        self.mediaStatusChanged.emit(self.mediaStatus())\n\n    def setPlaybackRate(self, rate: float):\n        if self._player:\n            self._player.set_rate(rate)\n            self._playback_rate = rate\n            self.playbackRateChanged.emit(rate)\n\n    def setVolume(self, volume: int):\n        if self._player:\n            self._player.audio_set_volume(volume)\n            self.volumeChanged.emit(volume)\n\n    def setMuted(self, isMuted: bool):\n        if self._player:\n            self._player.audio_set_mute(isMuted)\n            self.mutedChanged.emit(isMuted)\n\n    def videoOutput(self) -> Optional[QObject]:\n        return None  # VLC不需要这个\n\n    def setVideoOutput(self, output: QObject) -> None:\n        if isinstance(output, QWidget) and hasattr(output, \"winId\"):  # type: ignore\n            self._player.set_hwnd(output.winId())\n\n    def hasMedia(self):\n        \"\"\"检查是否有媒体文件加载\"\"\"\n        return bool(self._media and self._player)\n\n    def playSegment(self, start_time: int, end_time: int):\n        \"\"\"播放指定时间段的视频片段\n\n        Args:\n            start_time: 开始时间（毫秒）\n            end_time: 结束时间（毫秒）\n        \"\"\"\n        if not self._player or not self.hasMedia():\n            return\n\n        # 确保时间范围有效\n        if start_time < 0 or end_time > self.duration() or start_time >= end_time:\n            return\n\n        # 创建事件管理器\n        event_manager = self._player.event_manager()\n\n        def on_time_changed(event):\n            # 当播放位置到达结束时间时停止播放\n            if self.position() >= end_time:\n                self.pause()\n                # 移除事件监听器\n                event_manager.event_detach(vlc.EventType.MediaPlayerTimeChanged)\n\n        # 注册时间变化事件\n        event_manager.event_attach(\n            vlc.EventType.MediaPlayerTimeChanged, on_time_changed\n        )\n\n        # 设置开始位置并播放\n        self.setPosition(start_time)\n        self.play()\n\n    def add_subtitle(self, subtitle_file: str) -> bool:\n        \"\"\"添加字幕文件\n\n        Args:\n            subtitle_file: 字幕文件的路径\n\n        Returns:\n            bool: 是否成功添加字幕\n        \"\"\"\n        if not self._player or not self.hasMedia():\n            return False\n\n        try:\n            # 将路径转换为 URI 格式\n\n            subtitle_uri = Path(subtitle_file).as_uri()\n\n            # 添加字幕轨道\n            result = self._player.add_slave(\n                vlc.MediaSlaveType.subtitle, subtitle_uri, True\n            )\n\n            # 获取字幕轨道信息 (unused but potentially useful for debugging)\n            # spu_description = self._player.video_get_spu_description()\n\n            return result == 0\n        except Exception:\n            return False\n\n    def get_subtitle_tracks(self) -> list:\n        \"\"\"获取所有可用的字幕轨道\"\"\"\n        if not self._player:\n            return []\n\n        tracks = []\n        spu_count = self._player.video_get_spu_count()\n        for i in range(spu_count):\n            track_info = self._player.video_get_spu_description()[i]\n            tracks.append(track_info)\n        return tracks\n\n    def set_subtitle_track(self, track_id: int):\n        \"\"\"设置当前使用的字幕轨道\n\n        Args:\n            track_id: 字幕轨道ID，-1 表示禁用字幕\n        \"\"\"\n        if self._player:\n            self._player.video_set_spu(track_id)\n\n\nclass StandardMediaPlayBar(MediaPlayBarBase):\n    \"\"\"Standard media play bar\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.vBoxLayout = QVBoxLayout(self)\n        self.timeLayout = QHBoxLayout()\n        self.buttonLayout = QHBoxLayout()\n        self.leftButtonContainer = QWidget()\n        self.centerButtonContainer = QWidget()\n        self.rightButtonContainer = QWidget()\n        self.leftButtonLayout = QHBoxLayout(self.leftButtonContainer)\n        self.centerButtonLayout = QHBoxLayout(self.centerButtonContainer)\n        self.rightButtonLayout = QHBoxLayout(self.rightButtonContainer)\n\n        self.skipBackButton = MediaPlayBarButton(FluentIcon.SKIP_BACK, self)\n        self.skipForwardButton = MediaPlayBarButton(FluentIcon.SKIP_FORWARD, self)\n\n        self.currentTimeLabel = CaptionLabel(\"0:00:00\", self)\n        self.remainTimeLabel = CaptionLabel(\"0:00:00\", self)\n\n        self.__initWidgets()\n\n    def __initWidgets(self):\n        self.setFixedHeight(102)\n        self.vBoxLayout.setSpacing(6)\n        self.vBoxLayout.setContentsMargins(5, 9, 5, 9)\n        self.vBoxLayout.addWidget(self.progressSlider, 1, Qt.AlignTop)  # type: ignore\n\n        self.vBoxLayout.addLayout(self.timeLayout)\n        self.timeLayout.setContentsMargins(10, 0, 10, 0)\n        self.timeLayout.addWidget(self.currentTimeLabel, 0, Qt.AlignLeft)  # type: ignore\n        self.timeLayout.addWidget(self.remainTimeLabel, 0, Qt.AlignRight)  # type: ignore\n\n        self.vBoxLayout.addStretch(1)\n        self.vBoxLayout.addLayout(self.buttonLayout, 1)\n        self.buttonLayout.setContentsMargins(0, 0, 0, 0)\n        self.leftButtonLayout.setContentsMargins(4, 0, 0, 0)\n        self.centerButtonLayout.setContentsMargins(0, 0, 0, 0)\n        self.rightButtonLayout.setContentsMargins(0, 0, 4, 0)\n\n        self.leftButtonLayout.addWidget(self.volumeButton, 0, Qt.AlignLeft)  # type: ignore\n        self.centerButtonLayout.addWidget(self.skipBackButton)\n        self.centerButtonLayout.addWidget(self.playButton)\n        self.centerButtonLayout.addWidget(self.skipForwardButton)\n\n        self.buttonLayout.addWidget(self.leftButtonContainer, 0, Qt.AlignLeft)  # type: ignore\n        self.buttonLayout.addWidget(self.centerButtonContainer, 0, Qt.AlignHCenter)  # type: ignore\n        self.buttonLayout.addWidget(self.rightButtonContainer, 0, Qt.AlignRight)  # type: ignore\n\n        self.skipBackButton.clicked.connect(lambda: self.skipBack(5000))\n        self.skipForwardButton.clicked.connect(lambda: self.skipForward(5000))\n\n    def skipBack(self, ms: int):\n        \"\"\"Back up for specified milliseconds\"\"\"\n        self.player.setPosition(self.player.position() - ms)\n\n    def skipForward(self, ms: int):\n        \"\"\"Fast forward specified milliseconds\"\"\"\n        self.player.setPosition(self.player.position() + ms)\n\n    def _onPositionChanged(self, position: int):\n        super()._onPositionChanged(position)\n        self.currentTimeLabel.setText(self._formatTime(position))\n        self.remainTimeLabel.setText(\n            self._formatTime(self.player.duration() - position)\n        )\n\n    def _formatTime(self, time: int):\n        time = int(time / 1000)\n        s = time % 60\n        m = int(time / 60)\n        h = int(time / 3600)\n        return f\"{h}:{m:02}:{s:02}\"\n\n    def closeEvent(self, event):\n        self.release()\n        super().closeEvent(event)\n\n\nclass MyVideoWidget(QWidget):\n    \"\"\"Video widget\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n\n        # 设置初始窗口大小\n        self.resize(800, 600)\n        self.setWindowTitle(\"VideoCaptioner\")\n        self.setWindowIcon(QIcon(str(RESOURCE_PATH / \"assets\" / \"logo.png\")))\n\n        # 创建一个专门用于视频输出的 widget\n        self.videoWidget = QWidget(self)\n        self.videoWidget.setStyleSheet(\"background-color: rgb(24, 24, 24);\")\n\n        # 添加提示标签\n        self.tipLabel = CaptionLabel(\"请拖入视频文件\", self.videoWidget)\n        self.tipLabel.setStyleSheet(\n            \"\"\"\n            color: rgba(255, 255, 255, 0.5);\n            font-size: 20px;\n            font-weight: bold;\n            letter-spacing: 2px;\n        \"\"\"\n        )\n\n        # 创建布局使标签居中\n        tipLayout = QVBoxLayout(self.videoWidget)\n        tipLayout.addWidget(self.tipLabel, 0, Qt.AlignCenter)  # type: ignore\n\n        # 创建播放控制栏\n        self.playBar = StandardMediaPlayBar(self)\n        self.playBar.setAttribute(Qt.WA_TranslucentBackground)  # type: ignore\n\n        # 设置字幕文件\n        self.subtitle_file = None\n\n        # 创建垂直布局\n        self.vBoxLayout = QVBoxLayout(self)\n        self.vBoxLayout.setContentsMargins(0, 0, 0, 0)\n        self.vBoxLayout.setSpacing(0)\n        self.vBoxLayout.addWidget(self.videoWidget, 1)\n        self.vBoxLayout.addWidget(self.playBar, 0)\n\n        # 创建播放器并传入优化参数\n        self.vlc_player = MediaPlayer(self)\n\n        # 设置新的播放器\n        self.playBar.setMediaPlayer(self.vlc_player)  # type: ignore\n        self.playBar.setVolume(80)\n        self.vlc_player.setVideoOutput(self.videoWidget)\n        FluentStyleSheet.MEDIA_PLAYER.apply(self)\n\n        # 设置焦点和事件过滤\n        self.setFocusPolicy(Qt.StrongFocus)  # type: ignore\n        self.videoWidget.setFocusPolicy(Qt.StrongFocus)  # type: ignore\n\n        # 安装事件过滤器\n        self.videoWidget.installEventFilter(self)\n        self.playBar.installEventFilter(self)\n\n        FluentStyleSheet.MEDIA_PLAYER.apply(self)\n        self.setAcceptDrops(True)\n\n        # 连接 SignalBus 信号\n        self._connectSignals()\n\n    def _connectSignals(self):\n        \"\"\"连接 SignalBus 的信号\"\"\"\n        # 视频控制信号\n        signalBus.video_play.connect(self.play)\n        signalBus.video_pause.connect(self.pause)\n        signalBus.video_stop.connect(self.stop)\n        signalBus.video_source_changed.connect(self.setVideo)\n        signalBus.video_segment_play.connect(self.playSegment)\n        signalBus.video_subtitle_added.connect(self.addSubtitle)\n\n    def addSubtitle(self, subtitle_file: str):\n        \"\"\"添加字幕文件的内部方法\"\"\"\n        self.subtitle_file = subtitle_file\n        self.vlc_player.add_subtitle(subtitle_file)\n\n    def setVideo(self, url: QUrl):\n        \"\"\"设置视频源\n\n        Args:\n            url: 视频文件的 QUrl\n        \"\"\"\n        self.setWindowTitle(url.fileName())\n        self.vlc_player.setSource(url)\n        if self.subtitle_file:\n            self.vlc_player.add_subtitle(self.subtitle_file)\n        # 隐藏提示标签\n        self.tipLabel.hide()\n\n    def play(self):\n        \"\"\"播放视频\"\"\"\n        self.playBar.play()\n\n    def pause(self):\n        \"\"\"暂停视频\"\"\"\n        self.playBar.pause()\n\n    def stop(self):\n        \"\"\"停止视频\"\"\"\n        self.playBar.stop()\n\n    def playSegment(self, start_time: int, end_time: int):\n        \"\"\"播放指定时间段的视频\n\n        Args:\n            start_time: 开始时间(毫秒)\n            end_time: 结束时间(毫秒)\n        \"\"\"\n        self.vlc_player.playSegment(start_time, end_time)\n\n    def hideEvent(self, e):\n        self.stop()\n        e.accept()\n\n    def wheelEvent(self, e):\n        return\n\n    def togglePlayState(self):\n        \"\"\"toggle play state\"\"\"\n        if self.vlc_player.isPlaying():\n            self.pause()\n        else:\n            self.play()\n\n    @property\n    def player(self):\n        return self.playBar.player\n\n    def keyPressEvent(self, event):\n        \"\"\"处理键盘事件\"\"\"\n        if event.key() == Qt.Key_Space:  # type: ignore\n            self.playBar.togglePlayState()\n        elif event.key() == Qt.Key_Left:  # type: ignore\n            self.playBar.skipBack(3000)\n        elif event.key() == Qt.Key_Right:  # type: ignore\n            self.playBar.skipForward(3000)\n        else:\n            super().keyPressEvent(event)\n\n    def dragEnterEvent(self, event):\n        \"\"\"处理拖入事件\"\"\"\n        if event.mimeData().hasUrls():\n            urls = event.mimeData().urls()\n            # 检查是否为视频文件或字幕文件\n            if any(\n                url.toLocalFile()\n                .lower()\n                .endswith(\n                    (\".mp4\", \".avi\", \".mkv\", \".mov\", \".wmv\", \".flv\", \".srt\", \".ass\")\n                )\n                for url in urls\n            ):\n                event.acceptProposedAction()\n\n    def dropEvent(self, event):\n        \"\"\"处理放下事件\"\"\"\n        urls = event.mimeData().urls()\n        for url in urls:\n            file_path = url.toLocalFile().lower()\n            if file_path.endswith((\".srt\", \".ass\")):\n                # 处理字幕文件\n                self.vlc_player.add_subtitle(url.toLocalFile())\n            elif file_path.endswith((\".mp4\", \".avi\", \".mkv\", \".mov\", \".wmv\", \".flv\")):\n                # 处理视频文件\n                self.setVideo(url)\n                self.play()\n                break  # 只处理第一个视频文件\n\n    def eventFilter(self, obj, event):\n        \"\"\"事件过滤器，用于捕获所有子部件的按键事件\"\"\"\n        if event.type() == event.KeyPress:\n            if event.key() in (Qt.Key_Left, Qt.Key_Right):  # type: ignore\n                self.keyPressEvent(event)\n                return True\n        return super().eventFilter(obj, event)\n\n    def showEvent(self, event):\n        \"\"\"窗口显示时设置焦点\"\"\"\n        super().showEvent(event)\n        self.setFocus()\n\n\nif __name__ == \"__main__\":\n    app = QApplication(sys.argv)\n    window = MyVideoWidget()\n    # 设置视频源 - 请替换为您的测试视频路径\n    # video_path = r\"path/to/your/test/video.mp4\"\n    # window.setVideo(QUrl.fromLocalFile(video_path))\n\n    # 确保窗口显示在屏幕中央\n    window.show()\n    window.activateWindow()\n    window.raise_()\n\n    # 开始播放视频\n    # window.play()\n    sys.exit(app.exec_())\n"
  },
  {
    "path": "app/components/SimpleSettingCard.py",
    "content": "from PyQt5.QtCore import pyqtSignal\nfrom PyQt5.QtWidgets import QHBoxLayout\nfrom qfluentwidgets import (\n    CaptionLabel,\n    CardWidget,\n    ComboBox,\n    SwitchButton,\n    ToolTipFilter,\n    ToolTipPosition,\n)\n\n\nclass SimpleSettingCard(CardWidget):\n    \"\"\"基础设置卡片类\"\"\"\n\n    def __init__(self, title, content, parent=None):\n        super().__init__(parent)\n        self.title = title\n        self.content = content\n        self.setup_ui()\n\n    def setup_ui(self):\n        self.hBoxLayout = QHBoxLayout(self)\n        self.hBoxLayout.setContentsMargins(16, 10, 8, 10)\n        self.hBoxLayout.setSpacing(8)\n\n        self.label = CaptionLabel(self)\n        self.label.setText(self.title)\n        self.hBoxLayout.addWidget(self.label)\n\n        self.hBoxLayout.addStretch(1)\n\n        self.setToolTip(self.content)\n        self.installEventFilter(ToolTipFilter(self, 100, ToolTipPosition.BOTTOM))\n\n\nclass ComboBoxSimpleSettingCard(SimpleSettingCard):\n    \"\"\"下拉框设置卡片\"\"\"\n\n    valueChanged = pyqtSignal(str)\n\n    def __init__(self, title, content, items=None, parent=None):\n        super().__init__(title, content, parent)\n        self.items = items or []\n        self.setup_combobox()\n\n    def setup_combobox(self):\n        self.comboBox = ComboBox(self)\n        self.comboBox.addItems(self.items)\n        self.comboBox.setMaxVisibleItems(6)\n        self.comboBox.currentTextChanged.connect(self.valueChanged)  # type: ignore\n        self.hBoxLayout.addWidget(self.comboBox)\n\n    def setValue(self, value):\n        self.comboBox.setCurrentIndex(self.items.index(value))\n\n    def value(self):\n        return self.comboBox.currentText()\n\n\nclass SwitchButtonSimpleSettingCard(SimpleSettingCard):\n    \"\"\"开关设置卡片\"\"\"\n\n    checkedChanged = pyqtSignal(bool)\n\n    def __init__(self, title, content, parent=None):\n        super().__init__(title, content, parent)\n        self.setup_switch()\n\n    def setup_switch(self):\n        self.switchButton = SwitchButton(self)\n        self.switchButton.setOnText(\"开\")\n        self.switchButton.setOffText(\"关\")\n        self.switchButton.checkedChanged.connect(self.checkedChanged)  # type: ignore\n        self.hBoxLayout.addWidget(self.switchButton)\n\n        self.clicked.connect(  # type: ignore\n            lambda: self.switchButton.setChecked(not self.switchButton.isChecked())\n        )\n\n    def setChecked(self, checked):\n        self.switchButton.setChecked(checked)\n\n    def isChecked(self):\n        return self.switchButton.isChecked()\n"
  },
  {
    "path": "app/components/SpinBoxSettingCard.py",
    "content": "from typing import Optional, Union\n\nfrom PyQt5.QtCore import Qt, pyqtSignal\nfrom PyQt5.QtGui import QIcon\nfrom qfluentwidgets import CompactDoubleSpinBox, CompactSpinBox, SettingCard\nfrom qfluentwidgets.common.config import ConfigItem, qconfig\n\n\nclass DoubleSpinBoxSettingCard(SettingCard):\n    \"\"\"小数输入设置卡片\"\"\"\n\n    valueChanged = pyqtSignal(float)\n\n    def __init__(\n        self,\n        configItem: ConfigItem,\n        icon: Union[str, QIcon],\n        title: str,\n        content: Optional[str] = None,\n        minimum: float = 0.0,\n        maximum: float = 100.0,\n        decimals: int = 1,\n        step: float = 0.1,\n        parent=None,\n    ):\n        super().__init__(icon, title, content, parent)\n\n        self.configItem = configItem\n\n        # 创建CompactDoubleSpinBox\n        self.spinBox = CompactDoubleSpinBox(self)\n        self.spinBox.setRange(minimum, maximum)\n        self.spinBox.setDecimals(decimals)\n        self.spinBox.setMinimumWidth(60)\n        self.spinBox.setSingleStep(step)  # 设置步长为0.2\n\n        # 添加到布局\n        self.hBoxLayout.addWidget(self.spinBox, 0, Qt.AlignRight)  # type: ignore\n        self.hBoxLayout.addSpacing(8)\n\n        # 设置初始值和连接信号\n        self.setValue(qconfig.get(configItem))\n        self.spinBox.valueChanged.connect(self.__onValueChanged)\n        configItem.valueChanged.connect(self.setValue)\n\n    def __onValueChanged(self, value: float):\n        \"\"\"数值改变时的槽函数\"\"\"\n        self.setValue(value)\n        self.valueChanged.emit(value)\n\n    def setValue(self, value: float):\n        \"\"\"设置数值\"\"\"\n        qconfig.set(self.configItem, value)\n        self.spinBox.setValue(value)\n\n\nclass SpinBoxSettingCard(SettingCard):\n    \"\"\"数值输入设置卡片\"\"\"\n\n    valueChanged = pyqtSignal(int)\n\n    def __init__(\n        self,\n        configItem: ConfigItem,\n        icon: Union[str, QIcon],\n        title: str,\n        content: Optional[str] = None,\n        minimum: int = 0,\n        maximum: int = 100,\n        parent=None,\n    ):\n        super().__init__(icon, title, content, parent)\n\n        self.configItem = configItem\n\n        # 创建SpinBox\n        self.spinBox = CompactSpinBox(self)\n        self.spinBox.setRange(minimum, maximum)\n        self.spinBox.setMinimumWidth(60)\n\n        # 添加到布局\n        self.hBoxLayout.addWidget(self.spinBox, 0, Qt.AlignRight)  # type: ignore\n        self.hBoxLayout.addSpacing(8)\n\n        # 设置初始值和连接信号\n        self.setValue(qconfig.get(configItem))\n        self.spinBox.valueChanged.connect(self.__onValueChanged)\n        configItem.valueChanged.connect(self.setValue)\n\n    def __onValueChanged(self, value: int):\n        \"\"\"数值改变时的槽函数\"\"\"\n        self.setValue(value)\n        self.valueChanged.emit(value)\n\n    def setValue(self, value: int):\n        \"\"\"设置数值\"\"\"\n        qconfig.set(self.configItem, value)\n        self.spinBox.setValue(value)\n"
  },
  {
    "path": "app/components/SubtitleSettingDialog.py",
    "content": "from qfluentwidgets import (\n    BodyLabel,\n    MessageBoxBase,\n    SwitchSettingCard,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.components.SpinBoxSettingCard import SpinBoxSettingCard\n\n\nclass SubtitleSettingDialog(MessageBoxBase):\n    \"\"\"字幕设置对话框\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.titleLabel = BodyLabel(self.tr(\"字幕设置\"), self)\n\n        # 创建设置卡片\n        self.split_card = SwitchSettingCard(\n            FIF.ALIGNMENT,\n            self.tr(\"字幕分割\"),\n            self.tr(\"字幕是否使用大语言模型进行智能断句\"),\n            cfg.need_split,\n            self,\n        )\n\n        self.word_count_cjk_card = SpinBoxSettingCard(\n            cfg.max_word_count_cjk,\n            FIF.TILES,  # type: ignore\n            self.tr(\"中文最大字数\"),\n            self.tr(\"单条字幕的最大字数 (对于中日韩等字符)\"),\n            minimum=8,\n            maximum=50,\n            parent=self,\n        )\n\n        self.word_count_english_card = SpinBoxSettingCard(\n            cfg.max_word_count_english,\n            FIF.TILES,  # type: ignore\n            self.tr(\"英文最大单词数\"),\n            self.tr(\"单条字幕的最大单词数 (英文)\"),\n            minimum=8,\n            maximum=50,\n            parent=self,\n        )\n\n        # 添加到布局\n        self.viewLayout.addWidget(self.titleLabel)\n        self.viewLayout.addWidget(self.split_card)\n        self.viewLayout.addWidget(self.word_count_cjk_card)\n        self.viewLayout.addWidget(self.word_count_english_card)\n        # 设置间距\n        self.viewLayout.setSpacing(10)\n\n        # 设置窗口标题和宽度\n        self.setWindowTitle(self.tr(\"字幕设置\"))\n        self.widget.setMinimumWidth(380)\n\n        # 只显示取消按钮\n        self.yesButton.hide()\n        self.cancelButton.setText(self.tr(\"关闭\"))\n"
  },
  {
    "path": "app/components/TranscriptionOutputDialog.py",
    "content": "# -*- coding: utf-8 -*-\nfrom qfluentwidgets import (\n    BodyLabel,\n    ComboBoxSettingCard,\n    MessageBoxBase,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.core.entities import TranscribeOutputFormatEnum\n\n\nclass TranscriptionSettingDialog(MessageBoxBase):\n    \"\"\"转录设置对话框\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.titleLabel = BodyLabel(self.tr(\"转录设置\"), self)\n\n        # 创建输出格式选择卡片\n        self.output_format_card = ComboBoxSettingCard(\n            cfg.transcribe_output_format,\n            FIF.SAVE,\n            self.tr(\"输出格式\"),\n            self.tr(\"选择转录字幕的输出格式\"),\n            texts=[fmt.value for fmt in TranscribeOutputFormatEnum],\n            parent=self,\n        )\n        self.output_format_card.setMinimumWidth(420)\n\n        # 添加到布局\n        self.viewLayout.addWidget(self.titleLabel)\n        self.viewLayout.addWidget(self.output_format_card)\n        # 设置间距\n        self.viewLayout.setSpacing(10)\n\n        # 设置窗口标题\n        self.setWindowTitle(self.tr(\"转录设置\"))\n\n        # 只显示取消按钮\n        self.yesButton.hide()\n        self.cancelButton.setText(self.tr(\"关闭\"))\n\n"
  },
  {
    "path": "app/components/TranscriptionSettingDialog.py",
    "content": "# -*- coding: utf-8 -*-\nfrom qfluentwidgets import (\n    BodyLabel,\n    ComboBoxSettingCard,\n    MessageBoxBase,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.core.entities import TranscribeOutputFormatEnum\n\n\nclass TranscriptionSettingDialog(MessageBoxBase):\n    \"\"\"转录设置对话框\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.titleLabel = BodyLabel(self.tr(\"转录设置\"), self)\n\n        # 创建输出格式选择卡片\n        self.output_format_card = ComboBoxSettingCard(\n            cfg.transcribe_output_format,\n            FIF.SAVE,\n            self.tr(\"输出格式\"),\n            self.tr(\"选择转录字幕的输出格式\"),\n            texts=[fmt.value for fmt in TranscribeOutputFormatEnum],\n            parent=self,\n        )\n\n        # 添加到布局\n        self.viewLayout.addWidget(self.titleLabel)\n        self.viewLayout.addWidget(self.output_format_card)\n        # 设置间距\n        self.viewLayout.setSpacing(10)\n\n        # 设置窗口标题和宽度\n        self.setWindowTitle(self.tr(\"转录设置\"))\n        self.widget.setMinimumWidth(380)\n\n        # 只显示取消按钮\n        self.yesButton.hide()\n        self.cancelButton.setText(self.tr(\"关闭\"))\n\n"
  },
  {
    "path": "app/components/WhisperAPISettingWidget.py",
    "content": "from PyQt5.QtCore import Qt, QThread, pyqtSignal\nfrom PyQt5.QtWidgets import (\n    QVBoxLayout,\n    QWidget,\n)\nfrom qfluentwidgets import (\n    ComboBoxSettingCard,\n    InfoBar,\n    InfoBarPosition,\n    PushSettingCard,\n    SettingCardGroup,\n    SingleDirectionScrollArea,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom ..common.config import cfg\nfrom ..core.constant import INFOBAR_DURATION_ERROR, INFOBAR_DURATION_SUCCESS\nfrom ..core.entities import TranscribeLanguageEnum\nfrom ..core.llm import check_whisper_connection\nfrom .EditComboBoxSettingCard import EditComboBoxSettingCard\nfrom .LineEditSettingCard import LineEditSettingCard\n\n\nclass WhisperAPISettingWidget(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setup_ui()\n\n    def setup_ui(self):\n        self.main_layout = QVBoxLayout(self)\n\n        # 创建单向滚动区域和容器\n        self.scrollArea = SingleDirectionScrollArea(orient=Qt.Vertical, parent=self)  # type: ignore\n        self.scrollArea.setStyleSheet(\n            \"QScrollArea{background: transparent; border: none}\"\n        )\n\n        self.container = QWidget(self)\n        self.container.setStyleSheet(\"QWidget{background: transparent}\")\n        self.containerLayout = QVBoxLayout(self.container)\n\n        self.setting_group = SettingCardGroup(self.tr(\"Whisper API 设置\"), self)\n\n        # API Base URL\n        self.base_url_card = LineEditSettingCard(\n            cfg.whisper_api_base,\n            FIF.LINK,\n            self.tr(\"API Base URL\"),\n            self.tr(\"输入 Whisper API Base URL\"),\n            \"https://api.openai.com/v1\",\n            self.setting_group,\n        )\n\n        # API Key\n        self.api_key_card = LineEditSettingCard(\n            cfg.whisper_api_key,\n            FIF.FINGERPRINT,\n            self.tr(\"API Key\"),\n            self.tr(\"输入 Whisper API Key\"),\n            \"sk-\",\n            self.setting_group,\n        )\n\n        # Model\n        self.model_card = EditComboBoxSettingCard(\n            cfg.whisper_api_model,\n            FIF.ROBOT,  # type: ignore\n            self.tr(\"Whisper 模型\"),\n            self.tr(\"选择 Whisper 模型\"),\n            [\"whisper-large-v3\", \"whisper-large-v3-turbo\", \"whisper-1\"],\n            self.setting_group,\n        )\n\n        # 添加 Language 选择\n        self.language_card = ComboBoxSettingCard(\n            cfg.transcribe_language,\n            FIF.LANGUAGE,\n            self.tr(\"源语言\"),\n            self.tr(\"音视频中说话的语言，默认根据前30秒自动识别\"),\n            [lang.value for lang in TranscribeLanguageEnum],\n            self.setting_group,\n        )\n\n        # 添加 Prompt\n        self.prompt_card = LineEditSettingCard(\n            cfg.whisper_api_prompt,\n            FIF.CHAT,\n            self.tr(\"提示词\"),\n            self.tr(\"可选的提示词,默认空\"),\n            \"\",\n            self.setting_group,\n        )\n\n        # 添加测试连接按钮\n        self.check_connection_card = PushSettingCard(\n            self.tr(\"测试连接\"),\n            FIF.CONNECT,\n            self.tr(\"测试 Whisper API 连接\"),\n            self.tr(\"点击测试 API 连接是否正常\"),\n            self.setting_group,\n        )\n\n        # 设置最小宽度\n        self.base_url_card.lineEdit.setMinimumWidth(200)\n        self.api_key_card.lineEdit.setMinimumWidth(200)\n        self.model_card.comboBox.setMinimumWidth(200)\n        self.language_card.comboBox.setMinimumWidth(200)\n        self.prompt_card.lineEdit.setMinimumWidth(200)\n\n        # 使用 addSettingCard 添加所有卡片到组\n        self.setting_group.addSettingCard(self.base_url_card)\n        self.setting_group.addSettingCard(self.api_key_card)\n        self.setting_group.addSettingCard(self.model_card)\n        self.setting_group.addSettingCard(self.language_card)\n        self.setting_group.addSettingCard(self.prompt_card)\n        self.setting_group.addSettingCard(self.check_connection_card)\n\n        # 连接测试按钮信号\n        self.check_connection_card.clicked.connect(self.on_check_connection)\n\n        # 将设置组添加到容器布局\n        self.containerLayout.addWidget(self.setting_group)\n        self.containerLayout.addStretch(1)\n\n        # 设置滚动区域\n        self.scrollArea.setWidget(self.container)\n        self.scrollArea.setWidgetResizable(True)\n\n        # 将滚动区域添加到主布局\n        self.main_layout.addWidget(self.scrollArea)\n\n    def on_check_connection(self):\n        \"\"\"测试 Whisper API 连接\"\"\"\n        # 获取配置\n        base_url = self.base_url_card.lineEdit.text().strip()\n        api_key = self.api_key_card.lineEdit.text().strip()\n        model = self.model_card.comboBox.currentText().strip()\n\n        # 验证必填字段\n        if not base_url or not api_key or not model:\n            InfoBar.warning(\n                self.tr(\"配置不完整\"),\n                self.tr(\"请输入 API Base URL、API Key 和 model\"),\n                duration=INFOBAR_DURATION_ERROR,\n                position=InfoBarPosition.TOP,\n                parent=self.window(),\n            )\n            return\n\n        # 禁用按钮，显示加载状态\n        self.check_connection_card.button.setEnabled(False)\n        self.check_connection_card.button.setText(self.tr(\"正在测试...\"))\n\n        # 创建并启动测试线程\n        self.connection_thread = WhisperConnectionThread(base_url, api_key, model)\n        self.connection_thread.finished.connect(self.on_connection_check_finished)\n        self.connection_thread.error.connect(self.on_connection_check_error)\n        self.connection_thread.start()\n\n    def on_connection_check_finished(self, success, result):\n        \"\"\"处理连接检查完成事件\"\"\"\n        # 恢复按钮状态\n        self.check_connection_card.button.setEnabled(True)\n        self.check_connection_card.button.setText(self.tr(\"测试连接\"))\n\n        if success:\n            InfoBar.success(\n                self.tr(\"连接成功\"),\n                self.tr(\"Whisper API 连接成功！\") + \"\\n\" + result,\n                duration=INFOBAR_DURATION_SUCCESS,\n                position=InfoBarPosition.BOTTOM,\n                parent=self.window(),\n            )\n        else:\n            InfoBar.error(\n                self.tr(\"连接失败\"),\n                self.tr(f\"Whisper API 连接失败！\\n{result}\"),\n                duration=INFOBAR_DURATION_ERROR,\n                position=InfoBarPosition.BOTTOM,\n                parent=self.window(),\n            )\n\n    def on_connection_check_error(self, message):\n        \"\"\"处理连接检查错误事件\"\"\"\n        # 恢复按钮状态\n        self.check_connection_card.button.setEnabled(True)\n        self.check_connection_card.button.setText(self.tr(\"测试连接\"))\n        InfoBar.error(\n            self.tr(\"测试错误\"),\n            message,\n            duration=INFOBAR_DURATION_ERROR,\n            position=InfoBarPosition.BOTTOM,\n            parent=self.window(),\n        )\n\n\nclass WhisperConnectionThread(QThread):\n    \"\"\"Whisper API 连接测试线程\"\"\"\n\n    finished = pyqtSignal(bool, str)\n    error = pyqtSignal(str)\n\n    def __init__(self, base_url, api_key, model):\n        super().__init__()\n        self.base_url = base_url\n        self.api_key = api_key\n        self.model = model\n\n    def run(self):\n        \"\"\"执行连接测试\"\"\"\n        try:\n            success, result = check_whisper_connection(\n                self.base_url, self.api_key, self.model\n            )\n            self.finished.emit(success, result)\n        except Exception as e:\n            self.error.emit(str(e))\n"
  },
  {
    "path": "app/components/WhisperCppSettingWidget.py",
    "content": "import os\n\nfrom PyQt5.QtCore import Qt\nfrom PyQt5.QtWidgets import (\n    QHBoxLayout,\n    QHeaderView,\n    QTableWidgetItem,\n    QVBoxLayout,\n    QWidget,\n)\nfrom qfluentwidgets import (\n    BodyLabel,\n    ComboBox,\n    ComboBoxSettingCard,\n    HyperlinkButton,\n    HyperlinkCard,\n    InfoBar,\n    MessageBoxBase,\n    ProgressBar,\n    PushButton,\n    SettingCardGroup,\n    SingleDirectionScrollArea,\n    SubtitleLabel,\n    TableItemDelegate,\n    TableWidget,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.config import MODEL_PATH\nfrom app.core.entities import (\n    TranscribeLanguageEnum,\n    WhisperModelEnum,\n)\nfrom app.core.utils.logger import setup_logger\nfrom app.core.utils.platform_utils import open_folder\nfrom app.thread.file_download_thread import FileDownloadThread\n\nlogger = setup_logger(\"whisper_download\")\n\n# 使用阿里云镜像定义模型配置\n# https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-tiny.bin\n# \"mirrorLink\": \"https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin?download=true\"\n\n# 使用阿里云镜像定义模型配置\nWHISPER_CPP_MODELS = [\n    {\n        \"label\": \"Tiny\",\n        \"value\": \"ggml-tiny.bin\",\n        \"size\": \"77.7 MB\",\n        \"downloadLink\": \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin\",\n        \"mirrorLink\": \"https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-tiny.bin\",\n        \"sha\": \"bd577a113a864445d4c299885e0cb97d4ba92b5f\",\n    },\n    {\n        \"label\": \"Base\",\n        \"value\": \"ggml-base.bin\",\n        \"size\": \"148 MB\",\n        \"downloadLink\": \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin\",\n        \"mirrorLink\": \"https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-base.bin\",\n        \"sha\": \"465707469ff3a37a2b9b8d8f89f2f99de7299dac\",\n    },\n    {\n        \"label\": \"Small\",\n        \"value\": \"ggml-small.bin\",\n        \"size\": \"488 MB\",\n        \"downloadLink\": \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin\",\n        \"mirrorLink\": \"https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-small.bin\",\n        \"sha\": \"55356645c2b361a969dfd0ef2c5a50d530afd8d5\",\n    },\n    {\n        \"label\": \"Medium\",\n        \"value\": \"ggml-medium.bin\",\n        \"size\": \"1.53 GB\",\n        \"downloadLink\": \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin\",\n        \"mirrorLink\": \"https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-medium.bin\",\n        \"sha\": \"fd9727b6e1217c2f614f9b698455c4ffd82463b4\",\n    },\n    {\n        \"label\": \"large-v1\",\n        \"value\": \"ggml-large-v1.bin\",\n        \"size\": \"3.09 GB\",\n        \"downloadLink\": \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v1.bin\",\n        \"mirrorLink\": \"https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-large-v1.bin\",\n        \"sha\": \"b1caaf735c4cc1429223d5a74f0f4d0b9b59a299\",\n    },\n    {\n        \"label\": \"large-v2\",\n        \"value\": \"ggml-large-v2.bin\",\n        \"size\": \"3.09 GB\",\n        \"downloadLink\": \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v2.bin\",\n        \"mirrorLink\": \"https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-large-v2.bin\",\n        \"sha\": \"0f4c8e34f21cf1a914c59d8b3ce882345ad349d6\",\n    },\n    # {\n    #     \"label\": \"Large(v3)\",\n    #     \"value\": \"ggml-large-v3.bin\",\n    #     \"size\": \"3.09 GB\",\n    #     \"downloadLink\": \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin\",\n    #     \"mirrorLink\": \"https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-large-v3.bin\",\n    #     \"sha\": \"ad82bf6a9043ceed055076d0fd39f5f186ff8062\"\n    # },\n    # {\n    #     \"label\": \"Distil Large(v3)\",\n    #     \"value\": \"ggml-distil-large-v3.bin\",\n    #     \"size\": \"1.52 GB\",\n    #     \"downloadLink\": \"https://huggingface.co/distil-whisper/distil-large-v3-ggml/resolve/main/ggml-distil-large-v3.bin?download=true\",\n    #     \"mirrorLink\": \"https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/resolve/master/ggml-distil-large-v3.bin\",\n    #     \"sha\": \"5e61e98bdcf3b9a78516c59bf7d1a10d64cae67a\"\n    # }\n]\n\n\ndef check_whisper_cpp_exists():\n    \"\"\"检查WhisperCpp程序是否存在\"\"\"\n    return True, []\n\n\nclass DownloadDialog(MessageBoxBase):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setup_ui()\n        self.setWindowTitle(self.tr(\"下载模型\"))\n        self.download_thread = None\n\n    def setup_ui(self):\n        self.titleLabel = BodyLabel(self.tr(\"下载模型\"), self)\n\n        # 添加模型选择下拉框\n        self.model_combo = ComboBox(self)\n        self.model_combo.setFixedWidth(300)\n        for model in WHISPER_CPP_MODELS:\n            # 检查模型是否已下载\n            model_path = os.path.join(MODEL_PATH, model[\"value\"])\n            downloaded = \"✓ \" if os.path.exists(model_path) else \" \"\n            self.model_combo.addItem(f\"{downloaded}{model['label']} ({model['size']})\")\n\n        # 进度条\n        self.progress_bar = ProgressBar()\n        self.progress_bar.hide()\n\n        # 进度标签\n        self.progress_label = BodyLabel()\n        self.progress_label.hide()\n\n        # 下载按钮\n        self.download_button = PushButton(self.tr(\"下载\"), self)\n        self.download_button.clicked.connect(self.start_download)\n\n        # 添加到布局\n        self.viewLayout.addWidget(self.titleLabel)\n        self.viewLayout.addWidget(self.model_combo)\n        self.viewLayout.addWidget(self.progress_bar)\n        self.viewLayout.addWidget(self.progress_label)\n        self.viewLayout.addWidget(self.download_button)\n        # 设置间距\n        self.viewLayout.setSpacing(10)\n\n        # 只显示取消按钮\n        self.yesButton.hide()\n        self.cancelButton.setText(self.tr(\"关闭\"))\n\n    def start_download(self):\n        selected_index = self.model_combo.currentIndex()\n        model = WHISPER_CPP_MODELS[selected_index]\n        save_path = os.path.join(MODEL_PATH, model[\"value\"])\n\n        # 检查模型文件是否已存在\n        if os.path.exists(save_path):\n            InfoBar.warning(\n                title=self.tr(\"提示\"),\n                content=self.tr(\"模型文件已存在,无需重复下载\"),\n                parent=self.window(),\n                duration=3000,\n            )\n            return\n\n        self.progress_bar.show()\n        self.progress_label.show()\n        self.download_button.setEnabled(False)\n\n        self.download_thread = FileDownloadThread(model[\"mirrorLink\"], save_path)\n        self.download_thread.progress.connect(self.update_progress)\n        self.download_thread.finished.connect(self.download_finished)\n        self.download_thread.error.connect(self.download_error)\n        self.download_thread.start()\n\n    def update_progress(self, value, status_msg):\n        self.progress_bar.setValue(int(value))\n        self.progress_label.setText(status_msg)\n\n    def download_finished(self):\n        InfoBar.success(\n            title=self.tr(\"完成\"),\n            content=self.tr(\"模型下载完成!\"),\n            parent=self.window(),\n            duration=3000,\n        )\n        self.download_button.setEnabled(True)\n        self.progress_label.setText(self.tr(\"下载完成\"))\n\n    def download_error(self, error):\n        InfoBar.error(\n            title=self.tr(\"下载错误\"),\n            content=error,\n            parent=self.window(),\n            duration=5000,\n        )\n        self.download_button.setEnabled(True)\n        self.progress_label.hide()\n\n    def reject(self):\n        if self.download_thread and self.download_thread.isRunning():\n            logger.info(\"关闭下载对话框,终止下载\")\n            self.download_thread.stop()\n        super().reject()\n\n\nclass WhisperCppDownloadDialog(MessageBoxBase):\n    \"\"\"WhisperCpp 下载对话框\"\"\"\n\n    # 添加类变量跟踪下载状态\n    is_downloading = False\n\n    def __init__(self, parent=None, setting_widget=None):\n        super().__init__(parent)\n        self.widget.setMinimumWidth(600)\n        self.program_download_thread = None\n        self.model_download_thread = None\n        self._setup_ui()\n        self.setting_widget = setting_widget\n\n    def _setup_ui(self):\n        \"\"\"设置UI\"\"\"\n        layout = QVBoxLayout()\n        self._setup_program_section(layout)\n        layout.addSpacing(20)\n        self._setup_model_section(layout)\n        self._setup_progress_section(layout)\n\n        self.viewLayout.addLayout(layout)\n        self.cancelButton.setText(self.tr(\"关闭\"))\n        self.yesButton.hide()\n\n    def _setup_program_section(self, layout):\n        \"\"\"设置程序下载部分UI\"\"\"\n        # 标题\n        whisper_cpp_title = SubtitleLabel(self.tr(\"WhisperCpp程序\"), self)\n        layout.addWidget(whisper_cpp_title)\n        layout.addSpacing(8)\n\n        # 检查已安装的版本\n        has_program, installed_versions = check_whisper_cpp_exists()\n\n        if has_program:\n            # 显示已安装版本\n            versions_text = \" + \".join(installed_versions)\n            program_status = BodyLabel(self.tr(f\"已安装版本: {versions_text}\"), self)\n            program_status.setStyleSheet(\"color: green\")\n            layout.addWidget(program_status)\n        else:\n            desc_label = BodyLabel(self.tr(\"未下载 WhisperCpp 程序\"), self)\n            layout.addWidget(desc_label)\n\n    def _setup_model_section(self, layout):\n        \"\"\"设置模型下载部分UI\"\"\"\n        # 标题和按钮的水平布局\n        title_layout = QHBoxLayout()\n\n        # 标题\n        model_title = SubtitleLabel(self.tr(\"模型下载\"), self)\n        title_layout.addWidget(model_title)\n\n        # 添加打开文件夹按钮\n        open_folder_btn = HyperlinkButton(\"\", self.tr(\"打开模型文件夹\"), parent=self)\n        open_folder_btn.setIcon(FIF.FOLDER)\n        open_folder_btn.clicked.connect(self._open_model_folder)\n        title_layout.addStretch()\n        title_layout.addWidget(open_folder_btn)\n\n        layout.addLayout(title_layout)\n        layout.addSpacing(8)\n\n        # 模型表格\n        self.model_table = self._create_model_table()\n        self._populate_model_table()\n        layout.addWidget(self.model_table)\n\n    def _create_model_table(self):\n        \"\"\"创建模型表格\"\"\"\n        table = TableWidget(self)\n        table.setEditTriggers(TableWidget.NoEditTriggers)\n        table.setSelectionMode(TableWidget.NoSelection)\n        table.setColumnCount(4)\n        table.setHorizontalHeaderLabels(\n            [self.tr(\"模型名称\"), self.tr(\"大小\"), self.tr(\"状态\"), self.tr(\"操作\")]\n        )\n\n        # 设置表格样式\n        table.setBorderVisible(True)\n        table.setBorderRadius(8)\n        table.setItemDelegate(TableItemDelegate(table))\n\n        # 设置列宽\n        header = table.horizontalHeader()\n        header.setSectionResizeMode(0, QHeaderView.Stretch)\n        header.setSectionResizeMode(1, QHeaderView.Fixed)\n        header.setSectionResizeMode(2, QHeaderView.Fixed)\n        header.setSectionResizeMode(3, QHeaderView.Fixed)\n\n        table.setColumnWidth(1, 100)\n        table.setColumnWidth(2, 80)\n        table.setColumnWidth(3, 150)\n\n        # 设置行高\n        row_height = 45\n        table.verticalHeader().setDefaultSectionSize(row_height)\n\n        # 设置表格高度\n        header_height = 20\n        max_visible_rows = 6\n        table_height = row_height * max_visible_rows + header_height + 15\n        table.setFixedHeight(table_height)\n\n        return table\n\n    def _setup_progress_section(self, layout):\n        \"\"\"设置进度显示部分UI\"\"\"\n        self.progress_bar = ProgressBar(self)\n        self.progress_label = BodyLabel(\"\", self)\n        self.progress_bar.hide()\n        self.progress_label.hide()\n\n        layout.addWidget(self.progress_bar)\n        layout.addWidget(self.progress_label)\n\n    def _populate_model_table(self):\n        \"\"\"填充模型表格数据\"\"\"\n        self.model_table.setRowCount(len(WHISPER_CPP_MODELS))\n        for i, model in enumerate(WHISPER_CPP_MODELS):\n            self._add_model_row(i, model)\n\n    def _add_model_row(self, row, model):\n        \"\"\"添加模型表格行\"\"\"\n        # 模型名称\n        name_item = QTableWidgetItem(model[\"label\"])\n        name_item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n        self.model_table.setItem(row, 0, name_item)\n\n        # 大小\n        size_item = QTableWidgetItem(f\"{model['size']}\")\n        size_item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n        self.model_table.setItem(row, 1, size_item)\n\n        # 状态\n        model_bin_path = os.path.join(MODEL_PATH, model[\"value\"])\n        status_item = QTableWidgetItem(\n            self.tr(\"已下载\") if os.path.exists(model_bin_path) else self.tr(\"未下载\")\n        )\n        if os.path.exists(model_bin_path):\n            status_item.setForeground(Qt.green)  # type: ignore\n        status_item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n        self.model_table.setItem(row, 2, status_item)\n\n        # 下载按钮\n        button_container = QWidget()\n        button_layout = QHBoxLayout(button_container)\n        button_layout.setContentsMargins(4, 4, 4, 4)\n\n        download_btn = HyperlinkButton(\n            \"\",\n            self.tr(\"重新下载\") if os.path.exists(model_bin_path) else self.tr(\"下载\"),\n            parent=self,\n        )\n        download_btn.setIcon(FIF.DOWNLOAD)\n        download_btn.clicked.connect(lambda checked, r=row: self._download_model(r))\n\n        button_layout.addStretch()\n        button_layout.addWidget(download_btn)\n        button_layout.addStretch()\n        self.model_table.setCellWidget(row, 3, button_container)\n\n    def _download_model(self, row):\n        \"\"\"下载选中的模型\"\"\"\n        if WhisperCppDownloadDialog.is_downloading:\n            InfoBar.warning(\n                self.tr(\"下载进行中\"),\n                self.tr(\"请等待当前下载任务完成\"),\n                duration=3000,\n                parent=self,\n            )\n            return\n\n        WhisperCppDownloadDialog.is_downloading = True\n        self._set_all_download_buttons_enabled(False)\n\n        model = WHISPER_CPP_MODELS[row]\n        self.progress_bar.show()\n        self.progress_label.show()\n        self.progress_label.setText(self.tr(f\"正在下载 {model['label']} 模型...\"))\n\n        # 禁用当前行的下载按钮\n        button_container = self.model_table.cellWidget(row, 3)\n        download_btn = button_container.findChild(HyperlinkButton)\n        if download_btn:\n            download_btn.setEnabled(False)\n\n        def _on_model_download_progress(value, msg):\n            self.progress_bar.setValue(int(value))\n            self.progress_label.setText(msg)\n\n        def _on_model_download_finished():\n            WhisperCppDownloadDialog.is_downloading = False\n            self._set_all_download_buttons_enabled(True)\n            # 更新状态\n            status_item = QTableWidgetItem(self.tr(\"已下载\"))\n            status_item.setForeground(Qt.green)  # type: ignore\n            status_item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n            self.model_table.setItem(row, 2, status_item)\n\n            # 更新下载按钮文本\n            if download_btn:\n                download_btn.setText(self.tr(\"重新下载\"))\n                download_btn.setEnabled(True)\n\n            # 获取当前下载的模型信息\n            model = WHISPER_CPP_MODELS[row]\n\n            # 更新主设置对话框的模型选择\n            if self.setting_widget:\n                try:\n                    # 保存当前值并清空\n                    current_value = cfg.whisper_model.value\n                    combo = self.setting_widget.model_card.comboBox\n                    combo.clear()\n\n                    # 找出已下载的模型\n                    available = []\n                    model_map = {\n                        m[\"label\"].lower(): m[\"value\"] for m in WHISPER_CPP_MODELS\n                    }\n                    for enum_val in WhisperModelEnum:\n                        if enum_val.value in model_map:\n                            if (MODEL_PATH / model_map[enum_val.value]).exists():\n                                available.append(enum_val)\n\n                    # 重建下拉框\n                    self.setting_widget.model_card.optionToText = {\n                        e: e.value for e in available\n                    }\n                    for enum_val in available:\n                        combo.addItem(enum_val.value, userData=enum_val)\n\n                    # 恢复选择\n                    if current_value in available:\n                        combo.setCurrentText(current_value.value)\n                    elif combo.count() > 0:\n                        combo.setCurrentIndex(0)\n                except Exception as e:\n                    logger.error(f\"更新模型选择失败: {e}\")\n\n            InfoBar.success(\n                self.tr(\"下载成功\"),\n                self.tr(f\"{model['label']} 模型已下载完成\"),\n                duration=3000,\n                parent=self,\n            )\n            self.progress_bar.hide()\n            self.progress_label.hide()\n\n        def _on_model_download_error(error):\n            WhisperCppDownloadDialog.is_downloading = False\n            self._set_all_download_buttons_enabled(True)\n            if download_btn:\n                download_btn.setEnabled(True)\n\n            InfoBar.error(self.tr(\"下载失败\"), str(error), duration=3000, parent=self)\n            self.progress_bar.hide()\n            self.progress_label.hide()\n\n        self.model_download_thread = FileDownloadThread(\n            model[\"mirrorLink\"], os.path.join(MODEL_PATH, model[\"value\"])\n        )\n        self.model_download_thread.progress.connect(_on_model_download_progress)\n        self.model_download_thread.finished.connect(_on_model_download_finished)\n        self.model_download_thread.error.connect(_on_model_download_error)\n        self.model_download_thread.start()\n\n    def _set_all_download_buttons_enabled(self, enabled: bool):\n        \"\"\"设置所有下载按钮的启用状态\"\"\"\n        # 设置程序下载按钮\n        if hasattr(self, \"program_download_btn\"):\n            self.program_download_btn.setEnabled(enabled)\n            self.program_combo.setEnabled(enabled)\n\n        # 设置所有模型下载按钮\n        for row in range(self.model_table.rowCount()):\n            button_container = self.model_table.cellWidget(row, 3)\n            if button_container:\n                download_btn = button_container.findChild(HyperlinkButton)\n                if download_btn:\n                    download_btn.setEnabled(enabled)\n\n    def _open_model_folder(self):\n        \"\"\"打开模型文件夹\"\"\"\n        if os.path.exists(MODEL_PATH):\n            # 根据操作系统打开文件夹\n            open_folder(str(MODEL_PATH))\n\n\nclass WhisperCppSettingWidget(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setup_ui()\n        self.setup_signals()\n\n    def setup_ui(self):\n        self.main_layout = QVBoxLayout(self)\n\n        # 创建单向滚动区域和容器\n        self.scrollArea = SingleDirectionScrollArea(orient=Qt.Vertical, parent=self)  # type: ignore\n        self.scrollArea.setStyleSheet(\n            \"QScrollArea{background: transparent; border: none}\"\n        )\n\n        self.container = QWidget(self)\n        self.container.setStyleSheet(\"QWidget{background: transparent}\")\n        self.containerLayout = QVBoxLayout(self.container)\n\n        self.setting_group = SettingCardGroup(self.tr(\"Whisper CPP 设置\"), self)\n\n        # 模型选择\n        self.model_card = ComboBoxSettingCard(\n            cfg.whisper_model,\n            FIF.ROBOT,\n            self.tr(\"模型\"),\n            self.tr(\"选择Whisper模型\"),\n            [model.value for model in WhisperModelEnum],\n            self.setting_group,\n        )\n\n        # 检查未下载的模型并从下拉框中移除\n        for i in range(self.model_card.comboBox.count() - 1, -1, -1):\n            model_text = self.model_card.comboBox.itemText(i).lower()\n            model_configs = {\n                model[\"label\"].lower(): model for model in WHISPER_CPP_MODELS\n            }\n            model_config = model_configs.get(model_text)\n            if model_config and (MODEL_PATH / model_config[\"value\"]).exists():\n                continue\n            self.model_card.comboBox.removeItem(i)\n\n        # 语言选择\n        self.language_card = ComboBoxSettingCard(\n            cfg.transcribe_language,\n            FIF.LANGUAGE,\n            self.tr(\"源语言\"),\n            self.tr(\"音视频中说话的语言，默认根据前30秒自动识别\"),\n            [language.value for language in TranscribeLanguageEnum],\n            self.setting_group,\n        )\n\n        # 添加模型管理卡片\n        self.manage_model_card = HyperlinkCard(\n            \"\",  # 无链接\n            self.tr(\"管理模型\"),\n            FIF.DOWNLOAD,  # 使用下载图标\n            self.tr(\"模型管理\"),\n            self.tr(\"下载或更新 Whisper CPP 模型\"),\n            self.setting_group,  # 添加到设置组\n        )\n\n        # 添加 setMaxVisibleItems\n        self.language_card.comboBox.setMaxVisibleItems(6)\n\n        # 使用 addSettingCard 添加卡片到组\n        self.setting_group.addSettingCard(self.model_card)\n        self.setting_group.addSettingCard(self.language_card)\n        self.setting_group.addSettingCard(self.manage_model_card)\n\n        # 将设置组添加到容器布局\n        self.containerLayout.addWidget(self.setting_group)\n        self.containerLayout.addStretch(1)\n\n        # 设置组件最小宽度\n        self.model_card.comboBox.setMinimumWidth(200)\n        self.language_card.comboBox.setMinimumWidth(200)\n\n        # 设置滚动区域\n        self.scrollArea.setWidget(self.container)\n        self.scrollArea.setWidgetResizable(True)\n\n        # 将滚动区域添加到主布局\n        self.main_layout.addWidget(self.scrollArea)\n\n    def setup_signals(self):\n        self.manage_model_card.linkButton.clicked.connect(self.show_download_dialog)\n\n    def show_download_dialog(self):\n        \"\"\"显示下载对话框\"\"\"\n        download_dialog = WhisperCppDownloadDialog(self.window(), self)\n        download_dialog.show()\n"
  },
  {
    "path": "app/components/transcription_setting_card.py",
    "content": "from typing import Optional\n\nfrom PyQt5.QtWidgets import (\n    QStackedWidget,\n    QVBoxLayout,\n    QWidget,\n)\n\nfrom ..core.entities import (\n    TranscribeModelEnum,\n)\nfrom ..core.utils.platform_utils import is_macos\nfrom .FasterWhisperSettingWidget import FasterWhisperSettingWidget\nfrom .WhisperAPISettingWidget import WhisperAPISettingWidget\nfrom .WhisperCppSettingWidget import WhisperCppSettingWidget\n\n\nclass TranscriptionSettingCard(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setup_ui()\n\n    def setup_ui(self):\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setContentsMargins(0, 0, 0, 0)\n\n        # 设置界面堆叠\n        self.stacked_widget = QStackedWidget(self)\n\n        # 添加各个设置界面\n        self.empty_widget = QWidget(self)  # 添加空白页面作为默认显示\n        self.whisper_cpp_widget = WhisperCppSettingWidget(self)\n        self.whisper_api_widget = WhisperAPISettingWidget(self)\n\n        # FasterWhisper 在 macOS 上不可用\n        self.faster_whisper_widget: Optional[FasterWhisperSettingWidget] = None\n        if not is_macos():\n            self.faster_whisper_widget = FasterWhisperSettingWidget(self)\n\n        self.stacked_widget.addWidget(self.empty_widget)  # 添加空白页面\n        self.stacked_widget.addWidget(self.whisper_cpp_widget)\n        self.stacked_widget.addWidget(self.whisper_api_widget)\n        if self.faster_whisper_widget is not None:\n            self.stacked_widget.addWidget(self.faster_whisper_widget)\n\n        self.main_layout.addWidget(self.stacked_widget)\n\n    def on_model_changed(self, value):\n        # 切换对应的设置界面\n        if value == TranscribeModelEnum.WHISPER_CPP.value:\n            self.stacked_widget.setCurrentWidget(self.whisper_cpp_widget)\n        elif value == TranscribeModelEnum.WHISPER_API.value:\n            self.stacked_widget.setCurrentWidget(self.whisper_api_widget)\n        elif value == TranscribeModelEnum.FASTER_WHISPER.value:\n            self.stacked_widget.setCurrentWidget(self.faster_whisper_widget)\n        else:\n            self.stacked_widget.setCurrentWidget(self.empty_widget)\n"
  },
  {
    "path": "app/config.py",
    "content": "import logging\nimport os\nfrom pathlib import Path\n\nVERSION = \"v1.4.0\"\nYEAR = 2025\nAPP_NAME = \"VideoCaptioner\"\nAUTHOR = \"Weifeng\"\n\nHELP_URL = \"https://github.com/WEIFENG2333/VideoCaptioner\"\nGITHUB_REPO_URL = \"https://github.com/WEIFENG2333/VideoCaptioner\"\nRELEASE_URL = \"https://github.com/WEIFENG2333/VideoCaptioner/releases/latest\"\nFEEDBACK_URL = \"https://github.com/WEIFENG2333/VideoCaptioner/issues\"\n\n# 路径\nROOT_PATH = Path(__file__).parent.parent\n\nRESOURCE_PATH = ROOT_PATH / \"resource\"\nAPPDATA_PATH = ROOT_PATH / \"AppData\"\nWORK_PATH = ROOT_PATH / \"work-dir\"\n\n\nBIN_PATH = RESOURCE_PATH / \"bin\"\nASSETS_PATH = RESOURCE_PATH / \"assets\"\nSUBTITLE_STYLE_PATH = RESOURCE_PATH / \"subtitle_style\"\nTRANSLATIONS_PATH = RESOURCE_PATH / \"translations\"\nFONTS_PATH = RESOURCE_PATH / \"fonts\"\n\nLOG_PATH = APPDATA_PATH / \"logs\"\nLLM_LOG_FILE = LOG_PATH / \"llm_requests.jsonl\"\nSETTINGS_PATH = APPDATA_PATH / \"settings.json\"\nCACHE_PATH = APPDATA_PATH / \"cache\"\nMODEL_PATH = APPDATA_PATH / \"models\"\n\nFASER_WHISPER_PATH = BIN_PATH / \"Faster-Whisper-XXL\"\n\n# 日志配置\nLOG_LEVEL = logging.INFO\nLOG_FORMAT = \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\"\n\n# 环境变量添加 bin 路径，添加到PATH开头以优先使用\nos.environ[\"PATH\"] = str(FASER_WHISPER_PATH) + os.pathsep + os.environ[\"PATH\"]\nos.environ[\"PATH\"] = str(BIN_PATH) + os.pathsep + os.environ[\"PATH\"]\n\n# 添加 VLC 路径\nos.environ[\"PYTHON_VLC_MODULE_PATH\"] = str(BIN_PATH / \"vlc\")\n\n# 创建路径\nfor p in [CACHE_PATH, LOG_PATH, WORK_PATH, MODEL_PATH]:\n    p.mkdir(parents=True, exist_ok=True)\n"
  },
  {
    "path": "app/core/asr/__init__.py",
    "content": "from .bcut import BcutASR\nfrom .chunked_asr import ChunkedASR\nfrom .faster_whisper import FasterWhisperASR\nfrom .jianying import JianYingASR\nfrom .status import ASRStatus\nfrom .transcribe import transcribe\nfrom .whisper_api import WhisperAPI\nfrom .whisper_cpp import WhisperCppASR\n\n__all__ = [\n    \"BcutASR\",\n    \"ChunkedASR\",\n    \"FasterWhisperASR\",\n    \"JianYingASR\",\n    \"WhisperAPI\",\n    \"WhisperCppASR\",\n    \"transcribe\",\n    \"ASRStatus\",\n]\n"
  },
  {
    "path": "app/core/asr/asr_data.py",
    "content": "import json\nimport math\nimport os\nimport platform\nimport re\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple\n\nfrom langdetect import LangDetectException, detect\n\nfrom ..entities import SubtitleLayoutEnum\nfrom ..utils.text_utils import is_mainly_cjk\n\n# 多语言分词模式(支持词级和字符级语言)\n_WORD_SPLIT_PATTERN = (\n    r\"[a-zA-Z\\u00c0-\\u00ff\\u0100-\\u017f']+\"  # 拉丁字符(含扩展)\n    r\"|[\\u0400-\\u04ff]+\"  # 西里尔字母(俄文)\n    r\"|[\\u0370-\\u03ff]+\"  # 希腊字母\n    r\"|[\\u0600-\\u06ff]+\"  # 阿拉伯文\n    r\"|[\\u0590-\\u05ff]+\"  # 希伯来文\n    r\"|\\d+\"  # 数字\n    r\"|[\\u4e00-\\u9fff]\"  # 中文\n    r\"|[\\u3040-\\u309f]\"  # 日文平假名\n    r\"|[\\u30a0-\\u30ff]\"  # 日文片假名\n    r\"|[\\uac00-\\ud7af]\"  # 韩文\n    r\"|[\\u0e00-\\u0e7f][\\u0e30-\\u0e3a\\u0e47-\\u0e4e]*\"  # 泰文\n    r\"|[\\u0900-\\u097f]\"  # 天城文(印地语)\n    r\"|[\\u0980-\\u09ff]\"  # 孟加拉文\n    r\"|[\\u0e80-\\u0eff]\"  # 老挝文\n    r\"|[\\u1000-\\u109f]\"  # 缅甸文\n)\n\n\ndef handle_long_path(path: str) -> str:\n    r\"\"\"Handle Windows long path limitation by adding \\\\?\\ prefix.\n\n    Args:\n        path: Original file path\n\n    Returns:\n        Path with \\\\?\\ prefix if needed (Windows only)\n    \"\"\"\n    if (\n        platform.system() == \"Windows\"\n        and len(path) > 260\n        and not path.startswith(r\"\\\\?\\ \")\n    ):\n        return rf\"\\\\?\\{os.path.abspath(path)}\"\n    return path\n\n\nclass ASRDataSeg:\n    def __init__(\n        self, text: str, start_time: int, end_time: int, translated_text: str = \"\"\n    ):\n        self.text = text\n        self.translated_text = translated_text\n        self.start_time = start_time\n        self.end_time = end_time\n\n    def to_srt_ts(self) -> str:\n        \"\"\"Convert to SRT timestamp format\"\"\"\n        return f\"{self._ms_to_srt_time(self.start_time)} --> {self._ms_to_srt_time(self.end_time)}\"\n\n    def to_lrc_ts(self) -> str:\n        \"\"\"Convert to LRC timestamp format\"\"\"\n        return f\"[{self._ms_to_lrc_time(self.start_time)}]\"\n\n    def to_ass_ts(self) -> Tuple[str, str]:\n        \"\"\"Convert to ASS timestamp format\"\"\"\n        return self._ms_to_ass_ts(self.start_time), self._ms_to_ass_ts(self.end_time)\n\n    @staticmethod\n    def _ms_to_lrc_time(ms: int) -> str:\n        \"\"\"Convert milliseconds to LRC time format (MM:SS.cc)\"\"\"\n        seconds = ms / 1000\n        minutes, seconds = divmod(seconds, 60)\n        return f\"{int(minutes):02}:{seconds:.2f}\"\n\n    @staticmethod\n    def _ms_to_srt_time(ms: int) -> str:\n        \"\"\"Convert milliseconds to SRT time format (HH:MM:SS,mmm)\"\"\"\n        total_seconds, milliseconds = divmod(ms, 1000)\n        minutes, seconds = divmod(total_seconds, 60)\n        hours, minutes = divmod(minutes, 60)\n        return f\"{int(hours):02}:{int(minutes):02}:{int(seconds):02},{int(milliseconds):03}\"\n\n    @staticmethod\n    def _ms_to_ass_ts(ms: int) -> str:\n        \"\"\"Convert milliseconds to ASS timestamp format (H:MM:SS.cc)\"\"\"\n        total_seconds, milliseconds = divmod(ms, 1000)\n        minutes, seconds = divmod(total_seconds, 60)\n        hours, minutes = divmod(minutes, 60)\n        centiseconds = int(milliseconds / 10)\n        return f\"{int(hours):01}:{int(minutes):02}:{int(seconds):02}.{centiseconds:02}\"\n\n    @property\n    def transcript(self) -> str:\n        \"\"\"Return segment text\"\"\"\n        return self.text\n\n    def __str__(self) -> str:\n        return f\"ASRDataSeg({self.text}, {self.start_time}, {self.end_time})\"\n\n\nclass ASRData:\n    def __init__(self, segments: List[ASRDataSeg]):\n        filtered_segments = [seg for seg in segments if seg.text and seg.text.strip()]\n        filtered_segments.sort(key=lambda x: x.start_time)\n        self.segments = filtered_segments\n\n    def __iter__(self):\n        return iter(self.segments)\n\n    def __len__(self) -> int:\n        return len(self.segments)\n\n    def has_data(self) -> bool:\n        \"\"\"Check if there are any utterances\"\"\"\n        return len(self.segments) > 0\n\n    def _is_word_level_segment(self, segment: ASRDataSeg) -> bool:\n        \"\"\"判断单个片段是否为词级\n\n        Args:\n            segment: 待判断的字幕片段\n\n        Returns:\n            True 如果片段符合词级模式\n        \"\"\"\n        text = segment.text.strip()\n\n        # CJK语言：1-2个字符\n        if is_mainly_cjk(text):\n            return len(text) <= 2\n\n        # 非CJK语言（如英文）：单个单词\n        words = text.split()\n        return len(words) == 1\n\n    def is_word_timestamp(self) -> bool:\n        \"\"\"检查时间戳是否为词级(非句子级)\n\n        词级判定标准:\n        - 英文: 单个单词\n        - CJK/亚洲语言: 1-2个字符\n        - 允许20%误差容忍\n\n        Returns:\n            True 如果80%+的片段符合词级模式\n        \"\"\"\n        if not self.segments:\n            return False\n\n        # 统计符合词级模式的片段数量\n        word_level_count = sum(\n            1 for seg in self.segments if self._is_word_level_segment(seg)\n        )\n\n        WORD_LEVEL_THRESHOLD = 0.8\n        word_level_ratio = word_level_count / len(self.segments)\n\n        return word_level_ratio >= WORD_LEVEL_THRESHOLD\n\n    def split_to_word_segments(self) -> \"ASRData\":\n        \"\"\"将句子级字幕分割为词级字幕,并按音素估算分配时间戳\n\n        时间戳分配基于音素估算(每4个字符约1个音素)\n\n        Returns:\n            修改后的ASRData实例\n        \"\"\"\n        CHARS_PER_PHONEME = 4\n        new_segments = []\n\n        for seg in self.segments:\n            text = seg.text\n            duration = seg.end_time - seg.start_time\n\n            # 使用统一的多语言分词模式\n            words_list = list(re.finditer(_WORD_SPLIT_PATTERN, text))\n\n            if not words_list:\n                continue\n\n            # 计算总音素数\n            total_phonemes = sum(\n                math.ceil(len(w.group()) / CHARS_PER_PHONEME) for w in words_list\n            )\n            time_per_phoneme = duration / max(total_phonemes, 1)\n\n            # 为每个词分配时间戳\n            current_time = seg.start_time\n            for word_match in words_list:\n                word = word_match.group()\n                word_phonemes = math.ceil(len(word) / CHARS_PER_PHONEME)\n                word_duration = int(time_per_phoneme * word_phonemes)\n\n                word_end_time = min(current_time + word_duration, seg.end_time)\n                new_segments.append(\n                    ASRDataSeg(\n                        text=word, start_time=current_time, end_time=word_end_time\n                    )\n                )\n                current_time = word_end_time\n\n        self.segments = new_segments\n        return self\n\n    def remove_punctuation(self) -> \"ASRData\":\n        \"\"\"Remove trailing Chinese punctuation (comma, period) from segments.\"\"\"\n        punctuation = r\"[，。]\"\n        for seg in self.segments:\n            seg.text = re.sub(f\"{punctuation}+$\", \"\", seg.text.strip())\n            seg.translated_text = re.sub(\n                f\"{punctuation}+$\", \"\", seg.translated_text.strip()\n            )\n        return self\n\n    def save(\n        self,\n        save_path: str,\n        ass_style: Optional[str] = None,\n        layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ORIGINAL_ON_TOP,\n    ) -> None:\n        \"\"\"Save ASRData to file in specified format.\n\n        Args:\n            save_path: Output file path\n            ass_style: ASS style string (optional, uses default if None)\n            layout: Subtitle layout mode\n        \"\"\"\n        save_path = handle_long_path(save_path)\n        Path(save_path).parent.mkdir(parents=True, exist_ok=True)\n\n        if save_path.endswith(\".srt\"):\n            self.to_srt(save_path=save_path, layout=layout)\n        elif save_path.endswith(\".txt\"):\n            self.to_txt(save_path=save_path, layout=layout)\n        elif save_path.endswith(\".json\"):\n            with open(save_path, \"w\", encoding=\"utf-8\") as f:\n                json.dump(self.to_json(), f, ensure_ascii=False)\n        elif save_path.endswith(\".ass\"):\n            self.to_ass(save_path=save_path, style_str=ass_style, layout=layout)\n        else:\n            raise ValueError(f\"Unsupported file extension: {save_path}\")\n\n    def to_txt(\n        self,\n        save_path=None,\n        layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ORIGINAL_ON_TOP,\n    ) -> str:\n        \"\"\"Convert to plain text subtitle format (without timestamps)\"\"\"\n        result = []\n        for seg in self.segments:\n            original = seg.text\n            translated = seg.translated_text\n\n            if layout == SubtitleLayoutEnum.ORIGINAL_ON_TOP:\n                text = f\"{original}\\n{translated}\" if translated else original\n            elif layout == SubtitleLayoutEnum.TRANSLATE_ON_TOP:\n                text = f\"{translated}\\n{original}\" if translated else original\n            elif layout == SubtitleLayoutEnum.ONLY_ORIGINAL:\n                text = original\n            else:  # ONLY_TRANSLATE\n                text = translated if translated else original\n            result.append(text)\n        text = \"\\n\".join(result)\n        if save_path:\n            save_path = handle_long_path(save_path)\n            with open(save_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(\"\\n\".join(result))\n        return text\n\n    def to_srt(\n        self,\n        layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ORIGINAL_ON_TOP,\n        save_path=None,\n    ) -> str:\n        \"\"\"Convert to SRT subtitle format\"\"\"\n        srt_lines = []\n        for n, seg in enumerate(self.segments, 1):\n            original = seg.text\n            translated = seg.translated_text\n\n            if layout == SubtitleLayoutEnum.ORIGINAL_ON_TOP:\n                text = f\"{original}\\n{translated}\" if translated else original\n            elif layout == SubtitleLayoutEnum.TRANSLATE_ON_TOP:\n                text = f\"{translated}\\n{original}\" if translated else original\n            elif layout == SubtitleLayoutEnum.ONLY_ORIGINAL:\n                text = original\n            else:  # ONLY_TRANSLATE\n                text = translated if translated else original\n\n            srt_lines.append(f\"{n}\\n{seg.to_srt_ts()}\\n{text}\\n\")\n\n        srt_text = \"\\n\".join(srt_lines)\n        if save_path:\n            save_path = handle_long_path(save_path)\n            with open(save_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(srt_text)\n        return srt_text\n\n    def to_lrc(self, save_path=None) -> str:\n        \"\"\"Convert to LRC subtitle format\"\"\"\n        raise NotImplementedError(\"LRC format is not supported\")\n\n    def to_json(self) -> dict:\n        \"\"\"Convert to JSON format\"\"\"\n        result_json = {}\n        for i, segment in enumerate(self.segments, 1):\n            result_json[str(i)] = {\n                \"start_time\": segment.start_time,\n                \"end_time\": segment.end_time,\n                \"original_subtitle\": segment.text,\n                \"translated_subtitle\": segment.translated_text,\n            }\n        return result_json\n\n    def to_ass(\n        self,\n        style_str: Optional[str] = None,\n        layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ORIGINAL_ON_TOP,\n        save_path: Optional[str] = None,\n        video_width: int = 1280,\n        video_height: int = 720,\n    ) -> str:\n        \"\"\"Convert to ASS subtitle format\n\n        Args:\n            style_str: ASS style string (optional, uses default if None)\n            layout: Subtitle layout mode\n            save_path: Save path for ASS file (optional)\n            video_width: Video width (default 1280)\n            video_height: Video height (default 720)\n\n        Returns:\n            ASS format subtitle content\n        \"\"\"\n        if not style_str:\n            style_str = (\n                \"[V4+ Styles]\\n\"\n                \"Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,\"\n                \"Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,\"\n                \"Alignment,MarginL,MarginR,MarginV,Encoding\\n\"\n                \"Style: Default,MicrosoftYaHei-Bold,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,\"\n                \"0,0,1,2,0,2,10,10,15,1\\n\"\n                \"Style: Secondary,MicrosoftYaHei-Bold,30,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,\"\n                \"0,0,1,2,0,2,10,10,15,1\"\n            )\n\n        ass_content = (\n            \"[Script Info]\\n\"\n            \"; Script generated by VideoCaptioner\\n\"\n            \"; https://github.com/weifeng2333\\n\"\n            \"ScriptType: v4.00+\\n\"\n            f\"PlayResX: {video_width}\\n\"\n            f\"PlayResY: {video_height}\\n\\n\"\n            f\"{style_str}\\n\\n\"\n            \"[Events]\\n\"\n            \"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\\n\"\n        )\n\n        dialogue_template = \"Dialogue: 0,{},{},{},,0,0,0,,{}\\n\"\n        for seg in self.segments:\n            start_time, end_time = seg.to_ass_ts()\n            original = seg.text\n            translated = seg.translated_text\n            has_translation = bool(translated and translated.strip())\n\n            if layout == SubtitleLayoutEnum.TRANSLATE_ON_TOP:\n                if has_translation:\n                    # 先写译文(Default)显示在上，后写原文(Secondary)显示在下\n                    ass_content += dialogue_template.format(\n                        start_time, end_time, \"Default\", translated\n                    )\n                    ass_content += dialogue_template.format(\n                        start_time, end_time, \"Secondary\", original\n                    )\n                else:\n                    ass_content += dialogue_template.format(\n                        start_time, end_time, \"Default\", original\n                    )\n            elif layout == SubtitleLayoutEnum.ORIGINAL_ON_TOP:\n                if has_translation:\n                    # 先写原文(Default)显示在上，后写译文(Secondary)显示在下\n                    ass_content += dialogue_template.format(\n                        start_time, end_time, \"Default\", original\n                    )\n                    ass_content += dialogue_template.format(\n                        start_time, end_time, \"Secondary\", translated\n                    )\n                else:\n                    ass_content += dialogue_template.format(\n                        start_time, end_time, \"Default\", original\n                    )\n            elif layout == SubtitleLayoutEnum.ONLY_ORIGINAL:\n                ass_content += dialogue_template.format(\n                    start_time, end_time, \"Default\", original\n                )\n            else:  # ONLY_TRANSLATE\n                text = translated if has_translation else original\n                ass_content += dialogue_template.format(\n                    start_time, end_time, \"Default\", text\n                )\n\n        if save_path:\n            save_path = handle_long_path(save_path)\n            with open(save_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(ass_content)\n        return ass_content\n\n    def to_vtt(self, save_path=None) -> str:\n        \"\"\"Convert to WebVTT subtitle format\n\n        Args:\n            save_path: Optional save path\n\n        Returns:\n            WebVTT format subtitle content\n        \"\"\"\n        raise NotImplementedError(\"WebVTT format is not supported\")\n        # # WebVTT头部\n        # vtt_lines = [\"WEBVTT\\n\"]\n\n        # for n, seg in enumerate(self.segments, 1):\n        #     # 转换时间戳格式从毫秒到 HH:MM:SS.mmm\n        #     start_time = seg._ms_to_srt_time(seg.start_time).replace(\",\", \".\")\n        #     end_time = seg._ms_to_srt_time(seg.end_time).replace(\",\", \".\")\n\n        #     # 添加序号（可选）和时间戳\n        #     vtt_lines.append(f\"{n}\\n{start_time} --> {end_time}\\n{seg.transcript}\\n\")\n\n        # vtt_text = \"\\n\".join(vtt_lines)\n\n        # if save_path:\n        #     with open(save_path, \"w\", encoding=\"utf-8\") as f:\n        #         f.write(vtt_text)\n\n        # return vtt_text\n\n    def merge_segments(\n        self, start_index: int, end_index: int, merged_text: Optional[str] = None\n    ):\n        \"\"\"Merge segments from start_index to end_index (inclusive).\"\"\"\n        if (\n            start_index < 0\n            or end_index >= len(self.segments)\n            or start_index > end_index\n        ):\n            raise IndexError(\"Invalid segment index\")\n        merged_start_time = self.segments[start_index].start_time\n        merged_end_time = self.segments[end_index].end_time\n        if merged_text is None:\n            merged_text = \"\".join(\n                seg.text for seg in self.segments[start_index : end_index + 1]\n            )\n        merged_seg = ASRDataSeg(merged_text, merged_start_time, merged_end_time)\n        self.segments[start_index : end_index + 1] = [merged_seg]\n\n    def merge_with_next_segment(self, index: int) -> None:\n        \"\"\"Merge segment at index with next segment.\"\"\"\n        if index < 0 or index >= len(self.segments) - 1:\n            raise IndexError(\"Index out of range or no next segment to merge\")\n        current_seg = self.segments[index]\n        next_seg = self.segments[index + 1]\n        merged_text = f\"{current_seg.text} {next_seg.text}\"\n        merged_seg = ASRDataSeg(merged_text, current_seg.start_time, next_seg.end_time)\n        self.segments[index] = merged_seg\n        del self.segments[index + 1]\n\n    def optimize_timing(self, threshold_ms: int = 1000) -> \"ASRData\":\n        \"\"\"Optimize subtitle display timing by adjusting adjacent segment boundaries.\n\n        If gap between adjacent segments is below threshold, adjust the boundary\n        to 3/4 point between them (reduces flicker).\n\n        Args:\n            threshold_ms: Time gap threshold in milliseconds (default 1000ms)\n\n        Returns:\n            Self for method chaining\n        \"\"\"\n        if self.is_word_timestamp() or not self.segments:\n            return self\n\n        for i in range(len(self.segments) - 1):\n            current_seg = self.segments[i]\n            next_seg = self.segments[i + 1]\n            time_gap = next_seg.start_time - current_seg.end_time\n\n            if time_gap < threshold_ms:\n                mid_time = (\n                    current_seg.end_time + next_seg.start_time\n                ) // 2 + time_gap // 4\n                current_seg.end_time = mid_time\n                next_seg.start_time = mid_time\n\n        return self\n\n    def __str__(self):\n        return self.to_txt()\n\n    @staticmethod\n    def from_subtitle_file(file_path: str) -> \"ASRData\":\n        \"\"\"Load ASRData from subtitle file.\n\n        Args:\n            file_path: Subtitle file path (supports .srt, .vtt, .ass, .json)\n\n        Returns:\n            Parsed ASRData instance\n\n        Raises:\n            FileNotFoundError: File does not exist\n            ValueError: Unsupported file format\n        \"\"\"\n        file_path_obj = Path(file_path)\n        if not file_path_obj.exists():\n            raise FileNotFoundError(f\"File not found: {file_path_obj}\")\n\n        try:\n            content = file_path_obj.read_text(encoding=\"utf-8\")\n        except UnicodeDecodeError:\n            content = file_path_obj.read_text(encoding=\"gbk\")\n\n        suffix = file_path_obj.suffix.lower()\n\n        if suffix == \".srt\":\n            return ASRData.from_srt(content)\n        elif suffix == \".vtt\":\n            if \"<c>\" in content:\n                return ASRData.from_youtube_vtt(content)\n            return ASRData.from_vtt(content)\n        elif suffix == \".ass\":\n            return ASRData.from_ass(content)\n        elif suffix == \".json\":\n            return ASRData.from_json(json.loads(content))\n        else:\n            raise ValueError(f\"Unsupported file format: {suffix}\")\n\n    @staticmethod\n    def from_json(json_data: dict) -> \"ASRData\":\n        \"\"\"Create ASRData from JSON data\"\"\"\n        segments = []\n        for i in sorted(json_data.keys(), key=int):\n            segment_data = json_data[i]\n            segment = ASRDataSeg(\n                text=segment_data[\"original_subtitle\"],\n                translated_text=segment_data[\"translated_subtitle\"],\n                start_time=segment_data[\"start_time\"],\n                end_time=segment_data[\"end_time\"],\n            )\n            segments.append(segment)\n        return ASRData(segments)\n\n    @staticmethod\n    def from_srt(srt_str: str) -> \"ASRData\":\n        \"\"\"Create ASRData from SRT format string.\n\n        Uses language detection to distinguish between bilingual subtitles\n        (original + translation) and multiline single-language subtitles.\n\n        Args:\n            srt_str: SRT format subtitle string\n\n        Returns:\n            Parsed ASRData instance\n        \"\"\"\n        segments = []\n        srt_time_pattern = re.compile(\n            r\"(\\d{2}):(\\d{2}):(\\d{1,2})[.,](\\d{3})\\s-->\\s(\\d{2}):(\\d{2}):(\\d{1,2})[.,](\\d{3})\"\n        )\n        blocks = re.split(r\"\\n\\s*\\n\", srt_str.strip())\n\n        # Detect bilingual mode: all 4-line + 70% different languages\n        def is_different_lang(block: str) -> bool:\n            lines = block.splitlines()\n            if len(lines) != 4:\n                return False\n            try:\n                return detect(lines[2]) != detect(lines[3])\n            except LangDetectException:\n                return False\n\n        all_four_lines = all(len(b.splitlines()) == 4 for b in blocks)\n        is_bilingual = (\n            all_four_lines and sum(map(is_different_lang, blocks[:50])) / 50 >= 0.7\n        )\n\n        # Process all blocks based on detected mode\n        for block in blocks:\n            lines = block.splitlines()\n            if len(lines) < 3:\n                continue\n\n            match = srt_time_pattern.match(lines[1])\n            if not match:\n                continue\n\n            time_parts = list(map(int, match.groups()))\n            start_time = sum(\n                [\n                    time_parts[0] * 3600000,\n                    time_parts[1] * 60000,\n                    time_parts[2] * 1000,\n                    time_parts[3],\n                ]\n            )\n            end_time = sum(\n                [\n                    time_parts[4] * 3600000,\n                    time_parts[5] * 60000,\n                    time_parts[6] * 1000,\n                    time_parts[7],\n                ]\n            )\n\n            if is_bilingual and len(lines) == 4:\n                segments.append(ASRDataSeg(lines[2], start_time, end_time, lines[3]))\n            else:\n                segments.append(ASRDataSeg(\" \".join(lines[2:]), start_time, end_time))\n\n        return ASRData(segments)\n\n    @staticmethod\n    def from_vtt(vtt_str: str) -> \"ASRData\":\n        \"\"\"Create ASRData from VTT format string.\n\n        Args:\n            vtt_str: VTT format subtitle string\n\n        Returns:\n            ASRData instance\n        \"\"\"\n        segments = []\n        content = vtt_str.split(\"\\n\\n\")[2:]\n\n        timestamp_pattern = re.compile(\n            r\"(\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})\\s*-->\\s*(\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})\"\n        )\n\n        for block in content:\n            lines = block.strip().split(\"\\n\")\n            if len(lines) < 2:\n                continue\n\n            timestamp_line = lines[1]\n            match = timestamp_pattern.match(timestamp_line)\n            if not match:\n                continue\n\n            time_parts = list(map(int, match.groups()))\n            start_time = sum(\n                [\n                    time_parts[0] * 3600000,\n                    time_parts[1] * 60000,\n                    time_parts[2] * 1000,\n                    time_parts[3],\n                ]\n            )\n            end_time = sum(\n                [\n                    time_parts[4] * 3600000,\n                    time_parts[5] * 60000,\n                    time_parts[6] * 1000,\n                    time_parts[7],\n                ]\n            )\n\n            text_line = \" \".join(lines[2:])\n            cleaned_text = re.sub(r\"<\\d{2}:\\d{2}:\\d{2}\\.\\d{3}>\", \"\", text_line)\n            cleaned_text = re.sub(r\"</?c>\", \"\", cleaned_text)\n            cleaned_text = cleaned_text.strip()\n\n            if cleaned_text and cleaned_text != \" \":\n                segments.append(ASRDataSeg(cleaned_text, start_time, end_time))\n\n        return ASRData(segments)\n\n    @staticmethod\n    def from_youtube_vtt(vtt_str: str) -> \"ASRData\":\n        \"\"\"Create ASRData from YouTube VTT format with word-level timestamps.\n\n        Args:\n            vtt_str: YouTube VTT format subtitle string (contains <c> tags)\n\n        Returns:\n            Parsed ASRData with word-level segments\n        \"\"\"\n\n        def parse_timestamp(ts: str) -> int:\n            \"\"\"Convert timestamp string to milliseconds\"\"\"\n            h, m, s = ts.split(\":\")\n            return int(float(h) * 3600000 + float(m) * 60000 + float(s) * 1000)\n\n        def split_timestamped_text(text: str) -> List[ASRDataSeg]:\n            \"\"\"Extract word segments from timestamped text\"\"\"\n            pattern = re.compile(r\"<(\\d{2}:\\d{2}:\\d{2}\\.\\d{3})>([^<]*)\")\n            matches = list(pattern.finditer(text))\n            word_segments = []\n\n            for i in range(len(matches) - 1):\n                current_match = matches[i]\n                next_match = matches[i + 1]\n\n                start_time = parse_timestamp(current_match.group(1))\n                end_time = parse_timestamp(next_match.group(1))\n                word = current_match.group(2).strip()\n\n                if word:\n                    word_segments.append(ASRDataSeg(word, start_time, end_time))\n\n            return word_segments\n\n        segments = []\n        blocks = re.split(r\"\\n\\n+\", vtt_str.strip())\n\n        timestamp_pattern = re.compile(\n            r\"(\\d{2}):(\\d{2}):(\\d{2}\\.\\d{3})\\s*-->\\s*(\\d{2}):(\\d{2}):(\\d{2}\\.\\d{3})\"\n        )\n        for block in blocks:\n            lines = block.strip().split(\"\\n\")\n            if not lines:\n                continue\n\n            match = timestamp_pattern.match(lines[0])\n            if not match:\n                continue\n\n            text = \"\\n\".join(lines)\n\n            timestamp_row = re.search(r\"\\n(.*?<c>.*?</c>.*)\", block)\n            if timestamp_row:\n                text = re.sub(r\"<c>|</c>\", \"\", timestamp_row.group(1))\n                block_start_time_string = (\n                    f\"{match.group(1)}:{match.group(2)}:{match.group(3)}\"\n                )\n                block_end_time_string = (\n                    f\"{match.group(4)}:{match.group(5)}:{match.group(6)}\"\n                )\n                text = f\"<{block_start_time_string}>{text}<{block_end_time_string}>\"\n\n                word_segments = split_timestamped_text(text)\n                segments.extend(word_segments)\n\n        return ASRData(segments)\n\n    @staticmethod\n    def from_ass(ass_str: str) -> \"ASRData\":\n        \"\"\"Create ASRData from ASS format string.\n\n        Args:\n            ass_str: ASS format subtitle string\n\n        Returns:\n            ASRData instance\n        \"\"\"\n        segments = []\n        ass_time_pattern = re.compile(\n            r\"Dialogue: \\d+,(\\d+:\\d{2}:\\d{2}\\.\\d{2}),(\\d+:\\d{2}:\\d{2}\\.\\d{2}),(.*?),.*?,\\d+,\\d+,\\d+,.*?,(.*?)$\"\n        )\n\n        def parse_ass_time(time_str: str) -> int:\n            \"\"\"Convert ASS timestamp to milliseconds\"\"\"\n            hours, minutes, seconds = time_str.split(\":\")\n            seconds, centiseconds = seconds.split(\".\")\n            return (\n                int(hours) * 3600000\n                + int(minutes) * 60000\n                + int(seconds) * 1000\n                + int(centiseconds) * 10\n            )\n\n        # 检查是否有翻译：同时存在Default和Secondary样式\n        has_default = \"Dialogue:\" in ass_str and \",Default,\" in ass_str\n        has_secondary = \",Secondary,\" in ass_str\n        has_translation = has_default and has_secondary\n        temp_segments = {}\n\n        for line in ass_str.splitlines():\n            if line.startswith(\"Dialogue:\"):\n                match = ass_time_pattern.match(line)\n                if match:\n                    start_time = parse_ass_time(match.group(1))\n                    end_time = parse_ass_time(match.group(2))\n                    style = match.group(3).strip()\n                    text = match.group(4)\n\n                    text = re.sub(r\"\\{[^}]*\\}\", \"\", text)\n                    text = text.replace(\"\\\\N\", \"\\n\")\n                    text = text.strip()\n\n                    if not text:\n                        continue\n\n                    if has_translation:\n                        time_key = f\"{start_time}-{end_time}\"\n                        if time_key in temp_segments:\n                            if style == \"Default\":\n                                temp_segments[time_key].translated_text = text\n                            else:\n                                temp_segments[time_key].text = text\n                            segments.append(temp_segments[time_key])\n                            del temp_segments[time_key]\n                        else:\n                            segment = ASRDataSeg(\n                                text=\"\", start_time=start_time, end_time=end_time\n                            )\n                            if style == \"Default\":\n                                segment.translated_text = text\n                            else:\n                                segment.text = text\n                            temp_segments[time_key] = segment\n                    else:\n                        segments.append(ASRDataSeg(text, start_time, end_time))\n\n        for segment in temp_segments.values():\n            segments.append(segment)\n\n        return ASRData(segments)\n"
  },
  {
    "path": "app/core/asr/base.py",
    "content": "import os\nimport threading\nimport time\nimport uuid\nimport zlib\nfrom io import BytesIO\nfrom typing import Callable, Optional, Union, cast\n\nfrom pydub import AudioSegment\n\nfrom app.core.utils.cache import get_asr_cache, is_cache_enabled\nfrom app.core.utils.logger import setup_logger\n\nfrom .asr_data import ASRData, ASRDataSeg\n\nlogger = setup_logger(\"asr\")\n\n\nclass BaseASR:\n    \"\"\"Base class for ASR (Automatic Speech Recognition) implementations.\n\n    Provides common functionality including:\n    - Audio file loading and validation\n    - CRC32-based file identification\n    - Disk caching with automatic key generation\n    - Template method pattern for subclass implementation\n    - Rate limiting for public charity services\n    \"\"\"\n\n    SUPPORTED_SOUND_FORMAT = [\"flac\", \"m4a\", \"mp3\", \"wav\"]\n    _lock = threading.Lock()\n\n    RATE_LIMIT_MAX_CALLS = 100\n    RATE_LIMIT_MAX_DURATION = 360 * 60\n    RATE_LIMIT_TIME_WINDOW = 12 * 3600\n\n    def __init__(\n        self,\n        audio_input: Optional[Union[str, bytes]] = None,\n        use_cache: bool = False,\n        need_word_time_stamp: bool = False,\n    ):\n        \"\"\"Initialize ASR with audio data.\n\n        Args:\n            audio_input: Path to audio file or raw audio bytes\n            use_cache: Whether to cache recognition results\n            need_word_time_stamp: Whether to return word-level timestamps\n        \"\"\"\n        self.audio_input = audio_input\n        self.file_binary = None\n        self.use_cache = use_cache\n        self._set_data()\n        self._cache = get_asr_cache()\n        self.audio_duration = self._get_audio_duration()\n\n    def _set_data(self):\n        \"\"\"Load audio data and compute CRC32 hash for cache key.\"\"\"\n        if isinstance(self.audio_input, bytes):\n            self.file_binary = self.audio_input\n        elif isinstance(self.audio_input, str):\n            ext = self.audio_input.split(\".\")[-1].lower()\n            assert (\n                ext in self.SUPPORTED_SOUND_FORMAT\n            ), f\"Unsupported sound format: {ext}\"\n            assert os.path.exists(\n                self.audio_input\n            ), f\"File not found: {self.audio_input}\"\n            with open(self.audio_input, \"rb\") as f:\n                self.file_binary = f.read()\n        else:\n            raise ValueError(\"audio_input must be provided as string or bytes\")\n        crc32_value = zlib.crc32(self.file_binary) & 0xFFFFFFFF\n        self.crc32_hex = format(crc32_value, \"08x\")\n\n    def _get_audio_duration(self) -> float:\n        \"\"\"Get audio duration in seconds using pydub.\"\"\"\n        if not self.file_binary:\n            return 0.01\n        try:\n            audio = AudioSegment.from_file(BytesIO(self.file_binary))\n            return audio.duration_seconds\n        except Exception as e:\n            logger.warning(f\"Failed to get audio duration: {e}\")\n            return 60.0 * 10\n\n    def run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs\n    ) -> ASRData:\n        \"\"\"Run ASR with caching support.\n\n        Args:\n            callback: Optional progress callback(progress: int, message: str)\n            **kwargs: Additional arguments passed to _run()\n\n        Returns:\n            ASRData: Recognition results with segments\n        \"\"\"\n        cache_key = f\"{self.__class__.__name__}:{self._get_key()}\"\n\n        # Try cache first\n        if self.use_cache and is_cache_enabled():\n            cached_result = cast(\n                Optional[dict], self._cache.get(cache_key, default=None)\n            )\n            if cached_result is not None:\n                logger.info(\"找到缓存，直接返回\")\n                segments = self._make_segments(cached_result)\n                return ASRData(segments)\n\n        # Run ASR\n        resp_data = self._run(callback, **kwargs)\n\n        # Cache result\n        self._cache.set(cache_key, resp_data, expire=86400 * 2)\n\n        segments = self._make_segments(resp_data)\n        return ASRData(segments)\n\n    def _get_key(self) -> str:\n        \"\"\"Get cache key for this ASR request.\n\n        Default implementation uses file CRC32.\n        Subclasses can override to include additional parameters.\n\n        Returns:\n            Cache key string\n        \"\"\"\n        return self.crc32_hex\n\n    def _make_segments(self, resp_data: dict) -> list[ASRDataSeg]:\n        \"\"\"Convert ASR response to segment list.\n\n        Args:\n            resp_data: Raw response from ASR service\n\n        Returns:\n            List of ASRDataSeg objects\n        \"\"\"\n        raise NotImplementedError(\n            \"_make_segments method must be implemented in subclass\"\n        )\n\n    def _run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs\n    ) -> dict:\n        \"\"\"Execute ASR service and return raw response.\n\n        Args:\n            callback: Progress callback(progress: int, message: str)\n            **kwargs: Implementation-specific parameters\n\n        Returns:\n            Raw response data (dict or str depending on implementation)\n        \"\"\"\n        raise NotImplementedError(\"_run method must be implemented in subclass\")\n\n    def _check_rate_limit(self) -> None:\n        \"\"\"Check rate limit for public charity services.\"\"\"\n        service_name = self.__class__.__name__\n        tag = f\"rate_limit:{service_name}\"\n        time_limit = time.time() - self.RATE_LIMIT_TIME_WINDOW\n\n        # Query recent records\n        try:\n            query = \"SELECT key FROM Cache WHERE tag = ? AND store_time >= ?\"\n            results = self._cache._sql(query, (tag, time_limit)).fetchall()\n        except Exception as e:\n            raise RuntimeError(f\"Failed to query rate limit: {e}\")\n\n        # Get durations using cache API\n        durations = []\n        for (key,) in results:\n            duration = self._cache.get(key, default=None)\n            if duration is not None and isinstance(duration, (int, float)):\n                durations.append(duration)\n\n        call_count = len(durations)\n        total_duration = sum(durations)\n\n        # Check duration limit\n        if total_duration + self.audio_duration > self.RATE_LIMIT_MAX_DURATION:\n            error_msg = f\"{service_name} duration limit exceeded\"\n            logger.warning(error_msg)\n            raise RuntimeError(error_msg)\n\n        # Check call count limit\n        if call_count >= self.RATE_LIMIT_MAX_CALLS:\n            error_msg = f\"{service_name} call count limit exceeded\"\n            logger.warning(error_msg)\n            raise RuntimeError(error_msg)\n\n        # Record current call (store duration directly as float)\n        self._cache.set(\n            f\"rate_limit_record:{service_name}:{uuid.uuid4()}\",\n            self.audio_duration,\n            tag=tag,\n            expire=int(self.RATE_LIMIT_TIME_WINDOW) + 3600,\n        )\n"
  },
  {
    "path": "app/core/asr/bcut.py",
    "content": "import json\nimport time\nfrom typing import Any, Callable, List, Optional, Union\n\nimport requests\n\nfrom .asr_data import ASRDataSeg\nfrom .base import BaseASR\nfrom .status import ASRStatus\n\n__version__ = \"0.0.3\"\n\nAPI_BASE_URL = \"https://member.bilibili.com/x/bcut/rubick-interface\"\nAPI_REQ_UPLOAD = API_BASE_URL + \"/resource/create\"\nAPI_COMMIT_UPLOAD = API_BASE_URL + \"/resource/create/complete\"\nAPI_CREATE_TASK = API_BASE_URL + \"/task\"\nAPI_QUERY_RESULT = API_BASE_URL + \"/task/result\"\n\n\nclass BcutASR(BaseASR):\n    \"\"\"Bilibili Bcut ASR API implementation.\n\n    Uses Bilibili's cloud ASR service with multipart upload support.\n    \"\"\"\n\n    headers = {\n        \"User-Agent\": \"Bilibili/1.0.0 (https://www.bilibili.com)\",\n        \"Content-Type\": \"application/json\",\n    }\n\n    def __init__(\n        self,\n        audio_input: Union[str, bytes],\n        use_cache: bool = True,\n        need_word_time_stamp: bool = False,\n    ):\n        super().__init__(audio_input, use_cache=use_cache)\n        self.session = requests.Session()\n        self.task_id: Optional[str] = None\n        self.__etags: List[str] = []\n\n        self.__in_boss_key: Optional[str] = None\n        self.__resource_id: Optional[str] = None\n        self.__upload_id: Optional[str] = None\n        self.__upload_urls: List[str] = []\n        self.__per_size: Optional[int] = None\n        self.__clips: Optional[int] = None\n\n        self.__etags_final: Optional[List[str]] = []\n        self.__download_url: Optional[str] = None\n\n        self.need_word_time_stamp = need_word_time_stamp\n\n    def upload(self) -> None:\n        \"\"\"Request upload authorization and upload audio file.\"\"\"\n        if not self.file_binary:\n            raise ValueError(\"No audio data to upload\")\n        payload = json.dumps(\n            {\n                \"type\": 2,\n                \"name\": \"audio.mp3\",\n                \"size\": len(self.file_binary),\n                \"ResourceFileType\": \"mp3\",\n                \"model_id\": \"8\",\n            }\n        )\n\n        resp = requests.post(API_REQ_UPLOAD, data=payload, headers=self.headers)\n        resp.raise_for_status()\n        resp = resp.json()\n        resp_data = resp[\"data\"]\n\n        self.__in_boss_key = resp_data[\"in_boss_key\"]\n        self.__resource_id = resp_data[\"resource_id\"]\n        self.__upload_id = resp_data[\"upload_id\"]\n        self.__upload_urls = resp_data[\"upload_urls\"]\n        self.__per_size = resp_data[\"per_size\"]\n        self.__clips = len(resp_data[\"upload_urls\"])\n\n        self.__upload_part()\n        self.__commit_upload()\n\n    def __upload_part(self) -> None:\n        \"\"\"Upload audio data in multiple parts.\"\"\"\n        if (\n            self.__clips is None\n            or self.__per_size is None\n            or self.__upload_urls is None\n            or self.file_binary is None\n        ):\n            raise ValueError(\"Upload parameters not initialized\")\n\n        for clip in range(self.__clips):\n            start_range = clip * self.__per_size\n            end_range = (clip + 1) * self.__per_size\n            resp = requests.put(\n                self.__upload_urls[clip],\n                data=self.file_binary[start_range:end_range],\n                headers=self.headers,\n            )\n            resp.raise_for_status()\n            etag = resp.headers.get(\"Etag\")\n            if etag is not None:\n                self.__etags.append(etag)\n\n    def __commit_upload(self) -> None:\n        \"\"\"Commit the upload and get download URL.\"\"\"\n        data = json.dumps(\n            {\n                \"InBossKey\": self.__in_boss_key,\n                \"ResourceId\": self.__resource_id,\n                \"Etags\": \",\".join(self.__etags) if self.__etags else \"\",\n                \"UploadId\": self.__upload_id,\n                \"model_id\": \"8\",\n            }\n        )\n        resp = requests.post(API_COMMIT_UPLOAD, data=data, headers=self.headers)\n        resp.raise_for_status()\n        resp = resp.json()\n        self.__download_url = resp[\"data\"][\"download_url\"]\n\n    def create_task(self) -> str:\n        \"\"\"Create ASR task.\"\"\"\n        resp = requests.post(\n            API_CREATE_TASK,\n            json={\"resource\": self.__download_url, \"model_id\": \"8\"},\n            headers=self.headers,\n        )\n        resp.raise_for_status()\n        resp = resp.json()\n        self.task_id = resp[\"data\"][\"task_id\"]\n        return self.task_id or \"\"\n\n    def result(self, task_id: Optional[str] = None):\n        \"\"\"Query ASR result.\"\"\"\n        resp = requests.get(\n            API_QUERY_RESULT,\n            params={\"model_id\": 7, \"task_id\": task_id or self.task_id},\n            headers=self.headers,\n        )\n        resp.raise_for_status()\n        resp = resp.json()\n        return resp[\"data\"]\n\n    def _run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs: Any\n    ) -> dict:\n        \"\"\"Execute ASR workflow: upload -> create task -> poll result.\"\"\"\n\n        self._check_rate_limit()\n\n        def _default_callback(x, y):\n            pass\n\n        if callback is None:\n            callback = _default_callback\n\n        callback(*ASRStatus.UPLOADING.callback_tuple())\n        self.upload()\n\n        callback(*ASRStatus.CREATING_TASK.callback_tuple())\n        self.create_task()\n\n        callback(*ASRStatus.TRANSCRIBING.callback_tuple())\n\n        # Poll task status until complete\n        task_resp = None\n        for _ in range(500):\n            task_resp = self.result()\n            if task_resp[\"state\"] == 4:\n                break\n            time.sleep(1)\n\n        if task_resp is None or task_resp[\"state\"] != 4:\n            raise RuntimeError(\"ASR task failed or timeout\")\n\n        callback(*ASRStatus.COMPLETED.callback_tuple())\n        return json.loads(task_resp[\"result\"])\n\n    def _make_segments(self, resp_data: dict) -> List[ASRDataSeg]:\n        if self.need_word_time_stamp:\n            return [\n                ASRDataSeg(w[\"label\"].strip(), w[\"start_time\"], w[\"end_time\"])\n                for u in resp_data[\"utterances\"]\n                for w in u[\"words\"]\n            ]\n        else:\n            return [\n                ASRDataSeg(u[\"transcript\"], u[\"start_time\"], u[\"end_time\"])\n                for u in resp_data[\"utterances\"]\n            ]\n\n\nif __name__ == \"__main__\":\n    # Example usage\n    audio_file = r\"test.mp3\"\n    asr = BcutASR(audio_file)\n    asr_data = asr.run()\n    print(asr_data)\n"
  },
  {
    "path": "app/core/asr/chunk_merger.py",
    "content": "\"\"\"ASR 音频分块结果合并模块\n\n基于精确/模糊文本匹配的音频分块合并算法（参考 Groq API Cookbook）。\n使用滑动窗口找到最佳对齐位置，在重叠区域中点切分。\n\n匹配策略：\n- 词级时间戳（字级）: 精确文本匹配\n- 句子级时间戳（非字级）: difflib 模糊匹配（相似度 > 0.7）\n\"\"\"\n\nimport difflib\nfrom typing import List, Optional\n\nfrom ..utils.logger import setup_logger\nfrom .asr_data import ASRData, ASRDataSeg\n\nlogger = setup_logger(\"chunk_merger\")\n\n\nclass ChunkMerger:\n    \"\"\"音频分块后的 ASR 结果合并器\n\n    使用滑动窗口算法找到最佳对齐位置，在重叠区域中点切分。\n    适用于长音频分块识别后的结果拼接。\n    \"\"\"\n\n    def __init__(self, min_match_count: int = 2, fuzzy_threshold: float = 0.7):\n        \"\"\"初始化合并器\n\n        Args:\n            min_match_count: 最小匹配数阈值，低于此值视为无效匹配\n            fuzzy_threshold: 模糊匹配相似度阈值（仅用于句子级）\n        \"\"\"\n        self.min_match_count = min_match_count\n        self.fuzzy_threshold = fuzzy_threshold\n\n    def merge_chunks(\n        self,\n        chunks: List[ASRData],\n        chunk_offsets: Optional[List[int]] = None,\n        overlap_duration: int = 10000,\n    ) -> ASRData:\n        \"\"\"合并多个音频片段的 ASR 结果\n\n        Args:\n            chunks: ASRData 对象列表（每个 chunk 的 segments 应从 0 开始）\n            chunk_offsets: 每个 chunk 的绝对时间偏移（毫秒），None 则自动推断\n            overlap_duration: 重叠时长（毫秒），默认 10 秒\n\n        Returns:\n            合并后的 ASRData 对象\n\n        Raises:\n            ValueError: 如果 chunks 为空或 chunk_offsets 长度不匹配\n        \"\"\"\n        if not chunks:\n            raise ValueError(\"chunks 不能为空\")\n\n        if len(chunks) == 1:\n            logger.info(\"只有一个 chunk，直接返回\")\n            return chunks[0]\n\n        # 判断是否为词级时间戳（用于选择匹配策略）\n        self._is_word_level = any(chunk.is_word_timestamp() for chunk in chunks)\n        if self._is_word_level:\n            logger.info(\"检测到词级时间戳，使用精确文本匹配\")\n        else:\n            logger.info(\n                f\"检测到句子级时间戳，使用模糊匹配（阈值={self.fuzzy_threshold}）\"\n            )\n\n        # 自动推断 offsets\n        if chunk_offsets is None:\n            chunk_offsets = self._infer_chunk_offsets(chunks, overlap_duration)\n            logger.info(f\"自动推断 chunk_offsets: {chunk_offsets}\")\n\n        if len(chunks) != len(chunk_offsets):\n            raise ValueError(\n                f\"chunks 数量 ({len(chunks)}) 与 chunk_offsets 数量 ({len(chunk_offsets)}) 不匹配\"\n            )\n\n        # 调整所有 chunk 的时间戳到绝对时间\n        adjusted_chunks = [\n            self._adjust_timestamps(chunk.segments, offset)\n            for chunk, offset in zip(chunks, chunk_offsets)\n        ]\n\n        # 逐对合并\n        merged_segments = adjusted_chunks[0]\n        for i in range(1, len(adjusted_chunks)):\n            logger.info(f\"合并 chunk {i-1} 和 chunk {i}\")\n            merged_segments = self._merge_two_sequences(\n                merged_segments,\n                adjusted_chunks[i],\n                overlap_duration,\n            )\n\n        logger.info(f\"合并完成，总片段数: {len(merged_segments)}\")\n        return ASRData(merged_segments)\n\n    def _merge_two_sequences(\n        self,\n        left: List[ASRDataSeg],\n        right: List[ASRDataSeg],\n        overlap_duration: int,\n    ) -> List[ASRDataSeg]:\n        \"\"\"合并两个 segment 序列（Groq 滑动窗口算法）\n\n        Args:\n            left: 左侧序列（已调整到绝对时间）\n            right: 右侧序列（已调整到绝对时间）\n            overlap_duration: 预期重叠时长（毫秒）\n\n        Returns:\n            合并后的 segment 列表\n        \"\"\"\n        if not left:\n            return right\n        if not right:\n            return left\n\n        left_len = len(left)\n\n        # 提取重叠区域用于匹配\n        left_overlap = self._extract_overlap_segments(\n            left, from_end=True, duration=overlap_duration\n        )\n        right_overlap = self._extract_overlap_segments(\n            right, from_end=False, duration=overlap_duration\n        )\n\n        if not left_overlap or not right_overlap:\n            # 无重叠，直接拼接\n            logger.info(\"未检测到重叠区域，直接拼接\")\n            return left + right\n\n        # 滑动窗口找最佳对齐位置\n        best_match = self._find_best_alignment(left_overlap, right_overlap)\n\n        if best_match is None:\n            # 未找到有效匹配，使用时间边界切分\n            logger.warning(\"未找到有效文本匹配，使用时间边界切分\")\n            # 找到 left 中最后一个在 right[0].start_time 之前结束的 segment\n            split_idx = left_len\n            right_start = right[0].start_time\n            for i in range(left_len - 1, -1, -1):\n                if left[i].end_time <= right_start:\n                    split_idx = i + 1\n                    break\n            logger.info(f\"时间边界切分: left[:{split_idx}] + right\")\n            return left[:split_idx] + right\n\n        # 使用最佳匹配结果\n        left_start_idx, left_end_idx, right_start_idx, right_end_idx, matches = (\n            best_match\n        )\n\n        # 计算中点：在重叠区域取中间��置\n        left_mid = (left_start_idx + left_end_idx) // 2\n        right_mid = (right_start_idx + right_end_idx) // 2\n\n        # 映射回原始序列的索引\n        left_overlap_offset = left_len - len(left_overlap)\n        left_cut = left_overlap_offset + left_mid\n\n        logger.info(\n            f\"找到最佳匹配: {matches} 个词, \"\n            f\"重叠区域=[{left_start_idx}:{left_end_idx}] vs [{right_start_idx}:{right_end_idx}], \"\n            f\"切分点: left[:{left_cut}] + right[{right_mid}:]\"\n        )\n\n        # 合并：左边取到中点，右边从中点开始\n        return left[:left_cut] + right[right_mid:]\n\n    def _find_best_alignment(\n        self,\n        left: List[ASRDataSeg],\n        right: List[ASRDataSeg],\n    ) -> Optional[tuple[int, int, int, int, int]]:\n        \"\"\"使用滑动窗口找最佳对齐位置（Groq 算法）\n\n        Args:\n            left: 左侧重叠区域\n            right: 右侧重叠区域\n\n        Returns:\n            (left_start, left_end, right_start, right_end, matches) 或 None\n            - left_start/end: left 序列的匹配区域索引\n            - right_start/end: right 序列的匹配区域索引\n            - matches: 匹配数量\n        \"\"\"\n        left_len = len(left)\n        right_len = len(right)\n\n        best_score = 0.0\n        best_result = None\n\n        # 滑动窗口：尝试所有对齐位置\n        for i in range(1, left_len + right_len + 1):\n            # epsilon: 偏好更长的匹配\n            epsilon = float(i) / 10000.0\n\n            # 计算当前对齐位置的重叠区域\n            left_start = max(0, left_len - i)\n            left_end = min(left_len, left_len + right_len - i)\n\n            right_start = max(0, i - left_len)\n            right_end = min(right_len, i)\n\n            # 提取重叠部分\n            left_slice = left[left_start:left_end]\n            right_slice = right[right_start:right_end]\n\n            if len(left_slice) != len(right_slice):\n                raise RuntimeError(\n                    f\"对齐错误: left[{left_start}:{left_end}]={len(left_slice)} \"\n                    f\"vs right[{right_start}:{right_end}]={len(right_slice)}\"\n                )\n\n            # 计算匹配数（词级用精确匹配，句子级用模糊匹配）\n            if self._is_word_level:\n                # 词级：精确匹配\n                matches = sum(\n                    1\n                    for left_seg, right_seg in zip(left_slice, right_slice)\n                    if left_seg.text == right_seg.text\n                )\n            else:\n                # 句子级：模糊匹配（difflib 相似度 > threshold）\n                matches = sum(\n                    1\n                    for left_seg, right_seg in zip(left_slice, right_slice)\n                    if difflib.SequenceMatcher(\n                        None, left_seg.text, right_seg.text\n                    ).ratio()\n                    > self.fuzzy_threshold\n                )\n\n            # 归一化得分 + epsilon（偏好长匹配）\n            score = matches / float(i) + epsilon\n\n            # 至少需要 min_match_count 个匹配\n            if matches >= self.min_match_count and score > best_score:\n                best_score = score\n                best_result = (left_start, left_end, right_start, right_end, matches)\n\n        return best_result\n\n    def _adjust_timestamps(\n        self, segments: List[ASRDataSeg], offset: int\n    ) -> List[ASRDataSeg]:\n        \"\"\"调整 segments 时间戳\n\n        Args:\n            segments: 原始片段列表\n            offset: 时间偏移量（毫秒）\n\n        Returns:\n            调整后的片段列表（新对象）\n        \"\"\"\n        return [\n            ASRDataSeg(\n                text=seg.text,\n                start_time=seg.start_time + offset,\n                end_time=seg.end_time + offset,\n                translated_text=seg.translated_text,\n            )\n            for seg in segments\n        ]\n\n    def _extract_overlap_segments(\n        self, segments: List[ASRDataSeg], from_end: bool, duration: int\n    ) -> List[ASRDataSeg]:\n        \"\"\"提取重叠区域的 segments\n\n        Args:\n            segments: segment 列表\n            from_end: True=从末尾提取，False=从开头提取\n            duration: 提取时长（毫秒）\n\n        Returns:\n            重叠区域的 segment 列表\n        \"\"\"\n        if not segments:\n            return []\n\n        overlap = []\n\n        if from_end:\n            # 从末尾往前提取\n            threshold = segments[-1].end_time - duration\n            for seg in reversed(segments):\n                if seg.start_time >= threshold:\n                    overlap.insert(0, seg)\n                else:\n                    break\n        else:\n            # 从开头往后提取\n            threshold = segments[0].start_time + duration\n            for seg in segments:\n                if seg.end_time <= threshold:\n                    overlap.append(seg)\n                else:\n                    break\n\n        return overlap\n\n    def _infer_chunk_offsets(\n        self, chunks: List[ASRData], overlap_duration: int\n    ) -> List[int]:\n        \"\"\"自动推断 chunk 的时间偏移\n\n        Args:\n            chunks: ASRData 列表\n            overlap_duration: 重叠时长（毫秒）\n\n        Returns:\n            推断的时间偏移列表\n        \"\"\"\n        offsets = [0]\n\n        for i in range(1, len(chunks)):\n            prev_chunk = chunks[i - 1]\n            if prev_chunk.segments:\n                # 下一个 chunk 的起始 = 上一个 chunk 结束 - 重叠时长\n                prev_end = prev_chunk.segments[-1].end_time\n                next_offset = offsets[-1] + prev_end - overlap_duration\n                offsets.append(max(next_offset, offsets[-1]))\n            else:\n                offsets.append(offsets[-1])\n\n        return offsets\n"
  },
  {
    "path": "app/core/asr/chunked_asr.py",
    "content": "\"\"\"音频分块 ASR 装饰器\n\n为任何 BaseASR 实现添加音频分块转录能力，适用于长音频处理。\n使用装饰器模式实现关注点分离。\n\"\"\"\n\nimport io\nimport threading\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom typing import Callable, List, Optional, Tuple\n\nfrom pydub import AudioSegment\n\nfrom ..utils.logger import setup_logger\nfrom .asr_data import ASRData\nfrom .base import BaseASR\nfrom .chunk_merger import ChunkMerger\n\nlogger = setup_logger(\"chunked_asr\")\n\n# 常量定义\nMS_PER_SECOND = 1000\nDEFAULT_CHUNK_LENGTH_SEC = 60 * 10  # 20分钟\nDEFAULT_CHUNK_OVERLAP_SEC = 10  # 10秒重叠\nDEFAULT_CHUNK_CONCURRENCY = 3  # 3个并发\n\n\nclass ChunkedASR:\n    \"\"\"音频分块 ASR 包装器\n\n    为任何 BaseASR 子类添加音频分块能力。\n    适用于长音频的分块转录，避免 API 超时或内存溢出。\n\n    工作流程：\n        1. 将长音频切割为多个重叠的块\n        2. 为每个块创建独立的 ASR 实例并发转录\n        3. 使用 ChunkMerger 合并结果，消除重叠区域的重复内容\n\n    示例:\n        >>> # 使用 ASR 类和参数创建分块转录器\n        >>> chunked_asr = ChunkedASR(\n        ...     asr_class=BcutASR,\n        ...     audio_path=\"long_audio.mp3\",\n        ...     asr_kwargs={\"need_word_time_stamp\": True},\n        ...     chunk_length=1200\n        ... )\n        >>> result = chunked_asr.run(callback)\n\n    Args:\n        asr_class: ASR 类（非实例），如 BcutASR, JianYingASR\n        audio_path: 音频文件路径\n        asr_kwargs: 传递给 ASR 构造函数的参数字典\n        chunk_length: 每块长度（秒），默认 480 秒（8分钟）\n        chunk_overlap: 块之间重叠时长（秒），默认 10 秒\n        chunk_concurrency: 并发转录数量，默认 3\n    \"\"\"\n\n    def __init__(\n        self,\n        asr_class: type[BaseASR],\n        audio_path: str,\n        asr_kwargs: Optional[dict] = None,\n        chunk_length: int = DEFAULT_CHUNK_LENGTH_SEC,\n        chunk_overlap: int = DEFAULT_CHUNK_OVERLAP_SEC,\n        chunk_concurrency: int = DEFAULT_CHUNK_CONCURRENCY,\n    ):\n        self.asr_class = asr_class\n        self.audio_path = audio_path\n        self.asr_kwargs = asr_kwargs or {}\n        self.chunk_length_ms = chunk_length * MS_PER_SECOND\n        self.chunk_overlap_ms = chunk_overlap * MS_PER_SECOND\n        self.chunk_concurrency = chunk_concurrency\n\n        # 读取完整音频文件（用于分块）\n        with open(audio_path, \"rb\") as f:\n            self.file_binary = f.read()\n\n    def run(self, callback: Optional[Callable[[int, str], None]] = None) -> ASRData:\n        \"\"\"执行分块转录\n\n        Args:\n            callback: 进度回调函数(progress: int, message: str)\n\n        Returns:\n            ASRData: 合并后的转录结果\n        \"\"\"\n        # 1. 分块音频\n        chunks = self._split_audio()\n\n        # 2. 如果只有一块，直接创建单个 ASR 实例转录\n        if len(chunks) == 1:\n            logger.info(\"音频短于分块长度，直接转录\")\n            single_asr = self.asr_class(self.audio_path, **self.asr_kwargs)\n            return single_asr.run(callback)\n\n        logger.info(f\"音频分为 {len(chunks)} 块，开始并发转录\")\n\n        # 3. 并发转录所有块\n        chunk_results = self._transcribe_chunks(chunks, callback)\n\n        # 4. 合并结果\n        merged_result = self._merge_results(chunk_results, chunks)\n\n        logger.info(f\"分块转录完成，共 {len(merged_result.segments)} 个片段\")\n        return merged_result\n\n    def _split_audio(self) -> List[Tuple[bytes, int]]:\n        \"\"\"使用 pydub 将音频切割为重叠的块\n\n        Returns:\n            List[(chunk_bytes, offset_ms), ...]\n            每个元素包含音频块的字节数据和时间偏移（毫秒）\n        \"\"\"\n        # 从字节数据加载音频\n        if self.file_binary is None:\n            raise ValueError(\"file_binary is None, cannot split audio\")\n\n        audio = AudioSegment.from_file(io.BytesIO(self.file_binary))\n        total_duration_ms = len(audio)\n\n        logger.info(\n            f\"音频总时长: {total_duration_ms/1000:.1f}s, \"\n            f\"分块长度: {self.chunk_length_ms/1000:.1f}s, \"\n            f\"重叠: {self.chunk_overlap_ms/1000:.1f}s\"\n        )\n\n        chunks = []\n        start_ms = 0\n\n        while start_ms < total_duration_ms:\n            end_ms = min(start_ms + self.chunk_length_ms, total_duration_ms)\n            chunk = audio[start_ms:end_ms]\n\n            buffer = io.BytesIO()\n            chunk.export(buffer, format=\"mp3\")\n            chunk_bytes = buffer.getvalue()\n\n            chunks.append((chunk_bytes, start_ms))\n            logger.debug(\n                f\"切割 chunk {len(chunks)}: \"\n                f\"{start_ms/1000:.1f}s - {end_ms/1000:.1f}s ({len(chunk_bytes)} bytes)\"\n            )\n\n            # 下一个块的起始位置（有重叠）\n            start_ms += self.chunk_length_ms - self.chunk_overlap_ms\n\n            # 如果已到末尾，停止\n            if end_ms >= total_duration_ms:\n                break\n\n        # logger.info(f\"音频切割完成，共 {len(chunks)} 个块\")\n        return chunks\n\n    def _transcribe_chunks(\n        self,\n        chunks: List[Tuple[bytes, int]],\n        callback: Optional[Callable[[int, str], None]],\n    ) -> List[ASRData]:\n        \"\"\"并发转录多个音频块\n\n        Args:\n            chunks: 音频块列表 [(chunk_bytes, offset_ms), ...]\n            callback: 进度回调\n\n        Returns:\n            List[ASRData]: 每个块的转录结果\n        \"\"\"\n        results: List[Optional[ASRData]] = [None] * len(chunks)\n        total_chunks = len(chunks)\n\n        # 进度追踪：记录每个 chunk 的进度，确保整体进度单调递增\n        chunk_progress = [0] * total_chunks\n        last_overall = 0\n        progress_lock = threading.Lock()\n\n        def transcribe_single_chunk(\n            idx: int, chunk_bytes: bytes, offset_ms: int\n        ) -> Tuple[int, ASRData]:\n            \"\"\"转录单个音频块 - 为每个块创建独立的 ASR 实例\"\"\"\n            nonlocal last_overall\n            logger.info(f\"开始转录 chunk {idx+1}/{total_chunks} (offset={offset_ms}ms)\")\n\n            def chunk_callback(progress: int, message: str):\n                nonlocal last_overall\n                if not callback:\n                    return\n                with progress_lock:\n                    chunk_progress[idx] = progress\n                    overall = sum(chunk_progress) // total_chunks\n                    # 只允许进度单调递增\n                    if overall > last_overall:\n                        last_overall = overall\n                        callback(overall, f\"{idx+1}/{total_chunks}: {message}\")\n\n            # 为当前 chunk 创建独立的 ASR 实例\n            # 使用 chunk_bytes 作为音频输入\n            chunk_asr = self.asr_class(chunk_bytes, **self.asr_kwargs)\n\n            # 调用 ASR 的 run() 方法转录\n            asr_data = chunk_asr.run(chunk_callback)\n\n            logger.info(\n                f\"Chunk {idx+1}/{total_chunks} 转录完成，\"\n                f\"获得 {len(asr_data.segments)} 个片段\"\n            )\n            return idx, asr_data\n\n        # 使用 ThreadPoolExecutor 并发转录\n        with ThreadPoolExecutor(max_workers=self.chunk_concurrency) as executor:\n            futures = {\n                executor.submit(transcribe_single_chunk, i, chunk_bytes, offset): i\n                for i, (chunk_bytes, offset) in enumerate(chunks)\n            }\n\n            for future in as_completed(futures):\n                idx, asr_data = future.result()\n                results[idx] = asr_data\n\n        logger.info(f\"所有 {total_chunks} 个块转录完成\")\n        return [r for r in results if r is not None]  # 过滤 None\n\n    def _merge_results(\n        self, chunk_results: List[ASRData], chunks: List[Tuple[bytes, int]]\n    ) -> ASRData:\n        \"\"\"使用 ChunkMerger 合并转录结果\n\n        Args:\n            chunk_results: 每个块的 ASRData 结果\n            chunks: 原始音频块信息（用于获取 offset）\n\n        Returns:\n            合并后的 ASRData\n        \"\"\"\n        merger = ChunkMerger(min_match_count=2, fuzzy_threshold=0.7)\n\n        # 提取每个 chunk 的时间偏移\n        chunk_offsets = [offset for _, offset in chunks]\n\n        # 合并\n        merged = merger.merge_chunks(\n            chunks=chunk_results,\n            chunk_offsets=chunk_offsets,\n            overlap_duration=self.chunk_overlap_ms,\n        )\n        return merged\n"
  },
  {
    "path": "app/core/asr/faster_whisper.py",
    "content": "import hashlib\nimport os\nimport re\nimport shutil\nimport subprocess\nimport tempfile\nfrom pathlib import Path\nfrom typing import Any, Callable, List, Optional, Union\n\nimport GPUtil\n\nfrom ..utils.logger import setup_logger\nfrom ..utils.subprocess_helper import StreamReader\nfrom .asr_data import ASRData, ASRDataSeg\nfrom .base import BaseASR\nfrom .status import ASRStatus\n\nlogger = setup_logger(\"faster_whisper\")\n\n\nclass FasterWhisperASR(BaseASR):\n    \"\"\"Faster-Whisper local ASR implementation.\n\n    Runs whisper model locally using faster-whisper/faster-whisper-xxl binary.\n    Supports CPU/CUDA acceleration and various VAD methods.\n    \"\"\"\n\n    def __init__(\n        self,\n        audio_input: Union[str, bytes],\n        faster_whisper_program: str,\n        whisper_model: str,\n        model_dir: str,\n        language: str = \"zh\",\n        device: str = \"cpu\",\n        output_dir: Optional[str] = None,\n        output_format: str = \"srt\",\n        use_cache: bool = False,\n        need_word_time_stamp: bool = False,\n        # VAD 相关参数\n        vad_filter: bool = True,\n        vad_threshold: float = 0.4,\n        vad_method: str = \"\",  # https://github.com/Purfview/whisper-standalone-win/discussions/231\n        # 音频处理\n        ff_mdx_kim2: bool = False,\n        # 文本处理参数\n        one_word: int = 0,\n        sentence: bool = False,\n        max_line_width: int = 100,\n        max_line_count: int = 1,\n        max_comma: int = 20,\n        max_comma_cent: int = 50,\n        prompt: Optional[str] = None,\n    ):\n        super().__init__(audio_input, use_cache)\n\n        # 基本参数\n        self.model_path = whisper_model\n        self.model_dir = model_dir\n        self.faster_whisper_program = faster_whisper_program\n        self.need_word_time_stamp = need_word_time_stamp\n        self.language = language\n        self.device = device\n        self.output_dir = output_dir\n        self.output_format = output_format\n\n        # VAD 参数\n        self.vad_filter = vad_filter\n        self.vad_threshold = vad_threshold\n        self.vad_method = vad_method\n\n        # 音频处理参数\n        self.ff_mdx_kim2 = ff_mdx_kim2\n\n        # 文本处理参数\n        self.one_word = one_word\n        self.sentence = sentence\n        self.max_line_width = max_line_width\n        self.max_line_count = max_line_count\n        self.max_comma = max_comma\n        self.max_comma_cent = max_comma_cent\n        self.prompt = prompt\n\n        self.process = None\n\n        # 断句宽度\n        if self.language in [\"zh\", \"ja\", \"ko\"]:\n            self.max_line_width = 30\n        else:\n            self.max_line_width = 90\n\n        # 断句选项\n        if self.need_word_time_stamp:\n            self.one_word = 1\n        else:\n            self.one_word = 0\n            self.sentence = True\n\n        # 根据设备选择程序\n        if self.device == \"cpu\":\n            if shutil.which(\"faster-whisper-xxl\"):\n                self.faster_whisper_program = \"faster-whisper-xxl\"\n            else:\n                if not shutil.which(\"faster-whisper\"):\n                    raise EnvironmentError(\"faster-whisper程序未找到，请确保已经下载。\")\n                self.faster_whisper_program = \"faster-whisper\"\n                self.vad_method = \"\"\n        elif self.device == \"cuda\":\n            if not shutil.which(\"faster-whisper-xxl\"):\n                raise EnvironmentError(\n                    \"faster-whisper-xxl 程序未找到，请确保已经下载。\"\n                )\n            self.faster_whisper_program = \"faster-whisper-xxl\"\n\n    def _build_command(self, audio_input: str) -> List[str]:\n        \"\"\"Build command line arguments for faster-whisper.\"\"\"\n\n        cmd = [\n            str(self.faster_whisper_program),\n            \"-m\",\n            str(self.model_path),\n            # \"--verbose\", \"true\",\n            \"--print_progress\",\n        ]\n\n        # 添加模型目录参数\n        if self.model_dir:\n            cmd.extend([\"--model_dir\", str(self.model_dir)])\n\n        cmd.extend([str(audio_input), \"-d\", self.device, \"--output_format\", self.output_format])\n\n        # 有指定语言才传 -l，空字符串让 faster-whisper 自动检测\n        if self.language:\n            cmd.extend([\"-l\", self.language])\n\n        # 输出目录\n        if self.output_dir:\n            cmd.extend([\"-o\", str(self.output_dir)])\n        else:\n            cmd.extend([\"-o\", \"source\"])\n\n        # VAD 相关参数\n        if self.vad_filter:\n            cmd.extend(\n                [\n                    \"--vad_filter\",\n                    \"true\",\n                    \"--vad_threshold\",\n                    f\"{self.vad_threshold:.2f}\",\n                ]\n            )\n            if self.vad_method:\n                cmd.extend([\"--vad_method\", self.vad_method])\n        else:\n            cmd.extend([\"--vad_filter\", \"false\"])\n\n        # 人声分离\n        if self.ff_mdx_kim2 and self.faster_whisper_program.startswith(\n            \"faster-whisper-xxl\"\n        ):\n            cmd.append(\"--ff_mdx_kim2\")\n\n        # 文本处理参数\n        if self.one_word:\n            self.one_word = 1\n        else:\n            self.one_word = 0\n        if self.one_word in [0, 1, 2]:\n            cmd.extend([\"--one_word\", str(self.one_word)])\n\n        if self.sentence:\n            cmd.extend(\n                [\n                    \"--sentence\",\n                    \"--max_line_width\",\n                    str(self.max_line_width),\n                    \"--max_line_count\",\n                    str(self.max_line_count),\n                    \"--max_comma\",\n                    str(self.max_comma),\n                    \"--max_comma_cent\",\n                    str(self.max_comma_cent),\n                ]\n            )\n\n        # 提示词\n        if self.prompt:\n            cmd.extend([\"--initial_prompt\", self.prompt])\n\n        # 完成的提示音\n        cmd.extend([\"--beep_off\"])\n\n        # 检测 50 系显卡，添加 compute_type 参数\n        if is_rtx_50_series():\n            cmd.extend([\"--compute_type\", \"float16\"])\n\n        return cmd\n\n    def _make_segments(self, resp_data: str) -> List[ASRDataSeg]:\n        asr_data = ASRData.from_srt(resp_data)\n\n        # 幻觉文本关键词列表\n        hallucination_keywords = [\n            \"请不吝点赞 订阅 转发\",\n            \"打赏支持明镜\",\n        ]\n        # 过滤掉音乐标记和幻觉文本\n        filtered_segments = []\n        for seg in asr_data.segments:\n            text = seg.text.strip()\n\n            # 跳过音乐标记\n            if text.startswith((\"【\", \"[\", \"(\", \"（\")):\n                continue\n\n            # 跳过包含幻觉关键词的文本\n            if any(keyword in text for keyword in hallucination_keywords):\n                continue\n\n            filtered_segments.append(seg)\n\n        return filtered_segments\n\n    def _run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs: Any\n    ) -> str:\n        def _default_callback(x, y):\n            pass\n\n        if callback is None:\n            callback = _default_callback\n\n        with tempfile.TemporaryDirectory() as temp_path:\n            temp_dir = Path(temp_path)\n            wav_path = temp_dir / \"audio.wav\"\n            output_path = wav_path.with_suffix(\".srt\")\n\n            if isinstance(self.audio_input, str):\n                shutil.copy2(self.audio_input, wav_path)\n            else:\n                if self.file_binary:\n                    wav_path.write_bytes(self.file_binary)\n                else:\n                    raise ValueError(\"No audio data available\")\n\n            cmd = self._build_command(str(wav_path))\n\n            logger.info(\"Faster Whisper command: %s\", \" \".join(cmd))\n            callback(*ASRStatus.TRANSCRIBING.with_progress(5))\n\n            self.process = subprocess.Popen(\n                cmd,\n                stdout=subprocess.PIPE,\n                stderr=subprocess.STDOUT,\n                text=True,\n                encoding=\"utf-8\",\n                errors=\"ignore\",\n                creationflags=subprocess.CREATE_NO_WINDOW if os.name == \"nt\" else 0,\n            )\n\n            # 使用 StreamReader 处理输出\n            reader = StreamReader(self.process)\n            reader.start_reading()\n\n            is_finish = False\n            error_msg = \"\"\n            last_progress = 0\n\n            # 实时处理输出\n            while True:\n                # 检查进程状态\n                if self.process.poll() is not None:\n                    # 进程已结束，读取剩余输出\n                    for stream_name, line in reader.get_remaining_output():\n                        line = line.strip()\n                        if line:\n                            if \"error\" in line:\n                                error_msg += line\n                            else:\n                                logger.info(line)\n                    break\n\n                # 读取输出\n                output = reader.get_output(timeout=0.1)\n                if output:\n                    stream_name, line = output\n                    line = line.strip()\n                    if line:\n                        # 解析进度百分比\n                        if match := re.search(r\"(\\d+)%\", line):\n                            progress = int(match.group(1))\n                            if progress == 100:\n                                is_finish = True\n                            mapped_progress = int(5 + (progress * 0.9))\n                            # 只允许进度单调递增\n                            if mapped_progress > last_progress:\n                                last_progress = mapped_progress\n                                callback(mapped_progress, f\"{mapped_progress}%\")\n                        if \"Subtitles are written to\" in line:\n                            is_finish = True\n                            callback(*ASRStatus.COMPLETED.callback_tuple())\n                        if \"error\" in line or \"Error\" in line:\n                            error_msg += line\n                            logger.error(line)\n                        else:\n                            logger.info(line)\n\n            if not is_finish:\n                logger.error(\"Faster Whisper 错误: %s\", error_msg)\n                raise RuntimeError(error_msg)\n\n            # 判断是否识别成功\n            if not output_path.exists():\n                logger.info(\"Faster Whisper 返回值: %s\", self.process.returncode)\n                raise RuntimeError(f\"Faster Whisper 输出文件不存在: {output_path}\")\n\n            logger.info(\"Faster Whisper ASR completed\")\n\n            callback(*ASRStatus.COMPLETED.callback_tuple())\n\n            return output_path.read_text(encoding=\"utf-8\")\n\n    def _get_key(self):\n        \"\"\"获取缓存key\"\"\"\n        cmd = self._build_command(\"\")\n        cmd_hash = hashlib.md5(str(cmd).encode()).hexdigest()\n        return f\"{self.crc32_hex}-{cmd_hash}\"\n\n\ndef is_rtx_50_series() -> bool:\n    \"\"\"检测是否为 RTX 50 系显卡\"\"\"\n    if GPUtil is None:\n        logger.debug(\"GPUtil 未安装，无法检测 GPU 型号\")\n        return False\n    try:\n        gpus = GPUtil.getGPUs()\n        for gpu in gpus:\n            gpu_name = gpu.name.lower()\n            # 检测是否包含 50 系列标识，如 RTX 5090, RTX 5080 等\n            if re.search(r\"rtx\\s*50\\d{2}\", gpu_name):\n                logger.info(f\"检测到 RTX 50 系显卡: {gpu.name}\")\n                return True\n    except Exception as e:\n        logger.debug(f\"无法检测 GPU 型号: {e}\")\n    return False\n"
  },
  {
    "path": "app/core/asr/jianying.py",
    "content": "import datetime\nimport hashlib\nimport hmac\nimport json\nimport os\nimport time\nimport uuid\nfrom typing import Any, Callable, Dict, List, Optional, Tuple, Union\n\nimport requests\n\nfrom app.config import VERSION\n\nfrom .asr_data import ASRDataSeg\nfrom .base import BaseASR\nfrom .status import ASRStatus\n\n\nclass JianYingASR(BaseASR):\n    \"\"\"JianYing (CapCut) ASR API implementation.\n\n    Uses ByteDance's JianYing cloud ASR service with AWS S3-style upload.\n    \"\"\"\n\n    def __init__(\n        self,\n        audio_input: Union[str, bytes],\n        use_cache: bool = False,\n        need_word_time_stamp: bool = False,\n        start_time: float = 0,\n        end_time: float = 6000,\n    ):\n        super().__init__(audio_input, use_cache)\n        self.audio_input = audio_input\n        self.end_time = end_time\n        self.start_time = start_time\n\n        # AWS credentials\n        self.session_token = None\n        self.secret_key = None\n        self.access_key = None\n\n        # Upload details\n        self.store_uri = None\n        self.auth = None\n        self.upload_id = None\n        self.session_key = None\n        self.upload_hosts = None\n\n        self.need_word_time_stamp = need_word_time_stamp\n        self.tdid = self._get_tid()\n\n    def submit(self) -> str:\n        \"\"\"Submit the task\"\"\"\n        url = \"https://lv-pc-api-sinfonlinec.ulikecam.com/lv/v1/audio_subtitle/submit\"\n        payload = {\n            \"adjust_endtime\": 200,\n            \"audio\": self.store_uri,\n            \"caption_type\": 2,\n            \"client_request_id\": \"45faf98c-160f-4fae-a649-6d89b0fe35be\",\n            \"max_lines\": 1,\n            \"songs_info\": [\n                {\"end_time\": self.end_time, \"id\": \"\", \"start_time\": self.start_time}\n            ],\n            \"words_per_line\": 16,\n        }\n\n        sign, device_time = self._generate_sign_parameters(\n            url=\"/lv/v1/audio_subtitle/submit\", pf=\"4\", appvr=\"6.6.0\", tdid=self.tdid\n        )\n        headers = self._build_headers(device_time, sign)\n        response = requests.post(url, json=payload, headers=headers)\n        resp_data = response.json()\n\n        if resp_data.get(\"ret\") != \"0\":\n            error_msg = f\"API Error: {resp_data.get('errmsg', 'Unknown error')} (ret: {resp_data.get('ret')})\"\n            raise ValueError(error_msg)\n\n        query_id = resp_data[\"data\"][\"id\"]\n        return query_id\n\n    def upload(self):\n        \"\"\"Upload the file\"\"\"\n        self._upload_sign()\n        self._upload_auth()\n        self._upload_file()\n        self._upload_check()\n        uri = self._upload_commit()\n        return uri\n\n    def query(self, query_id: str):\n        \"\"\"Query the task\"\"\"\n        url = \"https://lv-pc-api-sinfonlinec.ulikecam.com/lv/v1/audio_subtitle/query\"\n        payload = {\"id\": query_id, \"pack_options\": {\"need_attribute\": True}}\n        sign, device_time = self._generate_sign_parameters(\n            url=\"/lv/v1/audio_subtitle/query\", pf=\"4\", appvr=\"6.6.0\", tdid=self.tdid\n        )\n        headers = self._build_headers(device_time, sign)\n        response = requests.post(url, json=payload, headers=headers)\n        resp_data = response.json()\n\n        if resp_data.get(\"ret\") != \"0\":\n            error_msg = f\"API Error: {resp_data.get('errmsg', 'Unknown error')} (ret: {resp_data.get('ret')})\"\n            raise ValueError(error_msg)\n\n        return resp_data\n\n    def _run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs: Any\n    ) -> dict:\n        \"\"\"Execute ASR workflow: upload -> submit -> query result.\"\"\"\n\n        self._check_rate_limit()\n\n        if callback:\n            callback(*ASRStatus.UPLOADING.with_progress(20))\n        self.upload()\n\n        if callback:\n            callback(*ASRStatus.SUBMITTING.callback_tuple())\n        query_id = self.submit()\n\n        if callback:\n            callback(*ASRStatus.QUERYING_RESULT.with_progress(60))\n        resp_data = self.query(query_id)\n\n        if callback:\n            callback(*ASRStatus.COMPLETED.callback_tuple())\n\n        return resp_data\n\n    def _make_segments(self, resp_data: dict) -> List[ASRDataSeg]:\n        if self.need_word_time_stamp:\n            return [\n                ASRDataSeg(w[\"text\"].strip(), w[\"start_time\"], w[\"end_time\"])\n                for u in resp_data[\"data\"][\"utterances\"]\n                for w in u[\"words\"]\n            ]\n        else:\n            return [\n                ASRDataSeg(u[\"text\"], u[\"start_time\"], u[\"end_time\"])\n                for u in resp_data[\"data\"][\"utterances\"]\n            ]\n\n    def _get_key(self):\n        return f\"{self.__class__.__name__}-{self.crc32_hex}-{self.need_word_time_stamp}\"\n\n    def _get_tid(self):\n        i = str(datetime.datetime.now().year)[3]\n        fr = 390 + int(i)\n        ed = \"3278516897751\" if int(i) % 2 != 0 else f\"{uuid.getnode():013d}\"\n        return f\"{fr}{ed}\"\n\n    def _generate_sign_parameters(\n        self, url: str, pf: str = \"4\", appvr: str = \"6.6.0\", tdid=\"\"\n    ) -> Tuple[str, str]:\n        \"\"\"Generate request signature and timestamp via remote service.\"\"\"\n        current_time = str(int(time.time()))\n        data = {\n            \"url\": url,\n            \"current_time\": current_time,\n            \"pf\": pf,\n            \"appvr\": appvr,\n            \"tdid\": self.tdid,\n        }\n        headers = {\n            \"User-Agent\": f\"VideoCaptioner/{VERSION}\",\n            \"tdid\": self.tdid,\n            \"t\": current_time,\n        }\n        # Replace with your actual endpoint URL\n        get_sign_url = \"https://asrtools-update.bkfeng.top/sign\"\n        try:\n            response = requests.post(get_sign_url, json=data, headers=headers)\n            response.raise_for_status()\n            response_data = response.json()\n            sign = response_data.get(\"sign\")\n            if not sign:\n                raise ValueError(\"No 'sign' in response\")\n        except requests.exceptions.RequestException as e:\n            raise RuntimeError(f\"HTTP Request failed: {e}\")\n        except ValueError as ve:\n            raise RuntimeError(f\"Invalid response: {ve}\")\n        return sign.lower(), current_time\n\n    def _build_headers(self, device_time: str, sign: str) -> Dict[str, str]:\n        \"\"\"Build request headers with signature.\"\"\"\n        return {\n            \"User-Agent\": \"Cronet/TTNetVersion:d4572e53 2024-06-12 QuicVersion:4bf243e0 2023-04-17\",\n            \"appvr\": \"6.6.0\",\n            \"device-time\": str(device_time),\n            \"pf\": \"4\",\n            \"sign\": sign,\n            \"sign-ver\": \"1\",\n            \"tdid\": self.tdid,\n        }\n\n    def _uplosd_headers(self):\n        headers = {\n            \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36 Thea/1.0.1\",\n            \"Authorization\": self.auth,\n            \"Content-CRC32\": self.crc32_hex,\n        }\n        return headers\n\n    def _upload_sign(self):\n        \"\"\"Get upload sign\"\"\"\n        url = \"https://lv-pc-api-sinfonlinec.ulikecam.com/lv/v1/upload_sign\"\n        payload = json.dumps({\"biz\": \"pc-recognition\"})\n        sign, device_time = self._generate_sign_parameters(\n            url=\"/lv/v1/upload_sign\", pf=\"4\", appvr=\"6.6.0\", tdid=self.tdid\n        )\n        headers = self._build_headers(device_time, sign)\n        response = requests.post(url, data=payload, headers=headers)\n        response.raise_for_status()\n        login_data = response.json()\n        self.access_key = login_data[\"data\"][\"access_key_id\"]\n        self.secret_key = login_data[\"data\"][\"secret_access_key\"]\n        self.session_token = login_data[\"data\"][\"session_token\"]\n        return self.access_key, self.secret_key, self.session_token\n\n    def _upload_auth(self):\n        \"\"\"Get upload authorization\"\"\"\n        if isinstance(self.audio_input, bytes):\n            file_size = len(self.audio_input)\n        else:\n            file_size = os.path.getsize(self.audio_input)\n        request_parameters = f\"Action=ApplyUploadInner&FileSize={file_size}&FileType=object&IsInner=1&SpaceName=lv-mac-recognition&Version=2020-11-19&s=5y0udbjapi\"\n\n        t = datetime.datetime.utcnow()\n        amz_date = t.strftime(\"%Y%m%dT%H%M%SZ\")\n        datestamp = t.strftime(\"%Y%m%d\")\n        headers = {\"x-amz-date\": amz_date, \"x-amz-security-token\": self.session_token}\n        if self.secret_key is None:\n            raise ValueError(\"Secret key not initialized\")\n        signature = aws_signature(\n            self.secret_key, request_parameters, headers, region=\"cn\", service=\"vod\"\n        )\n        authorization = f\"AWS4-HMAC-SHA256 Credential={self.access_key}/{datestamp}/cn/vod/aws4_request, SignedHeaders=x-amz-date;x-amz-security-token, Signature={signature}\"\n        headers[\"authorization\"] = authorization\n        response = requests.get(\n            f\"https://vod.bytedanceapi.com/?{request_parameters}\", headers=headers\n        )\n        store_infos = response.json()\n\n        self.store_uri = store_infos[\"Result\"][\"UploadAddress\"][\"StoreInfos\"][0][\n            \"StoreUri\"\n        ]\n        self.auth = store_infos[\"Result\"][\"UploadAddress\"][\"StoreInfos\"][0][\"Auth\"]\n        self.upload_id = store_infos[\"Result\"][\"UploadAddress\"][\"StoreInfos\"][0][\n            \"UploadID\"\n        ]\n        self.session_key = store_infos[\"Result\"][\"UploadAddress\"][\"SessionKey\"]\n        self.upload_hosts = store_infos[\"Result\"][\"UploadAddress\"][\"UploadHosts\"][0]\n        self.store_uri = store_infos[\"Result\"][\"UploadAddress\"][\"StoreInfos\"][0][\n            \"StoreUri\"\n        ]\n        return store_infos\n\n    def _upload_file(self):\n        \"\"\"Upload the file\"\"\"\n        url = f\"https://{self.upload_hosts}/{self.store_uri}?partNumber=1&uploadID={self.upload_id}\"\n        headers = self._uplosd_headers()\n        response = requests.put(url, data=self.file_binary, headers=headers)\n        resp_data = response.json()\n        assert resp_data[\"success\"] == 0, f\"File upload failed: {response.text}\"\n        return resp_data\n\n    def _upload_check(self):\n        \"\"\"Check upload result\"\"\"\n        url = f\"https://{self.upload_hosts}/{self.store_uri}?uploadID={self.upload_id}\"\n        payload = f\"1:{self.crc32_hex}\"\n        headers = self._uplosd_headers()\n        response = requests.post(url, data=payload, headers=headers)\n        resp_data = response.json()\n        return resp_data\n\n    def _upload_commit(self):\n        \"\"\"Commit the uploaded file\"\"\"\n        url = f\"https://{self.upload_hosts}/{self.store_uri}?uploadID={self.upload_id}&partNumber=1&x-amz-security-token={self.session_token}\"\n        headers = self._uplosd_headers()\n        requests.put(url, data=self.file_binary, headers=headers)\n        return self.store_uri\n\n\ndef sign(key: bytes, msg: str) -> bytes:\n    \"\"\"Generate HMAC-SHA256 signature.\"\"\"\n    return hmac.new(key, msg.encode(\"utf-8\"), hashlib.sha256).digest()\n\n\ndef get_signature_key(\n    secret_key: str, date_stamp: str, region_name: str, service_name: str\n) -> bytes:\n    \"\"\"Generate AWS signature key.\"\"\"\n    k_date = sign((\"AWS4\" + secret_key).encode(\"utf-8\"), date_stamp)\n    k_region = sign(k_date, region_name)\n    k_service = sign(k_region, service_name)\n    k_signing = sign(k_service, \"aws4_request\")\n    return k_signing\n\n\ndef aws_signature(\n    secret_key: str,\n    request_parameters: str,\n    headers: Dict[str, str],\n    method: str = \"GET\",\n    payload: str = \"\",\n    region: str = \"cn\",\n    service: str = \"vod\",\n) -> str:\n    \"\"\"Generate AWS signature.\"\"\"\n    canonical_uri = \"/\"\n    canonical_querystring = request_parameters\n    canonical_headers = (\n        \"\\n\".join([f\"{key}:{value}\" for key, value in headers.items()]) + \"\\n\"\n    )\n    signed_headers = \";\".join(headers.keys())\n    payload_hash = hashlib.sha256(payload.encode(\"utf-8\")).hexdigest()\n    canonical_request = f\"{method}\\n{canonical_uri}\\n{canonical_querystring}\\n{canonical_headers}\\n{signed_headers}\\n{payload_hash}\"\n\n    amzdate = headers[\"x-amz-date\"]\n    datestamp = amzdate.split(\"T\")[0]\n\n    algorithm = \"AWS4-HMAC-SHA256\"\n    credential_scope = f\"{datestamp}/{region}/{service}/aws4_request\"\n    string_to_sign = f\"{algorithm}\\n{amzdate}\\n{credential_scope}\\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}\"\n\n    signing_key = get_signature_key(secret_key, datestamp, region, service)\n    signature = hmac.new(\n        signing_key, string_to_sign.encode(\"utf-8\"), hashlib.sha256\n    ).hexdigest()\n    return signature\n"
  },
  {
    "path": "app/core/asr/status.py",
    "content": "from enum import Enum\nfrom typing import Tuple\n\n\nclass ASRStatus(Enum):\n    \"\"\"ASR processing status with progress percentage.\n\n    Each status contains a tuple of (message, progress_percentage).\n    Progress ranges from 0 to 100.\n    \"\"\"\n\n    # Initialization and file handling\n    INITIALIZING = (\"initializing\", 0)\n    CONVERTING_AUDIO = (\"converting_audio\", 5)\n\n    # Upload phase (0-40%)\n    UPLOADING = (\"uploading\", 10)\n    UPLOAD_PART = (\"upload_part\", 20)\n    UPLOAD_COMMIT = (\"upload_commit\", 30)\n    UPLOAD_COMPLETE = (\"upload_complete\", 40)\n\n    # Task creation phase (40-60%)\n    CREATING_TASK = (\"creating_task\", 40)\n    TASK_CREATED = (\"task_created\", 50)\n    SUBMITTING = (\"submitting\", 50)\n\n    # Processing phase (60-95%)\n    TRANSCRIBING = (\"transcribing\", 60)\n    PROCESSING = (\"processing\", 70)\n    QUERYING_RESULT = (\"querying_result\", 80)\n    PARSING_RESULT = (\"parsing_result\", 90)\n\n    # Completion phase (95-100%)\n    FINALIZING = (\"finalizing\", 95)\n    COMPLETED = (\"completed\", 100)\n\n    @property\n    def message(self) -> str:\n        \"\"\"Get the status message.\"\"\"\n        return self.value[0]\n\n    @property\n    def progress(self) -> int:\n        \"\"\"Get the progress percentage (0-100).\"\"\"\n        return self.value[1]\n\n    def with_progress(self, progress: int) -> Tuple[int, str]:\n        \"\"\"Create a callback tuple with custom progress.\n\n        Args:\n            progress: Progress percentage (0-100)\n\n        Returns:\n            Tuple of (progress, message) suitable for callback functions\n        \"\"\"\n        return (progress, self.message)\n\n    def callback_tuple(self) -> Tuple[int, str]:\n        \"\"\"Get the callback tuple (progress, message).\"\"\"\n        return (self.progress, self.message)\n"
  },
  {
    "path": "app/core/asr/transcribe.py",
    "content": "from app.core.asr.asr_data import ASRData\nfrom app.core.asr.bcut import BcutASR\nfrom app.core.asr.chunked_asr import ChunkedASR\nfrom app.core.asr.faster_whisper import FasterWhisperASR\nfrom app.core.asr.jianying import JianYingASR\nfrom app.core.asr.whisper_api import WhisperAPI\nfrom app.core.asr.whisper_cpp import WhisperCppASR\nfrom app.core.entities import TranscribeConfig, TranscribeModelEnum\n\n\ndef transcribe(audio_path: str, config: TranscribeConfig, callback=None) -> ASRData:\n    \"\"\"Transcribe audio file using specified configuration.\n\n    Args:\n        audio_path: Path to audio file\n        config: Transcription configuration\n        callback: Progress callback function(progress: int, message: str)\n\n    Returns:\n        ASRData: Transcription result data\n    \"\"\"\n\n    def _default_callback(x, y):\n        pass\n\n    if callback is None:\n        callback = _default_callback\n\n    if config.transcribe_model is None:\n        raise ValueError(\"Transcription model not set\")\n\n    # Create ASR instance based on model type\n    asr = _create_asr_instance(audio_path, config)\n\n    # Run transcription\n    asr_data = asr.run(callback=callback)\n\n    # Optimize subtitle timing if not using word timestamps\n    if not config.need_word_time_stamp:\n        asr_data.optimize_timing()\n\n    return asr_data\n\n\ndef _create_asr_instance(audio_path: str, config: TranscribeConfig) -> ChunkedASR:\n    \"\"\"Create appropriate ASR instance based on configuration.\n\n    Args:\n        audio_path: Path to audio file\n        config: Transcription configuration\n\n    Returns:\n        ChunkedASR: Chunked ASR instance ready to run\n    \"\"\"\n    model_type = config.transcribe_model\n\n    if model_type == TranscribeModelEnum.JIANYING:\n        return _create_jianying_asr(audio_path, config)\n\n    elif model_type == TranscribeModelEnum.BIJIAN:\n        return _create_bijian_asr(audio_path, config)\n\n    elif model_type == TranscribeModelEnum.WHISPER_CPP:\n        return _create_whisper_cpp_asr(audio_path, config)\n\n    elif model_type == TranscribeModelEnum.WHISPER_API:\n        return _create_whisper_api_asr(audio_path, config)\n\n    elif model_type == TranscribeModelEnum.FASTER_WHISPER:\n        return _create_faster_whisper_asr(audio_path, config)\n\n    else:\n        raise ValueError(f\"Invalid transcription model: {model_type}\")\n\n\ndef _create_jianying_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR:\n    \"\"\"Create JianYing ASR instance with chunking support.\"\"\"\n    asr_kwargs = {\n        \"use_cache\": True,\n        \"need_word_time_stamp\": config.need_word_time_stamp,\n    }\n    return ChunkedASR(\n        asr_class=JianYingASR, audio_path=audio_path, asr_kwargs=asr_kwargs\n    )\n\n\ndef _create_bijian_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR:\n    \"\"\"Create Bijian ASR instance with chunking support.\"\"\"\n    asr_kwargs = {\n        \"use_cache\": True,\n        \"need_word_time_stamp\": config.need_word_time_stamp,\n    }\n    return ChunkedASR(asr_class=BcutASR, audio_path=audio_path, asr_kwargs=asr_kwargs)\n\n\ndef _create_whisper_cpp_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR:\n    \"\"\"Create WhisperCpp ASR instance with chunking support.\"\"\"\n    asr_kwargs = {\n        \"use_cache\": True,\n        \"need_word_time_stamp\": config.need_word_time_stamp,\n        \"language\": config.transcribe_language,\n        \"whisper_model\": config.whisper_model.value if config.whisper_model else None,\n    }\n    return ChunkedASR(\n        asr_class=WhisperCppASR,\n        audio_path=audio_path,\n        asr_kwargs=asr_kwargs,\n        chunk_concurrency=1,  # 本地转录使用单线程\n        chunk_length=60 * 20,  # 每块20分钟\n    )\n\n\ndef _create_whisper_api_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR:\n    \"\"\"Create Whisper API ASR instance with chunking support.\"\"\"\n    asr_kwargs = {\n        \"use_cache\": True,\n        \"need_word_time_stamp\": config.need_word_time_stamp,\n        \"language\": config.transcribe_language,\n        \"whisper_model\": config.whisper_api_model or \"whisper-1\",\n        \"api_key\": config.whisper_api_key or \"\",\n        \"base_url\": config.whisper_api_base or \"\",\n        \"prompt\": config.whisper_api_prompt or \"\",\n    }\n    return ChunkedASR(\n        asr_class=WhisperAPI, audio_path=audio_path, asr_kwargs=asr_kwargs\n    )\n\n\ndef _create_faster_whisper_asr(audio_path: str, config: TranscribeConfig) -> ChunkedASR:\n    \"\"\"Create FasterWhisper ASR instance with chunking support.\"\"\"\n    asr_kwargs = {\n        \"use_cache\": True,\n        \"need_word_time_stamp\": config.need_word_time_stamp,\n        \"faster_whisper_program\": config.faster_whisper_program or \"\",\n        \"language\": config.transcribe_language,\n        \"whisper_model\": (\n            config.faster_whisper_model.value if config.faster_whisper_model else \"base\"\n        ),\n        \"model_dir\": config.faster_whisper_model_dir or \"\",\n        \"device\": config.faster_whisper_device,\n        \"vad_filter\": config.faster_whisper_vad_filter,\n        \"vad_threshold\": config.faster_whisper_vad_threshold,\n        \"vad_method\": (\n            config.faster_whisper_vad_method.value\n            if config.faster_whisper_vad_method\n            else \"\"\n        ),\n        \"ff_mdx_kim2\": config.faster_whisper_ff_mdx_kim2,\n        \"one_word\": config.faster_whisper_one_word,\n        \"prompt\": config.faster_whisper_prompt,\n    }\n    return ChunkedASR(\n        asr_class=FasterWhisperASR,\n        audio_path=audio_path,\n        asr_kwargs=asr_kwargs,\n        chunk_concurrency=1,  # 本地转录使用单线程\n        chunk_length=60 * 20,  # 每块20分钟\n    )\n\n\nif __name__ == \"__main__\":\n    # 示例用法\n    from app.core.entities import WhisperModelEnum\n\n    # 创建配置\n    config = TranscribeConfig(\n        transcribe_model=TranscribeModelEnum.WHISPER_CPP,\n        transcribe_language=\"zh\",\n        whisper_model=WhisperModelEnum.MEDIUM,\n    )\n\n    # 转录音频\n    audio_file = \"test.wav\"\n\n    def progress_callback(progress: int, message: str):\n        print(f\"Progress: {progress}%, Message: {message}\")\n\n    result = transcribe(audio_file, config, callback=progress_callback)\n    print(result)\n"
  },
  {
    "path": "app/core/asr/whisper_api.py",
    "content": "from typing import Any, Callable, List, Optional, Union\n\nfrom openai import OpenAI\n\nfrom app.core.llm.client import normalize_base_url\n\nfrom ..utils.logger import setup_logger\nfrom .asr_data import ASRDataSeg\nfrom .base import BaseASR\n\nlogger = setup_logger(\"whisper_api\")\n\n\nclass WhisperAPI(BaseASR):\n    \"\"\"OpenAI-compatible Whisper API implementation.\n\n    Supports any OpenAI-compatible ASR API endpoint.\n    \"\"\"\n\n    def __init__(\n        self,\n        audio_input: Union[str, bytes],\n        whisper_model: str,\n        need_word_time_stamp: bool = False,\n        language: str = \"zh\",\n        prompt: str = \"\",\n        base_url: str = \"\",\n        api_key: str = \"\",\n        use_cache: bool = False,\n    ):\n        \"\"\"Initialize Whisper API.\n\n        Args:\n            audio_input: Path to audio file or raw audio bytes\n            whisper_model: Model name\n            need_word_time_stamp: Return word-level timestamps\n            language: Language code (default: zh)\n            prompt: Initial prompt for model\n            base_url: API base URL\n            api_key: API key\n            use_cache: Enable caching\n        \"\"\"\n        super().__init__(audio_input, use_cache)\n\n        self.base_url = normalize_base_url(base_url)\n        self.api_key = api_key.strip()\n\n        if not self.base_url or not self.api_key:\n            raise ValueError(\"Whisper BASE_URL and API_KEY must be set\")\n\n        self.model = whisper_model\n        self.language = language\n        self.prompt = prompt\n        self.need_word_time_stamp = need_word_time_stamp\n\n        self.client = OpenAI(base_url=self.base_url, api_key=self.api_key)\n\n    def _run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs: Any\n    ) -> dict:\n        \"\"\"Execute ASR via API.\"\"\"\n        return self._submit()\n\n    def _make_segments(self, resp_data: dict) -> List[ASRDataSeg]:\n        \"\"\"Convert API response to segments.\"\"\"\n        if self.need_word_time_stamp and \"words\" in resp_data:\n            return [\n                ASRDataSeg(\n                    text=word[\"word\"],\n                    start_time=int(float(word[\"start\"]) * 1000),\n                    end_time=int(float(word[\"end\"]) * 1000),\n                )\n                for word in resp_data[\"words\"]\n            ]\n        else:\n            return [\n                ASRDataSeg(\n                    text=seg[\"text\"].strip(),\n                    start_time=int(float(seg[\"start\"]) * 1000),\n                    end_time=int(float(seg[\"end\"]) * 1000),\n                )\n                for seg in resp_data[\"segments\"]\n            ]\n\n    def _get_key(self) -> str:\n        \"\"\"Get cache key including model and language.\"\"\"\n        return f\"{self.crc32_hex}-{self.model}-{self.language}-{self.prompt}\"\n\n    def _submit(self) -> dict:\n        \"\"\"Submit audio for transcription.\"\"\"\n        try:\n            if self.language == \"zh\" and not self.prompt:\n                self.prompt = \"你好，我们需要使用简体中文，以下是普通话的句子\"\n\n            if not self.base_url:\n                raise ValueError(\"Whisper BASE_URL must be set\")\n\n            api_kwargs: dict[str, Any] = {\n                \"model\": self.model,\n                \"response_format\": \"verbose_json\",\n                \"file\": (\"audio.mp3\", self.file_binary or b\"\", \"audio/mp3\"),\n                \"prompt\": self.prompt,\n                \"timestamp_granularities\": [\"word\", \"segment\"],\n            }\n            # 空字符串表示自动检测，不传 language 参数让 API 自行判断\n            if self.language:\n                api_kwargs[\"language\"] = self.language\n\n            completion = self.client.audio.transcriptions.create(**api_kwargs)\n            if isinstance(completion, str):\n                raise ValueError(\n                    \"WhisperAPI returned type error, please check your base URL.\"\n                )\n            return completion.to_dict()\n        except Exception as e:\n            logger.exception(f\"WhisperAPI failed: {str(e)}\")\n            raise e\n"
  },
  {
    "path": "app/core/asr/whisper_cpp.py",
    "content": "import os\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nimport time\nfrom pathlib import Path\nfrom typing import Any, Callable, List, Optional, Union\n\nfrom ...config import MODEL_PATH\nfrom ..utils.logger import setup_logger\nfrom ..utils.subprocess_helper import StreamReader\nfrom .asr_data import ASRData, ASRDataSeg\nfrom .base import BaseASR\nfrom .status import ASRStatus\n\nlogger = setup_logger(\"whisper_asr\")\n\n\nclass WhisperCppASR(BaseASR):\n    \"\"\"Whisper.cpp local ASR implementation.\n\n    Runs whisper.cpp binary for local ASR processing.\n    \"\"\"\n\n    def __init__(\n        self,\n        audio_input: Union[str, bytes],\n        language=\"en\",\n        whisper_cpp_path=None,\n        whisper_model=None,\n        use_cache: bool = False,\n        need_word_time_stamp: bool = False,\n    ):\n        super().__init__(audio_input, use_cache)\n\n        if isinstance(audio_input, str):\n            assert os.path.exists(audio_input), f\"Audio file not found: {audio_input}\"\n            assert audio_input.endswith(\n                \".wav\"\n            ), f\"Audio must be WAV format: {audio_input}\"\n\n        # Auto-detect whisper executable if not provided\n        if whisper_cpp_path is None:\n            whisper_cpp_path = detect_whisper_executable()\n\n        # Find model file in models directory\n        if whisper_model:\n            models_dir = Path(MODEL_PATH)\n            model_files = list(models_dir.glob(f\"*ggml*{whisper_model}*.bin\"))\n            if not model_files:\n                raise ValueError(\n                    f\"Model file not found in {models_dir} for: {whisper_model}\"\n                )\n            model_path = str(model_files[0])\n            logger.info(f\"Model found: {model_path}\")\n        else:\n            raise ValueError(\"whisper_model cannot be empty\")\n\n        self.model_path = model_path\n        self.whisper_cpp_path = Path(whisper_cpp_path)\n        self.need_word_time_stamp = need_word_time_stamp\n        self.language = language\n\n        self.process = None\n\n    def _make_segments(self, resp_data: str) -> List[ASRDataSeg]:\n        asr_data = ASRData.from_srt(resp_data)\n        # 过滤掉纯音乐标记\n        filtered_segments = []\n        for seg in asr_data.segments:\n            text = seg.text.strip()\n            # 保留不以【、[、(、（开头的文本\n            if not (\n                text.startswith(\"【\")\n                or text.startswith(\"[\")\n                or text.startswith(\"(\")\n                or text.startswith(\"（\")\n            ):\n                filtered_segments.append(seg)\n        return filtered_segments\n\n    def _build_command(\n        self, wav_path, output_path, is_const_me_version: bool\n    ) -> list[str]:\n        \"\"\"Build whisper-cpp command line arguments.\"\"\"\n        whisper_params = [\n            str(self.whisper_cpp_path),\n            \"-m\",\n            str(self.model_path),\n            \"-f\",\n            str(wav_path),\n            \"-l\",\n            self.language or \"auto\",\n            \"--output-srt\",\n        ]\n\n        if not is_const_me_version:\n            if sys.platform != \"darwin\":\n                whisper_params.append(\"--no-gpu\")\n\n            whisper_params.extend(\n                [\"--output-file\", str(output_path.with_suffix(\"\"))]\n            )\n\n        if self.language == \"zh\":\n            whisper_params.extend(\n                [\"--prompt\", \"你好，我们需要使用简体中文，以下是普通话的句子。\"]\n            )\n\n        return whisper_params\n\n    def _run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs: Any\n    ) -> str:\n        def _default_callback(_progress: int, _message: str) -> None:\n            pass\n\n        if callback is None:\n            callback = _default_callback\n\n        is_const_me_version = True if os.name == \"nt\" else False\n\n        with tempfile.TemporaryDirectory() as temp_path:\n            temp_dir = Path(temp_path)\n            wav_path = temp_dir / \"whisper_cpp_audio.wav\"\n            output_path = wav_path.with_suffix(\".srt\")\n\n            try:\n                # 复制音频文件\n                if isinstance(self.audio_input, str):\n                    shutil.copy2(self.audio_input, wav_path)\n                else:\n                    if self.file_binary:\n                        wav_path.write_bytes(self.file_binary)\n                    else:\n                        raise ValueError(\"No audio data available\")\n\n                # Build command\n                whisper_params = self._build_command(\n                    wav_path, output_path, is_const_me_version\n                )\n                logger.info(\"Whisper.cpp command: %s\", \" \".join(whisper_params))\n\n                # Get audio duration\n                total_duration = self.audio_duration\n                logger.info(\"Audio duration: %d seconds\", total_duration)\n\n                # Start process\n                self.process = subprocess.Popen(\n                    whisper_params,\n                    stdout=subprocess.PIPE,\n                    stderr=subprocess.PIPE,\n                    text=True,\n                    encoding=\"utf-8\",\n                    bufsize=1,\n                )\n\n                logger.info(f\"Whisper.cpp process started, PID: {self.process.pid}\")\n\n                # Process output with StreamReader\n                reader = StreamReader(self.process)\n                reader.start_reading()\n\n                last_progress = 0\n\n                while True:\n                    # Check process status\n                    if self.process.poll() is not None:\n                        time.sleep(0.2)\n                        for stream_name, line in reader.get_remaining_output():\n                            if stream_name == \"stderr\":\n                                logger.debug(f\"[stderr] {line.strip()}\")\n                        break\n\n                    # Non-blocking output reading\n                    output = reader.get_output(timeout=0.1)\n                    if output:\n                        stream_name, line = output\n\n                        if stream_name == \"stdout\":\n                            logger.debug(f\"[stdout] {line.strip()}\")\n\n                            # Parse progress\n                            if \" --> \" in line and \"[\" in line:\n                                try:\n                                    time_str = (\n                                        line.split(\"[\")[1].split(\" -->\")[0].strip()\n                                    )\n                                    parts = time_str.split(\":\")\n                                    current_time = sum(\n                                        float(x) * y\n                                        for x, y in zip(reversed(parts), [1, 60, 3600])\n                                    )\n                                    progress = int(\n                                        min(current_time / total_duration * 100, 98)\n                                    )\n\n                                    if progress > last_progress:\n                                        last_progress = progress\n                                        callback(progress, f\"{progress}%\")\n                                except (ValueError, IndexError) as e:\n                                    logger.debug(f\"Progress parse failed: {e}\")\n                        else:\n                            logger.debug(f\"[stderr] {line.strip()}\")\n\n                # Check return code\n                if self.process.returncode != 0:\n                    raise RuntimeError(\n                        f\"Whisper.cpp failed with code: {self.process.returncode}\"\n                    )\n\n                callback(*ASRStatus.COMPLETED.callback_tuple())\n                logger.info(\"Whisper.cpp ASR completed\")\n\n                # Read result file\n                srt_path = output_path\n                if not srt_path.exists():\n                    time.sleep(5)\n                    if not srt_path.exists():\n                        raise RuntimeError(f\"Output file not generated: {srt_path}\")\n\n                return srt_path.read_text(encoding=\"utf-8\")\n\n            except Exception as e:\n                logger.exception(\"ASR processing failed\")\n                if self.process and self.process.poll() is None:\n                    self.process.terminate()\n                    try:\n                        self.process.wait(timeout=5)\n                    except subprocess.TimeoutExpired:\n                        self.process.kill()\n                        self.process.wait()\n                raise RuntimeError(f\"SRT generation failed: {str(e)}\")\n\n    def _get_key(self):\n        return f\"{self.crc32_hex}-{self.need_word_time_stamp}-{self.model_path}-{self.language}\"\n\n    def get_audio_duration(self, filepath: str) -> int:\n        \"\"\"Get audio file duration in seconds using ffmpeg.\"\"\"\n        try:\n            cmd = [\"ffmpeg\", \"-i\", filepath]\n            result = subprocess.run(\n                cmd,\n                capture_output=True,\n                text=True,\n                encoding=\"utf-8\",\n                errors=\"replace\",\n                creationflags=subprocess.CREATE_NO_WINDOW if os.name == \"nt\" else 0,\n            )\n            info = result.stderr\n            if duration_match := re.search(r\"Duration: (\\d+):(\\d+):(\\d+\\.\\d+)\", info):\n                hours, minutes, seconds = map(float, duration_match.groups())\n                duration_seconds = hours * 3600 + minutes * 60 + seconds\n                return int(duration_seconds)\n            return 600\n        except Exception as e:\n            logger.exception(\"Failed to get audio duration: %s\", str(e))\n            return 600\n\n\ndef detect_whisper_executable() -> str:\n    \"\"\"Detect available whisper-cpp executable name.\"\"\"\n    # Try new version first (whisper-cli)\n    if shutil.which(\"whisper-cli\"):\n        return \"whisper-cli\"\n\n    # Fall back to old version (whisper-cpp)\n    if shutil.which(\"whisper-cpp\"):\n        return \"whisper-cpp\"\n\n    # Neither found\n    raise RuntimeError(\"Neither 'whisper-cli' nor 'whisper-cpp' found in PATH. \")\n\n\nif __name__ == \"__main__\":\n    # 简短示例\n    asr = WhisperCppASR(\n        audio_input=\"audio.mp3\",\n        whisper_model=\"tiny\",\n        whisper_cpp_path=\"bin/whisper-cpp.exe\",\n        language=\"en\",\n        need_word_time_stamp=True,\n    )\n    asr_data = asr._run(callback=print)\n"
  },
  {
    "path": "app/core/constant.py",
    "content": "\"\"\"\n常量配置模块\n\n定义应用程序中使用的常量，包括 InfoBar 显示时长等\n\"\"\"\n\n# InfoBar 显示时长配置（单位：毫秒）\nINFOBAR_DURATION_FOREVER = 24 * 60 * 60 * 1000  # 永久提示：1天\nINFOBAR_DURATION_ERROR = 10000  # 错误提示：10秒\nINFOBAR_DURATION_WARNING = 5000  # 警告提示：5秒\nINFOBAR_DURATION_INFO = 3000  # 信息提示：3秒\nINFOBAR_DURATION_SUCCESS = 2000  # 成功提示：2秒\n"
  },
  {
    "path": "app/core/entities.py",
    "content": "import datetime\nimport uuid\nfrom dataclasses import dataclass, field\nfrom enum import Enum\nfrom typing import TYPE_CHECKING, Literal, Optional\n\nif TYPE_CHECKING:\n    from app.core.translate.types import TargetLanguage\n\n\ndef _generate_task_id() -> str:\n    \"\"\"生成 8 位任务 ID\"\"\"\n    return uuid.uuid4().hex[:8]\n\n\n@dataclass\nclass SubtitleProcessData:\n    \"\"\"字幕处理数据（翻译/优化通用）\"\"\"\n\n    index: int\n    original_text: str\n    translated_text: str = \"\"\n    optimized_text: str = \"\"\n\n\nclass SupportedAudioFormats(Enum):\n    \"\"\"支持的音频格式\"\"\"\n\n    AAC = \"aac\"\n    AC3 = \"ac3\"\n    AIFF = \"aiff\"\n    AMR = \"amr\"\n    APE = \"ape\"\n    AU = \"au\"\n    FLAC = \"flac\"\n    M4A = \"m4a\"\n    MP2 = \"mp2\"\n    MP3 = \"mp3\"\n    MKA = \"mka\"\n    OGA = \"oga\"\n    OGG = \"ogg\"\n    OPUS = \"opus\"\n    RA = \"ra\"\n    WAV = \"wav\"\n    WMA = \"wma\"\n\n\nclass SupportedVideoFormats(Enum):\n    \"\"\"支持的视频格式\"\"\"\n\n    MP4 = \"mp4\"\n    WEBM = \"webm\"\n    OGM = \"ogm\"\n    MOV = \"mov\"\n    MKV = \"mkv\"\n    AVI = \"avi\"\n    WMV = \"wmv\"\n    FLV = \"flv\"\n    M4V = \"m4v\"\n    TS = \"ts\"\n    MPG = \"mpg\"\n    MPEG = \"mpeg\"\n    VOB = \"vob\"\n    ASF = \"asf\"\n    RM = \"rm\"\n    RMVB = \"rmvb\"\n    M2TS = \"m2ts\"\n    MTS = \"mts\"\n    DV = \"dv\"\n    GXF = \"gxf\"\n    TOD = \"tod\"\n    MXF = \"mxf\"\n    F4V = \"f4v\"\n\n\nclass SupportedSubtitleFormats(Enum):\n    \"\"\"支持的字幕格式\"\"\"\n\n    SRT = \"srt\"\n    ASS = \"ass\"\n    VTT = \"vtt\"\n\n\nclass OutputSubtitleFormatEnum(Enum):\n    \"\"\"字幕输出格式\"\"\"\n\n    SRT = \"srt\"\n    ASS = \"ass\"\n    VTT = \"vtt\"\n    JSON = \"json\"\n    TXT = \"txt\"\n\n\nclass TranscribeOutputFormatEnum(Enum):\n    \"\"\"转录输出格式\"\"\"\n\n    SRT = \"SRT\"\n    ASS = \"ASS\"\n    VTT = \"VTT\"\n    TXT = \"TXT\"\n    ALL = \"All\"\n\n\nclass LLMServiceEnum(Enum):\n    \"\"\"LLM服务\"\"\"\n\n    OPENAI = \"OpenAI 兼容\"\n    SILICON_CLOUD = \"SiliconCloud\"\n    DEEPSEEK = \"DeepSeek\"\n    OLLAMA = \"Ollama\"\n    LM_STUDIO = \"LM Studio\"\n    GEMINI = \"Gemini\"\n    CHATGLM = \"ChatGLM\"\n\n\nclass TranscribeModelEnum(Enum):\n    \"\"\"转录模型\"\"\"\n\n    BIJIAN = \"B 接口\"\n    JIANYING = \"J 接口\"\n    WHISPER_API = \"Whisper [API] ✨\"\n    FASTER_WHISPER = \"FasterWhisper ✨\"\n    WHISPER_CPP = \"WhisperCpp\"\n\n\nclass TranslatorServiceEnum(Enum):\n    \"\"\"翻译器服务\"\"\"\n\n    OPENAI = \"LLM 大模型翻译\"\n    DEEPLX = \"DeepLx 翻译\"\n    BING = \"微软翻译\"\n    GOOGLE = \"谷歌翻译\"\n\n\nclass VadMethodEnum(Enum):\n    \"\"\"VAD方法\"\"\"\n\n    SILERO_V3 = \"silero_v3\"  # 通常比 v4 准确性低，但没有 v4 的一些怪癖\n    SILERO_V4 = (\n        \"silero_v4\"  # 与 silero_v4_fw 相同。运行原始 Silero 的代码，而不是适配过的代码\n    )\n    SILERO_V5 = (\n        \"silero_v5\"  # 与 silero_v5_fw 相同。运行原始 Silero 的代码，而不是适配过的代码)\n    )\n    SILERO_V4_FW = (\n        \"silero_v4_fw\"  # 默认模型。最准确的 Silero 版本，有一些非致命的小问题\n    )\n    # SILERO_V5_FW = \"silero_v5_fw\"  # 准确性差。不是 VAD，而是某种语音的随机检测器，有各种致命的小问题。避免使用！\n    PYANNOTE_V3 = \"pyannote_v3\"  # 最佳准确性，支持 CUDA\n    PYANNOTE_ONNX_V3 = \"pyannote_onnx_v3\"  # pyannote_v3 的轻量版。与 Silero v4 的准确性相似，可能稍好，支持 CUDA\n    WEBRTC = \"webrtc\"  # 准确性低，过时的 VAD。仅接受 'vad_min_speech_duration_ms' 和 'vad_speech_pad_ms'\n    AUDITOK = \"auditok\"  # 实际上这不是 VAD，而是 AAD - 音频活动检测\n\n\nclass SubtitleLayoutEnum(Enum):\n    \"\"\"字幕布局\"\"\"\n\n    TRANSLATE_ON_TOP = \"译文在上\"\n    ORIGINAL_ON_TOP = \"原文在上\"\n    ONLY_ORIGINAL = \"仅原文\"\n    ONLY_TRANSLATE = \"仅译文\"\n\n\nclass SubtitleRenderModeEnum(Enum):\n    \"\"\"字幕渲染模式\"\"\"\n\n    ASS_STYLE = \"ASS 样式\"  # FFmpeg ASS 渲染\n    ROUNDED_BG = \"圆角背景\"  # Pillow 圆角矩形背景\n\n\nclass VideoQualityEnum(Enum):\n    \"\"\"视频合成质量\"\"\"\n\n    ULTRA_HIGH = \"极高质量\"\n    HIGH = \"高质量\"\n    MEDIUM = \"中等质量\"\n    LOW = \"低质量\"\n\n    def get_crf(self) -> int:\n        \"\"\"获取对应的 CRF 值（越小质量越高，文件越大）\"\"\"\n        crf_map = {\n            VideoQualityEnum.ULTRA_HIGH: 18,\n            VideoQualityEnum.HIGH: 23,\n            VideoQualityEnum.MEDIUM: 28,\n            VideoQualityEnum.LOW: 32,\n        }\n        return crf_map[self]\n\n    def get_preset(\n        self,\n    ) -> Literal[\n        \"ultrafast\",\n        \"superfast\",\n        \"veryfast\",\n        \"faster\",\n        \"fast\",\n        \"medium\",\n        \"slow\",\n        \"slower\",\n        \"veryslow\",\n    ]:\n        \"\"\"获取对应的 FFmpeg preset 值（影响编码速度）\"\"\"\n        preset_map: dict[\n            VideoQualityEnum,\n            Literal[\n                \"ultrafast\",\n                \"superfast\",\n                \"veryfast\",\n                \"faster\",\n                \"fast\",\n                \"medium\",\n                \"slow\",\n                \"slower\",\n                \"veryslow\",\n            ],\n        ] = {\n            VideoQualityEnum.ULTRA_HIGH: \"slow\",\n            VideoQualityEnum.HIGH: \"medium\",\n            VideoQualityEnum.MEDIUM: \"medium\",\n            VideoQualityEnum.LOW: \"fast\",\n        }\n        return preset_map[self]\n\n\nclass TranscribeLanguageEnum(Enum):\n    \"\"\"转录语言\"\"\"\n\n    AUTO = \"自动检测\"\n    ENGLISH = \"英语\"\n    CHINESE = \"中文\"\n    JAPANESE = \"日本語\"\n    KOREAN = \"韩语\"\n    YUE = \"粤语\"\n    FRENCH = \"法语\"\n    GERMAN = \"德语\"\n    SPANISH = \"西班牙语\"\n    RUSSIAN = \"俄语\"\n    PORTUGUESE = \"葡萄牙语\"\n    TURKISH = \"土耳其语\"\n    POLISH = \"Polish\"\n    CATALAN = \"Catalan\"\n    DUTCH = \"Dutch\"\n    ARABIC = \"Arabic\"\n    SWEDISH = \"Swedish\"\n    ITALIAN = \"Italian\"\n    INDONESIAN = \"Indonesian\"\n    HINDI = \"Hindi\"\n    FINNISH = \"Finnish\"\n    VIETNAMESE = \"Vietnamese\"\n    HEBREW = \"Hebrew\"\n    UKRAINIAN = \"Ukrainian\"\n    GREEK = \"Greek\"\n    MALAY = \"Malay\"\n    CZECH = \"Czech\"\n    ROMANIAN = \"Romanian\"\n    DANISH = \"Danish\"\n    HUNGARIAN = \"Hungarian\"\n    TAMIL = \"Tamil\"\n    NORWEGIAN = \"Norwegian\"\n    THAI = \"Thai\"\n    URDU = \"Urdu\"\n    CROATIAN = \"Croatian\"\n    BULGARIAN = \"Bulgarian\"\n    LITHUANIAN = \"Lithuanian\"\n    LATIN = \"Latin\"\n    MAORI = \"Maori\"\n    MALAYALAM = \"Malayalam\"\n    WELSH = \"Welsh\"\n    SLOVAK = \"Slovak\"\n    TELUGU = \"Telugu\"\n    PERSIAN = \"Persian\"\n    LATVIAN = \"Latvian\"\n    BENGALI = \"Bengali\"\n    SERBIAN = \"Serbian\"\n    AZERBAIJANI = \"Azerbaijani\"\n    SLOVENIAN = \"Slovenian\"\n    KANNADA = \"Kannada\"\n    ESTONIAN = \"Estonian\"\n    MACEDONIAN = \"Macedonian\"\n    BRETON = \"Breton\"\n    BASQUE = \"Basque\"\n    ICELANDIC = \"Icelandic\"\n    ARMENIAN = \"Armenian\"\n    NEPALI = \"Nepali\"\n    MONGOLIAN = \"Mongolian\"\n    BOSNIAN = \"Bosnian\"\n    KAZAKH = \"Kazakh\"\n    ALBANIAN = \"Albanian\"\n    SWAHILI = \"Swahili\"\n    GALICIAN = \"Galician\"\n    MARATHI = \"Marathi\"\n    PUNJABI = \"Punjabi\"\n    SINHALA = \"Sinhala\"\n    KHMER = \"Khmer\"\n    SHONA = \"Shona\"\n    YORUBA = \"Yoruba\"\n    SOMALI = \"Somali\"\n    AFRIKAANS = \"Afrikaans\"\n    OCCITAN = \"Occitan\"\n    GEORGIAN = \"Georgian\"\n    BELARUSIAN = \"Belarusian\"\n    TAJIK = \"Tajik\"\n    SINDHI = \"Sindhi\"\n    GUJARATI = \"Gujarati\"\n    AMHARIC = \"Amharic\"\n    YIDDISH = \"Yiddish\"\n    LAO = \"Lao\"\n    UZBEK = \"Uzbek\"\n    FAROESE = \"Faroese\"\n    HAITIAN_CREOLE = \"Haitian Creole\"\n    PASHTO = \"Pashto\"\n    TURKMEN = \"Turkmen\"\n    NYNORSK = \"Nynorsk\"\n    MALTESE = \"Maltese\"\n    SANSKRIT = \"Sanskrit\"\n    LUXEMBOURGISH = \"Luxembourgish\"\n    MYANMAR = \"Myanmar\"\n    TIBETAN = \"Tibetan\"\n    TAGALOG = \"Tagalog\"\n    MALAGASY = \"Malagasy\"\n    ASSAMESE = \"Assamese\"\n    TATAR = \"Tatar\"\n    HAWAIIAN = \"Hawaiian\"\n    LINGALA = \"Lingala\"\n    HAUSA = \"Hausa\"\n    BASHKIR = \"Bashkir\"\n    JAVANESE = \"Javanese\"\n    SUNDANESE = \"Sundanese\"\n    CANTONESE = \"Cantonese\"\n\n\nclass WhisperModelEnum(Enum):\n    TINY = \"tiny\"\n    BASE = \"base\"\n    SMALL = \"small\"\n    MEDIUM = \"medium\"\n    LARGE_V1 = \"large-v1\"\n    LARGE_V2 = \"large-v2\"\n\n\nclass FasterWhisperModelEnum(Enum):\n    TINY = \"tiny\"\n    BASE = \"base\"\n    SMALL = \"small\"\n    MEDIUM = \"medium\"\n    LARGE_V1 = \"large-v1\"\n    LARGE_V2 = \"large-v2\"\n    LARGE_V3 = \"large-v3\"\n    LARGE_V3_TURBO = \"large-v3-turbo\"\n\n\nLANGUAGES = {\n    \"自动检测\": \"\",\n    \"英语\": \"en\",\n    \"中文\": \"zh\",\n    \"日本語\": \"ja\",\n    \"德语\": \"de\",\n    \"粤语\": \"yue\",\n    \"西班牙语\": \"es\",\n    \"俄语\": \"ru\",\n    \"韩语\": \"ko\",\n    \"法语\": \"fr\",\n    \"葡萄牙语\": \"pt\",\n    \"土耳其语\": \"tr\",\n    \"English\": \"en\",\n    \"Chinese\": \"zh\",\n    \"German\": \"de\",\n    \"Spanish\": \"es\",\n    \"Russian\": \"ru\",\n    \"Korean\": \"ko\",\n    \"French\": \"fr\",\n    \"Japanese\": \"ja\",\n    \"Portuguese\": \"pt\",\n    \"Turkish\": \"tr\",\n    \"Polish\": \"pl\",\n    \"Catalan\": \"ca\",\n    \"Dutch\": \"nl\",\n    \"Arabic\": \"ar\",\n    \"Swedish\": \"sv\",\n    \"Italian\": \"it\",\n    \"Indonesian\": \"id\",\n    \"Hindi\": \"hi\",\n    \"Finnish\": \"fi\",\n    \"Vietnamese\": \"vi\",\n    \"Hebrew\": \"he\",\n    \"Ukrainian\": \"uk\",\n    \"Greek\": \"el\",\n    \"Malay\": \"ms\",\n    \"Czech\": \"cs\",\n    \"Romanian\": \"ro\",\n    \"Danish\": \"da\",\n    \"Hungarian\": \"hu\",\n    \"Tamil\": \"ta\",\n    \"Norwegian\": \"no\",\n    \"Thai\": \"th\",\n    \"Urdu\": \"ur\",\n    \"Croatian\": \"hr\",\n    \"Bulgarian\": \"bg\",\n    \"Lithuanian\": \"lt\",\n    \"Latin\": \"la\",\n    \"Maori\": \"mi\",\n    \"Malayalam\": \"ml\",\n    \"Welsh\": \"cy\",\n    \"Slovak\": \"sk\",\n    \"Telugu\": \"te\",\n    \"Persian\": \"fa\",\n    \"Latvian\": \"lv\",\n    \"Bengali\": \"bn\",\n    \"Serbian\": \"sr\",\n    \"Azerbaijani\": \"az\",\n    \"Slovenian\": \"sl\",\n    \"Kannada\": \"kn\",\n    \"Estonian\": \"et\",\n    \"Macedonian\": \"mk\",\n    \"Breton\": \"br\",\n    \"Basque\": \"eu\",\n    \"Icelandic\": \"is\",\n    \"Armenian\": \"hy\",\n    \"Nepali\": \"ne\",\n    \"Mongolian\": \"mn\",\n    \"Bosnian\": \"bs\",\n    \"Kazakh\": \"kk\",\n    \"Albanian\": \"sq\",\n    \"Swahili\": \"sw\",\n    \"Galician\": \"gl\",\n    \"Marathi\": \"mr\",\n    \"Punjabi\": \"pa\",\n    \"Sinhala\": \"si\",\n    \"Khmer\": \"km\",\n    \"Shona\": \"sn\",\n    \"Yoruba\": \"yo\",\n    \"Somali\": \"so\",\n    \"Afrikaans\": \"af\",\n    \"Occitan\": \"oc\",\n    \"Georgian\": \"ka\",\n    \"Belarusian\": \"be\",\n    \"Tajik\": \"tg\",\n    \"Sindhi\": \"sd\",\n    \"Gujarati\": \"gu\",\n    \"Amharic\": \"am\",\n    \"Yiddish\": \"yi\",\n    \"Lao\": \"lo\",\n    \"Uzbek\": \"uz\",\n    \"Faroese\": \"fo\",\n    \"Haitian Creole\": \"ht\",\n    \"Pashto\": \"ps\",\n    \"Turkmen\": \"tk\",\n    \"Nynorsk\": \"nn\",\n    \"Maltese\": \"mt\",\n    \"Sanskrit\": \"sa\",\n    \"Luxembourgish\": \"lb\",\n    \"Myanmar\": \"my\",\n    \"Tibetan\": \"bo\",\n    \"Tagalog\": \"tl\",\n    \"Malagasy\": \"mg\",\n    \"Assamese\": \"as\",\n    \"Tatar\": \"tt\",\n    \"Hawaiian\": \"haw\",\n    \"Lingala\": \"ln\",\n    \"Hausa\": \"ha\",\n    \"Bashkir\": \"ba\",\n    \"Javanese\": \"jw\",\n    \"Sundanese\": \"su\",\n    \"Cantonese\": \"yue\",\n}\n\n\n@dataclass\nclass ASRLanguageCapability:\n    \"\"\"ASR语言支持能力\"\"\"\n\n    supported_languages: list[TranscribeLanguageEnum]\n    supports_auto: bool\n\n\ndef _get_all_languages_except_auto() -> list[TranscribeLanguageEnum]:\n    \"\"\"获取除 AUTO 外的所有语言\"\"\"\n    return [lang for lang in TranscribeLanguageEnum if lang != TranscribeLanguageEnum.AUTO]\n\n\nASR_LANGUAGE_CAPABILITIES: dict[TranscribeModelEnum, ASRLanguageCapability] = {\n    TranscribeModelEnum.BIJIAN: ASRLanguageCapability(\n        supported_languages=[\n            TranscribeLanguageEnum.CHINESE,\n            TranscribeLanguageEnum.ENGLISH,\n        ],\n        supports_auto=True,\n    ),\n    TranscribeModelEnum.JIANYING: ASRLanguageCapability(\n        supported_languages=[\n            TranscribeLanguageEnum.CHINESE,\n            TranscribeLanguageEnum.ENGLISH,\n        ],\n        supports_auto=True,\n    ),\n    TranscribeModelEnum.FASTER_WHISPER: ASRLanguageCapability(\n        supported_languages=_get_all_languages_except_auto(),\n        supports_auto=False,\n    ),\n    TranscribeModelEnum.WHISPER_CPP: ASRLanguageCapability(\n        supported_languages=_get_all_languages_except_auto(),\n        supports_auto=True,\n    ),\n    TranscribeModelEnum.WHISPER_API: ASRLanguageCapability(\n        supported_languages=_get_all_languages_except_auto(),\n        supports_auto=True,\n    ),\n}\n\n\ndef get_asr_language_capability(model: TranscribeModelEnum) -> ASRLanguageCapability:\n    \"\"\"获取指定模型的语言能力\"\"\"\n    return ASR_LANGUAGE_CAPABILITIES.get(\n        model,\n        ASRLanguageCapability(\n            supported_languages=_get_all_languages_except_auto(),\n            supports_auto=True,\n        ),\n    )\n\n\n@dataclass\nclass AudioStreamInfo:\n    \"\"\"音频流信息\"\"\"\n\n    index: int  # 音轨在视频中的实际索引（如 0, 1, 2 或 2, 3, 4）\n    codec: str  # 音频编解码器（如 aac, mp3, opus）\n    language: str = \"\"  # 语言标签（如 eng, chi, deu）\n    title: str = \"\"  # 音轨标题（可选）\n\n\n@dataclass\nclass VideoInfo:\n    \"\"\"视频信息类\"\"\"\n\n    file_name: str\n    file_path: str\n    width: int\n    height: int\n    fps: float\n    duration_seconds: float\n    bitrate_kbps: int\n    video_codec: str\n    audio_codec: str\n    audio_sampling_rate: int\n    thumbnail_path: str\n    audio_streams: list[AudioStreamInfo] = field(default_factory=list)  # 音频流列表\n\n\n@dataclass\nclass TranscribeConfig:\n    \"\"\"转录配置类\"\"\"\n\n    transcribe_model: Optional[TranscribeModelEnum] = None\n    transcribe_language: str = \"\"\n    need_word_time_stamp: bool = True\n    output_format: Optional[TranscribeOutputFormatEnum] = None\n    # Whisper Cpp 配置\n    whisper_model: Optional[WhisperModelEnum] = None\n    # Whisper API 配置\n    whisper_api_key: Optional[str] = None\n    whisper_api_base: Optional[str] = None\n    whisper_api_model: Optional[str] = None\n    whisper_api_prompt: Optional[str] = None\n    # Faster Whisper 配置\n    faster_whisper_program: Optional[str] = None\n    faster_whisper_model: Optional[FasterWhisperModelEnum] = None\n    faster_whisper_model_dir: Optional[str] = None\n    faster_whisper_device: str = \"cuda\"\n    faster_whisper_vad_filter: bool = True\n    faster_whisper_vad_threshold: float = 0.5\n    faster_whisper_vad_method: Optional[VadMethodEnum] = VadMethodEnum.SILERO_V3\n    faster_whisper_ff_mdx_kim2: bool = False\n    faster_whisper_one_word: bool = True\n    faster_whisper_prompt: Optional[str] = None\n\n    def _mask_key(self, key: Optional[str]) -> str:\n        \"\"\"Mask sensitive key for display\"\"\"\n        if not key or len(key) <= 12:\n            return \"****\"\n        return f\"{key[:4]}...{key[-4:]}\"\n\n    def print_config(self) -> str:\n        \"\"\"Print transcription configuration\"\"\"\n        lines = [\"=========== Transcription Task ===========\"]\n        lines.append(\n            f\"Model: {self.transcribe_model.value if self.transcribe_model else 'None'}\"\n        )\n        lines.append(f\"Language: {self.transcribe_language or 'Auto'}\")\n        lines.append(f\"Word Timestamp: {self.need_word_time_stamp}\")\n        lines.append(\n            f\"Output Format: {self.output_format.value if self.output_format else 'None'}\"\n        )\n\n        if self.transcribe_model == TranscribeModelEnum.WHISPER_API:\n            lines.append(f\"API Base: {self.whisper_api_base}\")\n            lines.append(f\"API Key: {self._mask_key(self.whisper_api_key)}\")\n            lines.append(f\"API Model: {self.whisper_api_model}\")\n            if self.whisper_api_prompt:\n                lines.append(f\"Prompt: {self.whisper_api_prompt[:30]}...\")\n\n        elif self.transcribe_model == TranscribeModelEnum.FASTER_WHISPER:\n            lines.append(\n                f\"Model: {self.faster_whisper_model.value if self.faster_whisper_model else 'None'}\"\n            )\n            lines.append(f\"Device: {self.faster_whisper_device}\")\n            lines.append(f\"VAD Filter: {self.faster_whisper_vad_filter}\")\n            if self.faster_whisper_vad_filter:\n                lines.append(\n                    f\"VAD Method: {self.faster_whisper_vad_method.value if self.faster_whisper_vad_method else 'None'}\"\n                )\n                lines.append(f\"VAD Threshold: {self.faster_whisper_vad_threshold}\")\n            lines.append(f\"One Word Per Segment: {self.faster_whisper_one_word}\")\n\n        elif self.transcribe_model == TranscribeModelEnum.WHISPER_CPP:\n            lines.append(\n                f\"Model: {self.whisper_model.value if self.whisper_model else 'None'}\"\n            )\n\n        lines.append(\"=\" * 42)\n        return \"\\n\".join(lines)\n\n\n@dataclass\nclass SubtitleConfig:\n    \"\"\"字幕处理配置类\"\"\"\n\n    # 翻译配置\n    base_url: Optional[str] = None\n    api_key: Optional[str] = None\n    llm_model: Optional[str] = None\n    deeplx_endpoint: Optional[str] = None\n    # 翻译服务\n    translator_service: Optional[TranslatorServiceEnum] = None\n    need_translate: bool = False\n    need_optimize: bool = False\n    need_reflect: bool = False\n    thread_num: int = 10\n    batch_size: int = 10\n    # 字幕布局和分割\n    subtitle_layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ORIGINAL_ON_TOP\n    max_word_count_cjk: int = 12\n    max_word_count_english: int = 18\n    need_split: bool = True\n    target_language: Optional[\"TargetLanguage\"] = None\n    subtitle_style: Optional[str] = None\n    custom_prompt_text: Optional[str] = None\n\n    def _mask_key(self, key: Optional[str]) -> str:\n        \"\"\"Mask sensitive key for display\"\"\"\n        if not key or len(key) <= 8:\n            return \"****\"\n        return f\"{key[:4]}...{key[-4:]}\"\n\n    def print_config(self) -> str:\n        \"\"\"Print subtitle processing configuration\"\"\"\n        lines = [\"=========== Subtitle Processing Task ===========\"]\n\n        if self.need_split:\n            lines.append(\"Split: Yes\")\n            lines.append(f\"  Max Words (CJK): {self.max_word_count_cjk}\")\n            lines.append(f\"  Max Words (English): {self.max_word_count_english}\")\n\n        if self.need_optimize:\n            lines.append(\"Optimize: Yes\")\n            lines.append(f\"  Model: {self.llm_model or 'None'}\")\n            if self.custom_prompt_text:\n                lines.append(f\"  Custom Prompt: {self.custom_prompt_text[:30]}...\")\n\n        if self.need_translate:\n            lines.append(\"Translate: Yes\")\n            lines.append(\n                f\"  Service: {self.translator_service.value if self.translator_service else 'None'}\"\n            )\n            if self.translator_service == TranslatorServiceEnum.OPENAI:\n                lines.append(f\"  API Base: {self.base_url}\")\n                lines.append(f\"  API Key: {self._mask_key(self.api_key)}\")\n                lines.append(f\"  Model: {self.llm_model}\")\n                lines.append(f\"  Reflect Translation: {self.need_reflect}\")\n            elif self.translator_service == TranslatorServiceEnum.DEEPLX:\n                lines.append(f\"  DeepLX Endpoint: {self.deeplx_endpoint}\")\n            lines.append(\n                f\"  Target Language: {self.target_language.value if self.target_language else 'None'}\"\n            )\n            lines.append(f\"  Concurrency: {self.thread_num}\")\n            lines.append(f\"  Batch Size: {self.batch_size}\")\n\n        lines.append(f\"Layout: {self.subtitle_layout.value}\")\n        lines.append(\"=\" * 48)\n        return \"\\n\".join(lines)\n\n\n@dataclass\nclass SynthesisConfig:\n    \"\"\"视频合成配置类\"\"\"\n\n    need_video: bool = True\n    soft_subtitle: bool = True\n    render_mode: SubtitleRenderModeEnum = SubtitleRenderModeEnum.ASS_STYLE\n    video_quality: VideoQualityEnum = VideoQualityEnum.MEDIUM\n    subtitle_layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ORIGINAL_ON_TOP\n    # 字幕样式配置\n    ass_style: str = \"\"  # ASS 样式字符串\n    rounded_style: Optional[dict] = None  # 圆角背景样式配置\n\n    def print_config(self) -> str:\n        \"\"\"Print video synthesis configuration\"\"\"\n        lines = [\"=========== Video Synthesis Task ===========\"]\n        lines.append(f\"Generate Video: {self.need_video}\")\n        if self.need_video:\n            lines.append(f\"Subtitle Type: {'Soft' if self.soft_subtitle else 'Hard'}\")\n            if not self.soft_subtitle:\n                lines.append(f\"Render Mode: {self.render_mode.value}\")\n            lines.append(f\"Video Quality: {self.video_quality.value}\")\n            lines.append(f\"  CRF: {self.video_quality.get_crf()}\")\n            lines.append(f\"  Preset: {self.video_quality.get_preset()}\")\n        lines.append(\"=\" * 44)\n        return \"\\n\".join(lines)\n\n\n@dataclass\nclass TranscribeTask:\n    \"\"\"转录任务类\"\"\"\n\n    # 任务标识\n    task_id: str = field(default_factory=_generate_task_id)\n\n    queued_at: Optional[datetime.datetime] = None\n    started_at: Optional[datetime.datetime] = None\n    completed_at: Optional[datetime.datetime] = None\n\n    # 输入文件\n    file_path: Optional[str] = None\n\n    # 输出字幕文件\n    output_path: Optional[str] = None\n\n    # 是否需要执行下一个任务（字幕处理）\n    need_next_task: bool = False\n\n    # 选中的音轨索引\n    selected_audio_track_index: int = 0\n\n    transcribe_config: Optional[TranscribeConfig] = None\n\n\n@dataclass\nclass SubtitleTask:\n    \"\"\"字幕任务类\"\"\"\n\n    # 任务标识\n    task_id: str = field(default_factory=_generate_task_id)\n\n    queued_at: Optional[datetime.datetime] = None\n    started_at: Optional[datetime.datetime] = None\n    completed_at: Optional[datetime.datetime] = None\n\n    # 输入原始字幕文件\n    subtitle_path: str = \"\"\n    # 输入原始视频文件\n    video_path: Optional[str] = None\n\n    # 输出 断句、优化、翻译 后的字幕文件\n    output_path: Optional[str] = None\n\n    # 是否需要执行下一个任务（视频合成）\n    need_next_task: bool = True\n\n    subtitle_config: Optional[SubtitleConfig] = None\n\n\n@dataclass\nclass SynthesisTask:\n    \"\"\"视频合成任务类\"\"\"\n\n    # 任务标识\n    task_id: str = field(default_factory=_generate_task_id)\n\n    queued_at: Optional[datetime.datetime] = None\n    started_at: Optional[datetime.datetime] = None\n    completed_at: Optional[datetime.datetime] = None\n\n    # 输入\n    video_path: Optional[str] = None\n    subtitle_path: Optional[str] = None\n\n    # 输出\n    output_path: Optional[str] = None\n\n    # 是否需要执行下一个任务（预留）\n    need_next_task: bool = False\n\n    synthesis_config: Optional[SynthesisConfig] = None\n\n\n@dataclass\nclass TranscriptAndSubtitleTask:\n    \"\"\"转录和字幕任务类\"\"\"\n\n    # 任务标识\n    task_id: str = field(default_factory=_generate_task_id)\n\n    queued_at: Optional[datetime.datetime] = None\n    started_at: Optional[datetime.datetime] = None\n    completed_at: Optional[datetime.datetime] = None\n\n    # 输入\n    file_path: Optional[str] = None\n\n    # 输出\n    output_path: Optional[str] = None\n\n    transcribe_config: Optional[TranscribeConfig] = None\n    subtitle_config: Optional[SubtitleConfig] = None\n\n\n@dataclass\nclass FullProcessTask:\n    \"\"\"完整处理任务类(转录+字幕+合成)\"\"\"\n\n    # 任务标识\n    task_id: str = field(default_factory=_generate_task_id)\n\n    queued_at: Optional[datetime.datetime] = None\n    started_at: Optional[datetime.datetime] = None\n    completed_at: Optional[datetime.datetime] = None\n\n    # 输入\n    file_path: Optional[str] = None\n    # 输出\n    output_path: Optional[str] = None\n\n    transcribe_config: Optional[TranscribeConfig] = None\n    subtitle_config: Optional[SubtitleConfig] = None\n    synthesis_config: Optional[SynthesisConfig] = None\n\n\nclass BatchTaskType(Enum):\n    \"\"\"批量处理任务类型\"\"\"\n\n    TRANSCRIBE = \"批量转录\"\n    SUBTITLE = \"批量字幕\"\n    TRANS_SUB = \"转录+字幕\"\n    FULL_PROCESS = \"全流程处理\"\n\n    def __str__(self):\n        return self.value\n\n\nclass BatchTaskStatus(Enum):\n    \"\"\"批量处理任务状态\"\"\"\n\n    WAITING = \"等待中\"\n    RUNNING = \"处理中\"\n    COMPLETED = \"已完成\"\n    FAILED = \"失败\"\n\n    def __str__(self):\n        return self.value\n"
  },
  {
    "path": "app/core/llm/__init__.py",
    "content": "\"\"\"LLM unified client module.\"\"\"\n\nfrom .check_llm import check_llm_connection, get_available_models\nfrom .check_whisper import check_whisper_connection\nfrom .client import call_llm, get_llm_client\n\n__all__ = [\n    \"call_llm\",\n    \"get_llm_client\",\n    \"check_llm_connection\",\n    \"get_available_models\",\n    \"check_whisper_connection\",\n]\n"
  },
  {
    "path": "app/core/llm/check_llm.py",
    "content": "\"\"\"LLM 连接测试工具\"\"\"\n\nfrom typing import Literal, Optional\n\nimport openai\n\nfrom app.core.llm.client import normalize_base_url\n\n\ndef check_llm_connection(\n    base_url: str, api_key: str, model: str\n) -> tuple[Literal[True], Optional[str]] | tuple[Literal[False], Optional[str]]:\n    \"\"\"测试 LLM API 连接\n\n    使用指定的API设置与LLM进行对话测试。\n\n    参数:\n        base_url: API 基础 URL\n        api_key: API 密钥\n        model: 模型名称\n\n    返回:\n        (是否成功, 错误信息或AI助手的回复)\n    \"\"\"\n    try:\n        # 创建OpenAI客户端并发送请求到API\n        base_url = normalize_base_url(base_url)\n        api_key = api_key.strip()\n        response = openai.OpenAI(\n            base_url=base_url, api_key=api_key, timeout=60\n        ).chat.completions.create(\n            model=model,\n            messages=[\n                {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n                {\"role\": \"user\", \"content\": 'Just respond with \"Hello\"!'},\n            ],\n            timeout=30,\n        )\n        return True, response.choices[0].message.content\n    except openai.APIConnectionError:\n        return False, \"API Connection Error. Please check your network or VPN.\"\n    except openai.RateLimitError as e:\n        return False, \"Rate Limit Error: \" + str(e)\n    except openai.AuthenticationError:\n        return False, \"Authentication Error. Please check your API key.\"\n    except openai.NotFoundError:\n        return False, \"URL Not Found Error. Please check your Base URL.\"\n    except openai.OpenAIError as e:\n        return False, \"OpenAI Error: \" + str(e)\n    except Exception as e:\n        return False, str(e)\n\n\ndef get_available_models(base_url: str, api_key: str) -> list[str]:\n    \"\"\"获取可用的模型列表\n\n    参数:\n        base_url: API 基础 URL\n        api_key: API 密钥\n\n    返回:\n        模型ID列表，按优先级排序\n    \"\"\"\n    try:\n        base_url = normalize_base_url(base_url)\n        # 创建OpenAI客户端并获取模型列表\n        models = openai.OpenAI(\n            base_url=base_url, api_key=api_key, timeout=5\n        ).models.list()\n\n        # 去除非文本模型\n        non_text_models = (\n            \"tts\",\n            \"transcribe\",\n            \"realtime\",\n            \"embedding\",\n            \"vision\",\n            \"audio\",\n            \"search\",\n            \"text-\",\n            \"image\",\n            \"audio\",\n            \"whisper\",\n            \"gpt-3.5\",\n            \"gpt-4-\",\n        )\n        models = [\n            model\n            for model in models\n            if not any(keyword in model.id.lower() for keyword in non_text_models)\n        ]\n\n        # 根据不同模型设置权重进行排序\n        def get_model_weight(model_name: str) -> int:\n            model_name = model_name.lower()\n            if model_name.startswith((\"gpt-5\", \"claude-4\", \"gemini-2\", \"gemini-3\")):\n                return 10\n            elif model_name.startswith((\"gpt-4\")):\n                return 5\n            elif model_name.startswith((\"deepseek\", \"glm\", \"qwen\", \"doubao\")):\n                return 3\n            return 0\n\n        sorted_models = sorted(\n            [model.id for model in models], key=lambda x: (-get_model_weight(x), x)\n        )\n        return sorted_models\n    except Exception:\n        return []\n"
  },
  {
    "path": "app/core/llm/check_whisper.py",
    "content": "\"\"\"Whisper API 连接测试工具\"\"\"\n\nfrom typing import Literal, Optional\n\nimport openai\n\nfrom app.config import ASSETS_PATH\nfrom app.core.llm.client import normalize_base_url\n\n# 测试音频文件路径\nTEST_AUDIO_PATH = ASSETS_PATH / \"en.mp3\"\n\n\ndef check_whisper_connection(\n    base_url: str, api_key: str, model: str\n) -> tuple[Literal[True], Optional[str]] | tuple[Literal[False], Optional[str]]:\n    \"\"\"\n    测试 Whisper API 连接\n\n    使用测试音频文件进行转录测试，并返回转录结果文本。\n\n    参数:\n        base_url: API 基础 URL\n        api_key: API 密钥\n        model: 模型名称\n\n    返回:\n        (是否成功, 转录结果文本或错误信息)\n    \"\"\"\n    try:\n        # 检查测试音频文件是否存在\n        if not TEST_AUDIO_PATH.exists():\n            return False, f\"Test audio file not found: {TEST_AUDIO_PATH}\"\n\n        # 创建 OpenAI 客户端\n        base_url = normalize_base_url(base_url)\n        api_key = api_key.strip()\n        client = openai.OpenAI(base_url=base_url, api_key=api_key, timeout=60)\n\n        # 读取音频文件\n        with open(TEST_AUDIO_PATH, \"rb\") as audio_file:\n            # 调用 Whisper API 进行转录\n            response = client.audio.transcriptions.create(\n                model=model,\n                file=audio_file,\n                response_format=\"verbose_json\",\n                timestamp_granularities=[\"word\", \"segment\"],\n                timeout=30,\n            )\n\n        # 返回成功结果和转录文本\n        if isinstance(response, str):\n            raise ValueError(\n                \"WhisperAPI returned type error, please check your base URL.\"\n            )\n        else:\n            resp = f\"{response.text}\"\n            return True, resp\n\n    except openai.APIConnectionError:\n        return False, \"API Connection Error. Please check your network or VPN.\"\n    except openai.RateLimitError as e:\n        return False, \"Rate Limit Error: \" + str(e)\n    except openai.AuthenticationError:\n        return False, \"Authentication Error. Please check your API key.\"\n    except openai.NotFoundError:\n        return False, \"URL Not Found Error. Please check your Base URL.\"\n    except openai.BadRequestError as e:\n        return False, \"Bad Request Error: \" + str(e)\n    except openai.OpenAIError as e:\n        return False, \"OpenAI Error: \" + str(e)\n    except FileNotFoundError:\n        return False, f\"Test audio file not found: {TEST_AUDIO_PATH}\"\n    except Exception as e:\n        return False, str(e)\n"
  },
  {
    "path": "app/core/llm/client.py",
    "content": "\"\"\"Unified LLM client for the application.\"\"\"\n\nimport os\nimport threading\nfrom typing import Any, List, Optional\nfrom urllib.parse import urlparse, urlunparse\n\nimport openai\nfrom openai import OpenAI\nfrom tenacity import (\n    RetryCallState,\n    retry,\n    retry_if_exception_type,\n    stop_after_attempt,\n    wait_random_exponential,\n)\n\nfrom app.core.utils.cache import get_llm_cache, memoize\nfrom app.core.utils.logger import setup_logger\n\nfrom .request_logger import create_logging_http_client, log_llm_response\n\n_global_client: Optional[OpenAI] = None\n_client_lock = threading.Lock()\n\nlogger = setup_logger(\"llm_client\")\n\n\ndef normalize_base_url(base_url: str) -> str:\n    \"\"\"Normalize API base URL by ensuring /v1 suffix when needed.\"\"\"\n    url = base_url.strip()\n    parsed = urlparse(url)\n    path = parsed.path.rstrip(\"/\")\n\n    if not path:\n        path = \"/v1\"\n\n    normalized = urlunparse(\n        (\n            parsed.scheme,\n            parsed.netloc,\n            path,\n            parsed.params,\n            parsed.query,\n            parsed.fragment,\n        )\n    )\n\n    return normalized\n\n\ndef get_llm_client() -> OpenAI:\n    \"\"\"Get global LLM client instance (thread-safe singleton).\"\"\"\n    global _global_client\n\n    if _global_client is None:\n        with _client_lock:\n            if _global_client is None:\n                base_url = os.getenv(\"OPENAI_BASE_URL\", \"\").strip()\n                base_url = normalize_base_url(base_url)\n                api_key = os.getenv(\"OPENAI_API_KEY\", \"\").strip()\n\n                if not base_url or not api_key:\n                    raise ValueError(\n                        \"OPENAI_BASE_URL and OPENAI_API_KEY environment variables must be set\"\n                    )\n\n                _global_client = OpenAI(\n                    base_url=base_url,\n                    api_key=api_key,\n                    http_client=create_logging_http_client(),\n                )\n\n    return _global_client\n\n\ndef before_sleep_log(retry_state: RetryCallState) -> None:\n    logger.warning(\n        \"Rate Limit Error, sleeping and retrying... Please lower your thread concurrency or use better OpenAI API.\"\n    )\n\n\n@retry(\n    stop=stop_after_attempt(10),\n    wait=wait_random_exponential(multiplier=1, min=5, max=60),\n    retry=retry_if_exception_type(openai.RateLimitError),\n    before_sleep=before_sleep_log,\n)\ndef _call_llm_api(\n    messages: List[dict],\n    model: str,\n    temperature: float = 1,\n    **kwargs: Any,\n) -> Any:\n    \"\"\"实际调用 LLM API（带重试）\"\"\"\n    client = get_llm_client()\n\n    response = client.chat.completions.create(\n        model=model,\n        messages=messages,  # pyright: ignore[reportArgumentType]\n        temperature=temperature,\n        **kwargs,\n    )\n\n    # 记录响应内容\n    log_llm_response(response)\n\n    return response\n\n\n@memoize(get_llm_cache(), expire=3600, typed=True)\ndef call_llm(\n    messages: List[dict],\n    model: str,\n    temperature: float = 1,\n    **kwargs: Any,\n) -> Any:\n    \"\"\"Call LLM API with automatic caching.\"\"\"\n    response = _call_llm_api(messages, model, temperature, **kwargs)\n\n    if not (\n        response\n        and hasattr(response, \"choices\")\n        and response.choices\n        and len(response.choices) > 0\n        and hasattr(response.choices[0], \"message\")\n        and response.choices[0].message.content\n    ):\n        raise ValueError(\"Invalid OpenAI API response: empty choices or content\")\n\n    return response\n"
  },
  {
    "path": "app/core/llm/context.py",
    "content": "\"\"\"任务上下文管理\n\n使用模块级变量存储任务上下文，确保跨线程池传递（ThreadPoolExecutor 不会自动复制 contextvars）。\n\"\"\"\n\nimport threading\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Optional\n\n\n@dataclass\nclass TaskContext:\n    \"\"\"任务上下文\"\"\"\n\n    task_id: str  # 任务唯一标识，如 \"a1b2c3d4\"\n    file_name: str  # 处理的文件名，如 \"video.mp4\"\n    stage: str  # 当前阶段: transcribe / split / optimize / translate / synthesis\n\n\n_lock = threading.Lock()\n_current_context: Optional[TaskContext] = None\n\n\ndef generate_task_id() -> str:\n    \"\"\"生成 8 位任务 ID\"\"\"\n    return uuid.uuid4().hex[:8]\n\n\ndef set_task_context(task_id: str, file_name: str, stage: str) -> None:\n    \"\"\"设置当前任务上下文\"\"\"\n    global _current_context\n    with _lock:\n        _current_context = TaskContext(task_id=task_id, file_name=file_name, stage=stage)\n\n\ndef get_task_context() -> Optional[TaskContext]:\n    \"\"\"获取当前任务上下文\"\"\"\n    with _lock:\n        return _current_context\n\n\ndef update_stage(stage: str) -> None:\n    \"\"\"更新当前阶段\"\"\"\n    global _current_context\n    with _lock:\n        if _current_context:\n            _current_context = TaskContext(\n                task_id=_current_context.task_id,\n                file_name=_current_context.file_name,\n                stage=stage,\n            )\n\n\ndef clear_task_context() -> None:\n    \"\"\"清除任务上下文\"\"\"\n    global _current_context\n    with _lock:\n        _current_context = None\n"
  },
  {
    "path": "app/core/llm/request_logger.py",
    "content": "import json\nimport threading\nimport time\nfrom datetime import datetime\nfrom typing import Any, Dict\n\nimport httpx\n\nfrom app.config import LOG_PATH\nfrom app.core.llm.context import get_task_context\n\nLLM_LOG_FILE = LOG_PATH / \"llm_requests.jsonl\"\nMAX_LOG_SIZE = 10 * 1024 * 1024  # 10MB\n\n\n_log_lock = threading.Lock()\n_pending_requests: Dict[int, Dict[str, Any]] = {}  # 暂存请求信息，等待响应后合并\n\n\n# ==================== 日志写入 ====================\n\n\ndef _rotate_if_needed() -> None:\n    \"\"\"日志文件过大时轮转\"\"\"\n    if not LLM_LOG_FILE.exists():\n        return\n    if LLM_LOG_FILE.stat().st_size < MAX_LOG_SIZE:\n        return\n\n    backup = LLM_LOG_FILE.with_suffix(\".jsonl.old\")\n    if backup.exists():\n        backup.unlink()\n    LLM_LOG_FILE.rename(backup)\n\n\ndef _write_log(entry: Dict[str, Any]) -> None:\n    \"\"\"写入日志\"\"\"\n    try:\n        LOG_PATH.mkdir(parents=True, exist_ok=True)\n        with _log_lock:\n            _rotate_if_needed()\n            with open(LLM_LOG_FILE, \"a\", encoding=\"utf-8\") as f:\n                f.write(json.dumps(entry, ensure_ascii=False) + \"\\n\")\n    except Exception:\n        pass\n\n\n# ==================== HTTPX Hooks ====================\n\n\ndef _on_request(request: httpx.Request) -> None:\n    \"\"\"请求发送前：暂存请求信息\"\"\"\n    if \"/chat/completions\" not in str(request.url):\n        return\n\n    try:\n        request_body = json.loads(request.content.decode(\"utf-8\"))\n    except (json.JSONDecodeError, UnicodeDecodeError):\n        request_body = {\"raw\": request.content.decode(\"utf-8\", errors=\"replace\")}\n\n    _pending_requests[id(request)] = {\n        \"start_time\": time.time(),\n        \"url\": str(request.url),\n        \"request\": request_body,\n    }\n\n\ndef _on_response(response: httpx.Response) -> None:\n    \"\"\"响应接收后：记录状态码和耗时\"\"\"\n    request = response.request\n    pending = _pending_requests.get(id(request))\n    if not pending:\n        return\n\n    pending[\"status\"] = response.status_code\n    pending[\"duration_ms\"] = int((time.time() - pending[\"start_time\"]) * 1000)\n    pending[\"completed\"] = True  # 标记响应已完成\n\n\n# ==================== 公开 API ====================\n\n\ndef create_logging_http_client() -> httpx.Client:\n    \"\"\"创建带日志记录的 HTTPX 客户端\"\"\"\n    return httpx.Client(\n        event_hooks={\n            \"request\": [_on_request],\n            \"response\": [_on_response],\n        }\n    )\n\n\ndef log_llm_response(response: Any) -> None:\n    \"\"\"记录完整的请求+响应（在 SDK 解析响应后调用）\"\"\"\n    if not _pending_requests:\n        return\n\n    # 优先选择已完成响应的请求（有 duration_ms）\n    completed_key = None\n    for key, pending in _pending_requests.items():\n        if pending.get(\"completed\"):\n            completed_key = key\n            break\n\n    # 如果没有已完成的，取第一个\n    key = completed_key if completed_key else next(iter(_pending_requests))\n    pending = _pending_requests.pop(key)\n\n    # 序列化完整响应体\n    response_data = {}\n    if response and hasattr(response, \"model_dump\"):\n        response_data = response.model_dump()\n\n    # 获取任务上下文\n    ctx = get_task_context()\n\n    log_entry = {\n        \"time\": datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\"),\n        \"task_id\": ctx.task_id if ctx else \"\",\n        \"file_name\": ctx.file_name if ctx else \"\",\n        \"stage\": ctx.stage if ctx else \"\",\n        \"url\": pending.get(\"url\", \"\"),\n        \"status\": pending.get(\"status\", 0),\n        \"duration_ms\": pending.get(\"duration_ms\", 0),\n        \"request\": pending.get(\"request\", {}),\n        \"response\": response_data,\n    }\n\n    _write_log(log_entry)\n"
  },
  {
    "path": "app/core/optimize/optimize.py",
    "content": "\"\"\"字幕优化模块\n\n使用LLM优化字幕内容，支持agent loop自动验证和修正。\n\"\"\"\n\nimport atexit\nimport difflib\nimport re\nfrom concurrent.futures import ThreadPoolExecutor\nfrom typing import Callable, Dict, List, Optional, Tuple, Union\n\nimport json_repair\n\nfrom ..asr.asr_data import ASRData, ASRDataSeg\nfrom ..entities import SubtitleProcessData\nfrom ..llm import call_llm\nfrom ..prompts import get_prompt\nfrom ..split.alignment import SubtitleAligner\nfrom ..utils.logger import setup_logger\nfrom ..utils.text_utils import count_words\n\nlogger = setup_logger(\"subtitle_optimizer\")\n\nMAX_STEPS = 3\n\n\nclass SubtitleOptimizer:\n    \"\"\"字幕优化器\n\n    使用LLM优化字幕内容，支持：\n    - Agent loop自动验证和修正\n    - 并发批量处理\n    - 自动对齐修复\n    \"\"\"\n\n    def __init__(\n        self,\n        thread_num: int,\n        batch_num: int,\n        model: str,\n        custom_prompt: str,\n        update_callback: Optional[Callable] = None,\n    ):\n        \"\"\"初始化优化器\n\n        Args:\n            thread_num: 并发线程数\n            batch_num: 每批处理的字幕数量\n            model: LLM模型名称\n            custom_prompt: 自定义优化提示词\n            temperature: LLM温度参数\n            update_callback: 进度更新回调函数\n        \"\"\"\n        self.thread_num = thread_num\n        self.batch_num = batch_num\n        self.model = model\n        self.custom_prompt = custom_prompt\n        self.update_callback = update_callback\n\n        self.is_running = True\n        self.executor: Optional[ThreadPoolExecutor] = None\n        self._init_thread_pool()\n\n    def _init_thread_pool(self) -> None:\n        \"\"\"初始化线程池并注册清理函数\"\"\"\n        self.executor = ThreadPoolExecutor(max_workers=self.thread_num)\n        atexit.register(self.stop)\n\n    def optimize_subtitle(self, subtitle_data: Union[str, ASRData]) -> ASRData:\n        \"\"\"优化字幕\n\n        Args:\n            subtitle_data: 字幕文件路径或ASRData对象\n\n        Returns:\n            优化后的ASRData对象\n        \"\"\"\n        try:\n            # 读取字幕\n            if isinstance(subtitle_data, str):\n                asr_data = ASRData.from_subtitle_file(subtitle_data)\n            else:\n                asr_data = subtitle_data\n\n            # 转换为字典格式\n            subtitle_dict = {\n                str(i): seg.text for i, seg in enumerate(asr_data.segments, 1)\n            }\n\n            # 分批处理\n            chunks = self._split_chunks(subtitle_dict)\n\n            # 并行优化\n            optimized_dict = self._parallel_optimize(chunks)\n\n            # 创建新segments\n            new_segments = self._create_segments(asr_data.segments, optimized_dict)\n\n            return ASRData(new_segments)\n\n        except Exception as e:\n            logger.error(f\"优化失败：{str(e)}\")\n            raise RuntimeError(f\"优化失败：{str(e)}\")\n\n    def _split_chunks(self, subtitle_dict: Dict[str, str]) -> List[Dict[str, str]]:\n        \"\"\"将字幕字典分割成批次\n\n        Args:\n            subtitle_dict: 字幕字典 {index: text}\n\n        Returns:\n            批次列表\n        \"\"\"\n        items = list(subtitle_dict.items())\n        return [\n            dict(items[i : i + self.batch_num])\n            for i in range(0, len(items), self.batch_num)\n        ]\n\n    def _parallel_optimize(self, chunks: List[Dict[str, str]]) -> Dict[str, str]:\n        \"\"\"并行优化所有批次\n\n        Args:\n            chunks: 字幕批次列表\n\n        Returns:\n            优化后的字幕字典\n        \"\"\"\n        if not self.executor:\n            raise ValueError(\"线程池未初始化\")\n\n        futures = []\n        optimized_dict: Dict[str, str] = {}\n\n        # 提交所有任务\n        for chunk in chunks:\n            future = self.executor.submit(self._optimize_chunk, chunk)\n            futures.append((future, chunk))\n\n        # 收集结果\n        for future, chunk in futures:\n            if not self.is_running:\n                break\n\n            try:\n                result = future.result()\n                optimized_dict.update(result)\n            except Exception as e:\n                logger.error(f\"优化批次失败：{str(e)}\")\n                optimized_dict.update(chunk)  # 失败时保留原文\n\n        return optimized_dict\n\n    def _optimize_chunk(self, subtitle_chunk: Dict[str, str]) -> Dict[str, str]:\n        \"\"\"优化单个字幕批次\n\n        Args:\n            subtitle_chunk: 字幕批次字典\n\n        Returns:\n            优化后的字幕批次\n        \"\"\"\n        start_idx = next(iter(subtitle_chunk))\n        end_idx = next(reversed(subtitle_chunk))\n        logger.info(f\"[+]正在优化字幕：{start_idx} - {end_idx}\")\n\n        try:\n            result = self.agent_loop(subtitle_chunk)\n\n            if self.update_callback:\n                callback_data = [\n                    SubtitleProcessData(\n                        index=int(idx),\n                        original_text=subtitle_chunk[idx],\n                        optimized_text=result[idx],\n                    )\n                    for idx in sorted(result.keys(), key=int)\n                ]\n                self.update_callback(callback_data)\n\n            return result\n\n        except Exception as e:\n            logger.error(f\"优化失败：{str(e)}\")\n            return subtitle_chunk\n\n    def agent_loop(self, subtitle_chunk: Dict[str, str]) -> Dict[str, str]:\n        \"\"\"使用agent loop优化字幕\n\n        LLM → 验证 → 反馈 → 重试 (最多MAX_STEPS次)\n\n        Args:\n            subtitle_chunk: 字幕批次字典\n\n        Returns:\n            优化后的字幕批次\n\n        Raises:\n            ValueError: LLM返回空结果\n        \"\"\"\n        # 构建提示词\n        user_prompt = (\n            f\"Correct the following subtitles. Keep the original language, do not translate:\\n\"\n            f\"<input_subtitle>{str(subtitle_chunk)}</input_subtitle>\"\n        )\n\n        if self.custom_prompt:\n            user_prompt += (\n                f\"\\nReference content:\\n<reference>{self.custom_prompt}</reference>\"\n            )\n\n        messages = [\n            {\"role\": \"system\", \"content\": get_prompt(\"optimize/subtitle\")},\n            {\"role\": \"user\", \"content\": user_prompt},\n        ]\n\n        last_result = None\n\n        # Agent loop\n        for step in range(MAX_STEPS):\n            # 调用LLM\n            response = call_llm(\n                messages=messages,\n                model=self.model,\n                temperature=0.2,\n            )\n\n            result_text = response.choices[0].message.content\n            if not result_text:\n                raise ValueError(\"LLM返回空结果\")\n\n            # 解析结果\n            parsed_result = json_repair.loads(result_text)\n            if not isinstance(parsed_result, dict):\n                raise ValueError(\n                    f\"LLM返回结果类型错误，期望dict，实际{type(parsed_result)}\"\n                )\n\n            result_dict: Dict[str, str] = parsed_result\n            last_result = result_dict\n\n            # 验证结果\n            is_valid, error_message = self._validate_optimization_result(\n                original_chunk=subtitle_chunk, optimized_chunk=result_dict\n            )\n\n            if is_valid:\n                return self._repair_subtitle(subtitle_chunk, result_dict)\n\n            # 验证失败，添加反馈\n            logger.warning(\n                f\"优化验证失败，开始反馈循环 (第{step + 1}次尝试): {error_message}\"\n            )\n            messages.append({\"role\": \"assistant\", \"content\": result_text})\n            messages.append(\n                {\n                    \"role\": \"user\",\n                    \"content\": (\n                        f\"Validation failed: {error_message}\\n\"\n                        f\"Please fix the errors and output ONLY a valid JSON dictionary.\"\n                    ),\n                }\n            )\n\n        # 达到最大步数\n        logger.warning(f\"达到最大尝试次数({MAX_STEPS})，返回最后结果\")\n        return (\n            self._repair_subtitle(subtitle_chunk, last_result)\n            if last_result\n            else subtitle_chunk\n        )\n\n    def _validate_optimization_result(\n        self, original_chunk: Dict[str, str], optimized_chunk: Dict[str, str]\n    ) -> Tuple[bool, str]:\n        \"\"\"验证优化结果\n\n        检查：\n        1. 键是否完全匹配\n        2. 改动是否过大（相似度 < 0.7）\n\n        Args:\n            original_chunk: 原始字幕批次\n            optimized_chunk: 优化后字幕批次\n\n        Returns:\n            (是否有效, 错误反馈)\n        \"\"\"\n        expected_keys = set(original_chunk.keys())\n        actual_keys = set(optimized_chunk.keys())\n\n        # 检查键匹配\n        if expected_keys != actual_keys:\n            missing = expected_keys - actual_keys\n            extra = actual_keys - expected_keys\n\n            error_parts = []\n            if missing:\n                error_parts.append(f\"Missing keys: {sorted(missing)}\")\n            if extra:\n                error_parts.append(f\"Extra keys: {sorted(extra)}\")\n\n            error_msg = (\n                \"\\n\".join(error_parts) + f\"\\nRequired keys: {sorted(expected_keys)}\\n\"\n                f\"Please return the COMPLETE optimized dictionary with ALL {len(expected_keys)} keys.\"\n            )\n            return False, error_msg\n\n        # 检查改动是否过大（逐条比较相似度）\n        excessive_changes = []\n        for key in expected_keys:\n            original_text = original_chunk[key]\n            optimized_text = optimized_chunk[key]\n\n            # 清理文本用于比较\n            original_cleaned = re.sub(r\"\\s+\", \" \", original_text).strip()\n            optimized_cleaned = re.sub(r\"\\s+\", \" \", optimized_text).strip()\n\n            # 计算相似度\n            matcher = difflib.SequenceMatcher(None, original_cleaned, optimized_cleaned)\n            similarity = matcher.ratio()\n            similarity_threshold = 0.3 if count_words(original_text) <= 10 else 0.7\n\n            # 相似度过低\n            if similarity < similarity_threshold:\n                excessive_changes.append(\n                    f\"Key '{key}': similarity {similarity:.1%} < {similarity_threshold:.0%}. \"\n                    f\"Original: '{original_text}' → Optimized: '{optimized_text}' \"\n                )\n\n        if excessive_changes:\n            error_msg = \";\\n\".join(excessive_changes)\n            error_msg += (\n                \"\\n\\nYour optimizations changed the text too much. \"\n                \"Keep high similarity (≥70% for normal text) by making MINIMAL changes: \"\n                \"only fix recognition errors and improve clarity, \"\n                \"but preserve the original wording, length and structure as much as possible.\"\n            )\n            return False, error_msg\n\n        return True, \"\"\n\n    @staticmethod\n    def _repair_subtitle(\n        original: Dict[str, str], optimized: Dict[str, str]\n    ) -> Dict[str, str]:\n        \"\"\"修复字幕对齐\n\n        使用SubtitleAligner对齐原文和优化后的文本，\n        处理优化过程中可能产生的段落合并或拆分。\n\n        Args:\n            original: 原始字幕字典\n            optimized: 优化后字幕字典\n\n        Returns:\n            对齐后的字幕字典\n        \"\"\"\n        try:\n            aligner = SubtitleAligner()\n            original_list = list(original.values())\n            optimized_list = list(optimized.values())\n\n            aligned_source, aligned_target = aligner.align_texts(\n                original_list, optimized_list\n            )\n\n            if len(aligned_source) != len(aligned_target):\n                logger.warning(\"对齐后长度不一致，返回原优化结果\")\n                return optimized\n\n            # 重建字典，保持原有索引\n            start_id = next(iter(original.keys()))\n            return {\n                str(int(start_id) + i): text for i, text in enumerate(aligned_target)\n            }\n\n        except Exception as e:\n            logger.error(f\"对齐失败：{str(e)}，返回原优化结果\")\n            return optimized\n\n    @staticmethod\n    def _create_segments(\n        original_segments: List[ASRDataSeg],\n        optimized_dict: Dict[str, str],\n    ) -> List[ASRDataSeg]:\n        \"\"\"从优化字典创建新的ASRDataSeg列表\n\n        Args:\n            original_segments: 原始字幕段列表\n            optimized_dict: 优化后字幕字典\n\n        Returns:\n            新的字幕段列表\n        \"\"\"\n        return [\n            ASRDataSeg(\n                text=optimized_dict.get(str(i), seg.text),\n                start_time=seg.start_time,\n                end_time=seg.end_time,\n            )\n            for i, seg in enumerate(original_segments, 1)\n        ]\n\n    def stop(self) -> None:\n        \"\"\"停止优化器并清理资源\"\"\"\n        if not self.is_running:\n            return\n\n        self.is_running = False\n\n        if self.executor:\n            try:\n                self.executor.shutdown(wait=False, cancel_futures=True)\n            except Exception:\n                pass\n            finally:\n                self.executor = None\n"
  },
  {
    "path": "app/core/prompts/__init__.py",
    "content": "\"\"\"提示词管理模块\n\n所有提示词以 Markdown 文件形式存储，支持模板变量替换。\n\n使用示例:\n    from app.core.prompts import get_prompt\n\n    # 加载提示词\n    prompt = get_prompt(\"split/semantic\")\n\n    # 带参数替换\n    prompt = get_prompt(\"split/semantic\", max_word_count_cjk=18)\n    prompt = get_prompt(\"translate/reflect\", target_language=\"简体中文\")\n\"\"\"\n\nimport functools\nfrom pathlib import Path\nfrom string import Template\nfrom typing import Dict\n\nPROMPTS_DIR = Path(__file__).parent\n\n\n@functools.lru_cache(maxsize=32)\ndef _load_prompt_file(prompt_path: str) -> str:\n    \"\"\"从文件加载提示词（带LRU缓存）\n\n    Args:\n        prompt_path: 提示词相对路径，如 \"split/semantic\"\n\n    Returns:\n        提示词原始文本\n\n    Raises:\n        FileNotFoundError: 提示词文件不存在\n    \"\"\"\n    file_path = PROMPTS_DIR / f\"{prompt_path}.md\"\n\n    if not file_path.exists():\n        raise FileNotFoundError(\n            f\"Prompt file not found: {prompt_path}.md\\n\"\n            f\"Expected location: {file_path}\"\n        )\n\n    return file_path.read_text(encoding=\"utf-8\")\n\n\ndef get_prompt(prompt_path: str, **kwargs) -> str:\n    \"\"\"获取提示词并进行变量替换\n\n    Args:\n        prompt_path: 提示词路径，如 \"split/semantic\", \"optimize/subtitle\"\n        **kwargs: 模板变量，用于替换提示词中的 $variable 或 ${variable}\n\n    Returns:\n        处理后的提示词文本\n\n    Examples:\n        >>> get_prompt(\"split/semantic\")\n        >>> get_prompt(\"split/semantic\", max_word_count_cjk=18, max_word_count_english=12)\n        >>> get_prompt(\"translate/reflect\", target_language=\"简体中文\", custom_prompt=\"保持术语\")\n    \"\"\"\n    # 加载原始提示词\n    raw_prompt = _load_prompt_file(prompt_path)\n\n    # 如果没有参数，直接返回\n    if not kwargs:\n        return raw_prompt\n\n    # 使用 Template 进行变量替换\n    template = Template(raw_prompt)\n    return template.safe_substitute(**kwargs)\n\n\ndef list_prompts() -> list[str]:\n    \"\"\"列出所有可用的提示词路径\n\n    Returns:\n        提示词路径列表，如 [\"split/semantic\", \"optimize/subtitle\"]\n    \"\"\"\n    prompts = []\n    for md_file in PROMPTS_DIR.rglob(\"*.md\"):\n        if md_file.name == \"README.md\":\n            continue\n        # 转换为相对路径，去掉 .md 后缀\n        rel_path = md_file.relative_to(PROMPTS_DIR)\n        prompt_path = str(rel_path.with_suffix(\"\")).replace(\"\\\\\", \"/\")\n        prompts.append(prompt_path)\n    return sorted(prompts)\n\n\ndef reload_cache():\n    \"\"\"清空提示词缓存（用于开发模式热重载）\"\"\"\n    _load_prompt_file.cache_clear()\n\n\n__all__ = [\"get_prompt\", \"list_prompts\", \"reload_cache\"]\n"
  },
  {
    "path": "app/core/prompts/analysis/video.md",
    "content": "你是一位专业视频分析师,擅长从视频字幕中提取关键信息并识别重要术语。\n\n<context>\n在视频翻译前,需要先理解视频内容和提取专业术语,以确保翻译准确性和一致性。这对于包含专业术语、人名、组织名的视频尤为重要。\n</context>\n\n<instructions>\n1. 分析视频类型和内容,总结主要信息\n2. 识别翻译时的关键注意事项(如专业领域、语言风格等)\n3. 提取重要术语:\n   - 实体(entities):人名、组织、产品、地点等专有名称\n   - 关键词(keywords):专业术语、技术词汇、核心概念\n4. 纠正字幕中因同音或相似发音导致的识别错误\n5. 使用原字幕语言输出(如字幕是英文则输出英文)\n</instructions>\n\n<output_format>\n以JSON格式返回,包含以下字段:\n\n{\n\"summary\": \"视频内容总结及翻译建议\",\n\"terms\": {\n\"entities\": [\"实体1\", \"实体2\", ...],\n\"keywords\": [\"关键词1\", \"关键词2\", ...]\n}\n}\n\n注意:\n\n- summary需包含:视频类型、主要内容、翻译注意事项\n- 术语保持原文,无需翻译\n- 确保JSON格式正确,可被程序解析\n  </output_format>\n"
  },
  {
    "path": "app/core/prompts/optimize/subtitle.md",
    "content": "You are a professional subtitle correction expert. Your task is to fix errors in video subtitles while preserving the original meaning and structure.\n\n<context>\nSubtitles often contain recognition errors, filler words, and formatting inconsistencies that reduce readability. Your corrections should maintain the original expression while fixing technical errors and improving clarity.\n</context>\n\n<input_format>\nYou will receive:\n\n1. A JSON object with numbered subtitle entries\n2. Optional reference information containing:\n   - Content context\n   - Important terminology\n   - Specific correction requirements\n</input_format>\n\n<instructions>\n1. Fix errors while preserving original sentence structure (no paraphrasing or synonyms)\n2. Remove filler words and non-verbal sounds: um, uh, ah, laughter markers, coughing sounds, etc.\n3. Standardize formatting:\n   - Correct punctuation\n   - Proper English capitalization\n   - Mathematical formulas in plain text (use ×, ÷, =, etc.)\n   - Code syntax (variable names, function calls)\n4. Maintain subtitle numbering (no merging or splitting entries)\n5. Use reference information to correct terminology when provided\n6. Keep original language (English stays English, Chinese stays Chinese)\n7. Output only the corrected JSON, no explanations\n</instructions>\n\n<output_format>\nReturn a pure JSON object with corrected subtitles:\n\n{\n\"0\": \"[corrected subtitle]\",\n\"1\": \"[corrected subtitle]\",\n...\n}\n\nDo not include any commentary, explanations, or markdown formatting.\n</output_format>\n\n<examples>\n\n<example>\n<input_subtitles>\n{\n  \"0\": \"the formula is ah x squared plus y squared equals uh z squared\",\n  \"1\": \"this is called the pathagrian theorem *laughs*\",\n  \"2\": \"it's um used in geometry and trigonomatry\"\n}\n</input_subtitles>\n<reference>\nContent: Mathematics - Pythagorean theorem\nTerms: Pythagorean theorem, geometry, trigonometry\n</reference>\n<output>\n{\n  \"0\": \"The formula is x² + y² = z²\",\n  \"1\": \"This is called the Pythagorean theorem\",\n  \"2\": \"It's used in geometry and trigonometry\"\n}\n</output>\n</example>\n\n<example>\n<input_subtitles>\n{\n  \"0\": \"大家好呃今天我们来学习机器学习\",\n  \"1\": \"首先介绍一下神经网络的几本概念\",\n  \"2\": \"它使用反向传播算法来训练模型嗯\"\n}\n</input_subtitles>\n<reference>\nContent: 机器学习基础\nTerms: 机器学习, 神经网络, 反向传播算法\n</reference>\n<output>\n{\n  \"0\": \"大家好,今天我们来学习机器学习\",\n  \"1\": \"首先介绍一下神经网络的基本概念\",\n  \"2\": \"它使用反向传播算法来训练模型\"\n}\n</output>\n</example>\n</examples>\n\n<critical_notes>\n\n- Preserve meaning and structure - only fix errors\n- Use reference information to correct misrecognized terms\n- Output pure JSON only, no explanations or markdown\n- Maintain original language throughout\n  </critical_notes>\n"
  },
  {
    "path": "app/core/prompts/split/semantic.md",
    "content": "你是一位专业的字幕分段专家。你的任务是将未分段的连续文本按语义断点拆分,使字幕便于阅读和理解。\n\n<instructions>\n1. 在语义自然断点处插入 <br>(可在句内、句间灵活分段)\n2. 字数限制:\n   - CJK语言(中文、日语、韩语等):每段≤ $max_word_count_cjk 字\n   - 拉丁语言(英语、法语等):每段≤ $max_word_count_english 词\n3. 每段需包含完整语义,避免过短碎片\n4. 原文保持不变:不增删改,仅插入 <br>\n5. 直接输出分段文本,无需解释\n</instructions>\n\n<output_format>\n直接输出分段后的文本,段与段之间用 <br> 分隔,不要包含任何其他内容或解释。\n</output_format>\n\n<examples>\n<example>\n<input>\n大家好今天我们带来的3d创意设计作品是进制演示器我是来自中山大学附属中学的方若涵我是陈欣然我们这一次作品介绍分为三个部分第一个部分提出问题第二个部分解决方案第三个部分作品介绍当我们学习进制的时候难以掌握老师教学 也比较抽象那有没有一种教具或演示器可以将进制的原理形象生动地展现出来\n</input>\n<output>\n大家好<br>今天我们带来的3d创意设计作品是<br>进制演示器<br>我是来自中山大学附属中学的方若涵<br>我是陈欣然<br>我们这一次作品介绍分为三个部分<br>第一个部分提出问题<br>第二个部分解决方案<br>第三个部分作品介绍<br>当我们学习进制的时候难以掌握<br>老师教学也比较抽象<br>那有没有一种教具或演示器<br>可以将进制的原理形象生动地展现出来\n</output>\n</example>\n\n<example>\n<input>\nthe upgraded claude sonnet is now available for all users developers can build with the computer use beta on the anthropic api amazon bedrock and google cloud's vertex ai the new claude haiku will be released later this month\n</input>\n<output>\nthe upgraded claude sonnet is now available for all users<br>developers can build with the computer use beta<br>on the anthropic api amazon bedrock and google cloud's vertex ai<br>the new claude haiku will be released later this month\n</output>\n</example>\n</examples>\n"
  },
  {
    "path": "app/core/prompts/split/sentence.md",
    "content": "你是一位专业的字幕分句专家。你的任务是将未分段的连续文本按句子结构拆分,在句子的自然停顿点或者语义断点插入分隔符。\n\n<instructions>\n1. 在句子边界处插入 <br> (句号、逗号、分号等标点符号应出现的位置)\n2. 分割段的字数限制:\n   - CJK语言(中文、日语、韩语等):每段≤ ${max_word_count_cjk} 字\n   - 拉丁语言(英语、法语等):每段≤ ${max_word_count_english} 词\n3. 在遵循字数限制的同时，保持每个分句的意思完整\n4. 原文保持不变:不增删改,不要翻译，仅插入 <br>\n5. 倒计时（每个数字进行分割）、关键信息揭示前及需要强调的位置需要进行适当分割\n</instructions>\n\n<output_format>\n直接输出分段后的文本,句与句之间用 <br> 分隔,不要包含任何其他内容或解释。\n</output_format>\n\n<examples>\n<example>\n<input>\n大家好今天我们带来的3d创意设计作品是进制演示器我是来自中山大学附属中学的方若涵我是陈欣然我们这一次作品介绍分为三个部分第一个部分提出问题第二个部分解决方案第三个部分作品介绍当我们学习进制的时候难以掌握老师教学也比较抽象那有没有一种教具或演示器可以将进制的原理形象生动地展现出来\n</input>\n<output>\n大家好<br>今天我们带来的3d创意设计作品是进制演示器<br>我是来自中山大学附属中学的方若涵<br>我是陈欣然<br>我们这一次作品介绍分为三个部分<br>第一个部分提出问题<br>第二个部分解决方案<br>第三个部分作品介绍<br>当我们学习进制的时候难以掌握<br>老师教学也比较抽象<br>那有没有一种教具或演示器可以将进制的原理形象生动地展现出来\n</output>\n</example>\n\n<example>\n<input>\nthe upgraded claude sonnet is now available for all users developers can build with the computer use beta on the anthropic api amazon bedrock and google cloud's vertex ai the new claude haiku will be released later this month\n</input>\n<output>\nthe upgraded claude sonnet is now available for all users<br>developers can build with the computer use beta on the anthropic api amazon bedrock and google cloud's vertex ai<br>the new claude haiku will be released later this month\n</output>\n</example>\n</examples>\n"
  },
  {
    "path": "app/core/prompts/translate/reflect.md",
    "content": "You are a professional subtitle translator specializing in ${target_language}. Your goal is to produce translations that sound natural and native, not machine-translated.\n\n<context>\nMachine translation often produces technically correct but unnatural text—it translates words rather than meaning, ignores context, and misses cultural nuances. Your task is to bridge this gap through reflective translation: identify machine-translation patterns in your initial attempt, then rewrite to match how native speakers actually communicate.\n</context>\n\n<terminology_and_requirements>\n${custom_prompt}\n</terminology_and_requirements>\n\n<instructions>\n**Stage 1: Initial Translation**\nTranslate the content, maintaining all information and subtitle numbering.\n\n**Stage 2: Machine Translation Detection & Deep Analysis**\nCritically examine your translation and identify:\n\n1. **Structural rigidity**: Does it mirror source language word order unnaturally?\n2. **Literal word choices**: Are there more natural/colloquial alternatives?\n3. **Missing context**: What implicit meaning or tone needs to be made explicit (or vice versa)?\n4. **Cultural mismatch**: Can we use local idioms（中文成语）, references, or expressions to localize the translation?\n5. **Register issues**: Is the formality level appropriate for the context?\n6. **Native speaker test**: Would a native speaker say it this way? If not, how WOULD they say it?\n7. **Cross-subtitle coherence**: Check the connection with the previous and next subtitles—does the flow feel natural and smooth when read together?\n\nFor each issue found, propose specific alternatives with reasoning.\n\n**Stage 3: Native-Quality Rewrite**\nBased on your analysis, rewrite the translation to sound completely natural in ${target_language}. Ask yourself: \"If a native speaker were explaining this idea, what exact words would they use?\"\n</instructions>\n\n<output_format>\n{\n\"1\": {\n\"initial_translation\": \"<<< First translation >>>\",\n\"reflection\": \"<<< Identify machine-translation patterns: What sounds unnatural? Why? What would a native speaker say instead? Consider structure, word choice, context, culture, register. Be specific about problems and alternatives. >>>\",\n\"native_translation\": \"<<< Natural, native-quality translation that eliminates all machine-translation artifacts >>>\"\n},\n...\n}\n</output_format>\n\n<examples>\n<example>\n<scenario>Motivational speech about life philosophy</scenario>\n<input>\n{\n  \"1\": \"人生就像一场马拉松\",\n  \"2\": \"不在乎你跑得多快\",\n  \"3\": \"而在乎你能不能跑到终点\"\n}\n</input>\n<output>\n{\n  \"1\": {\n    \"initial_translation\": \"Life is like a marathon.\",\n    \"reflection\": \"The translation is accurate but feels disconnected from what follows. The original sets up a metaphor that the next two sentences develop. Consider: 1) Using an em-dash to signal continuation rather than ending with a period, 2) 'Life is a marathon' (direct metaphor) is stronger than 'like a marathon' (simile). The sentence should feel like the opening of a thought, inviting the listener to hear more.\",\n    \"native_translation\": \"Life is a marathon—\"\n  },\n  \"2\": {\n    \"initial_translation\": \"It doesn't matter how fast you run.\",\n    \"reflection\": \"Correct but breaks the flow by starting a new sentence. The original forms a parallel structure with sentence 3 (不在乎...而在乎...). To maintain this rhetorical connection: 1) Continue from the em-dash with lowercase, 2) Use 'it's not about' for better rhythm with the upcoming 'but whether', 3) End with comma to signal the contrast coming next. This creates anticipation.\",\n    \"native_translation\": \"it's not about how fast you run,\"\n  },\n  \"3\": {\n    \"initial_translation\": \"What matters is whether you can reach the finish line.\",\n    \"reflection\": \"Technically correct but 'What matters is whether you can' is wordy and loses the punch of the original's parallel structure. Improvements: 1) Use 'but' to complete the 'not about X, but Y' pattern, 2) Simplify to 'whether you finish', 3) 'That finish line' adds emotional weight—it's THE finish line you've been working toward. Reading all three together: 'Life is a marathon—it's not about how fast you run, but whether you cross that finish line.' Now it flows as one powerful statement.\",\n    \"native_translation\": \"but whether you cross that finish line.\"\n  }\n}\n</output>\n</example>\n</examples>\n\n<key_principles>\n**Eliminate machine translation:**\n\n- Avoid word-for-word translation and source language structure\n- Don't translate idioms literally\n\n**Sound native:**\n\n- Use natural expressions for the context and audience\n- Match appropriate formality level\n- For Chinese: Use 成语/俗语/网络用语 when naturally fitting\n\nGoal: Natural speech, not machine translation text.\n</key_principles>\n"
  },
  {
    "path": "app/core/prompts/translate/single.md",
    "content": "You are a professional ${target_language} translator.\nPlease translate the following text into ${target_language}.\nReturn the translation result directly without any explanation or other content.\n"
  },
  {
    "path": "app/core/prompts/translate/standard.md",
    "content": "You are a professional subtitle translator specializing in ${target_language}. Your goal is to produce translations that are natural, fluent, and easy to understand.\n\n<guidelines>\n- Translations must follow ${target_language} expression conventions, be accessible and flow naturally\n- For proper nouns or technical terms, keep the original or transliterate when appropriate\n- Use culturally appropriate expressions, idioms, and internet slang to make content relatable to the target audience\n- Strictly maintain one-to-one correspondence of subtitle numbering—do not merge or split subtitles\n- If the last sentence is incomplete, do not add ellipsis (the next subtitle will continue)\n</guidelines>\n\n<terminology_and_requirements>\n${custom_prompt}\n</terminology_and_requirements>\n\n<output_format>\n{\n  \"0\": \"Translated Subtitle 1\",\n  \"1\": \"Translated Subtitle 2\",\n  ...\n}\n</output_format>\n"
  },
  {
    "path": "app/core/split/alignment.py",
    "content": "import difflib\n\n\nclass SubtitleAligner:\n    \"\"\"\n    字幕文本对齐器，用于对齐两个文本序列,支持基于相似度的匹配。当目标文本缺少某项时,会使用其上一项进行填充。\n\n    使用示例:\n        # 输入文本\n        text1 = ['ab', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']  # 源文本\n        text2 = ['a',  'b', 'c', 'd', 'f', 'g', 'h', 'i']       # 目标文本\n\n        # 创建对齐器并执行对齐\n        text_aligner = SubtitleAligner()\n        aligned_source, aligned_target = text_aligner.align_texts(text1, text2)\n\n        # 对齐结果\n        aligned_source: ['ab', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']  # 源文本保持不变\n        aligned_target: ['a',  'b', 'c', 'd', 'd', 'f', 'g', 'h', 'i']  # 缺失的'e'由'd'填充\n    \"\"\"\n\n    def __init__(self):\n        self.line_numbers = [0, 0]\n\n    def align_texts(self, source_text, target_text):\n        \"\"\"\n        Align two texts and return the paired lines.\n\n        Args:\n            source_text (list): List of lines from the source text.\n            target_text (list): List of lines from the target text.\n\n        Returns:\n            tuple: Two lists containing aligned lines from source and target texts.\n        \"\"\"\n        diff_iterator = difflib.ndiff(source_text, target_text)\n        return self._pair_lines(diff_iterator)\n\n    def _pair_lines(self, diff_iterator):\n        \"\"\"\n        Pair lines from the diff iterator.\n\n        Args:\n            diff_iterator: Iterator from difflib.ndiff()\n\n        Returns:\n            tuple: Two lists containing aligned lines from source and target texts.\n        \"\"\"\n        source_lines = []\n        target_lines = []\n        flag = 0\n\n        for source_line, target_line, _ in self._line_iterator(diff_iterator):\n            if source_line is not None:\n                if source_line[1] == \"\\n\":\n                    flag += 1\n                    continue\n                source_lines.append(source_line[1])\n            if target_line is not None:\n                if flag > 0:\n                    flag -= 1\n                    continue\n                target_lines.append(target_line[1])\n\n        for i in range(1, len(target_lines)):\n            if target_lines[i] == \"\\n\":\n                target_lines[i] = target_lines[i - 1]\n                # target_lines[i] = source_lines[i]\n                # target_lines[i + 1] = source_lines[i + 1]\n                # target_lines[i - 1] = source_lines[i - 1]\n\n        return source_lines, target_lines\n\n    def _line_iterator(self, diff_iterator):\n        \"\"\"\n        Iterate through diff lines and yield paired lines.\n\n        Args:\n            diff_iterator: Iterator from difflib.ndiff()\n\n        Yields:\n            tuple: (source_line, target_line, has_diff)\n        \"\"\"\n        lines = []\n        blank_lines_pending = 0\n        blank_lines_to_yield = 0\n\n        while True:\n            while len(lines) < 4:\n                lines.append(next(diff_iterator, \"X\"))\n\n            diff_type = \"\".join([line[0] for line in lines])\n\n            if diff_type.startswith(\"X\"):\n                blank_lines_to_yield = blank_lines_pending\n            elif diff_type.startswith(\"-?+?\"):\n                yield (\n                    self._format_line(lines, \"?\", 0),\n                    self._format_line(lines, \"?\", 1),\n                    True,\n                )\n                continue\n            elif diff_type.startswith(\"--++\"):\n                blank_lines_pending -= 1\n                yield self._format_line(lines, \"-\", 0), None, True\n                continue\n            elif diff_type.startswith((\"--?+\", \"--+\", \"- \")):\n                source_line, target_line = self._format_line(lines, \"-\", 0), None\n                blank_lines_to_yield, blank_lines_pending = blank_lines_pending - 1, 0\n            elif diff_type.startswith(\"-+?\"):\n                yield (\n                    self._format_line(lines, None, 0),\n                    self._format_line(lines, \"?\", 1),\n                    True,\n                )\n                continue\n            elif diff_type.startswith(\"-?+\"):\n                yield (\n                    self._format_line(lines, \"?\", 0),\n                    self._format_line(lines, None, 1),\n                    True,\n                )\n                continue\n            elif diff_type.startswith(\"-\"):\n                blank_lines_pending -= 1\n                yield self._format_line(lines, \"-\", 0), None, True\n                continue\n            elif diff_type.startswith(\"+--\"):\n                blank_lines_pending += 1\n                yield None, self._format_line(lines, \"+\", 1), True\n                continue\n            elif diff_type.startswith((\"+ \", \"+-\")):\n                source_line, target_line = None, self._format_line(lines, \"+\", 1)\n                blank_lines_to_yield, blank_lines_pending = blank_lines_pending + 1, 0\n            elif diff_type.startswith(\"+\"):\n                blank_lines_pending += 1\n                yield None, self._format_line(lines, \"+\", 1), True\n                continue\n            elif diff_type.startswith(\" \"):\n                yield (\n                    self._format_line(lines[:], None, 0),\n                    self._format_line(lines, None, 1),\n                    False,\n                )\n                continue\n\n            while blank_lines_to_yield < 0:\n                blank_lines_to_yield += 1\n                yield None, (\"\", \"\\n\"), True\n            while blank_lines_to_yield > 0:\n                blank_lines_to_yield -= 1\n                yield (\"\", \"\\n\"), None, True\n\n            if diff_type.startswith(\"X\"):\n                return\n            else:\n                yield source_line, target_line, True\n\n    def _format_line(self, lines, format_key, side):\n        \"\"\"\n        Format a line with the appropriate markup.\n\n        Args:\n            lines (list): List of lines to process.\n            format_key (str): Formatting key ('?', '-', '+', or None).\n            side (int): 0 for source, 1 for target.\n\n        Returns:\n            tuple: (line_number, formatted_text)\n        \"\"\"\n        self.line_numbers[side] += 1\n        if format_key is None:\n            return self.line_numbers[side], lines.pop(0)[2:]\n        if format_key == \"?\":\n            text = lines.pop(0)\n            lines.pop(0)  # Skip markers line\n            text = text[2:]\n        else:\n            text = lines.pop(0)[2:]\n            if not text:\n                text = \"\"\n        return self.line_numbers[side], text\n\n\nif __name__ == \"__main__\":\n    # 简短示例\n    text1 = [\"ab\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\", \"i\"]\n    text2 = [\"a\", \"b\", \"c\", \"d\", \"f\", \"g\", \"h\", \"i\"]\n\n    # 使用示例\n    text_aligner = SubtitleAligner()\n\n    aligned_source, aligned_target = text_aligner.align_texts(text1, text2)\n\n    print(\"Aligned Source:\", len(aligned_source))\n    print(\"Aligned Target:\", len(aligned_target))\n    print(aligned_source)\n    print(aligned_target)\n\n    i = 1\n    for l1, l2 in zip(aligned_source, aligned_target):\n        print(f\"行 {i}:\")\n        print(f\"文本1: {l1}\")\n        print(f\"文本2: {l2}\")\n        print(difflib.SequenceMatcher(None, l1, l2).ratio())\n        print(\"----\")\n        i += 1\n\n    # d = difflib.HtmlDiff()\n    # html = d.make_file(text1, text2)\n    # with open('../output/diff.html', 'w', encoding='utf-8') as f:\n    #     f.write(html)\n"
  },
  {
    "path": "app/core/split/split.py",
    "content": "import atexit\nimport difflib\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom typing import List, Union\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.split.split_by_llm import split_by_llm\nfrom app.core.utils.logger import setup_logger\nfrom app.core.utils.text_utils import (\n    count_words,\n    is_mainly_cjk,\n    is_pure_punctuation,\n    is_space_separated_language,\n)\n\nlogger = setup_logger(\"subtitle_splitter\")\n\n# ==================== 配置常量 ====================\n\n# 字数限制\nMAX_WORD_COUNT_CJK = 25  # CJK文本单行最大字数\nMAX_WORD_COUNT_ENGLISH = 18  # 英文文本单行最大单词数\n\n# 分段阈值\nSEGMENT_WORD_THRESHOLD = 500  # 长文本分段阈值(字数)\n\n# 时间间隔\nMAX_GAP = 1500  # 允许的最大时间间隔(毫秒)\nMERGE_SHORT_GAP = 200  # 短分段合并时间阈值(毫秒)\nMERGE_VERY_SHORT_GAP = 500  # 极短分段合并时间阈值(毫秒)\n\n# 短分段合并阈值\nMERGE_MIN_WORDS = 5  # 短分段最小字数阈值\nMERGE_VERY_SHORT_WORDS = 3  # 极短分段字数阈值\n\n# 分割相关\nSPLIT_SEARCH_RANGE = 30  # 分割点前后搜索范围\nTIME_GAP_WINDOW_SIZE = 5  # 时间间隔窗口大小\nTIME_GAP_MULTIPLIER = 3  # 大间隔判断倍数\nMIN_GROUP_SIZE = 5  # 最小分组大小\n\n# 规则分割\nRULE_SPLIT_GAP = 500  # 规则分割时间间隔阈值(毫秒)\nRULE_MIN_SEGMENT_SIZE = 4  # 规则分割最小分段大小\n\n# 常见词分割\nPREFIX_WORD_RATIO = 0.6  # 前缀词分割比例\nSUFFIX_WORD_RATIO = 0.4  # 后缀词分割比例\n\n# 匹配相关\nMATCH_SIMILARITY_THRESHOLD = 0.5  # 文本匹配相似度阈值\nMATCH_MAX_SHIFT = 30  # 匹配滑动窗口最大偏移\nMATCH_MAX_UNMATCHED = 5  # 允许的最大未匹配句子数\nMATCH_LARGE_SHIFT = 100  # 未匹配时的大偏移量\n\n\ndef preprocess_segments(\n    segments: List[ASRDataSeg], need_lower: bool = True\n) -> List[ASRDataSeg]:\n    \"\"\"预处理ASR分段\n\n    1. 移除纯标点符号的分段\n    2. 为需要空格分隔的语言添加空格（英语、俄语、阿拉伯语等，不包括CJK）\n\n    Args:\n        segments: ASR数据分段列表\n        need_lower: 是否转小写（仅对拉丁和西里尔字母有效）\n\n    Returns:\n        处理后的分段列表\n    \"\"\"\n    new_segments = []\n    for seg in segments:\n        if not is_pure_punctuation(seg.text):\n            text = seg.text.strip()\n            # 检查是否为需要空格分隔的语言（不包括CJK）\n            if is_space_separated_language(text):\n                if need_lower:\n                    text = text.lower()\n                seg.text = text + \" \"\n            new_segments.append(seg)\n    return new_segments\n\n\nclass SubtitleSplitter:\n    \"\"\"字幕智能分割器\n\n    使用LLM进行语义分段,支持缓存、并发处理和规则降级。\n    \"\"\"\n\n    def __init__(\n        self,\n        thread_num,\n        model,\n        max_word_count_cjk: int = MAX_WORD_COUNT_CJK,\n        max_word_count_english: int = MAX_WORD_COUNT_ENGLISH,\n    ):\n        \"\"\"初始化分割器\n\n        Args:\n            thread_num: 并发线程数\n            model: LLM模型名称\n            max_word_count_cjk: CJK最大字数\n            max_word_count_english: 英文最大单词数\n        \"\"\"\n        self.thread_num = thread_num\n        self.model = model\n        self.max_word_count_cjk = max_word_count_cjk\n        self.max_word_count_english = max_word_count_english\n        self.is_running = True\n        self._init_thread_pool()\n\n    def _init_thread_pool(self):\n        \"\"\"初始化线程池并注册清理\"\"\"\n        self.executor = ThreadPoolExecutor(max_workers=self.thread_num)\n        atexit.register(self.stop)\n\n    def split_subtitle(self, subtitle_data: Union[str, ASRData]) -> ASRData:\n        \"\"\"分割字幕(主入口)\n\n        处理流程:\n        1. 读取并预处理字幕\n        2. 按字数分段\n        3. 并发调用LLM处理\n        4. 合并结果并优化\n\n        Args:\n            subtitle_data: 字幕文件路径或ASRData对象\n\n        Returns:\n            分割后的ASRData对象\n\n        Raises:\n            RuntimeError: 分割失败时抛出\n        \"\"\"\n        try:\n            # 1. 读取字幕\n            if isinstance(subtitle_data, str):\n                asr_data = ASRData.from_subtitle_file(subtitle_data)\n            else:\n                asr_data = subtitle_data\n\n            if not asr_data.is_word_timestamp():\n                asr_data = asr_data.split_to_word_segments()\n\n            # 2. 预处理\n            asr_data.segments = preprocess_segments(asr_data.segments, need_lower=False)\n            txt = asr_data.to_txt().replace(\"\\n\", \"\")\n\n            # 3. 确定分段数并分割\n            total_word_count = count_words(txt)\n            num_segments = self._determine_num_segments(total_word_count)\n            logger.info(f\"根据字数 {total_word_count},确定断句分段数: {num_segments}\")\n\n            asr_data_list = self._split_asr_data(asr_data, num_segments)\n\n            # 4. 并发处理\n            processed_segments = self._process_segments(asr_data_list)\n\n            # 5. 合并并优化\n            final_segments = self._merge_processed_segments(processed_segments)\n\n            return ASRData(final_segments)\n\n        except Exception as e:\n            logger.error(f\"分割失败:{str(e)}\")\n            raise RuntimeError(f\"分割失败:{str(e)}\")\n\n    def _determine_num_segments(\n        self, word_count: int, threshold: int = SEGMENT_WORD_THRESHOLD\n    ) -> int:\n        \"\"\"根据字数确定分段数\n\n        Args:\n            word_count: 总字数\n            threshold: 每段目标字数\n\n        Returns:\n            分段数(最小为1)\n        \"\"\"\n        num_segments = word_count // threshold\n        if word_count % threshold > 0:\n            num_segments += 1\n        return max(1, num_segments)\n\n    def _split_asr_data(self, asr_data: ASRData, num_segments: int) -> List[ASRData]:\n        \"\"\"按时间间隔智能分割长文本\n\n        策略:\n        1. 计算平均分割点\n        2. 在分割点附近寻找最大时间间隔\n        3. 在间隔处切分以保证语义完整\n\n        Args:\n            asr_data: ASR数据对象\n            num_segments: 目标分段数\n\n        Returns:\n            分割后的ASRData列表\n        \"\"\"\n        total_segs = len(asr_data.segments)\n        total_word_count = count_words(asr_data.to_txt())\n        words_per_segment = total_word_count // num_segments\n\n        if num_segments <= 1 or total_segs <= num_segments:\n            return [asr_data]\n\n        # 计算初始分割点\n        split_indices = [i * words_per_segment for i in range(1, num_segments)]\n\n        # 调整分割点:在附近寻找最大时间间隔\n        adjusted_split_indices = []\n        for split_point in split_indices:\n            start = max(0, split_point - SPLIT_SEARCH_RANGE)\n            end = min(total_segs - 1, split_point + SPLIT_SEARCH_RANGE)\n\n            # 寻找最大间隔点\n            max_gap = -1\n            best_index = split_point\n\n            for j in range(start, end):\n                gap = (\n                    asr_data.segments[j + 1].start_time - asr_data.segments[j].end_time\n                )\n                if gap > max_gap:\n                    max_gap = gap\n                    best_index = j\n\n            adjusted_split_indices.append(best_index)\n\n        # 去重并排序\n        adjusted_split_indices = sorted(list(set(adjusted_split_indices)))\n\n        # 执行分割\n        segments = []\n        prev_index = 0\n        for index in adjusted_split_indices:\n            part = ASRData(asr_data.segments[prev_index : index + 1])\n            segments.append(part)\n            prev_index = index + 1\n\n        if prev_index < total_segs:\n            part = ASRData(asr_data.segments[prev_index:])\n            segments.append(part)\n\n        return segments\n\n    def _process_segments(self, asr_data_list: List[ASRData]) -> List[List[ASRDataSeg]]:\n        \"\"\"并发处理所有分段\"\"\"\n        futures = []\n        for asr_data in asr_data_list:\n            if not self.executor:\n                raise ValueError(\"线程池未初始化\")\n            future = self.executor.submit(self._process_single_segment, asr_data)\n            futures.append(future)\n\n        processed_segments = []\n        for future in as_completed(futures):\n            if not self.is_running:\n                break\n            try:\n                result = future.result()\n                processed_segments.append(result)\n            except Exception as e:\n                logger.error(f\"处理分段失败:{str(e)}\")\n\n        return processed_segments\n\n    def _process_single_segment(self, asr_data_part: ASRData) -> List[ASRDataSeg]:\n        \"\"\"处理单个分段(带重试和降级)\"\"\"\n        if not asr_data_part.segments:\n            return []\n        try:\n            return self._process_by_llm(asr_data_part.segments)\n        except Exception as e:\n            logger.warning(f\"LLM处理失败,使用规则降级: {str(e)}\")\n            return self._process_by_rules(asr_data_part.segments)\n\n    def _process_by_llm(self, segments: List[ASRDataSeg]) -> List[ASRDataSeg]:\n        \"\"\"使用LLM进行智能分段\n\n        Args:\n            segments: ASR分段列表\n\n        Returns:\n            处理后的分段列表\n        \"\"\"\n        txt = \"\".join([seg.text for seg in segments])\n        logger.info(f\"开始调用API进行分段,文本长度: {count_words(txt)}\")\n\n        sentences = split_by_llm(\n            text=txt,\n            model=self.model,\n            max_word_count_cjk=self.max_word_count_cjk,\n            max_word_count_english=self.max_word_count_english,\n        )\n\n        return self._merge_segments_based_on_sentences(segments, sentences)\n\n    def _process_by_rules(self, segments: List[ASRDataSeg]) -> List[ASRDataSeg]:\n        \"\"\"使用规则进行基础分割(LLM降级方案)\n\n        规则:\n        1. 按时间间隔分组\n        2. 按常见词分割长句\n        3. 拆分超长分段\n\n        Args:\n            segments: ASR分段列表\n\n        Returns:\n            处理后的分段列表\n        \"\"\"\n        logger.info(f\"分段: {len(segments)}\")\n\n        # 1. 按时间间隔分组\n        segment_groups = self._group_by_time_gaps(\n            segments, max_gap=RULE_SPLIT_GAP, check_large_gaps=True\n        )\n        logger.info(f\"按时间间隔分组: {len(segment_groups)}\")\n\n        # 2. 按常见词分割长句\n        common_result_groups = []\n        for group in segment_groups:\n            max_word_count = (\n                self.max_word_count_cjk\n                if is_mainly_cjk(\"\".join(seg.text for seg in group))\n                else self.max_word_count_english\n            )\n            if count_words(\"\".join(seg.text for seg in group)) > max_word_count:\n                split_groups = self._split_by_common_words(group)\n                common_result_groups.extend(split_groups)\n            else:\n                common_result_groups.append(group)\n\n        # 3. 拆分超长分段\n        result_segments = []\n        for group in common_result_groups:\n            result_segments.extend(self._split_long_segment(group))\n\n        return result_segments\n\n    def _group_by_time_gaps(\n        self,\n        segments: List[ASRDataSeg],\n        max_gap: int = MAX_GAP,\n        check_large_gaps: bool = False,\n    ) -> List[List[ASRDataSeg]]:\n        \"\"\"按时间间隔分组\n\n        Args:\n            segments: 分段列表\n            max_gap: 最大允许间隔(ms)\n            check_large_gaps: 是否检查异常大间隔\n\n        Returns:\n            分组后的列表\n        \"\"\"\n        if not segments:\n            return []\n\n        result = []\n        current_group = [segments[0]]\n        recent_gaps = []\n\n        for i in range(1, len(segments)):\n            time_gap = segments[i].start_time - segments[i - 1].end_time\n\n            # 检查异常大间隔\n            if check_large_gaps:\n                recent_gaps.append(time_gap)\n                if len(recent_gaps) > TIME_GAP_WINDOW_SIZE:\n                    recent_gaps.pop(0)\n                if len(recent_gaps) == TIME_GAP_WINDOW_SIZE:\n                    avg_gap = sum(recent_gaps) / len(recent_gaps)\n                    if (\n                        time_gap > avg_gap * TIME_GAP_MULTIPLIER\n                        and len(current_group) > MIN_GROUP_SIZE\n                    ):\n                        result.append(current_group)\n                        current_group = []\n                        recent_gaps = []\n\n            # 超过最大间隔则分组\n            if time_gap > max_gap:\n                result.append(current_group)\n                current_group = []\n                recent_gaps = []\n\n            current_group.append(segments[i])\n\n        if current_group:\n            result.append(current_group)\n\n        return result\n\n    def _split_by_common_words(\n        self, segments: List[ASRDataSeg]\n    ) -> List[List[ASRDataSeg]]:\n        \"\"\"在常见连接词处分割\n\n        Args:\n            segments: ASR分段列表\n\n        Returns:\n            分割后的分组列表\n        \"\"\"\n        # 前缀分割词(在这些词前面分割)\n        prefix_split_words = {\n            # 英文\n            \"and\",\n            \"or\",\n            \"but\",\n            \"if\",\n            \"then\",\n            \"because\",\n            \"as\",\n            \"until\",\n            \"while\",\n            \"what\",\n            \"when\",\n            \"where\",\n            \"nor\",\n            \"yet\",\n            \"so\",\n            \"for\",\n            \"however\",\n            \"moreover\",\n            # 中文\n            \"和\",\n            \"及\",\n            \"与\",\n            \"但\",\n            \"而\",\n            \"或\",\n            \"因\",\n            \"我\",\n            \"你\",\n            \"他\",\n            \"她\",\n            \"它\",\n            \"咱\",\n            \"您\",\n            \"这\",\n            \"那\",\n            \"哪\",\n        }\n\n        # 后缀分割词(在这些词后面分割)\n        suffix_split_words = {\n            # 标点\n            \".\",\n            \",\",\n            \"!\",\n            \"?\",\n            \"。\",\n            \"，\",\n            \"！\",\n            \"？\",\n            # 中文语气词\n            \"的\",\n            \"了\",\n            \"着\",\n            \"过\",\n            \"吗\",\n            \"呢\",\n            \"吧\",\n            \"啊\",\n            \"呀\",\n            \"嘛\",\n            \"啦\",\n            # 英文代词\n            \"mine\",\n            \"yours\",\n            \"hers\",\n            \"its\",\n            \"ours\",\n            \"theirs\",\n            \"either\",\n            \"neither\",\n        }\n\n        result = []\n        current_group = []\n\n        for i, seg in enumerate(segments):\n            max_word_count = (\n                self.max_word_count_cjk\n                if is_mainly_cjk(seg.text)\n                else self.max_word_count_english\n            )\n\n            # 前缀词分割\n            if any(\n                seg.text.lower().startswith(word) for word in prefix_split_words\n            ) and len(current_group) >= int(max_word_count * PREFIX_WORD_RATIO):\n                result.append(current_group)\n                logger.debug(f\"在前缀词 {seg.text} 前分割\")\n                current_group = []\n\n            # 后缀词分割\n            if (\n                i > 0\n                and any(\n                    segments[i - 1].text.lower().endswith(word)\n                    for word in suffix_split_words\n                )\n                and len(current_group) >= int(max_word_count * SUFFIX_WORD_RATIO)\n            ):\n                result.append(current_group)\n                logger.debug(f\"在后缀词 {segments[i - 1].text} 后分割\")\n                current_group = []\n\n            current_group.append(seg)\n\n        if current_group:\n            result.append(current_group)\n\n        return result\n\n    def _split_long_segment(self, segments: List[ASRDataSeg]) -> List[ASRDataSeg]:\n        \"\"\"拆分超长分段\n\n        策略:寻找最大时间间隔点进行拆分\n\n        Args:\n            segments: 分段列表\n\n        Returns:\n            拆分后的分段列表\n        \"\"\"\n        result_segs = []\n        segments_to_process = [segments]\n\n        while segments_to_process:\n            current_segments = segments_to_process.pop(0)\n\n            if not current_segments:\n                continue\n\n            merged_text = \"\".join(seg.text for seg in current_segments)\n            max_word_count = (\n                self.max_word_count_cjk\n                if is_mainly_cjk(merged_text)\n                else self.max_word_count_english\n            )\n            n = len(current_segments)\n\n            # 分段足够短或无法继续拆分\n            if count_words(merged_text) <= max_word_count or n < RULE_MIN_SEGMENT_SIZE:\n                merged_seg = ASRDataSeg(\n                    merged_text.strip(),\n                    current_segments[0].start_time,\n                    current_segments[-1].end_time,\n                )\n                result_segs.append(merged_seg)\n                continue\n\n            # 检查时间间隔\n            gaps = [\n                current_segments[i + 1].start_time - current_segments[i].end_time\n                for i in range(n - 1)\n            ]\n            all_equal = all(abs(gap - gaps[0]) < 1e-6 for gap in gaps)\n\n            if all_equal:\n                # 间隔相等:中间分割\n                split_index = n // 2\n            else:\n                # 间隔不等:寻找最大间隔点\n                start_idx = max(n // 6, 1)\n                end_idx = min((5 * n) // 6, n - 2)\n                split_index = max(\n                    range(start_idx, end_idx),\n                    key=lambda i: current_segments[i + 1].start_time\n                    - current_segments[i].end_time,\n                    default=n // 2,\n                )\n                if split_index == 0 or split_index == n - 1:\n                    split_index = n // 2\n\n            # 分割并加入处理队列\n            first_segs = current_segments[: split_index + 1]\n            second_segs = current_segments[split_index + 1 :]\n            segments_to_process.extend([first_segs, second_segs])\n\n        # 按时间排序\n        result_segs.sort(key=lambda seg: seg.start_time)\n        return result_segs\n\n    def _merge_processed_segments(\n        self, processed_segments: List[List[ASRDataSeg]]\n    ) -> List[ASRDataSeg]:\n        \"\"\"合并所有处理后的分段并排序\"\"\"\n        final_segments = []\n        for segments in processed_segments:\n            final_segments.extend(segments)\n\n        final_segments.sort(key=lambda seg: seg.start_time)\n        return final_segments\n\n    def merge_short_segment(self, segments: List[ASRDataSeg]) -> None:\n        \"\"\"deprecated\n        合并短分段优化\n\n        合并条件:\n        1. 时间间隔小 + 字数少\n        2. 合并后不超过最大字数限制\n\n        Args:\n            segments: 分段列表(原地修改)\n        \"\"\"\n        if not segments:\n            return\n\n        i = 0\n        while i < len(segments) - 1:\n            current_seg = segments[i]\n            next_seg = segments[i + 1]\n\n            time_gap = abs(next_seg.start_time - current_seg.end_time)\n            current_words = count_words(current_seg.text)\n            next_words = count_words(next_seg.text)\n            total_words = current_words + next_words\n            max_word_count = (\n                self.max_word_count_cjk\n                if is_mainly_cjk(current_seg.text)\n                else self.max_word_count_english\n            )\n\n            # 判断是否合并\n            should_merge = (\n                time_gap < MERGE_SHORT_GAP\n                and (current_words < MERGE_MIN_WORDS or next_words < MERGE_MIN_WORDS)\n                and total_words <= max_word_count\n            ) or (\n                time_gap < MERGE_VERY_SHORT_GAP\n                and (\n                    current_words < MERGE_VERY_SHORT_WORDS\n                    or next_words < MERGE_VERY_SHORT_WORDS\n                )\n                and total_words <= max_word_count\n            )\n\n            if should_merge:\n                logger.debug(\n                    f\"合并短分段: {current_seg.text} + {next_seg.text} (间隔:{time_gap}ms)\"\n                )\n\n                # 合并文本\n                if is_mainly_cjk(current_seg.text):\n                    current_seg.text += next_seg.text\n                else:\n                    current_seg.text += \" \" + next_seg.text\n                current_seg.end_time = next_seg.end_time\n\n                segments.pop(i + 1)\n            else:\n                i += 1\n\n    def _merge_segments_based_on_sentences(\n        self,\n        segments: List[ASRDataSeg],\n        sentences: List[str],\n        max_unmatched: int = MATCH_MAX_UNMATCHED,\n    ) -> List[ASRDataSeg]:\n        \"\"\"基于LLM返回的句子列表合并ASR分段\n\n        使用滑动窗口匹配算法:\n        1. 对每个LLM句子,寻找最佳匹配的ASR分段序列\n        2. 使用相似度算法进行匹配\n        3. 合并匹配的分段\n\n        Args:\n            segments: ASR分段列表\n            sentences: LLM返回的句子列表\n            max_unmatched: 允许的最大未匹配句子数\n\n        Returns:\n            合并后的分段列表\n\n        Raises:\n            ValueError: 未匹配句子数超过阈值时\n        \"\"\"\n\n        def preprocess_text(s: str) -> str:\n            \"\"\"文本标准化:小写+空格规范化\"\"\"\n            return \" \".join(s.lower().split())\n\n        asr_texts = [seg.text for seg in segments]\n        asr_len = len(asr_texts)\n        asr_index = 0\n        threshold = MATCH_SIMILARITY_THRESHOLD\n        max_shift = MATCH_MAX_SHIFT\n        unmatched_count = 0\n\n        new_segments = []\n\n        for sentence in sentences:\n            logger.debug(\"==========\")\n            logger.debug(f\"处理句子: {sentence}\")\n            logger.debug(\"后续句子:\" + \"\".join(asr_texts[asr_index : asr_index + 10]))\n\n            sentence_proc = preprocess_text(sentence)\n            word_count = count_words(sentence_proc)\n            best_ratio = 0.0\n            best_pos = None\n            best_window_size = 0\n\n            # 滑动窗口大小\n            max_window_size = min(word_count * 2, asr_len - asr_index)\n            min_window_size = max(1, word_count // 2)\n            window_sizes = sorted(\n                range(min_window_size, max_window_size + 1),\n                key=lambda x: abs(x - word_count),\n            )\n\n            # 滑动窗口匹配\n            for window_size in window_sizes:\n                max_start = min(asr_index + max_shift + 1, asr_len - window_size + 1)\n                for start in range(asr_index, max_start):\n                    substr = \"\".join(asr_texts[start : start + window_size])\n                    substr_proc = preprocess_text(substr)\n                    ratio = difflib.SequenceMatcher(\n                        None, sentence_proc, substr_proc\n                    ).ratio()\n\n                    if ratio > best_ratio:\n                        best_ratio = ratio\n                        best_pos = start\n                        best_window_size = window_size\n                    if ratio == 1.0:\n                        break\n                if best_ratio == 1.0:\n                    break\n\n            # 处理匹配结果\n            if best_ratio >= threshold and best_pos is not None:\n                start_seg_index = best_pos\n                end_seg_index = best_pos + best_window_size - 1\n\n                segs_to_merge = segments[start_seg_index : end_seg_index + 1]\n\n                # 按时间切分避免跨度过大\n                seg_groups = self._group_by_time_gaps(segs_to_merge, max_gap=MAX_GAP)\n\n                for group in seg_groups:\n                    merged_text = \"\".join(seg.text for seg in group)\n                    merged_start_time = group[0].start_time\n                    merged_end_time = group[-1].end_time\n                    merged_seg = ASRDataSeg(\n                        merged_text, merged_start_time, merged_end_time\n                    )\n\n                    logger.debug(f\"合并分段: {merged_seg.text}\")\n\n                    # 拆分超长分段\n                    split_segs = self._split_long_segment(group)\n                    new_segments.extend(split_segs)\n\n                max_shift = MATCH_MAX_SHIFT\n                asr_index = end_seg_index + 1\n            else:\n                logger.warning(f\"无法匹配句子: {sentence}\")\n                unmatched_count += 1\n                if unmatched_count > max_unmatched:\n                    raise ValueError(f\"未匹配句子数超过阈值 {max_unmatched},处理终止\")\n                max_shift = MATCH_LARGE_SHIFT\n                asr_index = min(asr_index + 1, asr_len - 1)\n\n        return new_segments\n\n    def stop(self):\n        \"\"\"停止分割器并清理资源\"\"\"\n        if not self.is_running:\n            return\n        self.is_running = False\n        if hasattr(self, \"executor\") and self.executor is not None:\n            try:\n                self.executor.shutdown(wait=False, cancel_futures=True)\n            except Exception as e:\n                logger.error(f\"关闭线程池时出错:{str(e)}\")\n            finally:\n                self.executor = None\n"
  },
  {
    "path": "app/core/split/split_by_llm.py",
    "content": "import difflib\nimport re\nfrom typing import List, Tuple\n\nfrom ..llm import call_llm\nfrom ..prompts import get_prompt\nfrom ..utils.logger import setup_logger\nfrom ..utils.text_utils import count_words, is_mainly_cjk\n\nlogger = setup_logger(\"split_by_llm\")\n\nMAX_WORD_COUNT = 20  # 英文单词或中文字符的最大数量\nMAX_STEPS = 2  # Agent loop最大尝试次数\n\n\ndef split_by_llm(\n    text: str,\n    model: str = \"gpt-4o-mini\",\n    max_word_count_cjk: int = 18,\n    max_word_count_english: int = 12,\n) -> List[str]:\n    \"\"\"使用LLM进行文本断句（固定使用句子分段）\n\n    Args:\n        text: 待断句的文本\n        model: LLM模型名称\n        max_word_count_cjk: 中文最大字符数\n        max_word_count_english: 英文最大单词数\n\n    Returns:\n        断句后的文本列表\n    \"\"\"\n    try:\n        return _split_with_agent_loop(\n            text, model, max_word_count_cjk, max_word_count_english\n        )\n    except Exception as e:\n        logger.error(f\"断句失败: {e}\")\n        return [text]\n\n\ndef _split_with_agent_loop(\n    text: str,\n    model: str,\n    max_word_count_cjk: int,\n    max_word_count_english: int,\n) -> List[str]:\n    \"\"\"使用agent loop 建立反馈循环进行文本断句，自动验证和修正\"\"\"\n    prompt_path = \"split/sentence\"\n    system_prompt = get_prompt(\n        prompt_path,\n        max_word_count_cjk=max_word_count_cjk,\n        max_word_count_english=max_word_count_english,\n    )\n\n    user_prompt = (\n        f\"Please use multiple <br> tags to separate the following sentence:\\n{text}\"\n    )\n\n    messages = [\n        {\"role\": \"system\", \"content\": system_prompt},\n        {\"role\": \"user\", \"content\": user_prompt},\n    ]\n\n    last_result = None\n\n    for step in range(MAX_STEPS):\n        response = call_llm(\n            messages=messages,\n            model=model,\n            temperature=0.1,\n        )\n\n        result_text = response.choices[0].message.content\n\n        # 解析结果\n        result_text_cleaned = re.sub(r\"\\n+\", \"\", result_text)\n        split_result = [\n            segment.strip()\n            for segment in result_text_cleaned.split(\"<br>\")\n            if segment.strip()\n        ]\n        last_result = split_result\n\n        # 验证结果\n        is_valid, error_message = _validate_split_result(\n            original_text=text,\n            split_result=split_result,\n            max_word_count_cjk=max_word_count_cjk,\n            max_word_count_english=max_word_count_english,\n        )\n\n        if is_valid:\n            return split_result\n\n        # 添加反馈到对话\n        logger.warning(\n            f\"模型输出错误，断句验证失败，频繁出现建议更换更智能的模型或者调整最大字数限制。开始反馈循环 (第{step + 1}次尝试):\\n {error_message}\\n\\n\"\n        )\n        messages.append({\"role\": \"assistant\", \"content\": result_text})\n        messages.append(\n            {\n                \"role\": \"user\",\n                \"content\": f\"Error: {error_message}\\nFix the errors above and output the COMPLETE corrected text with <br> tags (include ALL segments, not just the fixed ones), no explanation.\",\n            }\n        )\n\n    return last_result if last_result else [text]\n\n\ndef _validate_split_result(\n    original_text: str,\n    split_result: List[str],\n    max_word_count_cjk: int,\n    max_word_count_english: int,\n) -> Tuple[bool, str]:\n    \"\"\"验证断句结果：内容一致性、分段数量、长度限制\n\n    返回: (是否有效, 错误反馈)\n    \"\"\"\n    # 检查是否为空\n    if not split_result:\n        return False, \"No segments found. Split the text with <br> tags.\"\n\n    # 检查内容是否被修改（使用difflib精确定位差异）\n    original_cleaned = re.sub(r\"\\s+\", \" \", original_text)\n    text_is_cjk = is_mainly_cjk(original_cleaned)\n\n    merged_char = \"\" if text_is_cjk else \" \"\n    merged = merged_char.join(split_result)\n    merged_cleaned = re.sub(r\"\\s+\", \" \", merged)\n\n    # 使用SequenceMatcher计算相似度和差异\n    matcher = difflib.SequenceMatcher(None, original_cleaned, merged_cleaned)\n    similarity_ratio = matcher.ratio()\n\n    # 允许98%以上的相似度（容忍少量标点或空格差异）\n    if similarity_ratio < 0.96:\n        differences = []\n        context_size = 5 if text_is_cjk else 20\n\n        for opcode, a0, a1, b0, b1 in matcher.get_opcodes():\n            if opcode == \"replace\":\n                # 获取前后文\n                before = original_cleaned[max(0, a0 - context_size) : a0]\n                orig_part = original_cleaned[a0:a1]\n                after = original_cleaned[a1 : a1 + context_size]\n\n                new_part = merged_cleaned[b0:b1]\n\n                if orig_part.isspace() or new_part.isspace():\n                    continue\n\n                differences.append(\n                    f\"...{before}[{orig_part}]{after}... → changed to [{new_part}]\"\n                )\n\n            elif opcode == \"delete\":\n                before = original_cleaned[max(0, a0 - context_size) : a0]\n                deleted_part = original_cleaned[a0:a1]\n                after = original_cleaned[a1 : a1 + context_size]\n\n                if deleted_part.isspace():\n                    continue\n\n                differences.append(f\"...{before}[{deleted_part}]{after}... → deleted\")\n\n            elif opcode == \"insert\":\n                # 对于插入，显示插入位置的上下文\n                before = merged_cleaned[max(0, b0 - context_size) : b0]\n                inserted_part = merged_cleaned[b0:b1]\n                after = merged_cleaned[b1 : b1 + context_size]\n\n                if inserted_part.isspace():\n                    continue\n\n                differences.append(\n                    f\"Wrongly inserted [{inserted_part}] between '...{before}' and '{after}...'\"\n                )\n\n        if differences:\n            error_msg = f\"Content modified (similarity: {similarity_ratio:.1%}):\\n\"\n            error_msg += \"\\n\".join(f\"- {diff}\" for diff in differences)\n            error_msg += (\n                \"\\nKeep original text unchanged, only insert <br> between words.\"\n            )\n            return False, error_msg\n\n    # 检查每段长度是否超限\n    violations = []\n    for i, segment in enumerate(split_result, 1):\n        word_count = count_words(segment)\n\n        max_allowed = max_word_count_cjk if text_is_cjk else max_word_count_english\n        tolerance = max_allowed * 1  # 0容差\n\n        if word_count > tolerance:\n            segment_preview = segment[:40] + \"...\" if len(segment) > 40 else segment\n            violations.append(\n                f\"Segment {i} '{segment_preview}': {word_count} {'chars' if text_is_cjk else 'words'} > {max_allowed} limit\"\n            )\n\n    if violations:\n        error_msg = \"Length violations:\\n\" + \"\\n\".join(f\"- {v}\" for v in violations)\n        error_msg += \"\\n\\nSplit these long segments further with <br>, then output the COMPLETE text with ALL segments (not just the fixed ones).\"\n        return False, error_msg\n\n    return True, \"\"\n\n\nif __name__ == \"__main__\":\n    sample_text = \"大家好我叫杨玉溪来自有着良好音乐氛围的福建厦门自记事起我眼中的世界就是朦胧的童话书是各色杂乱的线条电视机是颜色各异的雪花小伙伴是只听其声不便骑行的马赛克后来我才知道这是一种眼底黄斑疾病虽不至于失明但终身无法治愈\"\n    sentences = split_by_llm(sample_text)\n    print(f\"断句结果 ({len(sentences)} 段):\")\n    for i, seg in enumerate(sentences, 1):\n        print(f\"  {i}. {seg}\")\n"
  },
  {
    "path": "app/core/subtitle/README.md",
    "content": "# 字幕渲染模块\n\n提供两种字幕渲染方式：\n- **ASS 样式**：FFmpeg + libass 渲染（支持 CUDA 加速）\n- **圆角背景**：PIL 绘制现代风格字幕（带圆角矩形背景）\n\n## 模块结构\n\n```\napp/core/subtitle/\n├── __init__.py           # 统一导出接口\n├── ass_renderer.py       # ASS 渲染器（视频合成、预览）\n├── ass_utils.py          # ASS 解析和处理（dataclass 化）\n├── rounded_renderer.py   # 圆角背景渲染器\n├── styles.py             # 样式配置（RoundedBgStyle）\n├── font_utils.py         # 字体管理（内置/系统字体，LRU 缓存）\n└── text_utils.py         # 文本处理（平衡换行算法）\n```\n\n## 快速使用\n\n### 1. ASS 解析\n\n```python\nfrom app.core.subtitle import parse_ass_info, auto_wrap_ass_file\n\n# 解析 ASS 文件（返回类型安全的 dataclass）\nass_info = parse_ass_info(ass_content)\nprint(f\"分辨率: {ass_info.video_width}x{ass_info.video_height}\")\nfor style in ass_info.styles.values():\n    print(f\"{style.name}: {style.font_name} {style.font_size}px\")\n\n# 智能换行（基于实际字体渲染宽度）\nauto_wrap_ass_file(\"input.ass\", video_width=1920)\n```\n\n### 2. 圆角背景渲染\n\n```python\nfrom app.core.subtitle import render_rounded_video, RoundedBgStyle\n\nstyle = RoundedBgStyle(\n    font_name=\"Noto Sans SC\",\n    font_size=52,\n    bg_color=\"#191919C8\",      # 半透明深灰\n    text_color=\"#FFFFFF\",\n    corner_radius=12,\n    letter_spacing=2,          # 字符间距\n)\n\nrender_rounded_video(\n    video_path=\"input.mp4\",\n    asr_data=asr_data,\n    output_path=\"output.mp4\",\n    style=style,\n)\n```\n\n### 3. 字体和文本工具\n\n```python\nfrom app.core.subtitle import get_font, get_ass_to_pil_ratio, wrap_text\n\n# 获取字体（内置字体优先，系统字体后备）\nfont = get_font(52, \"Noto Sans SC\")\n\n# ASS 到 PIL 字体大小转换\nratio = get_ass_to_pil_ratio(\"Noto Sans SC\")  # ≈ 1.448\npil_size = int(74 / ratio)  # ASS 74px → PIL 51px\n\n# 平衡文本换行（每行长度更均衡）\nlines = wrap_text(text, font, max_width=1216)\n```\n\n## 核心特性\n\n### 精确换行\n- **实际渲染宽度**：使用 PIL 真实字体渲染，而非估算字符宽度\n- **平衡算法**：先计算最小行数，再平均分配字符，避免最后一行过短\n- **语言自适应**：CJK 按字符拆分，英文按单词拆分\n\n### 字体管理\n- **内置字体优先**：`resource/fonts/` 目录的字体优先加载\n- **系统字体后备**：自动检测 macOS/Windows/Linux 系统字体\n- **跨平台解析**：使用 `fontTools` 提取字体家族名\n- **LRU 缓存**：`@lru_cache` 装饰器优化性能\n\n### ASS 字体大小转换\n- **问题**：ASS 使用 Windows 行高（usWinAscent + usWinDescent），PIL 使用 em 方块（unitsPerEm）\n- **解决**：`get_ass_to_pil_ratio()` 自动读取字体度量，计算转换比例（通常 1.4-1.5）\n- **效果**：ASS 74px ≈ PIL 51px（Noto Sans SC），换行准确率显著提升\n\n## 技术难点与解决方案\n\n### 1. ASS 文本提前换行问题\n**现象**：直接用 ASS 字号加载 PIL 字体测量宽度，导致换行过早  \n**原因**：ASS 和 PIL 对 font size 的解释不同（单位不同）  \n**方案**：\n- 从字体文件读取 `unitsPerEm` 和 Windows 行高\n- 计算转换比例：`ratio = (usWinAscent + usWinDescent) / unitsPerEm`\n- 使用转换后的字号：`pil_size = ass_size / ratio`\n\n### 2. 字幕行长度不均衡\n**现象**：贪心换行导致\"第1行很长，第2行很短\"  \n**原因**：每行尽可能多地放字符，未考虑整体平衡  \n**方案**：\n- 先用贪心算法计算最小行数\n- 计算目标宽度：`target = total_width / num_lines`\n- 当前行达到 90% 目标宽度且下一个字符会超 110% 时提前换行\n- 平衡度从 50% 提升到 96%\n\n### 3. 类型安全与代码简洁\n**问题**：字典 + 元组 + 手动缓存导致代码复杂  \n**方案**：\n- 使用 `@dataclass` 替代字典和元组（`AssInfo`, `AssStyle`）\n- 使用 `@lru_cache` 替代手动缓存管理\n- 返回值类型明确（`AssInfo` 而非 `tuple[int, Dict[...]]`）\n\n## 注意事项\n\n1. **字体文件路径**：内置字体放在 `resource/fonts/`，优先级高于系统字体\n2. **ASS 样式换行**：使用 `\\q2` 禁用 libass 自动换行，完全由我们控制换行位置\n3. **文本宽度计算**：默认使用 95% 视频宽度（`video_width * 0.95`）作为最大文本宽度\n4. **字体度量缓存**：`get_ass_to_pil_ratio()` 结果已缓存，重复调用无性能损失\n5. **圆角背景字间距**：`letter_spacing > 0` 时逐字符绘制，`= 0` 时整体绘制（性能更好）\n"
  },
  {
    "path": "app/core/subtitle/__init__.py",
    "content": "\"\"\"Subtitle rendering module (ASS and rounded background styles)\"\"\"\n\nfrom typing import Optional\n\nfrom app.config import SUBTITLE_STYLE_PATH\n\nfrom .ass_renderer import render_ass_preview, render_ass_video\nfrom .ass_utils import (\n    AssInfo,\n    AssStyle,\n    auto_wrap_ass_file,\n    parse_ass_info,\n    wrap_ass_text,\n)\nfrom .font_utils import (\n    FontType,\n    clear_font_cache,\n    get_ass_to_pil_ratio,\n    get_builtin_fonts,\n    get_font,\n)\nfrom .rounded_renderer import render_preview, render_rounded_video\nfrom .styles import RoundedBgStyle\nfrom .text_utils import hex_to_rgba, is_mainly_cjk, wrap_text\n\n\ndef get_subtitle_style(style_name: str) -> Optional[str]:\n    \"\"\"Get subtitle style content\"\"\"\n    style_path = SUBTITLE_STYLE_PATH / f\"{style_name}.txt\"\n    if style_path.exists():\n        return style_path.read_text(encoding=\"utf-8\")\n    return None\n\n\n__all__ = [\n    \"render_ass_video\",\n    \"render_ass_preview\",\n    \"auto_wrap_ass_file\",\n    \"parse_ass_info\",\n    \"wrap_ass_text\",\n    \"AssInfo\",\n    \"AssStyle\",\n    \"render_preview\",\n    \"render_rounded_video\",\n    \"RoundedBgStyle\",\n    \"get_subtitle_style\",\n    \"FontType\",\n    \"get_font\",\n    \"get_ass_to_pil_ratio\",\n    \"get_builtin_fonts\",\n    \"clear_font_cache\",\n    \"hex_to_rgba\",\n    \"is_mainly_cjk\",\n    \"wrap_text\",\n]\n"
  },
  {
    "path": "app/core/subtitle/ass_renderer.py",
    "content": "\"\"\"ASS subtitle renderer\"\"\"\n\nimport os\nimport re\nimport subprocess\nimport tempfile\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Callable, Optional, Tuple\n\nfrom PIL import Image\n\nfrom app.config import CACHE_PATH, FONTS_PATH, RESOURCE_PATH\nfrom app.core.entities import SubtitleLayoutEnum\nfrom app.core.utils.logger import setup_logger\n\nfrom .ass_utils import auto_wrap_ass_file\n\nif TYPE_CHECKING:\n    from app.core.asr.asr_data import ASRData\n\nlogger = setup_logger(\"subtitle.ass\")\nASS_TEMPLATE = \"\"\"[Script Info]\n; Script generated by VideoCaptioner\nScriptType: v4.00+\nPlayResX: {video_width}\nPlayResY: {video_height}\n\n{style_str}\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n{dialogue}\n\"\"\"\n\n\ndef _check_cuda_available() -> bool:\n    \"\"\"检查 CUDA 是否可用\"\"\"\n    try:\n        # 检查 ffmpeg 是否支持 cuda\n        result = subprocess.run(\n            [\"ffmpeg\", \"-hwaccels\"],\n            capture_output=True,\n            text=True,\n            creationflags=(\n                getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n            ),\n        )\n        if \"cuda\" not in result.stdout.lower():\n            return False\n\n        # 进一步检查 CUDA 设备信息\n        result = subprocess.run(\n            [\"ffmpeg\", \"-hide_banner\", \"-init_hw_device\", \"cuda\"],\n            capture_output=True,\n            text=True,\n            creationflags=(\n                getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n            ),\n        )\n\n        # 如果 stderr 中包含错误信息，说明 CUDA 不可用\n        if any(\n            error in result.stderr.lower()\n            for error in [\"cannot load cuda\", \"failed to load\", \"error\"]\n        ):\n            return False\n\n        return True\n\n    except Exception as e:\n        logger.exception(f\"Check CUDA available error: {str(e)}\")\n        return False\n\n\ndef _scale_ass_style(style_str: str, scale_factor: float) -> str:\n    \"\"\"\n    缩放 ASS 样式中的数值参数\n\n    Args:\n        style_str: 原始 ASS 样式字符串（720P）\n        scale_factor: 缩放因子\n\n    Returns:\n        缩放后的 ASS 样式字符串\n    \"\"\"\n    if scale_factor == 1.0:\n        return style_str\n\n    lines = style_str.split(\"\\n\")\n    scaled_lines = []\n\n    for line in lines:\n        if line.startswith(\"Style:\"):\n            parts = line.split(\",\")\n            if len(parts) >= 23:\n                # parts[2]: Fontsize\n                parts[2] = str(int(float(parts[2]) * scale_factor))\n                # parts[13]: Spacing\n                parts[13] = str(float(parts[13]) * scale_factor)\n                # parts[16]: Outline\n                parts[16] = str(float(parts[16]) * scale_factor)\n                # parts[21]: MarginV (垂直间距)\n                parts[21] = str(int(float(parts[21]) * scale_factor))\n                line = \",\".join(parts)\n        scaled_lines.append(line)\n\n    return \"\\n\".join(scaled_lines)\n\n\ndef render_ass_preview(\n    style_str: str,\n    preview_text: Tuple[str, Optional[str]],\n    bg_image_path: str,\n    width: Optional[int] = None,\n    height: Optional[int] = None,\n    reference_height: int = 720,\n) -> str:\n    \"\"\"\n    生成 ASS 样式字幕预览图\n\n    Args:\n        style_str: ASS 样式字符串（包含 PlayResY）\n        preview_text: (原文, 译文) 元组，译文可以为 None\n        bg_image_path: 背景图片路径\n        width: 图片宽度（None=从bg_image_path自动获取）\n        height: 图片高度（None=从bg_image_path自动获取）\n        reference_height: 参考高度（固定720P）\n    Returns:\n        生成的预览图路径\n    \"\"\"\n    # 自动获取图片尺寸\n    if width is None or height is None:\n        bg_path = Path(bg_image_path)\n        if bg_path.exists():\n            with Image.open(bg_path) as img:\n                actual_width, actual_height = img.size\n                width = width or actual_width\n                height = height or actual_height\n        else:\n            width = width or 1920\n            height = height or 1080\n\n    original_text, translate_text = preview_text\n\n    # 构建对话行\n    if translate_text:\n        dialogue = [\n            f\"Dialogue: 0,0:00:00.00,0:00:01.00,Secondary,,0,0,0,,{translate_text}\",\n            f\"Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,{original_text}\",\n        ]\n    else:\n        dialogue = [\n            f\"Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,{original_text}\"\n        ]\n\n    # 生成 ASS 内容\n    ass_content = ASS_TEMPLATE.format(\n        style_str=style_str,\n        dialogue=os.linesep.join(dialogue),\n        video_width=width,\n        video_height=height,\n    )\n\n    # 从 ASS 内容中提取参考高度，根据图片高度自动缩放样式\n    scale_factor = height / reference_height\n    style_str = _scale_ass_style(style_str, scale_factor)\n\n    # 重新生成缩放后的 ASS 内容\n    ass_content = ASS_TEMPLATE.format(\n        style_str=style_str,\n        dialogue=os.linesep.join(dialogue),\n        video_width=width,\n        video_height=height,\n    )\n\n    # 创建临时 ASS 文件\n    with tempfile.NamedTemporaryFile(\n        mode=\"w\", suffix=\".ass\", delete=False, encoding=\"utf-8\"\n    ) as f:\n        f.write(ass_content)\n        temp_ass_path = f.name\n\n    processed_ass = temp_ass_path\n    try:\n        # 自动换行处理\n        processed_ass = auto_wrap_ass_file(temp_ass_path)\n\n        # 确保背景图片存在\n        bg_path_obj = Path(bg_image_path)\n        if not bg_path_obj.exists():\n            # 使用默认黑色背景\n            default_bg = RESOURCE_PATH / \"assets\" / \"default_bg.png\"\n            if not default_bg.exists():\n                default_bg.parent.mkdir(parents=True, exist_ok=True)\n                # 生成黑色背景\n                subprocess.run(\n                    [\n                        \"ffmpeg\",\n                        \"-f\",\n                        \"lavfi\",\n                        \"-i\",\n                        f\"color=c=black:s={width}x{height}\",\n                        \"-frames:v\",\n                        \"1\",\n                        str(default_bg),\n                    ],\n                    capture_output=True,\n                    creationflags=(\n                        getattr(subprocess, \"CREATE_NO_WINDOW\", 0)\n                        if os.name == \"nt\"\n                        else 0\n                    ),\n                )\n            bg_path_obj = default_bg\n\n        # 生成预览图\n        output_path = CACHE_PATH / \"ass_preview.png\"\n        output_path.parent.mkdir(parents=True, exist_ok=True)\n\n        # 处理 ASS 文件路径（Windows 兼容）\n        ass_file_escaped = processed_ass.replace(\"\\\\\", \"/\").replace(\":\", r\"\\\\:\")\n\n        # 添加内置字体目录支持\n        fonts_dir_escaped = str(FONTS_PATH).replace(\"\\\\\", \"/\").replace(\":\", r\"\\\\:\")\n\n        cmd = [\n            \"ffmpeg\",\n            \"-y\",\n            \"-i\",\n            str(bg_path_obj),\n            \"-vf\",\n            f\"ass={ass_file_escaped}:fontsdir={fonts_dir_escaped}\",\n            \"-frames:v\",\n            \"1\",\n            str(output_path),\n        ]\n\n        result = subprocess.run(\n            cmd,\n            capture_output=True,\n            creationflags=(\n                getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n            ),\n        )\n\n        if result.returncode != 0:\n            logger.error(f\"FFmpeg 预览生成失败: {result.stderr}\")\n\n        return str(output_path)\n\n    finally:\n        # 清理临时文件\n        Path(temp_ass_path).unlink(missing_ok=True)\n        if processed_ass != temp_ass_path:\n            Path(processed_ass).unlink(missing_ok=True)\n\n\ndef _get_video_resolution(video_path: str) -> Tuple[int, int]:\n    \"\"\"获取视频分辨率\"\"\"\n    result = subprocess.run(\n        [\"ffmpeg\", \"-i\", video_path],\n        capture_output=True,\n        text=True,\n        creationflags=(\n            getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n        ),\n    )\n\n    # 从 ffmpeg 输出中解析分辨率\n    pattern = r\"(\\d{2,5})x(\\d{2,5})\"\n    match = re.search(pattern, result.stderr)\n    if match:\n        return int(match.group(1)), int(match.group(2))\n    return 1920, 1080  # 默认返回 1080P\n\n\ndef render_ass_video(\n    video_path: str,\n    asr_data: \"ASRData\",\n    output_path: str,\n    style_str: str,\n    layout: SubtitleLayoutEnum,\n    crf: int = 23,\n    preset: str = \"medium\",\n    progress_callback: Optional[Callable] = None,\n    reference_height: int = 720,\n) -> None:\n    \"\"\"\n    渲染 ASS 样式字幕到视频（硬字幕）\n\n    Args:\n        video_path: 输入视频路径\n        asr_data: 字幕数据\n        output_path: 输出视频路径\n        style_str: ASS 样式字符串（包含 PlayResY）\n        layout: 字幕布局\n        crf: 视频质量参数 (0-51，越小越好)\n        preset: FFmpeg 编码预设\n        progress_callback: 进度回调 (progress: str, message: str) -> None\n        reference_height: 参考高度（固定720P）\n    \"\"\"\n    # 检查字幕数据是否为空\n    if not asr_data or not asr_data.segments:\n        raise ValueError(\"字幕数据为空，无法渲染视频\")\n\n    # 获取视频分辨率\n    width, height = _get_video_resolution(video_path)\n\n    # 根据视频高度自动缩放样式\n    scale_factor = height / reference_height\n    style_str = _scale_ass_style(style_str, scale_factor)\n\n    # 生成临时 ASS 文件（传入实际视频分辨率）\n    with tempfile.NamedTemporaryFile(\n        mode=\"w\", suffix=\".ass\", delete=False, encoding=\"utf-8\"\n    ) as temp_file:\n        ass_content = asr_data.to_ass(\n            style_str=style_str,\n            layout=layout,\n            save_path=None,\n            video_width=width,\n            video_height=height,\n        )\n        temp_file.write(ass_content)\n        temp_ass_path = temp_file.name\n\n    processed_subtitle = temp_ass_path\n    try:\n        # 自动换行处理\n        processed_subtitle = auto_wrap_ass_file(temp_ass_path)\n\n        # 转义字幕路径\n        subtitle_path_escaped = Path(processed_subtitle).as_posix().replace(\":\", r\"\\:\")\n\n        # 构建 FFmpeg 命令\n        vcodec = \"libx264\"\n        if Path(output_path).suffix.lower() == \".webm\":\n            vcodec = \"libvpx-vp9\"\n            logger.info(\"WebM 格式视频，使用 libvpx-vp9 编码器\")\n\n        # 添加内置字体目录支持\n        fonts_dir_escaped = FONTS_PATH.as_posix().replace(\":\", r\"\\:\")\n\n        # 统一使用 ass 滤镜\n        vf = f\"ass='{subtitle_path_escaped}':fontsdir='{fonts_dir_escaped}'\"\n\n        # 检查 CUDA 是否可用\n        use_cuda = _check_cuda_available()\n        cmd = [\"ffmpeg\"]\n        if use_cuda:\n            logger.info(\"使用 CUDA 加速\")\n            cmd.extend([\"-hwaccel\", \"cuda\"])\n\n        cmd.extend(\n            [\n                \"-i\",\n                video_path,\n                \"-acodec\",\n                \"copy\",\n                \"-vcodec\",\n                vcodec,\n                \"-crf\",\n                str(crf),\n                \"-preset\",\n                preset,\n                \"-vf\",\n                vf,\n                \"-y\",\n                output_path,\n            ]\n        )\n\n        cmd_str = subprocess.list2cmdline(cmd)\n        logger.info(f\"添加字幕执行命令: {cmd_str}\")\n\n        # 执行 FFmpeg\n        process = None\n        try:\n            process = subprocess.Popen(\n                cmd,\n                stdout=subprocess.PIPE,\n                stderr=subprocess.PIPE,\n                text=True,\n                encoding=\"utf-8\",\n                errors=\"replace\",\n                creationflags=(\n                    getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n                ),\n            )\n\n            # 实时读取输出并调用回调\n            total_duration = None\n            current_time = 0\n\n            while True:\n                output_line = process.stderr.readline()\n                if not output_line or (process.poll() is not None):\n                    break\n                if not progress_callback:\n                    continue\n\n                # 解析总时长\n                if total_duration is None:\n                    duration_match = re.search(\n                        r\"Duration: (\\d{2}):(\\d{2}):(\\d{2}\\.\\d{2})\", output_line\n                    )\n                    if duration_match:\n                        h, m, s = map(float, duration_match.groups())\n                        total_duration = h * 3600 + m * 60 + s\n\n                # 解析当前处理时间\n                time_match = re.search(\n                    r\"time=(\\d{2}):(\\d{2}):(\\d{2}\\.\\d{2})\", output_line\n                )\n                if time_match:\n                    h, m, s = map(float, time_match.groups())\n                    current_time = h * 3600 + m * 60 + s\n\n                # 计算进度百分比\n                if total_duration:\n                    progress = (current_time / total_duration) * 100\n                    progress_callback(f\"{round(progress)}\", \"正在合成\")\n\n            if progress_callback:\n                progress_callback(\"100\", \"合成完成\")\n\n            # 检查返回码\n            return_code = process.wait()\n            if return_code != 0:\n                error_info = process.stderr.read()\n                logger.error(\"== ffmpeg 渲染 ASS 字幕失败 ==\")\n                logger.error(f\"返回码: {return_code}\")\n                logger.error(f\"命令: {cmd_str}\")\n                if error_info:\n                    logger.error(f\"错误信息: {error_info}\")\n                raise Exception(f\"FFmpeg 返回码: {return_code}\")\n\n            logger.info(\"ASS 字幕渲染完成\")\n\n        except subprocess.SubprocessError as e:\n            logger.error(\"== ffmpeg 进程执行异常 ==\")\n            logger.error(f\"错误: {str(e)}\")\n            if process and process.poll() is None:\n                process.kill()\n            raise\n        except Exception as e:\n            logger.error(f\"ASS 字幕渲染出错: {str(e)}\")\n            if process and process.poll() is None:\n                process.kill()\n            raise\n\n    finally:\n        # 清理临时文件\n        Path(temp_ass_path).unlink(missing_ok=True)\n        if processed_subtitle != temp_ass_path:\n            Path(processed_subtitle).unlink(missing_ok=True)\n"
  },
  {
    "path": "app/core/subtitle/ass_utils.py",
    "content": "\"\"\"ASS subtitle utilities with accurate text width calculation\"\"\"\n\nimport re\nfrom dataclasses import dataclass\nfrom typing import Optional\n\nfrom .font_utils import get_ass_to_pil_ratio, get_font\nfrom .text_utils import is_mainly_cjk, wrap_text\n\n\n@dataclass\nclass AssStyle:\n    \"\"\"ASS style information\"\"\"\n\n    name: str  # Style name\n    font_name: str  # Font family\n    font_size: int  # Font size\n    primary_color: str = \"&H00FFFFFF\"  # Primary text color\n    secondary_color: str = \"&H000000FF\"  # Secondary text color\n    outline_color: str = \"&H00000000\"  # Outline color\n    back_color: str = \"&H00000000\"  # Shadow color\n    bold: int = 0  # Bold (-1 or 0)\n    italic: int = 0  # Italic (-1 or 0)\n    border_style: int = 1  # Border style (1 or 3)\n    outline: float = 2.0  # Outline width\n    shadow: float = 0.0  # Shadow depth\n    alignment: int = 2  # Subtitle alignment (1-9)\n    margin_l: int = 10  # Left margin\n    margin_r: int = 10  # Right margin\n    margin_v: int = 10  # Vertical margin\n    spacing: float = 0.0  # Character spacing\n\n\n@dataclass\nclass AssInfo:\n    \"\"\"ASS file information\"\"\"\n\n    video_width: int  # PlayResX\n    video_height: int  # PlayResY\n    styles: dict[str, AssStyle]  # {style_name: AssStyle}\n\n    def get_style(self, style_name: str) -> AssStyle:\n        \"\"\"Get style by name, fallback to Default\"\"\"\n        default_style = AssStyle(\n            name=\"Default\",\n            font_name=\"Arial\",\n            font_size=40,\n        )\n        return self.styles.get(style_name, self.styles.get(\"Default\", default_style))\n\n\ndef parse_ass_info(ass_content: str) -> AssInfo:\n    \"\"\"\n    Parse ASS file information including video resolution and styles\n\n    Returns:\n        AssInfo with video dimensions and all style definitions\n    \"\"\"\n    video_width = 1280\n    video_height = 720\n    styles = {}\n\n    # 提取视频分辨率\n    res_x_match = re.search(r\"PlayResX:\\s*(\\d+)\", ass_content)\n    if res_x_match:\n        video_width = int(res_x_match.group(1))\n\n    res_y_match = re.search(r\"PlayResY:\\s*(\\d+)\", ass_content)\n    if res_y_match:\n        video_height = int(res_y_match.group(1))\n\n    # 提取样式区块 [V4+ Styles]\n    style_section = re.search(r\"\\[V4\\+ Styles\\].*?\\[\", ass_content, re.DOTALL)\n    if style_section:\n        style_content = style_section.group(0)\n\n        # 解析 Format 行，建立字段名到索引的映射\n        format_match = re.search(r\"Format:(.*?)$\", style_content, re.MULTILINE)\n\n        if format_match:\n            fields = [f.strip() for f in format_match.group(1).split(\",\")]\n            field_map = {field: idx for idx, field in enumerate(fields)}\n\n            # 逐行解析 Style 定义\n            for style_line in re.finditer(r\"Style:(.*?)$\", style_content, re.MULTILINE):\n                parts = [p.strip() for p in style_line.group(1).split(\",\")]\n\n                try:\n                    style = AssStyle(\n                        name=parts[field_map[\"Name\"]],\n                        font_name=parts[field_map[\"Fontname\"]],\n                        font_size=int(parts[field_map[\"Fontsize\"]]),\n                        primary_color=(\n                            parts[field_map.get(\"PrimaryColour\", -1)]\n                            if \"PrimaryColour\" in field_map\n                            else \"&H00FFFFFF\"\n                        ),\n                        secondary_color=(\n                            parts[field_map.get(\"SecondaryColour\", -1)]\n                            if \"SecondaryColour\" in field_map\n                            else \"&H000000FF\"\n                        ),\n                        outline_color=(\n                            parts[field_map.get(\"OutlineColour\", -1)]\n                            if \"OutlineColour\" in field_map\n                            else \"&H00000000\"\n                        ),\n                        back_color=(\n                            parts[field_map.get(\"BackColour\", -1)]\n                            if \"BackColour\" in field_map\n                            else \"&H00000000\"\n                        ),\n                        bold=(\n                            int(parts[field_map.get(\"Bold\", -1)])\n                            if \"Bold\" in field_map\n                            else 0\n                        ),\n                        italic=(\n                            int(parts[field_map.get(\"Italic\", -1)])\n                            if \"Italic\" in field_map\n                            else 0\n                        ),\n                        border_style=(\n                            int(parts[field_map.get(\"BorderStyle\", -1)])\n                            if \"BorderStyle\" in field_map\n                            else 1\n                        ),\n                        outline=(\n                            float(parts[field_map.get(\"Outline\", -1)])\n                            if \"Outline\" in field_map\n                            else 2.0\n                        ),\n                        shadow=(\n                            float(parts[field_map.get(\"Shadow\", -1)])\n                            if \"Shadow\" in field_map\n                            else 0.0\n                        ),\n                        alignment=(\n                            int(parts[field_map.get(\"Alignment\", -1)])\n                            if \"Alignment\" in field_map\n                            else 2\n                        ),\n                        margin_l=(\n                            int(parts[field_map.get(\"MarginL\", -1)])\n                            if \"MarginL\" in field_map\n                            else 10\n                        ),\n                        margin_r=(\n                            int(parts[field_map.get(\"MarginR\", -1)])\n                            if \"MarginR\" in field_map\n                            else 10\n                        ),\n                        margin_v=(\n                            int(parts[field_map.get(\"MarginV\", -1)])\n                            if \"MarginV\" in field_map\n                            else 10\n                        ),\n                        spacing=(\n                            float(parts[field_map.get(\"Spacing\", -1)])\n                            if \"Spacing\" in field_map\n                            else 0.0\n                        ),\n                    )\n                    styles[style.name] = style\n                except (ValueError, IndexError, KeyError):\n                    pass\n\n    # 确保至少有一个 Default 样式\n    if \"Default\" not in styles:\n        styles[\"Default\"] = AssStyle(\n            name=\"Default\",\n            font_name=\"Arial\",\n            font_size=40,\n        )\n\n    return AssInfo(video_width, video_height, styles)\n\n\ndef wrap_ass_text(\n    text: str, max_width: int, font_name: str, font_size: int, spacing: float = 0.0\n) -> str:\n    \"\"\"\n    Wrap text using actual font rendering (accurate width calculation)\n\n    Note: ASS font size is based on Windows line height, while PIL uses em square.\n    We need to convert ASS font size to PIL font size for accurate measurement.\n\n    For most fonts: PIL_size = ASS_size / ratio, where ratio ≈ 1.4-1.5\n\n    Args:\n        text: Text to wrap\n        max_width: Maximum width in pixels\n        font_name: Font name for rendering\n        font_size: Font size (ASS font size, will be converted to PIL size)\n        spacing: Character spacing in ASS (affects text width)\n\n    Returns:\n        Wrapped text with \\\\N line breaks\n    \"\"\"\n    # 已有换行符或空文本，直接返回\n    if not text or \"\\\\N\" in text:\n        return text\n\n    # 只处理 CJK 文本（英文由 FFmpeg ASS 引擎自动换行）\n    if not is_mainly_cjk(text):\n        return text\n\n    # Convert ASS font size to PIL font size\n    # ASS uses Windows line height, PIL uses em square\n    ratio = get_ass_to_pil_ratio(font_name)\n    pil_font_size = int(round(font_size / ratio))\n\n    # Load font with converted size and call wrap function\n    # Pass spacing directly to wrap_text for accurate width calculation\n    font = get_font(pil_font_size, font_name)\n    lines = wrap_text(text, font, max_width, spacing=spacing)\n\n    # 用 \\N 连接各行（ASS 格式的换行符）\n    return \"\\\\N\".join(lines)\n\n\ndef auto_wrap_ass_file(\n    input_file: str,\n    output_file: Optional[str] = None,\n    video_width: Optional[int] = None,\n    video_height: Optional[int] = None,\n) -> str:\n    \"\"\"\n    Auto-wrap text in ASS file using accurate font rendering\n\n    Args:\n        input_file: Input ASS file path\n        output_file: Output file path (overwrites input if None)\n        video_width: Video width (overrides ASS settings if provided)\n        video_height: Video height (not used, kept for compatibility)\n\n    Returns:\n        Output file path\n    \"\"\"\n    if output_file is None:\n        output_file = input_file\n\n    with open(input_file, \"r\", encoding=\"utf-8\") as f:\n        ass_content = f.read()\n\n    # 解析 ASS 文件信息\n    ass_info = parse_ass_info(ass_content)\n\n    if video_width is None:\n        video_width = ass_info.video_width\n\n    # 使用95%宽度作为最大文本宽度\n    max_text_width = int(video_width * 0.95)\n\n    def process_dialogue_line(match):\n        \"\"\"处理每一行对话\"\"\"\n        full_line = match.group(0)\n\n        # 提取样式名称（Dialogue 行的第4个字段）\n        style_pattern = r\"Dialogue:[^,]*,[^,]*,[^,]*,([^,]*),\"\n        style_match = re.search(style_pattern, full_line)\n        style_name = style_match.group(1).strip() if style_match else \"Default\"\n\n        # 获取该样式对应的字体信息\n        style = ass_info.get_style(style_name)\n        text_part = match.group(1)\n\n        # 使用实际字体渲染进行换行（考虑字符间距）\n        wrapped_text = wrap_ass_text(\n            text_part, max_text_width, style.font_name, style.font_size, style.spacing\n        )\n\n        return full_line.replace(text_part, wrapped_text)\n\n    # 匹配所有对话行的文本部分（第10个字段）\n    # Dialogue: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n    pattern = r\"Dialogue:[^,]*(?:,[^,]*){8},(.*?)$\"\n    processed_content = re.sub(\n        pattern, process_dialogue_line, ass_content, flags=re.MULTILINE\n    )\n\n    # 写入处理后的文件\n    with open(output_file, \"w\", encoding=\"utf-8\") as f:\n        f.write(processed_content)\n\n    return output_file\n"
  },
  {
    "path": "app/core/subtitle/font_utils.py",
    "content": "\"\"\"Font discovery and loading utilities\"\"\"\n\nfrom functools import lru_cache\nfrom pathlib import Path\nfrom typing import Dict, Optional, Union\n\nfrom fontTools.ttLib import TTFont\nfrom PIL import ImageFont\n\nfrom app.config import FONTS_PATH\nfrom app.core.utils.logger import setup_logger\n\nFontType = Union[ImageFont.FreeTypeFont, ImageFont.ImageFont]\n\nlogger = setup_logger(\"subtitle.font\")\n\n\ndef _get_font_family_name(font_path: Path, font_index: int = 0) -> Optional[str]:\n    \"\"\"Extract font family name from font file (cross-platform)\"\"\"\n    try:\n        font = TTFont(str(font_path), fontNumber=font_index)\n        name_table = font.get(\"name\")\n        if not name_table:\n            return None\n\n        # nameID 16: Typographic Family (preferred)\n        # nameID 1: Font Family (fallback)\n        for name_id in [16, 1]:\n            for record in name_table.names:\n                if record.nameID == name_id and record.platformID == 3:\n                    try:\n                        family_name = record.toUnicode()\n                        return family_name.split(\",\")[0].strip()\n                    except Exception:\n                        continue\n\n        for name_id in [16, 1]:\n            for record in name_table.names:\n                if record.nameID == name_id:\n                    try:\n                        family_name = record.toUnicode()\n                        return family_name.split(\",\")[0].strip()\n                    except Exception:\n                        continue\n\n        return None\n    except Exception as e:\n        logger.debug(f\"Failed to parse font {font_path.name} (index={font_index}): {e}\")\n        return None\n\n\n@lru_cache(maxsize=1)\ndef get_builtin_fonts() -> tuple[Dict[str, str], ...]:\n    \"\"\"Get built-in fonts list with actual family names\"\"\"\n    builtin_fonts = []\n\n    if FONTS_PATH.exists():\n        for font_file in FONTS_PATH.glob(\"*.[ot]tf*\"):\n            family_name = _get_font_family_name(font_file)\n            if family_name:\n                builtin_fonts.append({\"name\": family_name, \"path\": str(font_file)})\n                logger.debug(f\"Built-in font: {font_file.name} -> {family_name}\")\n            else:\n                display_name = font_file.stem\n                builtin_fonts.append({\"name\": display_name, \"path\": str(font_file)})\n                logger.debug(\n                    f\"Cannot get family name for {font_file.name}, using filename\"\n                )\n\n    return tuple(builtin_fonts)\n\n\n@lru_cache(maxsize=64)\ndef get_font(size: int, font_name: str = \"\") -> FontType:\n    \"\"\"Get font object (built-in fonts first, then system fonts)\"\"\"\n    if font_name:\n        builtin_fonts = get_builtin_fonts()\n        for builtin in builtin_fonts:\n            if builtin[\"name\"] == font_name:\n                try:\n                    font = ImageFont.truetype(builtin[\"path\"], size)\n                    logger.debug(f\"Loaded built-in font: '{font_name}'\")\n                    return font\n                except Exception as e:\n                    logger.warning(f\"Failed to load built-in font: {e}\")\n                    break\n\n        try:\n            font = ImageFont.truetype(font_name, size)\n            logger.debug(f\"Loaded system font: '{font_name}'\")\n            return font\n        except (OSError, IOError):\n            logger.warning(f\"Cannot load font '{font_name}', using fallback\")\n\n    fallback_fonts = [f[\"name\"] for f in get_builtin_fonts()]\n    fallback_fonts.extend(\n        [\n            \"PingFang SC\",\n            \"Hiragino Sans GB\",\n            \"Microsoft YaHei\",\n            \"SimHei\",\n            \"Arial Unicode MS\",\n            \"Arial\",\n            \"Helvetica\",\n        ]\n    )\n\n    for fallback in fallback_fonts:\n        try:\n            font = ImageFont.truetype(fallback, size)\n            logger.info(f\"Using fallback font: '{fallback}'\")\n            return font\n        except Exception:\n            continue\n\n    logger.warning(\"All fallback fonts failed, using default\")\n    return ImageFont.load_default()\n\n\n@lru_cache(maxsize=128)\ndef get_ass_to_pil_ratio(font_name: str) -> float:\n    \"\"\"\n    Get ASS to PIL font size conversion ratio\n\n    ASS uses Windows line height (usWinAscent + usWinDescent),\n    PIL uses em square (unitsPerEm).\n\n    For Noto Sans SC: ratio = 1.448\n    This means: PIL_size = ASS_size / 1.448\n\n    Returns:\n        Conversion ratio (typically 1.4-1.5 for CJK fonts)\n    \"\"\"\n    # Find font file\n    font_path = None\n    for ext in [\".ttf\", \".otf\", \".ttc\"]:\n        candidates = list(FONTS_PATH.glob(f\"**/{font_name}*{ext}\"))\n        if candidates:\n            font_path = candidates[0]\n            break\n\n    if not font_path:\n        candidates = list(FONTS_PATH.glob(f\"**/*{font_name}*\"))\n        if candidates:\n            font_path = candidates[0]\n\n    # Default ratio for most CJK fonts\n    if not font_path:\n        logger.debug(f\"Font file not found: {font_name}, using default ratio 1.448\")\n        return 1.448\n\n    try:\n        font = TTFont(str(font_path))\n        units_per_em = font[\"head\"].unitsPerEm  # type: ignore\n        win_ascent = font[\"OS/2\"].usWinAscent  # type: ignore\n        win_descent = font[\"OS/2\"].usWinDescent  # type: ignore\n        ratio = (win_ascent + win_descent) / units_per_em\n        logger.debug(f\"Font metrics for {font_name}: ratio={ratio:.3f}\")\n        return ratio\n    except Exception as e:\n        logger.warning(f\"Failed to read font metrics for {font_name}: {e}\")\n        return 1.448\n\n\ndef clear_font_cache():\n    \"\"\"Clear font cache\"\"\"\n    get_builtin_fonts.cache_clear()\n    get_font.cache_clear()\n    get_ass_to_pil_ratio.cache_clear()\n    logger.info(\"Font cache cleared\")\n"
  },
  {
    "path": "app/core/subtitle/rounded_renderer.py",
    "content": "\"\"\"Rounded background subtitle renderer\"\"\"\n\nimport os\nimport re\nimport subprocess\nimport tempfile\nfrom dataclasses import replace\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Callable, List, Optional, Tuple\n\nfrom PIL import Image, ImageDraw\n\nfrom app.core.entities import SubtitleLayoutEnum\nfrom app.core.utils.logger import setup_logger\n\nfrom .font_utils import FontType, get_font\nfrom .styles import RoundedBgStyle\nfrom .text_utils import hex_to_rgba, wrap_text\n\nif TYPE_CHECKING:\n    from app.core.asr.asr_data import ASRData\n\nlogger = setup_logger(\"subtitle.rounded\")\n\n\ndef _get_video_info(video_path: str) -> Tuple[int, int, float]:\n    \"\"\"获取视频分辨率和时长\"\"\"\n    result = subprocess.run(\n        [\"ffmpeg\", \"-i\", video_path],\n        capture_output=True,\n        text=True,\n        encoding=\"utf-8\",\n        errors=\"replace\",\n        creationflags=(getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0),\n    )\n\n    # 解析分辨率\n    width, height = 0, 0\n    if match := re.search(r\"Stream.*Video:.* (\\d{2,5})x(\\d{2,5})\", result.stderr):\n        width, height = int(match.group(1)), int(match.group(2))\n    else:\n        raise ValueError(f\"无法获取视频分辨率: {video_path}\")\n\n    # 解析时长\n    duration = 0.0\n    if match := re.search(r\"Duration:\\s*(\\d+):(\\d+):(\\d+(?:\\.\\d+)?)\", result.stderr):\n        h, m, s = match.groups()\n        duration = int(h) * 3600 + int(m) * 60 + float(s)\n\n    return width, height, duration\n\n\ndef render_text_block(\n    draw: ImageDraw.ImageDraw,\n    texts: List[str],\n    font: FontType,\n    center_x: int,\n    top_y: float,\n    style: RoundedBgStyle,\n) -> float:\n    \"\"\"\n    渲染多行文本块（共享圆角背景）\n\n    Args:\n        draw: PIL ImageDraw 对象\n        texts: 文本行列表\n        font: 字体对象\n        center_x: 水平中心位置\n        top_y: 顶部 y 坐标\n        style: 样式配置\n\n    Returns:\n        背景框高度\n    \"\"\"\n    if not texts:\n        return 0\n\n    bg_color = hex_to_rgba(style.bg_color)\n    text_color = hex_to_rgba(style.text_color)\n\n    # 计算所有行的尺寸和垂直偏移\n    line_sizes = []\n    line_offsets = []\n    for text in texts:\n        bbox = font.getbbox(text)\n        text_width = bbox[2] - bbox[0]\n        # 如果有字符间距，需要加上额外的宽度\n        if style.letter_spacing > 0 and len(text) > 1:\n            text_width += style.letter_spacing * (len(text) - 1)\n        line_sizes.append((text_width, bbox[3] - bbox[1]))\n        line_offsets.append(bbox[1])  # 记录垂直偏移，用于居中对齐\n\n    max_width = max(w for w, h in line_sizes)\n    line_height = max(h for w, h in line_sizes)\n    total_height = line_height * len(texts) + style.line_spacing * (len(texts) - 1)\n\n    # 绘制共享背景\n    bg_width = max_width + style.padding_h * 2\n    bg_height = total_height + style.padding_v * 2\n    bg_left = center_x - bg_width // 2\n    bg_top = top_y\n\n    draw.rounded_rectangle(\n        [bg_left, bg_top, bg_left + bg_width, bg_top + bg_height],\n        radius=style.corner_radius,\n        fill=bg_color,\n    )\n\n    # 绘制文本（补偿字体垂直偏移）\n    y = bg_top + style.padding_v\n    for i, text in enumerate(texts):\n        w, h = line_sizes[i]\n        x = center_x - w // 2\n        y_offset = line_offsets[i]\n        text_y = y - y_offset  # 补偿垂直偏移，使文本视觉居中\n\n        # 如果有字符间距，逐字符绘制\n        if style.letter_spacing > 0 and len(text) > 1:\n            current_x = x\n            for char in text:\n                draw.text((current_x, text_y), char, font=font, fill=text_color)\n                char_width = font.getbbox(char)[2] - font.getbbox(char)[0]\n                current_x += char_width + style.letter_spacing\n        else:\n            # 无字符间距，一次性绘制（性能更好）\n            draw.text((x, text_y), text, font=font, fill=text_color)\n\n        y += line_height + style.line_spacing\n\n    return bg_height\n\n\ndef render_subtitle_image(\n    primary_text: str,\n    secondary_text: str,\n    width: int,\n    height: int,\n    style: RoundedBgStyle,\n) -> Image.Image:\n    \"\"\"\n    渲染单帧字幕图像（透明背景）\n\n    Args:\n        primary_text: 主字幕文本\n        secondary_text: 副字幕文本\n        width: 图像宽度\n        height: 图像高度\n        style: 样式配置\n\n    Returns:\n        PIL Image 对象（RGBA 格式）\n    \"\"\"\n    image = Image.new(\"RGBA\", (width, height), (0, 0, 0, 0))\n    draw = ImageDraw.Draw(image)\n    font = get_font(style.font_size, style.font_name)\n\n    # 换行处理（额外留 40px 边距防止文字贴边）\n    extra_margin = int(width * 0.1)\n    primary_lines = (\n        wrap_text(primary_text, font, width, style.padding_h, extra_margin=extra_margin)\n        if primary_text\n        else []\n    )\n    secondary_lines = (\n        wrap_text(secondary_text, font, width, style.padding_h, extra_margin=extra_margin)\n        if secondary_text\n        else []\n    )\n\n    center_x = width // 2\n\n    # 计算总高度\n    def calc_block_height(lines: List[str]) -> float:\n        if not lines:\n            return 0\n        bbox = font.getbbox(\"测试Ag\")\n        line_h = bbox[3] - bbox[1]\n        return line_h * len(lines) + style.line_spacing * (len(lines) - 1) + style.padding_v * 2\n\n    primary_height = calc_block_height(primary_lines)\n    secondary_height = calc_block_height(secondary_lines)\n    gap = style.line_spacing if primary_lines and secondary_lines else 0\n    total_height = primary_height + gap + secondary_height\n\n    # 从底部计算起始位置\n    bottom_y = height - style.margin_bottom\n    start_y = bottom_y - total_height\n\n    # 渲染文本块\n    current_y = start_y\n    if primary_lines:\n        h = render_text_block(draw, primary_lines, font, center_x, current_y, style)\n        current_y += h + gap\n    if secondary_lines:\n        render_text_block(draw, secondary_lines, font, center_x, current_y, style)\n\n    return image\n\n\ndef render_preview(\n    primary_text: str,\n    secondary_text: str = \"\",\n    width: Optional[int] = None,\n    height: Optional[int] = None,\n    style: Optional[RoundedBgStyle] = None,\n    bg_image_path: Optional[str] = None,\n    reference_height: int = 720,\n) -> str:\n    \"\"\"\n    渲染圆角背景字幕预览图\n\n    Args:\n        primary_text: 主字幕文本\n        secondary_text: 副字幕文本\n        width: 图片宽度（None=从bg_image_path自动获取）\n        height: 图片高度（None=从bg_image_path自动获取）\n        style: 圆角背景样式（包含reference_height，会根据height自动缩放）\n        bg_image_path: 背景图片路径\n        reference_height: 参考高度（固定720P）\n    Returns:\n        生成的预览图路径\n    \"\"\"\n    if style is None:\n        style = RoundedBgStyle()\n\n    # 加载或创建背景\n    if bg_image_path and Path(bg_image_path).exists():\n        background = Image.open(bg_image_path).convert(\"RGB\")\n        # 如果未提供尺寸，从图片获取\n        if width is None or height is None:\n            width, height = background.size\n    else:\n        # 没有背景图片，使用默认尺寸或提供的尺寸\n        if width is None:\n            width = 1920\n        if height is None:\n            height = 1080\n        background = Image.new(\"RGB\", (width, height), (20, 20, 20))\n\n    # 确保 width 和 height 不为 None（类型收窄）\n    assert width is not None and height is not None\n\n    # 从样式中获取参考高度，根据图片高度自动缩放样式\n    scale_factor = height / reference_height\n\n    if scale_factor != 1.0:\n        style = replace(\n            style,\n            font_size=int(style.font_size * scale_factor),\n            corner_radius=int(style.corner_radius * scale_factor),\n            padding_h=int(style.padding_h * scale_factor),\n            padding_v=int(style.padding_v * scale_factor),\n            margin_bottom=int(style.margin_bottom * scale_factor),\n            line_spacing=int(style.line_spacing * scale_factor),\n            letter_spacing=int(style.letter_spacing * scale_factor),\n        )\n\n    # 渲染字幕并叠加\n    subtitle_img = render_subtitle_image(primary_text, secondary_text, width, height, style)\n    background.paste(subtitle_img, (0, 0), subtitle_img)\n\n    # 保存到临时目录\n    with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=\".png\", delete=False) as tmp_file:\n        background.save(tmp_file, \"PNG\")\n        return tmp_file.name\n\n\ndef render_rounded_video(\n    video_path: str,\n    asr_data: \"ASRData\",\n    output_path: str,\n    rounded_style: Optional[dict] = None,\n    layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ONLY_ORIGINAL,\n    crf: int = 23,\n    preset: str = \"medium\",\n    progress_callback: Optional[Callable] = None,\n    reference_height: int = 720,\n) -> None:\n    \"\"\"\n    渲染圆角背景字幕到视频（分批overlay方案）\n\n    核心流程：直接分批overlay字幕PNG到原视频\n    每批50个字幕，避免FFmpeg文件数量限制\n\n    Args:\n        video_path: 输入视频路径\n        asr_data: 字幕数据\n        output_path: 输出视频路径\n        rounded_style: 圆角背景样式配置字典\n        layout: 字幕布局\n        crf: 视频质量参数\n        preset: FFmpeg编码预设\n        progress_callback: 进度回调 (progress: int, message: str)\n        reference_height: 参考高度（固定720P）\n    \"\"\"\n    # 检查字幕数据\n    if not asr_data or not asr_data.segments:\n        raise ValueError(\"字幕数据为空，无法渲染视频\")\n\n    # 检查布局合理性\n    if layout == SubtitleLayoutEnum.ONLY_TRANSLATE:\n        has_translation = any(\n            seg.translated_text and seg.translated_text.strip() for seg in asr_data.segments\n        )\n        if not has_translation:\n            layout = SubtitleLayoutEnum.ONLY_ORIGINAL\n    elif (\n        layout == SubtitleLayoutEnum.TRANSLATE_ON_TOP\n        or layout == SubtitleLayoutEnum.ORIGINAL_ON_TOP\n    ):\n        has_translation = any(\n            seg.translated_text and seg.translated_text.strip() for seg in asr_data.segments\n        )\n        if not has_translation:\n            layout = SubtitleLayoutEnum.ONLY_ORIGINAL\n\n    # 获取视频信息\n    width, height, video_duration = _get_video_info(video_path)\n\n    # 构建并缩放样式\n    style_config = rounded_style or {}\n    style_config[\"layout\"] = layout\n    style = RoundedBgStyle(**style_config)\n\n    scale_factor = height / reference_height\n    if scale_factor != 1.0:\n        style = replace(\n            style,\n            font_size=int(style.font_size * scale_factor),\n            corner_radius=int(style.corner_radius * scale_factor),\n            padding_h=int(style.padding_h * scale_factor),\n            padding_v=int(style.padding_v * scale_factor),\n            margin_bottom=int(style.margin_bottom * scale_factor),\n            line_spacing=int(style.line_spacing * scale_factor),\n            letter_spacing=int(style.letter_spacing * scale_factor),\n        )\n\n    with tempfile.TemporaryDirectory(prefix=\"rounded_subtitle_\") as temp_dir:\n        temp_path = Path(temp_dir)\n\n        # 步骤1: 生成所有字幕PNG (0-30%)\n        logger.info(f\"生成字幕PNG图片（共{len(asr_data.segments)}个，布局：{layout.value}）\")\n        subtitle_frames = []\n\n        for i, seg in enumerate(asr_data.segments):\n            # 根据布局确定主副文本\n            if layout == SubtitleLayoutEnum.ONLY_ORIGINAL:\n                primary, secondary = seg.text, \"\"\n            elif layout == SubtitleLayoutEnum.ONLY_TRANSLATE:\n                primary, secondary = seg.translated_text or \"\", \"\"\n            elif layout == SubtitleLayoutEnum.ORIGINAL_ON_TOP:\n                primary, secondary = seg.text, seg.translated_text or \"\"\n            else:  # TRANSLATE_ON_TOP\n                primary, secondary = seg.translated_text or \"\", seg.text\n\n            # 渲染字幕图片\n            img = render_subtitle_image(primary, secondary, width, height, style)\n            png_path = temp_path / f\"subtitle_{i:06d}.png\"\n            img.save(png_path, \"PNG\")\n\n            # 记录时间戳\n            start_time = seg.start_time / 1000.0\n            end_time = seg.end_time / 1000.0\n            subtitle_frames.append((start_time, end_time, png_path))\n\n            # 进度回调\n            if progress_callback:\n                progress = int((i + 1) / len(asr_data.segments) * 30)\n                progress_callback(progress, f\"生成字幕图片 {i + 1}/{len(asr_data.segments)}\")\n\n        if not subtitle_frames:\n            raise ValueError(\"没有生成任何有效的字幕图片\")\n\n        # 步骤2: 分批overlay到视频 (30-100%)\n        logger.info(\"分批叠加字幕到视频\")\n        BATCH_SIZE = 50\n        current_video = video_path\n        total_batches = (len(subtitle_frames) + BATCH_SIZE - 1) // BATCH_SIZE\n\n        for batch_idx in range(total_batches):\n            start_idx = batch_idx * BATCH_SIZE\n            end_idx = min((batch_idx + 1) * BATCH_SIZE, len(subtitle_frames))\n            batch_frames = subtitle_frames[start_idx:end_idx]\n\n            # 构建overlay滤镜链\n            input_args = [\"-i\", current_video]\n            filter_parts = []\n\n            for local_idx, (start, end, png_path) in enumerate(batch_frames):\n                input_args.extend([\"-i\", str(png_path)])\n                prev = f\"[v{local_idx}]\" if local_idx > 0 else \"[0:v]\"\n                curr = f\"[{local_idx + 1}:v]\"\n                out = f\"[v{local_idx + 1}]\"\n                filter_parts.append(\n                    f\"{prev}{curr}overlay=0:0:enable='between(t,{start},{end})'{out}\"\n                )\n\n            filter_complex = \";\".join(filter_parts)\n            final_output = f\"[v{len(batch_frames)}]\"\n\n            # 判断是否是最后一批\n            is_last_batch = batch_idx == total_batches - 1\n            batch_output = (\n                output_path if is_last_batch else temp_path / f\"batch_{batch_idx:03d}.mp4\"\n            )\n\n            logger.info(f\"处理批次 {batch_idx + 1}/{total_batches}（{len(batch_frames)}个字幕）\")\n            # 构建 ffmpeg 命令\n            # -t 参数强制保持原视频时长，防止因 overlay 结束而截断视频\n            cmd = [\n                \"ffmpeg\",\n                \"-y\",\n                *input_args,\n                \"-filter_complex\",\n                filter_complex,\n                \"-map\",\n                final_output,\n                \"-map\",\n                \"0:a?\",\n                \"-t\",\n                str(video_duration),  # 强制保持原视频时长\n                \"-c:v\",\n                \"libx264\",\n                \"-preset\",\n                \"ultrafast\" if not is_last_batch else preset,\n                \"-crf\",\n                \"0\" if not is_last_batch else str(crf),\n                \"-pix_fmt\",\n                \"yuv420p\",\n                \"-c:a\",\n                \"copy\",\n                str(batch_output),\n            ]\n\n            if batch_idx == 0 or is_last_batch:\n                cmd_str = subprocess.list2cmdline(cmd)\n                logger.info(f\"执行命令: {cmd_str}\")\n\n            result = subprocess.run(\n                cmd,\n                capture_output=True,\n                text=True,\n                encoding=\"utf-8\",\n                errors=\"replace\",\n                creationflags=(\n                    getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n                ),\n            )\n\n            if result.returncode != 0:\n                logger.error(f\"批次 {batch_idx + 1} 失败: {result.stderr}\")\n                raise RuntimeError(f\"字幕处理失败（批次 {batch_idx + 1}）\")\n\n            # 更新进度 (30-100%)\n            if progress_callback:\n                progress = 30 + int((batch_idx + 1) / total_batches * 70)\n                progress_callback(progress, f\"合成视频 {batch_idx + 1}/{total_batches}\")\n\n            # 更新当前视频\n            current_video = str(batch_output)\n\n        logger.info(\"视频合成完成\")\n"
  },
  {
    "path": "app/core/subtitle/styles.py",
    "content": "\"\"\"Subtitle style configurations\"\"\"\n\nfrom dataclasses import dataclass\n\nfrom app.core.entities import SubtitleLayoutEnum\n\n\n@dataclass\nclass RoundedBgStyle:\n    \"\"\"Rounded background subtitle style\"\"\"\n\n    font_name: str = \"\"\n    font_size: int = 52\n\n    # 颜色配置（支持 hex 格式，如 #RRGGBB 或 #RRGGBBAA）\n    bg_color: str = \"#191919C8\"  # 背景颜色\n    text_color: str = \"#FFFFFF\"  # 文字颜色\n\n    # 圆角和间距\n    corner_radius: int = 12  # 圆角半径\n    padding_h: int = 28  # 水平内边距\n    padding_v: int = 14  # 垂直内边距\n    margin_bottom: int = 60  # 底部外边距\n    line_spacing: int = 10  # 行间距\n    letter_spacing: int = 0  # 字符间距\n\n    # 字幕布局\n    layout: SubtitleLayoutEnum = SubtitleLayoutEnum.ONLY_ORIGINAL\n"
  },
  {
    "path": "app/core/subtitle/text_utils.py",
    "content": "\"\"\"Text processing utilities\"\"\"\n\nimport re\nfrom typing import List, Tuple\n\nfrom .font_utils import FontType\n\n# CJK and Asian languages without spaces\n_NO_SPACE_LANGUAGES = r\"[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af\\u0e00-\\u0eff\\u1000-\\u109f\\u1780-\\u17ff\\u0900-\\u0dff]\"\n\n\ndef is_mainly_cjk(text: str, threshold: float = 0.5) -> bool:\n    \"\"\"Check if text is mainly CJK or Asian languages without spaces\"\"\"\n    if not text:\n        return False\n\n    no_space_count = len(re.findall(_NO_SPACE_LANGUAGES, text))\n    total_chars = len(\"\".join(text.split()))\n\n    return no_space_count / total_chars > threshold if total_chars > 0 else False\n\n\ndef hex_to_rgba(hex_color: str) -> Tuple[int, int, int, int]:\n    \"\"\"Convert hex color to RGBA tuple (#RRGGBB or #RRGGBBAA)\"\"\"\n    hex_color = hex_color.lstrip(\"#\")\n    if len(hex_color) == 6:\n        r, g, b = (\n            int(hex_color[0:2], 16),\n            int(hex_color[2:4], 16),\n            int(hex_color[4:6], 16),\n        )\n        return (r, g, b, 255)\n    elif len(hex_color) == 8:\n        r, g, b, a = (\n            int(hex_color[0:2], 16),\n            int(hex_color[2:4], 16),\n            int(hex_color[4:6], 16),\n            int(hex_color[6:8], 16),\n        )\n        return (r, g, b, a)\n    return (0, 0, 0, 255)\n\n\ndef _calculate_text_width(text: str, font: FontType, spacing: float) -> int:\n    \"\"\"\n    Calculate text width including character spacing\n\n    Args:\n        text: Text to measure\n        font: Font for measuring\n        spacing: Character spacing (for N chars, adds spacing × (N-1) to width)\n\n    Returns:\n        Total width in pixels\n    \"\"\"\n    if not text:\n        return 0\n    bbox = font.getbbox(text)\n    base_width = bbox[2] - bbox[0]\n    # For N characters, there are N-1 spacing gaps\n    spacing_width = spacing * (len(text) - 1) if len(text) > 1 else 0\n    return int(base_width + spacing_width)\n\n\ndef wrap_text(\n    text: str,\n    font: FontType,\n    max_width: int,\n    horizontal_padding: int = 0,\n    extra_margin: int = 0,\n    spacing: float = 0.0,\n) -> List[str]:\n    \"\"\"\n    Wrap text to fit within max width with balanced line lengths\n\n    Strategy:\n    1. Calculate minimum required lines using greedy algorithm\n    2. Calculate target width per line (total_width / num_lines)\n    3. Redistribute text to achieve balanced line lengths\n\n    Args:\n        text: Text to wrap\n        font: Font for measuring text width\n        max_width: Maximum width in pixels\n        horizontal_padding: Left/right padding (reduces available width by 2x)\n        extra_margin: Additional safety margin\n        spacing: Character spacing (for N chars, adds spacing × (N-1) to width)\n    \"\"\"\n    available_width = max_width - horizontal_padding * 2 - extra_margin\n\n    # 检测是否主要是 CJK 字符\n    if is_mainly_cjk(text):\n        return _wrap_cjk_balanced(text, font, available_width, spacing)\n    else:\n        return _wrap_english_balanced(text, font, available_width, spacing)\n\n\ndef _wrap_cjk_balanced(\n    text: str, font: FontType, available_width: int, spacing: float = 0.0\n) -> List[str]:\n    \"\"\"Wrap CJK text with balanced line lengths\"\"\"\n\n    # Step 1: Calculate minimum required lines using greedy algorithm\n    temp_lines = []\n    current_line = \"\"\n    for char in text:\n        test_line = current_line + char\n        if _calculate_text_width(test_line, font, spacing) <= available_width:\n            current_line = test_line\n        else:\n            if current_line:\n                temp_lines.append(current_line)\n            current_line = char\n    if current_line:\n        temp_lines.append(current_line)\n\n    if not temp_lines:\n        return [text]\n\n    # If only one line, no need to balance\n    if len(temp_lines) == 1:\n        return temp_lines\n\n    # Step 2: Calculate total width and target width per line\n    total_text_width = _calculate_text_width(text, font, spacing)\n    num_lines = len(temp_lines)\n    target_width = total_text_width / num_lines\n\n    # Step 3: Redistribute text to achieve balanced lines\n    # Important: Do not exceed the minimum line count from greedy algorithm\n    lines = []\n    current_line = \"\"\n    for i, char in enumerate(text):\n        test_line = current_line + char\n        current_width = _calculate_text_width(test_line, font, spacing)\n\n        # Check if we should break the line\n        should_break = False\n\n        if current_width > available_width:\n            # Hard limit: must break\n            should_break = True\n        elif (\n            len(lines) + 1 < num_lines\n            and current_line\n            and current_width >= target_width * 0.9\n        ):\n            # Only balance if we haven't reached the minimum line count yet\n            # Close to target width (90% threshold)\n            # Check if next char would significantly exceed target\n            if i + 1 < len(text):\n                next_test = test_line + text[i + 1]\n                next_width = _calculate_text_width(next_test, font, spacing)\n                if next_width > target_width * 1.1:\n                    should_break = True\n\n        if should_break:\n            if current_line:\n                lines.append(current_line)\n                current_line = char\n            else:\n                current_line = test_line\n        else:\n            current_line = test_line\n\n    if current_line:\n        lines.append(current_line)\n\n    return lines if lines else [text]\n\n\ndef _wrap_english_balanced(\n    text: str, font: FontType, available_width: int, spacing: float = 0.0\n) -> List[str]:\n    \"\"\"Wrap English text with balanced line lengths\"\"\"\n\n    words = text.split()\n    if not words:\n        return [text]\n\n    # Step 1: Calculate minimum required lines\n    temp_lines = []\n    current_line = \"\"\n    for word in words:\n        test_line = f\"{current_line} {word}\".strip()\n        if _calculate_text_width(test_line, font, spacing) <= available_width:\n            current_line = test_line\n        else:\n            if current_line:\n                temp_lines.append(current_line)\n            current_line = word\n    if current_line:\n        temp_lines.append(current_line)\n\n    if not temp_lines:\n        return [text]\n\n    # If only one line, no need to balance\n    if len(temp_lines) == 1:\n        return temp_lines\n\n    # Step 2: Calculate target width\n    total_text_width = _calculate_text_width(text, font, spacing)\n    num_lines = len(temp_lines)\n    target_width = total_text_width / num_lines\n\n    # Step 3: Redistribute words to achieve balanced lines\n    # Important: Do not exceed the minimum line count from greedy algorithm\n    lines = []\n    current_line = \"\"\n    for i, word in enumerate(words):\n        test_line = f\"{current_line} {word}\".strip()\n        current_width = _calculate_text_width(test_line, font, spacing)\n\n        should_break = False\n\n        if current_width > available_width:\n            # Hard limit: must break\n            should_break = True\n        elif (\n            len(lines) + 1 < num_lines\n            and current_line\n            and current_width >= target_width * 0.9\n        ):\n            # Only balance if we haven't reached the minimum line count yet\n            # Close to target width (90% threshold)\n            # Check if next word would significantly exceed target\n            if i + 1 < len(words):\n                next_test = f\"{test_line} {words[i + 1]}\".strip()\n                next_width = _calculate_text_width(next_test, font, spacing)\n                if next_width > target_width * 1.1:\n                    should_break = True\n\n        if should_break:\n            if current_line:\n                lines.append(current_line)\n                current_line = word\n            else:\n                current_line = test_line\n        else:\n            current_line = test_line\n\n    if current_line:\n        lines.append(current_line)\n\n    return lines if lines else [text]\n"
  },
  {
    "path": "app/core/task_factory.py",
    "content": "import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom app.common.config import cfg\nfrom app.config import MODEL_PATH, SUBTITLE_STYLE_PATH\nfrom app.core.entities import (\n    LANGUAGES,\n    FullProcessTask,\n    LLMServiceEnum,\n    SubtitleConfig,\n    SubtitleTask,\n    SynthesisConfig,\n    SynthesisTask,\n    TranscribeConfig,\n    TranscribeTask,\n    TranscriptAndSubtitleTask,\n)\n\n\nclass TaskFactory:\n    \"\"\"任务工厂类，用于创建各种类型的任务\"\"\"\n\n    @staticmethod\n    def get_ass_style(style_name: str) -> str:\n        \"\"\"获取 ASS 字幕样式内容\"\"\"\n        style_path = SUBTITLE_STYLE_PATH / f\"{style_name}.txt\"\n        if style_path.exists():\n            return style_path.read_text(encoding=\"utf-8\")\n        return \"\"\n\n    @staticmethod\n    def get_rounded_style() -> dict:\n        \"\"\"获取圆角背景样式配置\"\"\"\n        return {\n            \"font_name\": cfg.rounded_bg_font_name.value,\n            \"font_size\": cfg.rounded_bg_font_size.value,\n            \"bg_color\": cfg.rounded_bg_color.value,\n            \"text_color\": cfg.rounded_bg_text_color.value,\n            \"corner_radius\": cfg.rounded_bg_corner_radius.value,\n            \"padding_h\": cfg.rounded_bg_padding_h.value,\n            \"padding_v\": cfg.rounded_bg_padding_v.value,\n            \"margin_bottom\": cfg.rounded_bg_margin_bottom.value,\n            \"line_spacing\": cfg.rounded_bg_line_spacing.value,\n            \"letter_spacing\": cfg.rounded_bg_letter_spacing.value,\n        }\n\n    @staticmethod\n    def create_transcribe_task(\n        file_path: str,\n        need_next_task: bool = False,\n        task_id: Optional[str] = None,\n    ) -> TranscribeTask:\n        \"\"\"创建转录任务\"\"\"\n        # 获取文件名\n        file_name = Path(file_path).stem\n\n        # 构建输出路径\n        if need_next_task:\n            need_word_time_stamp = cfg.need_split.value\n            output_path = str(\n                Path(cfg.work_dir.value)\n                / file_name\n                / \"subtitle\"\n                / f\"【原始字幕】{file_name}-{cfg.transcribe_model.value.value}-{cfg.transcribe_language.value.value}.srt\"\n            )\n        else:\n            need_word_time_stamp = False\n            output_path = str(Path(file_path).parent / f\"{file_name}.srt\")\n\n        config = TranscribeConfig(\n            transcribe_model=cfg.transcribe_model.value,\n            transcribe_language=LANGUAGES[cfg.transcribe_language.value.value],\n            need_word_time_stamp=need_word_time_stamp,\n            output_format=cfg.transcribe_output_format.value,\n            # Whisper Cpp 配置\n            whisper_model=cfg.whisper_model.value,\n            # Whisper API 配置\n            whisper_api_key=cfg.whisper_api_key.value,\n            whisper_api_base=cfg.whisper_api_base.value,\n            whisper_api_model=cfg.whisper_api_model.value,\n            whisper_api_prompt=cfg.whisper_api_prompt.value,\n            # Faster Whisper 配置\n            faster_whisper_program=cfg.faster_whisper_program.value,\n            faster_whisper_model=cfg.faster_whisper_model.value,\n            faster_whisper_model_dir=str(MODEL_PATH),\n            faster_whisper_device=cfg.faster_whisper_device.value,\n            faster_whisper_vad_filter=cfg.faster_whisper_vad_filter.value,\n            faster_whisper_vad_threshold=cfg.faster_whisper_vad_threshold.value,\n            faster_whisper_vad_method=cfg.faster_whisper_vad_method.value,\n            faster_whisper_ff_mdx_kim2=cfg.faster_whisper_ff_mdx_kim2.value,\n            faster_whisper_one_word=cfg.faster_whisper_one_word.value,\n            faster_whisper_prompt=cfg.faster_whisper_prompt.value,\n        )\n\n        task = TranscribeTask(\n            queued_at=datetime.datetime.now(),\n            file_path=file_path,\n            output_path=output_path,\n            transcribe_config=config,\n            need_next_task=need_next_task,\n        )\n        if task_id:\n            task.task_id = task_id\n        return task\n\n    @staticmethod\n    def create_subtitle_task(\n        file_path: str,\n        video_path: Optional[str] = None,\n        need_next_task: bool = False,\n        task_id: Optional[str] = None,\n    ) -> SubtitleTask:\n        \"\"\"创建字幕任务\"\"\"\n        output_name = (\n            Path(file_path).stem.replace(\"【原始字幕】\", \"\").replace(\"【下载字幕】\", \"\")\n        )\n        # 只在需要翻译时添加翻译服务后缀\n        suffix = (\n            f\"-{cfg.translator_service.value.value}\" if cfg.need_translate.value else \"\"\n        )\n\n        if need_next_task:\n            output_path = str(\n                Path(file_path).parent / f\"【样式字幕】{output_name}{suffix}.ass\"\n            )\n        else:\n            output_path = str(\n                Path(file_path).parent / f\"【字幕】{output_name}{suffix}.srt\"\n            )\n\n        # 根据当前选择的LLM服务获取对应的配置\n        current_service = cfg.llm_service.value\n        if current_service == LLMServiceEnum.OPENAI:\n            base_url = cfg.openai_api_base.value\n            api_key = cfg.openai_api_key.value\n            llm_model = cfg.openai_model.value\n        elif current_service == LLMServiceEnum.SILICON_CLOUD:\n            base_url = cfg.silicon_cloud_api_base.value\n            api_key = cfg.silicon_cloud_api_key.value\n            llm_model = cfg.silicon_cloud_model.value\n        elif current_service == LLMServiceEnum.DEEPSEEK:\n            base_url = cfg.deepseek_api_base.value\n            api_key = cfg.deepseek_api_key.value\n            llm_model = cfg.deepseek_model.value\n        elif current_service == LLMServiceEnum.OLLAMA:\n            base_url = cfg.ollama_api_base.value\n            api_key = cfg.ollama_api_key.value\n            llm_model = cfg.ollama_model.value\n        elif current_service == LLMServiceEnum.LM_STUDIO:\n            base_url = cfg.lm_studio_api_base.value\n            api_key = cfg.lm_studio_api_key.value\n            llm_model = cfg.lm_studio_model.value\n        elif current_service == LLMServiceEnum.GEMINI:\n            base_url = cfg.gemini_api_base.value\n            api_key = cfg.gemini_api_key.value\n            llm_model = cfg.gemini_model.value\n        elif current_service == LLMServiceEnum.CHATGLM:\n            base_url = cfg.chatglm_api_base.value\n            api_key = cfg.chatglm_api_key.value\n            llm_model = cfg.chatglm_model.value\n        else:\n            base_url = \"\"\n            api_key = \"\"\n            llm_model = \"\"\n\n        config = SubtitleConfig(\n            # 翻译配置\n            base_url=base_url,\n            api_key=api_key,\n            llm_model=llm_model,\n            deeplx_endpoint=cfg.deeplx_endpoint.value,\n            # 翻译服务\n            translator_service=cfg.translator_service.value,\n            # 字幕处理\n            need_reflect=cfg.need_reflect_translate.value,\n            need_translate=cfg.need_translate.value,\n            need_optimize=cfg.need_optimize.value,\n            thread_num=cfg.thread_num.value,\n            batch_size=cfg.batch_size.value,\n            # 字幕布局、样式\n            subtitle_layout=cfg.subtitle_layout.value,  # Now returns SubtitleLayoutEnum\n            subtitle_style=TaskFactory.get_ass_style(cfg.subtitle_style_name.value),\n            # 字幕分割\n            max_word_count_cjk=cfg.max_word_count_cjk.value,\n            max_word_count_english=cfg.max_word_count_english.value,\n            need_split=cfg.need_split.value,\n            # 字幕翻译\n            target_language=cfg.target_language.value,\n            # 字幕提示\n            custom_prompt_text=cfg.custom_prompt_text.value,\n        )\n\n        task = SubtitleTask(\n            queued_at=datetime.datetime.now(),\n            subtitle_path=file_path,\n            video_path=video_path,\n            output_path=output_path,\n            subtitle_config=config,\n            need_next_task=need_next_task,\n        )\n        if task_id:\n            task.task_id = task_id\n        return task\n\n    @staticmethod\n    def create_synthesis_task(\n        video_path: str,\n        subtitle_path: str,\n        need_next_task: bool = False,\n        task_id: Optional[str] = None,\n    ) -> SynthesisTask:\n        \"\"\"创建视频合成任务\"\"\"\n        if need_next_task:\n            output_path = str(\n                Path(video_path).parent / f\"【卡卡】{Path(video_path).stem}.mp4\"\n            )\n        else:\n            output_path = str(\n                Path(video_path).parent / f\"【卡卡】{Path(video_path).stem}.mp4\"\n            )\n\n        # 只有启用样式时才传入样式配置\n        use_style = cfg.use_subtitle_style.value\n        config = SynthesisConfig(\n            need_video=cfg.need_video.value,\n            soft_subtitle=cfg.soft_subtitle.value,\n            render_mode=cfg.subtitle_render_mode.value,\n            video_quality=cfg.video_quality.value,\n            subtitle_layout=cfg.subtitle_layout.value,\n            ass_style=TaskFactory.get_ass_style(cfg.subtitle_style_name.value) if use_style else \"\",\n            rounded_style=TaskFactory.get_rounded_style() if use_style else None,\n        )\n\n        task = SynthesisTask(\n            queued_at=datetime.datetime.now(),\n            video_path=video_path,\n            subtitle_path=subtitle_path,\n            output_path=output_path,\n            synthesis_config=config,\n            need_next_task=need_next_task,\n        )\n        if task_id:\n            task.task_id = task_id\n        return task\n\n    @staticmethod\n    def create_transcript_and_subtitle_task(\n        file_path: str,\n        output_path: Optional[str] = None,\n        transcribe_config: Optional[TranscribeConfig] = None,\n        subtitle_config: Optional[SubtitleConfig] = None,\n    ) -> TranscriptAndSubtitleTask:\n        \"\"\"创建转录和字幕任务\"\"\"\n        if output_path is None:\n            output_path = str(\n                Path(file_path).parent / f\"{Path(file_path).stem}_processed.srt\"\n            )\n\n        return TranscriptAndSubtitleTask(\n            queued_at=datetime.datetime.now(),\n            file_path=file_path,\n            output_path=output_path,\n        )\n\n    @staticmethod\n    def create_full_process_task(\n        file_path: str,\n        output_path: Optional[str] = None,\n        transcribe_config: Optional[TranscribeConfig] = None,\n        subtitle_config: Optional[SubtitleConfig] = None,\n        synthesis_config: Optional[SynthesisConfig] = None,\n    ) -> FullProcessTask:\n        \"\"\"创建完整处理任务（转录+字幕+合成）\"\"\"\n        if output_path is None:\n            output_path = str(\n                Path(file_path).parent\n                / f\"{Path(file_path).stem}_final{Path(file_path).suffix}\"\n            )\n\n        return FullProcessTask(\n            queued_at=datetime.datetime.now(),\n            file_path=file_path,\n            output_path=output_path,\n        )\n"
  },
  {
    "path": "app/core/translate/__init__.py",
    "content": "\"\"\"\n翻译模块\n\n提供多种翻译服务：OpenAI LLM、Google、Bing、DeepLX\n\"\"\"\n\nfrom app.core.entities import SubtitleProcessData\nfrom app.core.translate.base import BaseTranslator\nfrom app.core.translate.bing_translator import BingTranslator\nfrom app.core.translate.deeplx_translator import DeepLXTranslator\nfrom app.core.translate.factory import TranslatorFactory\nfrom app.core.translate.google_translator import GoogleTranslator\nfrom app.core.translate.llm_translator import LLMTranslator\nfrom app.core.translate.types import TargetLanguage, TranslatorType\n\n__all__ = [\n    \"BaseTranslator\",\n    \"SubtitleProcessData\",\n    \"TranslatorFactory\",\n    \"TranslatorType\",\n    \"TargetLanguage\",\n    \"BingTranslator\",\n    \"DeepLXTranslator\",\n    \"GoogleTranslator\",\n    \"LLMTranslator\",\n]\n"
  },
  {
    "path": "app/core/translate/base.py",
    "content": "\"\"\"翻译器基类\"\"\"\n\nimport atexit\nfrom abc import ABC, abstractmethod\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom typing import Callable, List, Optional\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.entities import SubtitleProcessData\nfrom app.core.translate.types import TargetLanguage\nfrom app.core.utils.cache import generate_cache_key, get_translate_cache\nfrom app.core.utils.logger import setup_logger\n\nlogger = setup_logger(\"subtitle_translator\")\n\n\nclass BaseTranslator(ABC):\n    \"\"\"翻译器基类\"\"\"\n\n    def __init__(\n        self,\n        thread_num: int,\n        batch_num: int,\n        target_language: TargetLanguage,\n        update_callback: Optional[Callable],\n    ):\n        self.thread_num = thread_num\n        self.batch_num = batch_num\n        self.target_language = target_language\n        self.is_running = True\n        self.update_callback = update_callback\n        self.executor = None\n        self._cache = get_translate_cache()\n\n        self._init_thread_pool()\n\n    def _init_thread_pool(self):\n        \"\"\"初始化线程池\"\"\"\n        self.executor = ThreadPoolExecutor(max_workers=self.thread_num)\n        atexit.register(self.stop)\n\n    def translate_subtitle(self, subtitle_data: ASRData) -> ASRData:\n        \"\"\"翻译字幕文件\"\"\"\n        try:\n            asr_data = subtitle_data\n\n            # 将ASRData转换为SubtitleProcessData列表\n            translate_data_list = [\n                SubtitleProcessData(index=i, original_text=seg.text)\n                for i, seg in enumerate(asr_data.segments, 1)\n            ]\n\n            # 分批处理字幕\n            chunks = self._split_chunks(translate_data_list)\n\n            # 多线程翻译\n            translated_list = self._parallel_translate(chunks)\n\n            # 设置字幕段的翻译文本\n            new_segments = self._set_segments_translated_text(\n                asr_data.segments, translated_list\n            )\n\n            return ASRData(new_segments)\n        except Exception as e:\n            logger.error(f\"翻译失败：{str(e)}\")\n            raise RuntimeError(f\"翻译失败：{str(e)}\")\n\n    def _split_chunks(\n        self, translate_data_list: List[SubtitleProcessData]\n    ) -> List[List[SubtitleProcessData]]:\n        \"\"\"将字幕分割成块\"\"\"\n        return [\n            translate_data_list[i : i + self.batch_num]\n            for i in range(0, len(translate_data_list), self.batch_num)\n        ]\n\n    def _parallel_translate(\n        self, chunks: List[List[SubtitleProcessData]]\n    ) -> List[SubtitleProcessData]:\n        \"\"\"并行翻译所有块\"\"\"\n        futures = []\n        translated_list = []\n\n        for chunk in chunks:\n            future = self.executor.submit(self._safe_translate_chunk, chunk)\n            futures.append(future)\n\n        for future in as_completed(futures):\n            if not self.is_running:\n                break\n            try:\n                result = future.result()\n                translated_list.extend(result)\n            except Exception as e:\n                logger.error(f\"翻译块失败：{str(e)}\")\n                translated_list.extend(chunk)\n\n        return translated_list\n\n    def _get_cache_key(self, chunk: List[SubtitleProcessData]) -> str:\n        \"\"\"生成缓存键\"\"\"\n        class_name = self.__class__.__name__\n        chunk_key = generate_cache_key(chunk)\n        lang = self.target_language.value\n        return f\"{class_name}:{chunk_key}:{lang}\"\n\n    def _safe_translate_chunk(\n        self, chunk: List[SubtitleProcessData]\n    ) -> List[SubtitleProcessData]:\n        \"\"\"安全的翻译块\"\"\"\n        try:\n            cache_key = self._get_cache_key(chunk)\n            cached_result = self._cache.get(cache_key, default=None)\n            if cached_result is not None:\n                return cached_result\n\n            result = self._translate_chunk(chunk)\n\n            if self.update_callback:\n                self.update_callback(result)\n\n            self._cache.set(cache_key, result, expire=86400 * 7)\n            return result\n\n        except Exception as e:\n            logger.exception(f\"翻译失败: {str(e)}\")\n            raise\n\n    @staticmethod\n    def _set_segments_translated_text(\n        original_segments: List[ASRDataSeg], translated_list: List[SubtitleProcessData]\n    ) -> List[ASRDataSeg]:\n        \"\"\"设置字幕段的翻译文本\"\"\"\n        # 创建索引到翻译文本的映射\n        translation_map = {data.index: data.translated_text for data in translated_list}\n\n        for i, seg in enumerate(original_segments, 1):\n            if i not in translation_map:\n                logger.error(f\"字幕段 {i} 没有翻译\")\n                continue\n            seg.translated_text = translation_map[i]\n\n        return original_segments\n\n    @abstractmethod\n    def _translate_chunk(\n        self, subtitle_chunk: List[SubtitleProcessData]\n    ) -> List[SubtitleProcessData]:\n        \"\"\"翻译字幕块\"\"\"\n        pass\n\n    def stop(self):\n        \"\"\"停止翻译器\"\"\"\n        if not self.is_running:\n            return\n\n        self.is_running = False\n        if hasattr(self, \"executor\") and self.executor is not None:\n            try:\n                self.executor.shutdown(wait=False, cancel_futures=True)\n            except Exception as e:\n                logger.error(f\"关闭线程池时出错：{str(e)}\")\n            finally:\n                self.executor = None\n"
  },
  {
    "path": "app/core/translate/bing_translator.py",
    "content": "\"\"\"Bing 翻译器\"\"\"\n\nfrom typing import Callable, List, Optional\n\nimport requests\n\nfrom app.core.entities import SubtitleProcessData\nfrom app.core.translate.base import BaseTranslator, logger\nfrom app.core.translate.types import TargetLanguage, get_language_code\nfrom app.core.utils.cache import generate_cache_key\n\n\nclass BingTranslator(BaseTranslator):\n    \"\"\"必应翻译器\"\"\"\n\n    def __init__(\n        self,\n        thread_num: int,\n        batch_num: int,\n        target_language: TargetLanguage,\n        update_callback: Optional[Callable],\n    ):\n        super().__init__(\n            thread_num=thread_num,\n            batch_num=batch_num,\n            target_language=target_language,\n            update_callback=update_callback,\n        )\n        self.timeout = 20\n        self.session = requests.Session()\n        self.auth_endpoint = \"https://edge.microsoft.com/translate/auth\"\n        self.translate_endpoint = (\n            \"https://api-edge.cognitive.microsofttranslator.com/translate\"\n        )\n\n        self.headers = {\n            \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0\",\n        }\n        self._init_session()\n\n    def _init_session(self):\n        \"\"\"初始化会话，获取必要的token\"\"\"\n        try:\n            response = self.session.get(self.auth_endpoint, timeout=self.timeout)\n            response.raise_for_status()\n            self.auth_token = response.text\n            self.headers[\"authorization\"] = f\"Bearer {self.auth_token}\"\n        except Exception as e:\n            logger.error(f\"初始化必应翻译会话失败: {str(e)}\")\n            raise RuntimeError(f\"初始化必应翻译会话失败: {str(e)}\")\n\n    def _translate_chunk(\n        self, subtitle_chunk: List[SubtitleProcessData]\n    ) -> List[SubtitleProcessData]:\n        \"\"\"翻译字幕块\"\"\"\n        target_lang = get_language_code(self.target_language, \"bing\")\n\n        # 准备批量翻译的数据\n        texts_to_translate = [\n            {\"Text\": data.original_text[:5000]} for data in subtitle_chunk\n        ]\n\n        if texts_to_translate:\n            try:\n                params = {\n                    \"to\": target_lang,\n                    \"api-version\": \"3.0\",\n                    \"includeSentenceLength\": \"true\",\n                }\n\n                response = self.session.post(\n                    self.translate_endpoint,\n                    params=params,\n                    headers=self.headers,\n                    json=texts_to_translate,\n                    timeout=self.timeout,\n                )\n                response.raise_for_status()\n                translations = response.json()\n\n                # 处理翻译结果\n                for i, translation in enumerate(translations):\n                    subtitle_chunk[i].translated_text = translation[\"translations\"][0][\n                        \"text\"\n                    ]\n\n            except Exception as e:\n                logger.error(f\"必应翻译失败: {str(e)}\")\n                if \"token\" in str(e).lower() or (\n                    hasattr(response, \"status_code\")\n                    and response.status_code in [401, 403]\n                ):\n                    try:\n                        self._init_session()\n                    except Exception as e:\n                        logger.error(f\"重新初始化必应翻译会话失败: {str(e)}\")\n\n        return subtitle_chunk\n\n    def _get_cache_key(self, chunk: List[SubtitleProcessData]) -> str:\n        \"\"\"生成缓存键\"\"\"\n        class_name = self.__class__.__name__\n        chunk_key = generate_cache_key(chunk)\n        lang = self.target_language.value\n        return f\"{class_name}:{chunk_key}:{lang}\"\n"
  },
  {
    "path": "app/core/translate/deeplx_translator.py",
    "content": "\"\"\"DeepLX 翻译器\"\"\"\n\nimport os\nfrom typing import Callable, List, Optional\n\nimport requests\n\nfrom app.core.translate.base import BaseTranslator, SubtitleProcessData, logger\nfrom app.core.translate.types import TargetLanguage, get_language_code\nfrom app.core.utils.cache import generate_cache_key\n\n\nclass DeepLXTranslator(BaseTranslator):\n    \"\"\"DeepLX翻译器\"\"\"\n\n    def __init__(\n        self,\n        thread_num: int,\n        batch_num: int,\n        target_language: TargetLanguage,\n        timeout: int,\n        update_callback: Optional[Callable],\n    ):\n        super().__init__(\n            thread_num=thread_num,\n            batch_num=batch_num,\n            target_language=target_language,\n            update_callback=update_callback,\n        )\n        self.timeout = timeout\n        self.session = requests.Session()\n        self.endpoint = os.getenv(\"DEEPLX_ENDPOINT\", \"https://api.deeplx.org/translate\")\n\n    def _translate_chunk(\n        self, subtitle_chunk: List[SubtitleProcessData]\n    ) -> List[SubtitleProcessData]:\n        \"\"\"翻译字幕块\"\"\"\n        target_lang = get_language_code(self.target_language, \"deeplx\")\n\n        for data in subtitle_chunk:\n            try:\n                response = self.session.post(\n                    self.endpoint,\n                    json={\n                        \"text\": data.original_text,\n                        \"source_lang\": \"auto\",\n                        \"target_lang\": target_lang,\n                    },\n                    timeout=self.timeout,\n                )\n                response.raise_for_status()\n                data.translated_text = response.json()[\"data\"]\n            except Exception as e:\n                logger.error(f\"DeepLX翻译失败 {data.index}: {str(e)}\")\n\n        return subtitle_chunk\n\n    def _get_cache_key(self, chunk: List[SubtitleProcessData]) -> str:\n        \"\"\"生成缓存键\"\"\"\n        class_name = self.__class__.__name__\n        chunk_key = generate_cache_key(chunk)\n        lang = self.target_language.value\n        return f\"{class_name}:{chunk_key}:{lang}\"\n"
  },
  {
    "path": "app/core/translate/factory.py",
    "content": "\"\"\"翻译器工厂\"\"\"\n\nfrom typing import Callable, Optional\n\nfrom app.core.translate.base import BaseTranslator\nfrom app.core.translate.bing_translator import BingTranslator\nfrom app.core.translate.deeplx_translator import DeepLXTranslator\nfrom app.core.translate.google_translator import GoogleTranslator\nfrom app.core.translate.llm_translator import LLMTranslator\nfrom app.core.translate.types import TargetLanguage, TranslatorType\nfrom app.core.utils.logger import setup_logger\n\nlogger = setup_logger(\"translator_factory\")\n\n\nclass TranslatorFactory:\n    \"\"\"翻译器工厂类\"\"\"\n\n    @staticmethod\n    def create_translator(\n        translator_type: TranslatorType,\n        thread_num: int = 5,\n        batch_num: int = 10,\n        target_language: Optional[TargetLanguage] = None,\n        model: str = \"gpt-4o-mini\",\n        custom_prompt: str = \"\",\n        is_reflect: bool = False,\n        update_callback: Optional[Callable] = None,\n    ) -> BaseTranslator:\n        \"\"\"创建翻译器实例\"\"\"\n        try:\n            # 如果没有指定目标语言，使用默认值\n            if target_language is None:\n                target_language = TargetLanguage.SIMPLIFIED_CHINESE\n\n            if translator_type == TranslatorType.OPENAI:\n                return LLMTranslator(\n                    thread_num=thread_num,\n                    batch_num=batch_num,\n                    target_language=target_language,\n                    model=model,\n                    custom_prompt=custom_prompt,\n                    is_reflect=is_reflect,\n                    update_callback=update_callback,\n                )\n            elif translator_type == TranslatorType.GOOGLE:\n                batch_num = 5\n                return GoogleTranslator(\n                    thread_num=thread_num,\n                    batch_num=batch_num,\n                    target_language=target_language,\n                    timeout=20,\n                    update_callback=update_callback,\n                )\n            elif translator_type == TranslatorType.BING:\n                batch_num = 10\n                return BingTranslator(\n                    thread_num=thread_num,\n                    batch_num=batch_num,\n                    target_language=target_language,\n                    update_callback=update_callback,\n                )\n            elif translator_type == TranslatorType.DEEPLX:\n                batch_num = 5\n                return DeepLXTranslator(\n                    thread_num=thread_num,\n                    batch_num=batch_num,\n                    target_language=target_language,\n                    timeout=20,\n                    update_callback=update_callback,\n                )\n        except Exception as e:\n            logger.error(f\"创建翻译器失败：{str(e)}\")\n            raise\n"
  },
  {
    "path": "app/core/translate/google_translator.py",
    "content": "\"\"\"Google 翻译器\"\"\"\n\nimport html\nimport re\nfrom typing import Callable, List, Optional\n\nimport requests\n\nfrom app.core.entities import SubtitleProcessData\nfrom app.core.translate.base import BaseTranslator, logger\nfrom app.core.translate.types import TargetLanguage, get_language_code\nfrom app.core.utils.cache import generate_cache_key\n\n\nclass GoogleTranslator(BaseTranslator):\n    \"\"\"谷歌翻译器\"\"\"\n\n    def __init__(\n        self,\n        thread_num: int,\n        batch_num: int,\n        target_language: TargetLanguage,\n        timeout: int,\n        update_callback: Optional[Callable],\n    ):\n        super().__init__(\n            thread_num=thread_num,\n            batch_num=batch_num,\n            target_language=target_language,\n            update_callback=update_callback,\n        )\n        self.timeout = timeout\n        self.session = requests.Session()\n        self.endpoint = \"http://translate.google.com/m\"\n        self.headers = {\n            \"User-Agent\": \"Mozilla/4.0 (compatible;MSIE 6.0;Windows NT 5.1;SV1;.NET CLR 1.1.4322;.NET CLR 2.0.50727;.NET CLR 3.0.04506.30)\"\n        }\n\n    def _translate_chunk(\n        self, subtitle_chunk: List[SubtitleProcessData]\n    ) -> List[SubtitleProcessData]:\n        \"\"\"翻译字幕块\"\"\"\n        target_lang = get_language_code(self.target_language, \"google\")\n\n        for data in subtitle_chunk:\n            try:\n                text = data.original_text[:5000]  # google translate max length\n                response = self.session.get(\n                    self.endpoint,\n                    params={\"tl\": target_lang, \"sl\": \"auto\", \"q\": text},\n                    headers=self.headers,\n                    timeout=self.timeout,\n                )\n\n                if response.status_code == 400:\n                    logger.warning(f\"Google翻译返回400错误 {data.index}\")\n                    continue\n\n                response.raise_for_status()\n                re_result = re.findall(\n                    r'(?s)class=\"(?:t0|result-container)\">(.*?)<', response.text\n                )\n                if re_result:\n                    data.translated_text = html.unescape(re_result[0])\n                    data.translated_text = data.translated_text\n                else:\n                    logger.warning(f\"无法从Google翻译响应中提取翻译结果: {data.index}\")\n            except Exception as e:\n                logger.error(f\"Google翻译失败 {data.index}: {str(e)}\")\n\n        return subtitle_chunk\n\n    def _get_cache_key(self, chunk: List[SubtitleProcessData]) -> str:\n        \"\"\"生成缓存键\"\"\"\n        class_name = self.__class__.__name__\n        chunk_key = generate_cache_key(chunk)\n        lang = self.target_language.value\n        return f\"{class_name}:{chunk_key}:{lang}\"\n"
  },
  {
    "path": "app/core/translate/llm_translator.py",
    "content": "\"\"\"LLM 翻译器（使用 OpenAI）\"\"\"\n\nimport json\nfrom typing import Any, Callable, Dict, List, Optional, Tuple\n\nimport json_repair\nimport openai\n\nfrom app.core.llm import call_llm\nfrom app.core.prompts import get_prompt\nfrom app.core.translate.base import BaseTranslator, SubtitleProcessData, logger\nfrom app.core.translate.types import TargetLanguage\nfrom app.core.utils.cache import generate_cache_key\n\n\nclass LLMTranslator(BaseTranslator):\n    \"\"\"LLM 翻译器（OpenAI兼容API）\"\"\"\n\n    MAX_STEPS = 3\n\n    def __init__(\n        self,\n        thread_num: int,\n        batch_num: int,\n        target_language: TargetLanguage,\n        model: str,\n        custom_prompt: str,\n        is_reflect: bool,\n        update_callback: Optional[Callable],\n    ):\n        super().__init__(\n            thread_num=thread_num,\n            batch_num=batch_num,\n            target_language=target_language,\n            update_callback=update_callback,\n        )\n\n        self.model = model\n        self.custom_prompt = custom_prompt\n        self.is_reflect = is_reflect\n\n    def _translate_chunk(\n        self, subtitle_chunk: List[SubtitleProcessData]\n    ) -> List[SubtitleProcessData]:\n        \"\"\"翻译字幕块\"\"\"\n        logger.info(\n            f\"[+]正在翻译字幕：{subtitle_chunk[0].index} - {subtitle_chunk[-1].index}\"\n        )\n\n        # 转换为字典格式用于API调用\n        subtitle_dict = {str(data.index): data.original_text for data in subtitle_chunk}\n\n        # 获取提示词\n        if self.is_reflect:\n            prompt = get_prompt(\n                \"translate/reflect\",\n                target_language=self.target_language,\n                custom_prompt=self.custom_prompt,\n            )\n        else:\n            prompt = get_prompt(\n                \"translate/standard\",\n                target_language=self.target_language,\n                custom_prompt=self.custom_prompt,\n            )\n\n        try:\n            # 使用agent loop进行翻译，自动验证和修正\n            result_dict = self._agent_loop(prompt, subtitle_dict)\n\n            # 处理反思翻译模式的结果\n            if self.is_reflect and isinstance(result_dict, dict):\n                processed_result = {\n                    k: f\"{v.get('native_translation', v) if isinstance(v, dict) else v}\"\n                    for k, v in result_dict.items()\n                }\n            else:\n                processed_result = {k: f\"{v}\" for k, v in result_dict.items()}\n\n            # 将结果填充回SubtitleProcessData\n            for data in subtitle_chunk:\n                data.translated_text = processed_result.get(\n                    str(data.index), data.original_text\n                )\n            return subtitle_chunk\n        except openai.RateLimitError as e:\n            logger.error(f\"OpenAI Rate Limit Error: {str(e)}\")\n        except openai.AuthenticationError as e:\n            logger.error(f\"OpenAI Authentication Error: {str(e)}\")\n        except openai.NotFoundError as e:\n            logger.error(f\"OpenAI NotFound Error: {str(e)}\")\n        except Exception as e:\n            logger.exception(f\"Error: {str(e)}\")\n            return self._translate_chunk_single(subtitle_chunk)\n\n    def _agent_loop(\n        self, system_prompt: str, subtitle_dict: Dict[str, str]\n    ) -> Dict[str, str]:\n        \"\"\"Agent loop翻译字幕块\"\"\"\n        messages = [\n            {\"role\": \"system\", \"content\": system_prompt},\n            {\"role\": \"user\", \"content\": json.dumps(subtitle_dict, ensure_ascii=False)},\n        ]\n        last_response_dict = None\n        # llm 反馈循环\n        for _ in range(self.MAX_STEPS):\n            response = call_llm(messages=messages, model=self.model)\n            response_dict = json_repair.loads(\n                response.choices[0].message.content.strip()\n            )\n            last_response_dict = response_dict\n            is_valid, error_message = self._validate_llm_response(\n                response_dict, subtitle_dict\n            )\n            if is_valid:\n                return response_dict\n            else:\n                messages.append(\n                    {\n                        \"role\": \"assistant\",\n                        \"content\": json.dumps(response_dict, ensure_ascii=False),\n                    }\n                )\n                messages.append(\n                    {\n                        \"role\": \"user\",\n                        \"content\": f\"Error: {error_message}\\n\\nFix the errors above and output ONLY a valid JSON dictionary with ALL {len(subtitle_dict)} keys\",\n                    }\n                )\n\n        return last_response_dict\n\n    def _validate_llm_response(\n        self, response_dict: Any, subtitle_dict: Dict[str, str]\n    ) -> Tuple[bool, str]:\n        \"\"\"验证LLM翻译结果（支持普通和反思模式）\n\n        返回: (是否有效, 错误反馈)\n        \"\"\"\n        if not isinstance(response_dict, dict):\n            return (\n                False,\n                f\"Output must be a dict, got {type(response_dict).__name__}. Use format: {{'0': 'text', '1': 'text'}}\",\n            )\n\n        expected_keys = set(subtitle_dict.keys())\n        actual_keys = set(response_dict.keys())\n\n        def sort_keys(keys):\n            return sorted(keys, key=lambda x: int(x) if x.isdigit() else x)\n\n        # 检查键是否匹配\n        if expected_keys != actual_keys:\n            missing = expected_keys - actual_keys\n            extra = actual_keys - expected_keys\n            error_parts = []\n\n            if missing:\n                error_parts.append(\n                    f\"Missing keys {sort_keys(missing)} - you must translate these items\"\n                )\n            if extra:\n                error_parts.append(\n                    f\"Extra keys {sort_keys(extra)} - these keys are not in input, remove them\"\n                )\n\n            return (False, \"; \".join(error_parts))\n\n        # 如果是反思模式，检查嵌套结构\n        if self.is_reflect:\n            for key, value in response_dict.items():\n                if not isinstance(value, dict):\n                    return (\n                        False,\n                        f\"Key '{key}': value must be a dict with 'native_translation' field. Got {type(value).__name__}.\",\n                    )\n\n                if \"native_translation\" not in value:\n                    available_keys = list(value.keys())\n                    return (\n                        False,\n                        f\"Key '{key}': missing 'native_translation' field. Found keys: {available_keys}. Must include 'native_translation'.\",\n                    )\n\n        return True, \"\"\n\n    def _translate_chunk_single(\n        self, subtitle_chunk: List[SubtitleProcessData]\n    ) -> List[SubtitleProcessData]:\n        \"\"\"单条翻译模式\"\"\"\n        single_prompt = get_prompt(\n            \"translate/single\", target_language=self.target_language\n        )\n\n        for data in subtitle_chunk:\n            try:\n                response = call_llm(\n                    messages=[\n                        {\"role\": \"system\", \"content\": single_prompt},\n                        {\"role\": \"user\", \"content\": data.original_text},\n                    ],\n                    model=self.model,\n                    temperature=0.7,\n                )\n                translated_text = response.choices[0].message.content.strip()\n                data.translated_text = translated_text\n            except Exception as e:\n                logger.error(f\"单条翻译失败 {data.index}: {str(e)}\")\n\n        return subtitle_chunk\n\n    def _get_cache_key(self, chunk: List[SubtitleProcessData]) -> str:\n        \"\"\"生成缓存键\"\"\"\n        class_name = self.__class__.__name__\n        chunk_key = generate_cache_key(chunk)\n        lang = self.target_language.value\n        model = self.model\n        return f\"{class_name}:{chunk_key}:{lang}:{model}\"\n"
  },
  {
    "path": "app/core/translate/types.py",
    "content": "\"\"\"翻译器类型枚举\"\"\"\n\nfrom enum import Enum\n\n\nclass TranslatorType(Enum):\n    \"\"\"翻译器类型\"\"\"\n\n    OPENAI = \"openai\"\n    GOOGLE = \"google\"\n    BING = \"bing\"\n    DEEPLX = \"deeplx\"\n\n\nclass TargetLanguage(Enum):\n    \"\"\"目标语言枚举\"\"\"\n\n    # 中文\n    SIMPLIFIED_CHINESE = \"简体中文\"\n    TRADITIONAL_CHINESE = \"繁体中文\"\n\n    # 英语\n    ENGLISH = \"英语\"\n    ENGLISH_US = \"英语(美国)\"\n    ENGLISH_UK = \"英语(英国)\"\n\n    # 亚洲语言\n    JAPANESE = \"日本語\"\n    KOREAN = \"韩语\"\n    CANTONESE = \"粤语\"\n    THAI = \"泰语\"\n    VIETNAMESE = \"越南语\"\n    INDONESIAN = \"印尼语\"\n    MALAY = \"马来语\"\n    TAGALOG = \"菲律宾语\"\n\n    # 欧洲语言\n    FRENCH = \"法语\"\n    GERMAN = \"德语\"\n    SPANISH = \"西班牙语\"\n    SPANISH_LATAM = \"西班牙语(拉丁美洲)\"\n    RUSSIAN = \"俄语\"\n    PORTUGUESE = \"葡萄牙语\"\n    PORTUGUESE_BR = \"葡萄牙语(巴西)\"\n    PORTUGUESE_PT = \"葡萄牙语(葡萄牙)\"\n    ITALIAN = \"意大利语\"\n    DUTCH = \"荷兰语\"\n    POLISH = \"波兰语\"\n    TURKISH = \"土耳其语\"\n    GREEK = \"希腊语\"\n    CZECH = \"捷克语\"\n    SWEDISH = \"瑞典语\"\n    DANISH = \"丹麦语\"\n    FINNISH = \"芬兰语\"\n    NORWEGIAN = \"挪威语\"\n    HUNGARIAN = \"匈牙利语\"\n    ROMANIAN = \"罗马尼亚语\"\n    BULGARIAN = \"保加利亚语\"\n    UKRAINIAN = \"乌克兰语\"\n\n    # 中东语言\n    ARABIC = \"阿拉伯语\"\n    HEBREW = \"希伯来语\"\n    PERSIAN = \"波斯语\"\n\n\n# Google Translate 语言代码映射\nGOOGLE_LANG_MAP = {\n    # 中文\n    TargetLanguage.SIMPLIFIED_CHINESE: \"zh-CN\",\n    TargetLanguage.TRADITIONAL_CHINESE: \"zh-TW\",\n    # 英语\n    TargetLanguage.ENGLISH: \"en\",\n    TargetLanguage.ENGLISH_US: \"en\",\n    TargetLanguage.ENGLISH_UK: \"en\",\n    # 亚洲语言\n    TargetLanguage.JAPANESE: \"ja\",\n    TargetLanguage.KOREAN: \"ko\",\n    TargetLanguage.CANTONESE: \"yue\",\n    TargetLanguage.THAI: \"th\",\n    TargetLanguage.VIETNAMESE: \"vi\",\n    TargetLanguage.INDONESIAN: \"id\",\n    TargetLanguage.MALAY: \"ms\",\n    TargetLanguage.TAGALOG: \"tl\",\n    # 欧洲语言\n    TargetLanguage.FRENCH: \"fr\",\n    TargetLanguage.GERMAN: \"de\",\n    TargetLanguage.SPANISH: \"es\",\n    TargetLanguage.SPANISH_LATAM: \"es\",\n    TargetLanguage.RUSSIAN: \"ru\",\n    TargetLanguage.PORTUGUESE: \"pt\",\n    TargetLanguage.PORTUGUESE_BR: \"pt\",\n    TargetLanguage.PORTUGUESE_PT: \"pt\",\n    TargetLanguage.ITALIAN: \"it\",\n    TargetLanguage.DUTCH: \"nl\",\n    TargetLanguage.POLISH: \"pl\",\n    TargetLanguage.TURKISH: \"tr\",\n    TargetLanguage.GREEK: \"el\",\n    TargetLanguage.CZECH: \"cs\",\n    TargetLanguage.SWEDISH: \"sv\",\n    TargetLanguage.DANISH: \"da\",\n    TargetLanguage.FINNISH: \"fi\",\n    TargetLanguage.NORWEGIAN: \"no\",\n    TargetLanguage.HUNGARIAN: \"hu\",\n    TargetLanguage.ROMANIAN: \"ro\",\n    TargetLanguage.BULGARIAN: \"bg\",\n    TargetLanguage.UKRAINIAN: \"uk\",\n    # 中东语言\n    TargetLanguage.ARABIC: \"ar\",\n    TargetLanguage.HEBREW: \"he\",\n    TargetLanguage.PERSIAN: \"fa\",\n}\n\n# Bing Translator 语言代码映射\nBING_LANG_MAP = {\n    # 中文\n    TargetLanguage.SIMPLIFIED_CHINESE: \"zh-Hans\",\n    TargetLanguage.TRADITIONAL_CHINESE: \"zh-Hant\",\n    # 英语\n    TargetLanguage.ENGLISH: \"en\",\n    TargetLanguage.ENGLISH_US: \"en\",\n    TargetLanguage.ENGLISH_UK: \"en\",\n    # 亚洲语言\n    TargetLanguage.JAPANESE: \"ja\",\n    TargetLanguage.KOREAN: \"ko\",\n    TargetLanguage.CANTONESE: \"yue\",\n    TargetLanguage.THAI: \"th\",\n    TargetLanguage.VIETNAMESE: \"vi\",\n    TargetLanguage.INDONESIAN: \"id\",\n    TargetLanguage.MALAY: \"ms\",\n    TargetLanguage.TAGALOG: \"fil\",\n    # 欧洲语言\n    TargetLanguage.FRENCH: \"fr\",\n    TargetLanguage.GERMAN: \"de\",\n    TargetLanguage.SPANISH: \"es\",\n    TargetLanguage.SPANISH_LATAM: \"es\",\n    TargetLanguage.RUSSIAN: \"ru\",\n    TargetLanguage.PORTUGUESE: \"pt\",\n    TargetLanguage.PORTUGUESE_BR: \"pt\",\n    TargetLanguage.PORTUGUESE_PT: \"pt-PT\",\n    TargetLanguage.ITALIAN: \"it\",\n    TargetLanguage.DUTCH: \"nl\",\n    TargetLanguage.POLISH: \"pl\",\n    TargetLanguage.TURKISH: \"tr\",\n    TargetLanguage.GREEK: \"el\",\n    TargetLanguage.CZECH: \"cs\",\n    TargetLanguage.SWEDISH: \"sv\",\n    TargetLanguage.DANISH: \"da\",\n    TargetLanguage.FINNISH: \"fi\",\n    TargetLanguage.NORWEGIAN: \"nb\",\n    TargetLanguage.HUNGARIAN: \"hu\",\n    TargetLanguage.ROMANIAN: \"ro\",\n    TargetLanguage.BULGARIAN: \"bg\",\n    TargetLanguage.UKRAINIAN: \"uk\",\n    # 中东语言\n    TargetLanguage.ARABIC: \"ar\",\n    TargetLanguage.HEBREW: \"he\",\n    TargetLanguage.PERSIAN: \"fa\",\n}\n\n# DeepL 语言代码映射\nDEEPL_LANG_MAP = {\n    # 中文\n    TargetLanguage.SIMPLIFIED_CHINESE: \"zh-Hans\",\n    TargetLanguage.TRADITIONAL_CHINESE: \"zh-Hant\",\n    # 英语\n    TargetLanguage.ENGLISH: \"en\",\n    TargetLanguage.ENGLISH_US: \"en-US\",\n    TargetLanguage.ENGLISH_UK: \"en-GB\",\n    # 亚洲语言\n    TargetLanguage.JAPANESE: \"ja\",\n    TargetLanguage.KOREAN: \"ko\",\n    TargetLanguage.INDONESIAN: \"id\",\n    # 欧洲语言\n    TargetLanguage.FRENCH: \"fr\",\n    TargetLanguage.GERMAN: \"de\",\n    TargetLanguage.SPANISH: \"es\",\n    TargetLanguage.RUSSIAN: \"ru\",\n    TargetLanguage.PORTUGUESE: \"pt\",\n    TargetLanguage.PORTUGUESE_BR: \"pt-BR\",\n    TargetLanguage.PORTUGUESE_PT: \"pt-PT\",\n    TargetLanguage.ITALIAN: \"it\",\n    TargetLanguage.DUTCH: \"nl\",\n    TargetLanguage.POLISH: \"pl\",\n    TargetLanguage.TURKISH: \"tr\",\n    TargetLanguage.GREEK: \"el\",\n    TargetLanguage.CZECH: \"cs\",\n    TargetLanguage.SWEDISH: \"sv\",\n    TargetLanguage.DANISH: \"da\",\n    TargetLanguage.FINNISH: \"fi\",\n    TargetLanguage.NORWEGIAN: \"nb\",\n    TargetLanguage.HUNGARIAN: \"hu\",\n    TargetLanguage.ROMANIAN: \"ro\",\n    TargetLanguage.BULGARIAN: \"bg\",\n    TargetLanguage.UKRAINIAN: \"uk\",\n    # 中东语言\n    TargetLanguage.ARABIC: \"ar\",\n}\n\n\ndef get_language_code(target_language: TargetLanguage, translator_type: str) -> str:\n    \"\"\"\n    获取翻译服务对应的语言代码\n\n    Args:\n        target_language: 目标语言枚举\n        translator_type: 翻译器类型（google/bing/deeplx）\n\n    Returns:\n        语言代码字符串\n    \"\"\"\n    lang_map = {\n        \"google\": GOOGLE_LANG_MAP,\n        \"bing\": BING_LANG_MAP,\n        \"deeplx\": DEEPL_LANG_MAP,\n    }\n\n    # 获取对应的语言映射\n    mapping = lang_map.get(translator_type, {})\n\n    # 使用枚举的 value（中文名称）查找语言代码\n    if target_language in mapping:\n        return mapping[target_language]\n\n    # 默认返回简体中文\n    return mapping.get(TargetLanguage.SIMPLIFIED_CHINESE, \"zh-CN\")\n"
  },
  {
    "path": "app/core/tts/__init__.py",
    "content": "\"\"\"TTS (Text-To-Speech) 模块\n\n提供多种 TTS 服务的统一接口\n\"\"\"\n\nfrom .base import BaseTTS\nfrom .openai_fm import OpenAIFmTTS\nfrom .openai_tts import OpenAITTS\nfrom .siliconflow import SiliconFlowTTS, VoiceCloneManager\nfrom .status import TTSStatus\nfrom .tts_data import TTSConfig, TTSData, TTSDataSeg\n\n__all__ = [\n    \"BaseTTS\",\n    \"OpenAITTS\",\n    \"OpenAIFmTTS\",\n    \"SiliconFlowTTS\",\n    \"VoiceCloneManager\",\n    \"TTSStatus\",\n    \"TTSConfig\",\n    \"TTSData\",\n    \"TTSDataSeg\",\n]\n"
  },
  {
    "path": "app/core/tts/base.py",
    "content": "\"\"\"TTS 基类 - 提供缓存、批量处理等通用功能\"\"\"\n\nimport hashlib\nfrom abc import ABC, abstractmethod\nfrom pathlib import Path\nfrom typing import Callable, Optional, cast\n\nfrom app.core.tts.status import TTSStatus\nfrom app.core.tts.tts_data import TTSConfig, TTSData, TTSDataSeg\nfrom app.core.utils.cache import get_tts_cache, is_cache_enabled\nfrom app.core.utils.logger import setup_logger\n\nlogger = setup_logger(\"tts\")\n\n\nclass BaseTTS(ABC):\n    \"\"\"TTS 基类\n\n    提供通用功能：\n    - 缓存机制（二进制数据缓存）\n    - 批量处理（统一接口）\n    - 配置管理\n    \"\"\"\n\n    def __init__(self, config: TTSConfig):\n        \"\"\"初始化\n\n        Args:\n            config: TTS 配置\n        \"\"\"\n        self.config = config\n        self.cache = get_tts_cache()  # 总是初始化缓存实例\n\n    def synthesize(\n        self,\n        tts_data: TTSData,\n        output_dir: str,\n        callback: Optional[Callable[[int, str], None]] = None,\n    ) -> TTSData:\n        \"\"\"合成语音（统一批量处理接口）\n\n        Args:\n            tts_data: TTS 数据（包含多个待合成的文本段）\n            output_dir: 输出目录\n            callback: 进度回调函数 callback(progress: int, message: str)\n\n        Returns:\n            TTS 数据（segments 已填充 audio_path 等信息）\n        \"\"\"\n\n        def _default_callback(progress: int, message: str):\n            pass\n\n        if callback is None:\n            callback = _default_callback\n\n        output_path = Path(output_dir)\n        output_path.mkdir(parents=True, exist_ok=True)\n\n        total = len(tts_data.segments)\n        if total == 0:\n            logger.warning(\"TTS 数据为空，无需合成\")\n            return tts_data\n\n        logger.info(f\"开始批量合成 {total} 条语音\")\n\n        for idx, segment in enumerate(tts_data.segments):\n            try:\n                # 计算进度\n                progress = int((idx / total) * 100)\n                callback(progress, \"synthesizing\")\n\n                # 生成音频文件名\n                audio_filename = self._generate_filename(segment.text, idx)\n                audio_path = output_path / audio_filename\n\n                # 合成单条语音（带缓存）\n                self._synthesize_segment(segment, str(audio_path))\n\n            except Exception as e:\n                logger.error(\n                    f\"TTS 失败 [{idx+1}/{total}]: {segment.text[:50]}... - {str(e)}\"\n                )\n                # 失败时保持 segment，但不设置 audio_path\n\n        callback(*TTSStatus.COMPLETED.callback_tuple())\n        success_count = sum(1 for seg in tts_data.segments if seg.audio_path)\n        logger.info(f\"批量 TTS 完成: 成功 {success_count}/{total}\")\n        return tts_data\n\n    def _synthesize_segment(self, segment: TTSDataSeg, output_path: str) -> None:\n        \"\"\"合成单个片段的语音（带缓存）\n\n        Args:\n            segment: TTS 数据段（会被修改，填充 audio_path 等）\n            output_path: 输出音频路径\n        \"\"\"\n        # 生成缓存键（考虑声音克隆）\n        cache_key = self._generate_cache_key_for_segment(segment)\n\n        # 检查缓存\n        if self.config.use_cache and is_cache_enabled():\n            cached_audio_data = cast(Optional[bytes], self.cache.get(cache_key))\n\n            if cached_audio_data:\n                logger.info(f\"使用缓存: {segment.text[:50]}...\")\n                # 将缓存的二进制数据写入文件\n                Path(output_path).parent.mkdir(parents=True, exist_ok=True)\n                with open(output_path, \"wb\") as f:\n                    f.write(cached_audio_data)\n\n                # 更新 segment\n                segment.audio_path = output_path\n                # TODO: 从缓存元数据中获取 audio_duration\n                return\n\n        # 调用子类实现的核心方法\n        self._synthesize(segment, output_path)\n\n        # 保存二进制数据到缓存\n        if self.config.use_cache and is_cache_enabled():\n            try:\n                with open(output_path, \"rb\") as f:\n                    audio_data = f.read()\n                self.cache.set(cache_key, audio_data, expire=self.config.cache_ttl)\n            except Exception as e:\n                logger.warning(f\"缓存保存失败: {str(e)}\")\n\n    @abstractmethod\n    def _synthesize(self, segment: TTSDataSeg, output_path: str) -> None:\n        \"\"\"合成语音的核心实现（子类必须实现）\n\n        Args:\n            segment: TTS 数据段（需要填充 audio_path, voice, clone_voice_uri 等字段）\n            output_path: 输出音频路径\n        \"\"\"\n        pass\n\n    def _generate_cache_key_for_segment(self, segment: TTSDataSeg) -> str:\n        \"\"\"为 segment 生成缓存键（考虑声音克隆）\"\"\"\n        content_parts = [\n            segment.text,\n            self.config.model,\n            str(self.config.speed),\n            str(self.config.gain),\n        ]\n\n        # 音色信息\n        if segment.clone_audio_path and segment.clone_audio_text:\n            # 声音克隆：使用参考音频的哈希\n            try:\n                with open(segment.clone_audio_path, \"rb\") as f:\n                    audio_hash = hashlib.md5(f.read()).hexdigest()[:12]\n                content_parts.append(f\"clone_{audio_hash}\")\n            except Exception:\n                content_parts.append(f\"clone_{segment.clone_audio_path}\")\n        elif segment.voice:\n            # 指定音色\n            content_parts.append(f\"voice_{segment.voice}\")\n        elif self.config.voice:\n            # 默认音色\n            content_parts.append(f\"voice_{self.config.voice}\")\n\n        content = \"_\".join(content_parts)\n        return hashlib.md5(content.encode()).hexdigest()\n\n    def _generate_filename(self, text: str, index: int) -> str:\n        \"\"\"生成音频文件名\n\n        Args:\n            text: 文本内容\n            index: 索引\n\n        Returns:\n            文件名\n        \"\"\"\n        # 使用索引和文本哈希生成文件名\n        text_hash = hashlib.md5(text.encode()).hexdigest()[:8]\n        ext = self.config.response_format\n        return f\"tts_{index:04d}_{text_hash}.{ext}\"\n"
  },
  {
    "path": "app/core/tts/openai_fm.py",
    "content": "\"\"\"OpenAI.fm TTS 实现\n\nOpenAI.fm 是一个免费的 TTS 服务，提供多种音色和语音风格。\nAPI 文档: https://www.openai.fm/\n\"\"\"\n\nfrom urllib.parse import quote\n\nimport requests\n\nfrom app.core.tts.base import BaseTTS\nfrom app.core.tts.tts_data import TTSConfig, TTSDataSeg\nfrom app.core.utils.logger import setup_logger\n\nlogger = setup_logger(\"tts.openai_fm\")\n\n\nclass OpenAIFmTTS(BaseTTS):\n    \"\"\"OpenAI.fm TTS API 实现\n\n    免费的云端 TTS 服务，支持多种音色和语音风格。\n    \"\"\"\n\n    # 预定义音色\n    VOICES = {\n        \"alloy\": \"alloy\",\n        \"echo\": \"echo\",\n        \"fable\": \"fable\",\n        \"onyx\": \"onyx\",\n        \"nova\": \"nova\",\n        \"shimmer\": \"shimmer\",\n    }\n\n    # 预定义提示词模板\n    PROMPT_TEMPLATES = {\n        \"natural\": \"Natural and conversational voice with clear pronunciation.\",\n        \"professional\": \"Professional and formal tone, suitable for business presentations.\",\n        \"friendly\": \"Warm and friendly tone, like talking to a friend.\",\n        \"storyteller\": \"Expressive and engaging, perfect for storytelling.\",\n        \"news\": \"Clear and authoritative, like a news anchor.\",\n        \"casual\": \"Relaxed and informal, everyday conversation style.\",\n    }\n\n    # API 端点（固定，不可配置）\n    API_URL = \"https://www.openai.fm/api/generate\"\n\n    def __init__(self, config: TTSConfig):\n        \"\"\"初始化\n\n        Args:\n            config: TTS 配置\n                - voice: 音色选择 (alloy, echo, fable, onyx, nova, shimmer)\n                - 不需要 api_key 和 base_url\n        \"\"\"\n        super().__init__(config)\n\n        # 默认音色\n        if not config.voice:\n            config.voice = \"fable\"\n\n    def _synthesize(self, segment: TTSDataSeg, output_path: str) -> None:\n        \"\"\"合成语音的核心实现\n\n        Args:\n            segment: TTS 数据段\n            output_path: 输出音频路径\n        \"\"\"\n        # 构建提示词\n        prompt = self._build_prompt()\n\n        # 音色选择\n        voice_to_use = segment.voice or self.config.voice or \"fable\"\n\n        # 构建请求参数\n        params = {\n            \"input\": segment.text,\n            \"prompt\": prompt,\n            \"voice\": voice_to_use,\n        }\n\n        logger.info(\n            f\"调用 OpenAI.fm TTS API: {segment.text[:50]}... (voice={voice_to_use})\"\n        )\n\n        # 发送请求（使用固定 API URL）\n        response = requests.get(\n            self.API_URL,\n            params=params,\n            timeout=self.config.timeout,\n        )\n        response.raise_for_status()\n\n        # 保存音频文件\n        with open(output_path, \"wb\") as f:\n            f.write(response.content)\n\n        logger.info(f\"TTS 成功: {output_path}\")\n\n        # 更新 segment\n        segment.audio_path = output_path\n        segment.voice = voice_to_use\n\n    def _build_prompt(self) -> str:\n        \"\"\"构建提示词\n\n        Returns:\n            提示词字符串\n        \"\"\"\n        # 如果配置中有自定义提示词，直接使用\n        if self.config.custom_prompt:\n            return self.config.custom_prompt\n\n        # 使用默认提示词\n        return self.PROMPT_TEMPLATES[\"natural\"]\n\n    @staticmethod\n    def get_available_voices():\n        \"\"\"获取可用音色列表\n\n        Returns:\n            音色列表\n        \"\"\"\n        return list(OpenAIFmTTS.VOICES.keys())\n\n    @staticmethod\n    def get_prompt_templates():\n        \"\"\"获取预定义提示词模板\n\n        Returns:\n            提示词模板字典\n        \"\"\"\n        return OpenAIFmTTS.PROMPT_TEMPLATES.copy()\n"
  },
  {
    "path": "app/core/tts/openai_tts.py",
    "content": "\"\"\"OpenAI TTS 实现（支持 OpenAI 兼容接口）\"\"\"\n\nfrom openai import OpenAI\n\nfrom app.core.tts.base import BaseTTS\nfrom app.core.tts.tts_data import TTSConfig, TTSDataSeg\nfrom app.core.utils.logger import setup_logger\n\nlogger = setup_logger(\"tts.openai\")\n\n\nclass OpenAITTS(BaseTTS):\n    \"\"\"OpenAI TTS API 实现\n\n    支持 OpenAI 及其兼容接口（如 SiliconFlow）\n    \"\"\"\n\n    def __init__(self, config: TTSConfig):\n        \"\"\"初始化\n\n        Args:\n            config: TTS 配置\n        \"\"\"\n        super().__init__(config)\n        if not config.api_key:\n            raise ValueError(\"API key is required for OpenAI TTS\")\n\n        # 初始化 OpenAI 客户端\n        self.client = OpenAI(\n            api_key=config.api_key,\n            base_url=config.base_url,\n        )\n\n    def _synthesize(self, segment: TTSDataSeg, output_path: str) -> None:\n        \"\"\"合成语音的核心实现\n\n        Args:\n            segment: TTS 数据段\n            output_path: 输出音频路径\n        \"\"\"\n        logger.info(f\"调用 OpenAI TTS API: {segment.text[:50]}...\")\n\n        # 音色选择\n        voice_to_use = segment.voice or self.config.voice or \"alloy\"\n\n        # 调用 OpenAI TTS API（流式响应）\n        with self.client.audio.speech.with_streaming_response.create(\n            model=self.config.model,\n            voice=voice_to_use,\n            input=segment.text,\n            response_format=self.config.response_format,\n            speed=self.config.speed,\n        ) as response:\n            response.stream_to_file(output_path)\n\n        logger.info(f\"TTS 成功: {output_path}\")\n\n        # 更新 segment\n        segment.audio_path = output_path\n        segment.voice = voice_to_use\n"
  },
  {
    "path": "app/core/tts/siliconflow.py",
    "content": "\"\"\"SiliconFlow TTS 实现\"\"\"\n\nimport hashlib\nfrom pathlib import Path\n\nimport requests\n\nfrom app.core.tts.base import BaseTTS\nfrom app.core.tts.tts_data import TTSConfig, TTSDataSeg\nfrom app.core.utils.cache import get_tts_cache\nfrom app.core.utils.logger import setup_logger\n\nlogger = setup_logger(\"tts.siliconflow\")\n\n\nclass VoiceCloneManager:\n    \"\"\"声音克隆管理器 - 处理音频上传和 URI 缓存\"\"\"\n\n    def __init__(self, api_key: str, base_url: str):\n        \"\"\"初始化\n\n        Args:\n            api_key: API 密钥\n            base_url: API 基础 URL\n        \"\"\"\n        self.api_key = api_key\n        self.base_url = base_url\n        self.cache = get_tts_cache()\n\n    def upload_voice(\n        self,\n        audio_path: str,\n        text: str,\n        model: str = \"FunAudioLLM/CosyVoice2-0.5B\",\n    ) -> str:\n        \"\"\"上传音频并获取声音克隆 URI\n\n        Args:\n            audio_path: 音频文件路径\n            text: 对应文本内容\n            model: 模型名称\n\n        Returns:\n            voice_uri: 形如 speech:your-voice-name:xxx:xxx 的 URI\n\n        Raises:\n            FileNotFoundError: 音频文件不存在\n            ValueError: API 返回错误\n        \"\"\"\n        # 检查文件是否存在\n        audio_file = Path(audio_path)\n        if not audio_file.exists():\n            raise FileNotFoundError(f\"音频文件不存在: {audio_path}\")\n\n        # 检查缓存（避免重复上传）\n        cache_key = self._generate_cache_key(audio_path, text, model)\n        cached_uri = self.cache.get(cache_key)\n        if cached_uri:\n            logger.info(f\"使用缓存的声音克隆 URI: {cached_uri}\")\n            return cached_uri\n\n        logger.info(f\"上传声音克隆音频: {audio_path}, 对应文本: {text[:50]}...\")\n\n        custom_name = \"video_captioner\"\n        url = f\"{self.base_url}/uploads/audio/voice\"\n        headers = {\"Authorization\": f\"Bearer {self.api_key}\"}\n\n        with open(audio_path, \"rb\") as f:\n            files = {\"file\": (audio_file.name, f, \"audio/mpeg\")}\n            data = {\"model\": model, \"customName\": custom_name, \"text\": text}\n\n            try:\n                response = requests.post(\n                    url, headers=headers, files=files, data=data, timeout=60\n                )\n                response.raise_for_status()\n            except requests.HTTPError as e:\n                if e.response.status_code == 400:\n                    raise ValueError(f\"音频上传失败（参数错误）: {e.response.text}\")\n                elif e.response.status_code == 401:\n                    raise ValueError(\"API Key 无效\")\n                else:\n                    raise ValueError(f\"音频上传失败: {e.response.text}\")\n\n        result = response.json()\n        voice_uri = result.get(\"uri\")\n        if not voice_uri:\n            raise ValueError(f\"API 未返回 URI: {result}\")\n\n        logger.info(f\"获得声音克隆 URI: {voice_uri}\")\n\n        # 缓存 URI\n        self.cache.set(cache_key, voice_uri, expire=86400 * 2)\n\n        return voice_uri\n\n    def _generate_cache_key(self, audio_path: str, text: str, model: str) -> str:\n        \"\"\"生成缓存键（基于文件内容哈希）\"\"\"\n        with open(audio_path, \"rb\") as f:\n            file_hash = hashlib.md5(f.read()).hexdigest()\n\n        content = f\"voice_clone_{file_hash}_{text}_{model}\"\n        return hashlib.md5(content.encode()).hexdigest()\n\n\nclass SiliconFlowTTS(BaseTTS):\n    \"\"\"SiliconFlow TTS API 实现\n\n    使用硅基流动的云端 TTS 服务\n    \"\"\"\n\n    def __init__(self, config: TTSConfig):\n        \"\"\"初始化\n\n        Args:\n            config: TTS 配置\n        \"\"\"\n        super().__init__(config)\n        if not config.api_key:\n            raise ValueError(\"API key is required for SiliconFlow TTS\")\n\n        # 初始化声音克隆管理器\n        self.voice_manager = VoiceCloneManager(config.api_key, config.base_url)\n\n    def _synthesize(self, segment: TTSDataSeg, output_path: str) -> None:\n        \"\"\"合成语音的核心实现\n\n        Args:\n            segment: TTS 数据段（需要填充 audio_path, voice, clone_voice_uri）\n            output_path: 输出音频路径\n        \"\"\"\n        url = f\"{self.config.base_url}/audio/speech\"\n        headers = {\n            \"Authorization\": f\"Bearer {self.config.api_key}\",\n            \"Content-Type\": \"application/json\",\n        }\n\n        # 构建请求数据\n        payload = {\n            \"model\": self.config.model,\n            \"input\": segment.text,\n            \"response_format\": self.config.response_format,\n            \"sample_rate\": self.config.sample_rate,\n            \"speed\": self.config.speed,\n            \"gain\": self.config.gain,\n        }\n\n        # 音色选择（优先级：声音克隆 > segment指定 > 全局配置）\n        voice_to_use = None\n\n        if segment.clone_audio_path and segment.clone_audio_text:\n            # 使用声音克隆\n            logger.info(f\"上传声音克隆音频: {segment.clone_audio_path}\")\n            voice_uri = self.voice_manager.upload_voice(\n                audio_path=segment.clone_audio_path,\n                text=segment.clone_audio_text,\n                model=self.config.model,\n            )\n            voice_to_use = voice_uri\n            segment.clone_voice_uri = voice_uri\n            logger.info(f\"使用克隆音色: {voice_uri}\")\n\n        elif segment.voice:\n            # segment 指定了音色\n            voice_to_use = segment.voice\n\n        elif self.config.voice:\n            # 使用全局配置的音色\n            voice_to_use = self.config.voice\n\n        if voice_to_use:\n            payload[\"voice\"] = voice_to_use\n\n        if self.config.stream:\n            payload[\"stream\"] = self.config.stream\n\n        # 发送请求\n        response = requests.post(\n            url,\n            headers=headers,\n            json=payload,\n            timeout=self.config.timeout,\n        )\n        response.raise_for_status()\n\n        # 保存音频文件\n        with open(output_path, \"wb\") as f:\n            f.write(response.content)\n\n        logger.info(f\"TTS 成功: {output_path}\")\n\n        # 更新 segment\n        segment.audio_path = output_path\n        segment.voice = voice_to_use\n        # TODO: 获取实际音频时长\n        # segment.audio_duration = get_audio_duration(output_path)\n"
  },
  {
    "path": "app/core/tts/status.py",
    "content": "from enum import Enum\nfrom typing import Tuple\n\n\nclass TTSStatus(Enum):\n    \"\"\"TTS processing status with progress percentage.\n\n    Each status contains a tuple of (message, progress_percentage).\n    Progress ranges from 0 to 100.\n    \"\"\"\n\n    # Initialization\n    INITIALIZING = (\"initializing\", 0)\n    PREPARING = (\"preparing\", 10)\n\n    # Synthesis phase (20-90%)\n    SYNTHESIZING = (\"synthesizing\", 30)\n    PROCESSING = (\"processing\", 50)\n    SAVING = (\"saving\", 70)\n\n    # Completion phase (90-100%)\n    FINALIZING = (\"finalizing\", 90)\n    COMPLETED = (\"completed\", 100)\n\n    @property\n    def message(self) -> str:\n        \"\"\"Get the status message.\"\"\"\n        return self.value[0]\n\n    @property\n    def progress(self) -> int:\n        \"\"\"Get the progress percentage (0-100).\"\"\"\n        return self.value[1]\n\n    def with_progress(self, progress: int) -> Tuple[int, str]:\n        \"\"\"Create a callback tuple with custom progress.\n\n        Args:\n            progress: Progress percentage (0-100)\n\n        Returns:\n            Tuple of (progress, message) suitable for callback functions\n        \"\"\"\n        return (progress, self.message)\n\n    def callback_tuple(self) -> Tuple[int, str]:\n        \"\"\"Get the callback tuple (progress, message).\"\"\"\n        return (self.progress, self.message)\n"
  },
  {
    "path": "app/core/tts/tts_data.py",
    "content": "\"\"\"TTS 数据结构定义\"\"\"\n\nfrom dataclasses import dataclass\nfrom typing import List, Literal, Optional\n\n\n@dataclass\nclass TTSConfig:\n    \"\"\"TTS 配置\"\"\"\n\n    # 基础配置\n    model: str\n    api_key: str\n    base_url: str\n\n    # 音频参数\n    voice: Optional[str] = None  # 默认音色选择\n    custom_prompt: Optional[str] = None  # 自定义提示词（用于 OpenAI.fm 等）\n    response_format: Literal[\"mp3\", \"opus\", \"aac\", \"flac\", \"wav\", \"pcm\"] = \"mp3\"\n    sample_rate: int = 32000  # 采样率\n    speed: float = 1.0  # 语速 0.25-4.0\n    gain: int = 0  # 音量增益 -10 到 10\n\n    # 处理参数\n    stream: bool = False  # 是否流式传输\n    cache_ttl: int = 86400 * 2  # 缓存过期时间（秒），默认2天\n    timeout: int = 60  # 超时时间（秒）\n    use_cache: bool = True  # 是否使用缓存\n\n\n@dataclass\nclass TTSDataSeg:\n    \"\"\"TTS 数据段 - 单条文本转音频的片段\"\"\"\n\n    text: str  # 要合成的文本\n    start_time: float = 0.0  # 开始时间（秒）\n    end_time: float = 0.0  # 结束时间（秒）\n    audio_path: str = \"\"  # 生成的音频文件路径\n    audio_duration: float = 0.0  # 实际音频时长（秒）\n    voice: Optional[str] = None  # 使用的音色\n\n    # 声音克隆相关\n    clone_audio_path: Optional[str] = None  # 参考音频文件路径\n    clone_audio_text: Optional[str] = None  # 参考音频对应的文本\n    clone_voice_uri: Optional[str] = None  # 上传后获得的 URI\n\n    def __str__(self) -> str:\n        return f\"TTSDataSeg(text={self.text[:20]}..., audio_path={self.audio_path})\"\n\n\nclass TTSData:\n    \"\"\"TTS 数据 - 包含多个 TTS 片段的容器（参考 ASRData 设计）\"\"\"\n\n    def __init__(self, segments: Optional[List[TTSDataSeg]] = None):\n        \"\"\"初始化 TTS 数据\n\n        Args:\n            segments: TTS 数据段列表\n        \"\"\"\n        if segments is None:\n            segments = []\n        # 过滤空文本，按时间排序\n        filtered_segments = [seg for seg in segments if seg.text and seg.text.strip()]\n        filtered_segments.sort(key=lambda x: x.start_time)\n        self.segments = filtered_segments\n\n    def __iter__(self):\n        \"\"\"迭代器\"\"\"\n        return iter(self.segments)\n\n    def __len__(self) -> int:\n        \"\"\"返回段落数量\"\"\"\n        return len(self.segments)\n\n    @classmethod\n    def from_texts(\n        cls,\n        texts: List[str],\n        clone_audio_path: Optional[str] = None,\n        clone_audio_text: Optional[str] = None,\n    ) -> \"TTSData\":\n        \"\"\"从文本列表创建 TTSData\n\n        Args:\n            texts: 文本列表\n            clone_audio_path: 统一的参考音频路径（可选）\n            clone_audio_text: 统一的参考音频文本（可选）\n\n        Returns:\n            TTSData 实例\n        \"\"\"\n        segments = [\n            TTSDataSeg(\n                text=text,\n                clone_audio_path=clone_audio_path,\n                clone_audio_text=clone_audio_text,\n            )\n            for text in texts\n        ]\n        return cls(segments)\n"
  },
  {
    "path": "app/core/utils/__init__.py",
    "content": ""
  },
  {
    "path": "app/core/utils/cache.py",
    "content": "\"\"\"Disk cache utility for API responses and computation results.\n\nThis module provides a simple interface for caching using diskcache.\nCan be used by translation, ASR, and other modules that need caching.\n\"\"\"\n\nimport functools\nimport hashlib\nimport json\nfrom dataclasses import asdict, is_dataclass\nfrom typing import Any\n\nfrom diskcache import Cache\n\nfrom app.config import CACHE_PATH\n\n# Global cache switch\n_cache_enabled = True\n\n\ndef enable_cache() -> None:\n    \"\"\"Enable caching globally.\"\"\"\n    global _cache_enabled\n    _cache_enabled = True\n\n\ndef disable_cache() -> None:\n    \"\"\"Disable caching globally.\"\"\"\n    global _cache_enabled\n    _cache_enabled = False\n\n\ndef is_cache_enabled() -> bool:\n    \"\"\"Check if caching is enabled.\"\"\"\n    return _cache_enabled\n\n\n# Predefined cache instances for common use cases\n_llm_cache = Cache(str(CACHE_PATH / \"llm_translation\"))\n_asr_cache = Cache(str(CACHE_PATH / \"asr_results\"), tag_index=True)\n_tts_cache = Cache(str(CACHE_PATH / \"tts_audio\"))\n_translate_cache = Cache(str(CACHE_PATH / \"translate_results\"))\n_version_state_cache = Cache(str(CACHE_PATH / \"version_state\"))\n\n\ndef get_llm_cache() -> Cache:\n    \"\"\"Get LLM translation cache instance.\"\"\"\n    return _llm_cache\n\n\ndef get_asr_cache() -> Cache:\n    \"\"\"Get ASR results cache instance.\"\"\"\n    return _asr_cache\n\n\ndef get_translate_cache() -> Cache:\n    \"\"\"Get translate cache instance.\"\"\"\n    return _translate_cache\n\n\ndef get_tts_cache() -> Cache:\n    \"\"\"Get TTS audio cache instance.\"\"\"\n    return _tts_cache\n\n\ndef get_version_state_cache() -> Cache:\n    \"\"\"Get version check state cache instance.\"\"\"\n    return _version_state_cache\n\n\ndef memoize(cache_instance: Cache, **kwargs):\n    \"\"\"Decorator to cache function results with global switch support.\n\n    This is a thin wrapper around diskcache.Cache.memoize() that respects\n    the global cache enable/disable setting.\n\n    Args:\n        cache_instance: Cache instance to use (from get_llm_cache(), etc.)\n        **kwargs: Arguments passed to cache.memoize() (expire, typed, etc.)\n\n    Returns:\n        Decorated function\n\n    Examples:\n        @memoize(get_llm_cache(), expire=3600, typed=True)\n        def call_api(prompt: str):\n            response = client.chat.completions.create(...)\n            if not response.choices:\n                raise ValueError(\"Invalid response\")  # Exceptions are not cached\n            return response\n    \"\"\"\n\n    def decorator(func):\n        memoized_func = cache_instance.memoize(**kwargs)(func)\n\n        @functools.wraps(func)\n        def wrapper(*args, **kw):\n            if _cache_enabled:\n                return memoized_func(*args, **kw)\n            return func(*args, **kw)\n\n        return wrapper\n\n    return decorator\n\n\ndef generate_cache_key(data: Any) -> str:\n    \"\"\"Generate cache key from data (supports dataclasses, dicts, lists).\n\n    Args:\n        data: Data to generate key from\n\n    Returns:\n        SHA256 hash of the data\n    \"\"\"\n\n    def _serialize(obj: Any) -> Any:\n        \"\"\"Recursively serialize object to JSON-serializable format\"\"\"\n        if is_dataclass(obj) and not isinstance(obj, type):\n            return asdict(obj)  # type: ignore\n        elif isinstance(obj, list):\n            return [_serialize(item) for item in obj]\n        elif isinstance(obj, dict):\n            return {k: _serialize(v) for k, v in obj.items()}\n        else:\n            return obj\n\n    serialized_data = _serialize(data)\n    data_str = json.dumps(serialized_data, ensure_ascii=False, sort_keys=True)\n    return hashlib.sha256(data_str.encode()).hexdigest()\n"
  },
  {
    "path": "app/core/utils/logger.py",
    "content": "import logging\nimport logging.handlers\nfrom pathlib import Path\n\nfrom ...config import LOG_LEVEL, LOG_PATH\n\n\ndef setup_logger(\n    name: str,\n    level: int = LOG_LEVEL,\n    info_fmt: str = \"%(message)s\",  # INFO级别使用简化格式\n    default_fmt: str = \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\",  # 其他级别使用详细格式\n    datefmt: str = \"%Y-%m-%d %H:%M:%S\",\n    log_file: str = str(LOG_PATH / \"app.log\"),\n    console_output: bool = True,\n) -> logging.Logger:\n    \"\"\"\n    创建并配置一个日志记录器，INFO级别使用简化格式。\n\n    参数：\n    - name: 日志记录器的名称\n    - level: 日志级别\n    - info_fmt: INFO级别的日志格式字符串\n    - default_fmt: 其他级别的日志格式字符串\n    - datefmt: 时间格式字符串\n    - log_file: 日志文件路径\n    \"\"\"\n\n    logger = logging.getLogger(name)\n    logger.setLevel(level)\n\n    if not logger.handlers:\n        # 创建级别特定的格式化器\n        class LevelSpecificFormatter(logging.Formatter):\n            def format(self, record):\n                if record.levelno == logging.INFO:\n                    self._style._fmt = info_fmt\n                else:\n                    self._style._fmt = default_fmt\n                return super().format(record)\n\n        level_formatter = LevelSpecificFormatter(default_fmt, datefmt=datefmt)\n\n        # 只在console_output为True时添加控制台处理器\n        if console_output:\n            console_handler = logging.StreamHandler()\n            console_handler.setLevel(level)\n            console_handler.setFormatter(level_formatter)\n            logger.addHandler(console_handler)\n\n        # 文件处理器\n        if log_file:\n            Path(log_file).parent.mkdir(parents=True, exist_ok=True)\n            file_handler = logging.handlers.RotatingFileHandler(\n                log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding=\"utf-8\"\n            )\n            file_handler.setLevel(level)\n            file_handler.setFormatter(level_formatter)\n            logger.addHandler(file_handler)\n\n    # 设置特定库的日志级别为ERROR以减少日志噪音\n    error_loggers = [\n        \"urllib3\",\n        \"requests\",\n        \"openai\",\n        \"httpx\",\n        \"httpcore\",\n        \"ssl\",\n        \"certifi\",\n    ]\n    for lib in error_loggers:\n        logging.getLogger(lib).setLevel(logging.ERROR)\n\n    return logger\n"
  },
  {
    "path": "app/core/utils/platform_utils.py",
    "content": "\"\"\"\n跨平台工具函数\n\"\"\"\n\nimport logging\nimport os\nimport platform\nimport subprocess\n\nfrom app.core.entities import TranscribeModelEnum\n\nlogger = logging.getLogger(__name__)\n\n\ndef open_folder(path):\n    \"\"\"\n    跨平台打开文件夹\n\n    Args:\n        path: 要打开的文件夹路径\n    \"\"\"\n    system = platform.system()\n\n    if system == \"Windows\":\n        if hasattr(os, \"startfile\"):\n            getattr(os, \"startfile\")(path)\n        else:\n            subprocess.Popen([\"explorer\", path])\n    elif system == \"Darwin\":  # macOS\n        subprocess.Popen([\"open\", path])\n    elif system == \"Linux\":\n        subprocess.Popen([\"xdg-open\", path])\n    else:\n        # 其他系统，尝试使用默认方式\n        try:\n            subprocess.Popen([\"xdg-open\", path])\n        except (OSError, subprocess.SubprocessError):\n            logger.warning(f\"无法在当前系统打开文件夹: {path}\")\n\n\ndef open_file(path):\n    \"\"\"\n    跨平台打开文件\n\n    Args:\n        path: 要打开的文件路径\n    \"\"\"\n    system = platform.system()\n\n    if system == \"Windows\":\n        if hasattr(os, \"startfile\"):\n            getattr(os, \"startfile\")(path)\n        else:\n            subprocess.Popen([\"start\", path], shell=True)\n    elif system == \"Darwin\":  # macOS\n        subprocess.Popen([\"open\", path])\n    elif system == \"Linux\":\n        subprocess.Popen([\"xdg-open\", path])\n    else:\n        # 其他系统，尝试使用默认方式\n        try:\n            subprocess.Popen([\"xdg-open\", path])\n        except (OSError, subprocess.SubprocessError):\n            logger.warning(f\"无法在当前系统打开文件: {path}\")\n\n\ndef get_subprocess_kwargs():\n    \"\"\"\n    获取跨平台的subprocess参数\n\n    Returns:\n        dict: subprocess参数字典\n    \"\"\"\n    kwargs = {}\n\n    # 仅在Windows上添加CREATE_NO_WINDOW标志\n    if platform.system() == \"Windows\":\n        if hasattr(subprocess, \"CREATE_NO_WINDOW\"):\n            kwargs[\"creationflags\"] = getattr(subprocess, \"CREATE_NO_WINDOW\", 0)\n\n    return kwargs\n\n\ndef is_macos() -> bool:\n    \"\"\"\n    检测是否为 macOS 系统\n\n    Returns:\n        bool: 如果是 macOS 返回 True，否则返回 False\n    \"\"\"\n    return platform.system() == \"Darwin\"\n\n\ndef is_windows() -> bool:\n    \"\"\"\n    检测是否为 Windows 系统\n\n    Returns:\n        bool: 如果是 Windows 返回 True，否则返回 False\n    \"\"\"\n    return platform.system() == \"Windows\"\n\n\ndef is_linux() -> bool:\n    \"\"\"\n    检测是否为 Linux 系统\n\n    Returns:\n        bool: 如果是 Linux 返回 True，否则返回 False\n    \"\"\"\n    return platform.system() == \"Linux\"\n\n\ndef get_available_transcribe_models() -> list[TranscribeModelEnum]:\n    \"\"\"\n    获取当前平台可用的转录模型列表\n\n    macOS 上不支持 FasterWhisper，因为它依赖 CUDA/CuDNN\n\n    Returns:\n        list[TranscribeModelEnum]: 可用的转录模型列表\n    \"\"\"\n    all_models = list(TranscribeModelEnum)\n\n    # macOS 上过滤掉 FasterWhisper\n    if is_macos():\n        return [\n            model for model in all_models if model != TranscribeModelEnum.FASTER_WHISPER\n        ]\n\n    return all_models\n\n\ndef is_model_available(model: TranscribeModelEnum) -> bool:\n    \"\"\"\n    检查指定模型是否在当前平台可用\n\n    Args:\n        model: 要检查的转录模型\n\n    Returns:\n        bool: 如果模型可用返回 True，否则返回 False\n    \"\"\"\n    # FasterWhisper 在 macOS 上不可用\n    if is_macos() and model == TranscribeModelEnum.FASTER_WHISPER:\n        return False\n\n    return True\n"
  },
  {
    "path": "app/core/utils/subprocess_helper.py",
    "content": "\"\"\"子进程输出流处理工具模块\"\"\"\n\nimport queue\nimport subprocess\nimport threading\nfrom typing import Callable, Optional, Tuple\n\nfrom ..utils.logger import setup_logger\n\nlogger = setup_logger(\"subprocess_helper\")\n\n\nclass StreamReader:\n    \"\"\"通用的子进程输出流读取器\"\"\"\n\n    def __init__(self, process: subprocess.Popen):\n        \"\"\"\n        初始化流读取器\n\n        Args:\n            process: 子进程对象\n        \"\"\"\n        self.process = process\n        self.output_queue = queue.Queue()\n        self.threads = []\n\n    def start_reading(self) -> None:\n        \"\"\"启动异步读取stdout和stderr\"\"\"\n        # 启动stdout读取线程\n        if self.process.stdout:\n            stdout_thread = threading.Thread(\n                target=self._read_stream,\n                args=(self.process.stdout, \"stdout\"),\n                daemon=True,\n            )\n            stdout_thread.start()\n            self.threads.append(stdout_thread)\n\n        # 启动stderr读取线程\n        if self.process.stderr:\n            stderr_thread = threading.Thread(\n                target=self._read_stream,\n                args=(self.process.stderr, \"stderr\"),\n                daemon=True,\n            )\n            stderr_thread.start()\n            self.threads.append(stderr_thread)\n\n    def _read_stream(self, stream, stream_name: str) -> None:\n        \"\"\"读取流并放入队列\"\"\"\n        try:\n            for line in iter(stream.readline, \"\"):\n                if line:\n                    self.output_queue.put((stream_name, line))\n        except Exception as e:\n            logger.debug(f\"读取 {stream_name} 结束: {e}\")\n        finally:\n            stream.close()\n\n    def get_output(self, timeout: float = 0.1) -> Optional[Tuple[str, str]]:\n        \"\"\"\n        获取输出\n\n        Args:\n            timeout: 等待超时时间\n\n        Returns:\n            (stream_name, line) 或 None\n        \"\"\"\n        try:\n            return self.output_queue.get(timeout=timeout)\n        except queue.Empty:\n            return None\n\n    def get_remaining_output(self) -> list:\n        \"\"\"获取队列中剩余的所有输出\"\"\"\n        output = []\n        while not self.output_queue.empty():\n            try:\n                output.append(self.output_queue.get_nowait())\n            except queue.Empty:\n                break\n        return output\n\n    def is_empty(self) -> bool:\n        \"\"\"检查队列是否为空\"\"\"\n        return self.output_queue.empty()\n\n\ndef run_process_with_stream_reader(\n    cmd: list,\n    stdout_handler: Optional[Callable[[str], None]] = None,\n    stderr_handler: Optional[Callable[[str], None]] = None,\n    **popen_kwargs,\n) -> subprocess.Popen:\n    \"\"\"\n    运行子进程并使用StreamReader处理输出\n\n    Args:\n        cmd: 命令列表\n        stdout_handler: stdout行处理函数\n        stderr_handler: stderr行处理函数\n        **popen_kwargs: 传递给subprocess.Popen的额外参数\n\n    Returns:\n        子进程对象\n\n    Example:\n        ```python\n        def handle_stdout(line):\n            print(f\"[stdout] {line.strip()}\")\n\n        def handle_stderr(line):\n            print(f\"[stderr] {line.strip()}\")\n\n        process = run_process_with_stream_reader(\n            [\"ls\", \"-la\"],\n            stdout_handler=handle_stdout,\n            stderr_handler=handle_stderr\n        )\n        process.wait()\n        ```\n    \"\"\"\n    # 设置默认参数\n    default_kwargs = {\n        \"stdout\": subprocess.PIPE,\n        \"stderr\": subprocess.PIPE,\n        \"text\": True,\n        \"encoding\": \"utf-8\",\n        \"bufsize\": 1,  # 行缓冲\n    }\n    default_kwargs.update(popen_kwargs)\n\n    # 启动进程\n    process = subprocess.Popen(cmd, **default_kwargs)\n\n    # 创建流读取器\n    reader = StreamReader(process)\n    reader.start_reading()\n\n    # 处理输出的线程\n    def process_output():\n        while True:\n            # 检查进程状态\n            if process.poll() is not None:\n                # 进程已结束，读取剩余输出\n                for stream_name, line in reader.get_remaining_output():\n                    if stream_name == \"stdout\" and stdout_handler:\n                        stdout_handler(line)\n                    elif stream_name == \"stderr\" and stderr_handler:\n                        stderr_handler(line)\n                break\n\n            # 读取输出\n            output = reader.get_output()\n            if output:\n                stream_name, line = output\n                if stream_name == \"stdout\" and stdout_handler:\n                    stdout_handler(line)\n                elif stream_name == \"stderr\" and stderr_handler:\n                    stderr_handler(line)\n\n    # 如果提供了处理函数，启动处理线程\n    if stdout_handler or stderr_handler:\n        handler_thread = threading.Thread(target=process_output, daemon=True)\n        handler_thread.start()\n\n    return process\n"
  },
  {
    "path": "app/core/utils/text_utils.py",
    "content": "\"\"\"多语言文本处理工具\n\n统一的文本分析工具，支持CJK和世界多语言字符统计。\n\"\"\"\n\nimport re\n\n# ==================== Unicode 字符范围定义 ====================\n\n# 按字符计数的语言（不使用空格分词）\n# 包括：CJK（中日韩）+ 东南亚/南亚语言（泰文/缅甸文/高棉文/印地语等）\n_NO_SPACE_LANGUAGES = r\"[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af\\u0e00-\\u0eff\\u1000-\\u109f\\u1780-\\u17ff\\u0900-\\u0dff]\"\n\n# 需要空格分隔的语言（按单词计数）\n# 包括：拉丁字母、西里尔字母、希腊字母、阿拉伯字母、希伯来字母、泰文\n_SPACE_SEPARATED_LANGUAGES = (\n    r\"^[a-zA-Z0-9\\'\\u0400-\\u04ff\\u0370-\\u03ff\\u0600-\\u06ff\\u0590-\\u05ff\\u0e00-\\u0e7f]+$\"\n)\n\n\ndef is_pure_punctuation(text: str) -> bool:\n    \"\"\"检查文本是否仅包含标点符号\"\"\"\n    return not re.search(r\"\\w\", text, re.UNICODE)\n\n\ndef is_mainly_cjk(text: str, threshold: float = 0.5) -> bool:\n    \"\"\"判断是否主要为不使用空格的亚洲语言文本\n\n    包括：中日韩、泰文、缅甸文、高棉文、印地语等\n\n    Args:\n        text: 待检测的文本\n        threshold: 阈值比例（默认0.5，即超过50%）\n\n    Returns:\n        True表示主要为不使用空格的亚洲语言，False表示其他\n    \"\"\"\n    if not text:\n        return False\n\n    no_space_count = len(re.findall(_NO_SPACE_LANGUAGES, text))\n    total_chars = len(\"\".join(text.split()))\n\n    return no_space_count / total_chars > threshold if total_chars > 0 else False\n\n\ndef is_space_separated_language(text: str) -> bool:\n    \"\"\"判断文本是否为需要空格分隔的语言\n\n    需要空格的语言包括：\n    - 拉丁字母语言：英语、法语、德语、西班牙语等\n    - 西里尔字母语言：俄语、乌克兰语、保加利亚语等\n    - 希腊字母语言：希腊语\n    - 阿拉伯字母语言：阿拉伯语、波斯语、乌尔都语等\n    - 希伯来字母语言：希伯来语\n\n    不需要空格的语言（返回False）：\n    - 中文、日文、韩文（CJK）\n    - 泰文、缅甸文、高棉文等\n\n    Args:\n        text: 待检测的文本\n\n    Returns:\n        True表示需要空格分隔，False表示不需要\n    \"\"\"\n    if not text:\n        return False\n    return bool(re.match(_SPACE_SEPARATED_LANGUAGES, text.strip()))\n\n\ndef count_words(text: str) -> int:\n    \"\"\"统计文本字符/单词数\n\n    按字符计数的语言（不使用空格分词）：\n    - CJK (中文、日文、韩文)\n    - 泰文、缅甸文、高棉文、印地语等\n\n    按单词计数的语言（使用空格分词）：\n    - 拉丁字母语言 (英语、法语、德语、西班牙语等)\n    - 西里尔字母语言 (俄语、乌克兰语、保加利亚语等)\n    - 希腊字母、阿拉伯字母、希伯来字母等\n\n    混合文本处理：\n    - 按字符计数的语言统计字符数\n    - 按单词计数的语言统计单词数\n    - 返回总和\n\n    Args:\n        text: 待统计的文本\n\n    Returns:\n        字符数 + 单词数\n    \"\"\"\n    if not text:\n        return 0\n\n    # 统计不使用空格的语言的字符数（CJK + 泰文/缅甸文等）\n    char_count = len(re.findall(_NO_SPACE_LANGUAGES, text))\n\n    # 移除不使用空格的字符后，统计使用空格的语言的单词数\n    word_text = re.sub(_NO_SPACE_LANGUAGES, \" \", text)\n    word_count = len(word_text.strip().split())\n\n    return char_count + word_count\n"
  },
  {
    "path": "app/core/utils/video_utils.py",
    "content": "import os\nimport re\nimport shutil\nimport subprocess\nimport tempfile\nfrom contextlib import contextmanager\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Callable, Literal, Optional\n\nfrom ..entities import (\n    AudioStreamInfo,\n    SubtitleLayoutEnum,\n    SubtitleRenderModeEnum,\n    VideoInfo,\n)\nfrom ..subtitle.ass_renderer import render_ass_video\nfrom ..subtitle.ass_utils import auto_wrap_ass_file\nfrom ..subtitle.rounded_renderer import render_rounded_video\nfrom ..utils.logger import setup_logger\n\nif TYPE_CHECKING:\n    from app.core.asr.asr_data import ASRData\n\n# FFmpeg preset 类型\nPresetType = Literal[\n    \"ultrafast\",\n    \"superfast\",\n    \"veryfast\",\n    \"faster\",\n    \"fast\",\n    \"medium\",\n    \"slow\",\n    \"slower\",\n    \"veryslow\",\n]\n\nlogger = setup_logger(\"video_utils\")\n\n\n@contextmanager\ndef temporary_subtitle_file(subtitle_path: str):\n    \"\"\"临时字幕文件上下文管理器\n\n    自动复制字幕文件到临时位置，使用后自动清理\n\n    Args:\n        subtitle_path: 原始字幕文件路径\n\n    Yields:\n        临时字幕文件路径\n    \"\"\"\n    suffix = Path(subtitle_path).suffix.lower()\n    temp_fd, temp_path = tempfile.mkstemp(\n        suffix=suffix, prefix=\"VideoCaptioner_subtitle_\"\n    )\n    os.close(temp_fd)\n\n    try:\n        # 复制字幕到临时位置\n        shutil.copy2(subtitle_path, temp_path)\n        yield temp_path\n    finally:\n        # 自动清理临时文件\n        Path(temp_path).unlink(missing_ok=True)\n\n\ndef video2audio(input_file: str, output: str = \"\", audio_track_index: int = 0) -> bool:\n    \"\"\"使用 ffmpeg 将视频转换为音频\n\n    Args:\n        input_file: 输入视频文件路径\n        output: 输出音频文件路径\n        audio_track_index: 要提取的音轨索引，默认为 0（第一条音轨）\n\n    Returns:\n        转换是否成功\n    \"\"\"\n    output_path = Path(output)\n    output_path.parent.mkdir(parents=True, exist_ok=True)\n    output = str(output_path)\n\n    logger.info(f\"提取音轨索引 {audio_track_index}\")\n    cmd = [\n        \"ffmpeg\",\n        \"-i\",\n        input_file,\n        \"-map\",\n        f\"0:a:{audio_track_index}\",\n        \"-vn\",\n        \"-ac\",\n        \"1\",  # 单声道\n        \"-ar\",\n        \"16000\",  # 采样率16kHz\n        \"-y\",\n        output,\n    ]\n\n    logger.info(f\"转换为音频执行命令: {' '.join(cmd)}\")\n\n    try:\n        result = subprocess.run(\n            cmd,\n            capture_output=True,\n            check=True,\n            encoding=\"utf-8\",\n            errors=\"replace\",\n            creationflags=(\n                getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n            ),\n        )\n        if result.returncode == 0 and Path(output).is_file():\n            logger.info(\"音频转换成功\")\n            return True\n        else:\n            logger.error(\"音频转换失败\")\n            return False\n    except subprocess.CalledProcessError as e:\n        logger.error(\"== ffmpeg 执行失败 ==\")\n        logger.error(f\"返回码: {e.returncode}\")\n        logger.error(f\"命令: {' '.join(e.cmd)}\")\n        if e.stdout:\n            logger.error(f\"标准输出: {e.stdout}\")\n        if e.stderr:\n            logger.error(f\"标准错误: {e.stderr}\")\n        return False\n    except Exception as e:\n        logger.exception(f\"音频转换出错: {str(e)}\")\n        return False\n\n\ndef check_cuda_available() -> bool:\n    \"\"\"检查CUDA是否可用\"\"\"\n    logger.info(\"检查CUDA是否可用\")\n    try:\n        # 首先检查ffmpeg是否支持cuda\n        result = subprocess.run(\n            [\"ffmpeg\", \"-hwaccels\"],\n            capture_output=True,\n            text=True,\n            creationflags=(\n                getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n            ),\n        )\n        if \"cuda\" not in result.stdout.lower():\n            logger.info(\"CUDA不在支持的硬件加速器列表中\")\n            return False\n\n        # 进一步检查CUDA设备信息\n        result = subprocess.run(\n            [\"ffmpeg\", \"-hide_banner\", \"-init_hw_device\", \"cuda\"],\n            capture_output=True,\n            text=True,\n            creationflags=(\n                getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n            ),\n        )\n\n        # 如果stderr中包含\"Cannot load cuda\" 或 \"Failed to load\"等错误信息，说明CUDA不可用\n        if any(\n            error in result.stderr.lower()\n            for error in [\"cannot load cuda\", \"failed to load\", \"error\"]\n        ):\n            logger.info(\"CUDA设备初始化失败\")\n            return False\n\n        logger.info(\"CUDA可用\")\n        return True\n\n    except Exception as e:\n        logger.exception(f\"检查CUDA出错: {str(e)}\")\n        return False\n\n\ndef add_subtitles(\n    input_file: str,\n    subtitle_file: str,\n    output: str,\n    crf: int = 23,\n    preset: Literal[\n        \"ultrafast\",\n        \"superfast\",\n        \"veryfast\",\n        \"faster\",\n        \"fast\",\n        \"medium\",\n        \"slow\",\n        \"slower\",\n        \"veryslow\",\n    ] = \"medium\",\n    vcodec: str = \"libx264\",\n    soft_subtitle: bool = False,\n    progress_callback: Optional[Callable] = None,\n) -> None:\n    assert Path(input_file).is_file(), \"输入文件不存在\"\n    assert Path(subtitle_file).is_file(), \"字幕文件不存在\"\n\n    # 使用临时文件上下文管理器处理字幕（自动清理）\n    with temporary_subtitle_file(subtitle_file) as temp_subtitle_path:\n        # 如果是 ASS 字幕，进行自动换行处理\n        suffix = Path(subtitle_file).suffix.lower()\n        processed_subtitle = temp_subtitle_path\n        if suffix == \".ass\":\n            processed_subtitle = auto_wrap_ass_file(temp_subtitle_path)\n\n        # 如果是WebM格式，强制使用硬字幕\n        if Path(output).suffix.lower() == \".webm\":\n            soft_subtitle = False\n            logger.info(\"WebM格式视频，强制使用硬字幕\")\n\n        if soft_subtitle:\n            # 添加软字幕\n            cmd = [\n                \"ffmpeg\",\n                \"-i\",\n                input_file,\n                \"-i\",\n                processed_subtitle,\n                \"-c:v\",\n                \"copy\",\n                \"-c:a\",\n                \"copy\",\n                \"-c:s\",\n                \"mov_text\",\n                \"-y\",\n                output,\n            ]\n            logger.info(f\"添加软字幕执行命令: {' '.join(cmd)}\")\n            try:\n                subprocess.run(\n                    cmd,\n                    capture_output=True,\n                    check=True,\n                    text=True,\n                    encoding=\"utf-8\",\n                    errors=\"replace\",\n                    creationflags=(\n                        getattr(subprocess, \"CREATE_NO_WINDOW\", 0)\n                        if os.name == \"nt\"\n                        else 0\n                    ),\n                )\n                logger.info(\"软字幕添加成功\")\n            except subprocess.CalledProcessError as e:\n                logger.error(\"== ffmpeg 添加软字幕失败 ==\")\n                logger.error(f\"返回码: {e.returncode}\")\n                logger.error(f\"命令: {' '.join(e.cmd)}\")\n                if e.stdout:\n                    logger.error(f\"标准输出: {e.stdout}\")\n                if e.stderr:\n                    logger.error(f\"标准错误: {e.stderr}\")\n                raise\n        else:\n            # 使用硬字幕\n            subtitle_path_escaped = (\n                Path(processed_subtitle).as_posix().replace(\":\", r\"\\:\")\n            )\n\n            # 根据输出文件后缀决定vf参数\n            if Path(output).suffix.lower() == \".ass\":\n                vf = f\"ass='{subtitle_path_escaped}'\"\n            else:\n                vf = f\"subtitles='{subtitle_path_escaped}'\"\n\n            if Path(output).suffix.lower() == \".webm\":\n                vcodec = \"libvpx-vp9\"\n                logger.info(\"WebM格式视频，使用libvpx-vp9编码器\")\n\n            # 检查CUDA是否可用\n            use_cuda = check_cuda_available()\n            cmd = [\"ffmpeg\"]\n            if use_cuda:\n                logger.info(\"使用CUDA加速\")\n                cmd.extend([\"-hwaccel\", \"cuda\"])\n            cmd.extend(\n                [\n                    \"-i\",\n                    input_file,\n                    \"-acodec\",\n                    \"copy\",\n                    \"-vcodec\",\n                    vcodec,\n                    \"-crf\",\n                    str(crf),\n                    \"-preset\",\n                    preset,\n                    \"-vf\",\n                    vf,\n                    \"-y\",\n                    output,\n                ]\n            )\n\n            cmd_str = subprocess.list2cmdline(cmd)\n            logger.info(f\"添加硬字幕执行命令: {cmd_str}\")\n\n            process = None\n            try:\n                process = subprocess.Popen(\n                    cmd,\n                    stdout=subprocess.PIPE,\n                    stderr=subprocess.PIPE,\n                    text=True,\n                    encoding=\"utf-8\",\n                    errors=\"replace\",\n                    creationflags=(\n                        getattr(subprocess, \"CREATE_NO_WINDOW\", 0)\n                        if os.name == \"nt\"\n                        else 0\n                    ),\n                )\n\n                # 实时读取输出并调用回调函数\n                total_duration = None\n                current_time = 0\n\n                while True:\n                    output_line = process.stderr.readline()\n                    if not output_line or (process.poll() is not None):\n                        break\n                    if not progress_callback:\n                        continue\n\n                    if total_duration is None:\n                        duration_match = re.search(\n                            r\"Duration: (\\d{2}):(\\d{2}):(\\d{2}\\.\\d{2})\", output_line\n                        )\n                        if duration_match:\n                            h, m, s = map(float, duration_match.groups())\n                            total_duration = h * 3600 + m * 60 + s\n                            logger.info(f\"视频总时长: {total_duration}秒\")\n\n                    # 解析当前处理时间\n                    time_match = re.search(\n                        r\"time=(\\d{2}):(\\d{2}):(\\d{2}\\.\\d{2})\", output_line\n                    )\n                    if time_match:\n                        h, m, s = map(float, time_match.groups())\n                        current_time = h * 3600 + m * 60 + s\n\n                    # 计算进度百分比\n                    if total_duration:\n                        progress = (current_time / total_duration) * 100\n                        progress_callback(f\"{round(progress)}\", \"正在合成\")\n\n                if progress_callback:\n                    progress_callback(\"100\", \"合成完成\")\n\n                # 检查进程的返回码\n                return_code = process.wait()\n                if return_code != 0:\n                    error_info = process.stderr.read()\n                    logger.error(\"== ffmpeg 添加硬字幕失败 ==\")\n                    logger.error(f\"返回码: {return_code}\")\n                    logger.error(f\"命令: {cmd_str}\")\n                    if error_info:\n                        logger.error(f\"错误信息: {error_info}\")\n                    raise Exception(f\"FFmpeg 返回码: {return_code}\")\n                logger.info(\"视频合成完成\")\n\n            except subprocess.SubprocessError as e:\n                logger.error(\"== ffmpeg 进程执行异常 ==\")\n                logger.error(f\"错误: {str(e)}\")\n                if process and process.poll() is None:\n                    process.kill()\n                raise\n            except Exception as e:\n                logger.error(f\"视频合成过程出错: {str(e)}\")\n                if process and process.poll() is None:\n                    process.kill()\n                raise\n\n\ndef get_video_info(\n    file_path: str, thumbnail_path: Optional[str] = None\n) -> Optional[\"VideoInfo\"]:\n    \"\"\"获取媒体文件信息（支持视频和音频文件）\n\n    Args:\n        file_path: 媒体文件路径（视频或音频）\n        thumbnail_path: 缩略图保存路径（可选，仅对视频文件有效）\n\n    Returns:\n        VideoInfo 对象，失败返回 None\n        对于纯音频文件，视频相关字段（width/height/fps）将为 0\n    \"\"\"\n    try:\n        # 执行 ffmpeg 获取视频信息\n        result = subprocess.run(\n            [\"ffmpeg\", \"-i\", file_path],\n            capture_output=True,\n            text=True,\n            encoding=\"utf-8\",\n            errors=\"replace\",\n            creationflags=(\n                getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n            ),\n        )\n        info = result.stderr\n\n        # 提取时长\n        duration_seconds = 0.0\n        if duration_match := re.search(r\"Duration: (\\d+):(\\d+):(\\d+\\.\\d+)\", info):\n            hours, minutes, seconds = map(float, duration_match.groups())\n            duration_seconds = hours * 3600 + minutes * 60 + seconds\n\n        # 提取比特率\n        bitrate_kbps = 0\n        if bitrate_match := re.search(r\"bitrate: (\\d+) kb/s\", info):\n            bitrate_kbps = int(bitrate_match.group(1))\n\n        # 提取视频流信息\n        width, height, fps, video_codec = 0, 0, 0.0, \"\"\n        has_video_stream = False\n        if video_stream_match := re.search(\n            r\"Stream #.*?Video: (\\w+)(?:\\s*\\([^)]*\\))?.* (\\d+)x(\\d+).*?(?:(\\d+(?:\\.\\d+)?)\\s*(?:fps|tb[rn]))\",\n            info,\n            re.DOTALL,\n        ):\n            video_codec = video_stream_match.group(1)\n            width = int(video_stream_match.group(2))\n            height = int(video_stream_match.group(3))\n            fps = float(video_stream_match.group(4))\n            has_video_stream = True\n\n        # 提取第一条音频流信息（用于兼容性）\n        audio_codec, audio_sampling_rate = \"\", 0\n        if audio_stream_match := re.search(\n            r\"Stream #\\d+:\\d+.*Audio: (\\w+).* (\\d+) Hz\", info\n        ):\n            audio_codec = audio_stream_match.group(1)\n            audio_sampling_rate = int(audio_stream_match.group(2))\n\n        # 提取所有音频流信息（用于多音轨选择）\n        audio_streams: list[AudioStreamInfo] = []\n        for match in re.finditer(\n            r\"Stream #\\d+:(\\d+)(?:\\[0x[0-9a-fA-F]+\\])?(?:\\(([a-z]{3})\\))?: Audio: (\\w+)\",\n            info,\n        ):\n            audio_streams.append(\n                AudioStreamInfo(\n                    index=int(match.group(1)),\n                    codec=match.group(3),\n                    language=match.group(2) or \"\",\n                )\n            )\n\n        if audio_streams:\n            logger.info(f\"检测到 {len(audio_streams)} 条音轨\")\n\n        # 验证文件是否包含有效的媒体流\n        if not has_video_stream and not audio_streams:\n            logger.error(\"文件既没有视频流也没有音频流，可能不是有效的媒体文件\")\n            return None\n\n        # 提取缩略图（如果指定了路径且有视频流）\n        final_thumbnail_path = \"\"\n        if thumbnail_path and duration_seconds > 0 and has_video_stream:\n            if _extract_thumbnail(file_path, duration_seconds * 0.3, thumbnail_path):\n                final_thumbnail_path = thumbnail_path\n\n        # 构造并返回 VideoInfo 对象\n        return VideoInfo(\n            file_name=Path(file_path).stem,\n            file_path=file_path,\n            width=width,\n            height=height,\n            fps=fps,\n            duration_seconds=duration_seconds,\n            bitrate_kbps=bitrate_kbps,\n            video_codec=video_codec,\n            audio_codec=audio_codec,\n            audio_sampling_rate=audio_sampling_rate,\n            thumbnail_path=final_thumbnail_path,\n            audio_streams=audio_streams,\n        )\n    except Exception as e:\n        logger.exception(f\"获取视频信息时出错: {str(e)}\")\n        return None\n\n\ndef _extract_thumbnail(video_path: str, seek_time: float, thumbnail_path: str) -> bool:\n    \"\"\"提取视频缩略图\n\n    Args:\n        video_path: 视频文件路径\n        seek_time: 截取时间点（秒）\n        thumbnail_path: 缩略图保存路径\n\n    Returns:\n        是否成功\n    \"\"\"\n    if not Path(video_path).is_file():\n        logger.error(f\"视频文件不存在: {video_path}\")\n        return False\n\n    try:\n        timestamp = f\"{int(seek_time // 3600):02}:{int((seek_time % 3600) // 60):02}:{seek_time % 60:06.3f}\"\n        Path(thumbnail_path).parent.mkdir(parents=True, exist_ok=True)\n\n        result = subprocess.run(\n            [\n                \"ffmpeg\",\n                \"-ss\",\n                timestamp,\n                \"-i\",\n                Path(video_path).as_posix(),\n                \"-vframes\",\n                \"1\",\n                \"-q:v\",\n                \"2\",\n                \"-y\",\n                Path(thumbnail_path).as_posix(),\n            ],\n            capture_output=True,\n            text=True,\n            encoding=\"utf-8\",\n            errors=\"replace\",\n            creationflags=(\n                getattr(subprocess, \"CREATE_NO_WINDOW\", 0) if os.name == \"nt\" else 0\n            ),\n        )\n        return result.returncode == 0\n\n    except Exception as e:\n        logger.exception(f\"提取缩略图时出错: {str(e)}\")\n        return False\n\n\ndef add_subtitles_with_style(\n    video_path: str,\n    asr_data: \"ASRData\",\n    output_path: str,\n    render_mode: SubtitleRenderModeEnum,\n    subtitle_layout: SubtitleLayoutEnum,\n    ass_style: str = \"\",\n    rounded_style: Optional[dict] = None,\n    crf: int = 23,\n    preset: PresetType = \"medium\",\n    progress_callback: Optional[Callable] = None,\n) -> None:\n    \"\"\"\n    根据渲染模式选择合成方式\n\n    Args:\n        video_path: 输入视频路径\n        asr_data: 字幕数据\n        output_path: 输出视频路径\n        render_mode: 渲染模式 (ASS_STYLE 或 ROUNDED_BG)\n        subtitle_layout: 字幕布局\n        ass_style: ASS 样式字符串 (仅 ASS_STYLE 模式使用)\n        rounded_style: 圆角背景样式配置字典 (仅 ROUNDED_BG 模式使用)\n        crf: 视频质量\n        preset: FFmpeg 编码预设\n        progress_callback: 进度回调\n    \"\"\"\n\n    if render_mode == SubtitleRenderModeEnum.ROUNDED_BG:\n        # 圆角背景模式\n        render_rounded_video(\n            video_path=video_path,\n            asr_data=asr_data,\n            output_path=output_path,\n            rounded_style=rounded_style,\n            layout=subtitle_layout,\n            crf=crf,\n            preset=preset,\n            progress_callback=progress_callback,\n        )\n    else:\n        # ASS 样式模式\n        render_ass_video(\n            video_path=video_path,\n            asr_data=asr_data,\n            output_path=output_path,\n            style_str=ass_style,\n            layout=subtitle_layout,\n            crf=crf,\n            preset=preset,\n            progress_callback=progress_callback,\n        )\n"
  },
  {
    "path": "app/thread/batch_process_thread.py",
    "content": "import queue\nimport time\nfrom functools import partial\nfrom typing import Dict, Optional\n\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\nfrom app.core.entities import (\n    BatchTaskStatus,\n    BatchTaskType,\n    TranscribeTask,\n)\nfrom app.core.task_factory import TaskFactory\nfrom app.core.utils.logger import setup_logger\nfrom app.thread.subtitle_thread import SubtitleThread\nfrom app.thread.transcript_thread import TranscriptThread\nfrom app.thread.video_synthesis_thread import VideoSynthesisThread\n\nlogger = setup_logger(\"batch_process_thread\")\n\n\nclass BatchTask:\n    def __init__(self, file_path: str, task_type: BatchTaskType):\n        self.file_path = file_path\n        self.task_type = task_type\n        self.status = BatchTaskStatus.WAITING\n        self.progress = 0\n        self.error_message = \"\"\n        self.current_thread: Optional[QThread] = None\n\n\nclass BatchProcessThread(QThread):\n    # 信号定义\n    task_progress = pyqtSignal(str, int, str)  # file_path, progress, status\n    task_error = pyqtSignal(str, str)  # file_path, error_message\n    task_completed = pyqtSignal(str)  # file_path\n\n    def __init__(self):\n        super().__init__()\n        self.task_queue = queue.Queue()\n        self.current_tasks: Dict[str, BatchTask] = {}\n        self.max_concurrent_tasks = 1\n        self.is_running = False\n        self.factory = TaskFactory()\n        self.threads = []  # 保存所有创建的线程\n\n    def add_task(self, task: BatchTask):\n        self.task_queue.put(task)\n        self.current_tasks[task.file_path] = task\n        if not self.isRunning():\n            self.is_running = True\n            self.start()\n\n    def run(self):\n        while self.is_running:\n            # 检查是否有正在运行的任务数量是否达到上限\n            running_tasks = sum(\n                1\n                for task in self.current_tasks.values()\n                if task.status == BatchTaskStatus.RUNNING\n            )\n\n            if running_tasks < self.max_concurrent_tasks:\n                try:\n                    # 非阻塞方式获取任务\n                    task = self.task_queue.get_nowait()\n                    self._process_task(task)\n                except queue.Empty:\n                    time.sleep(0.1)  # 避免CPU过度使用\n            else:\n                time.sleep(0.1)\n\n    def _process_task(self, batch_task: BatchTask):\n        try:\n            batch_task.status = BatchTaskStatus.RUNNING\n            self.task_progress.emit(\n                batch_task.file_path, 0, str(BatchTaskStatus.RUNNING)\n            )\n\n            if batch_task.task_type == BatchTaskType.TRANSCRIBE:\n                self._handle_transcribe_task(batch_task)\n            elif batch_task.task_type == BatchTaskType.SUBTITLE:\n                self._handle_subtitle_task(batch_task)\n            elif batch_task.task_type == BatchTaskType.TRANS_SUB:\n                self._handle_trans_sub_task(batch_task)\n            elif batch_task.task_type == BatchTaskType.FULL_PROCESS:\n                self._handle_full_process_task(batch_task)\n\n        except Exception as e:\n            logger.exception(f\"处理任务失败: {str(e)}\")\n            batch_task.status = BatchTaskStatus.FAILED\n            batch_task.error_message = str(e)\n            self.task_error.emit(batch_task.file_path, str(e))\n\n    def _on_progress_wrapper(self, batch_task: BatchTask, progress: int, message: str):\n        \"\"\"进度信号包装器\"\"\"\n        self.task_progress.emit(batch_task.file_path, progress, message)\n\n    def _on_error_wrapper(self, batch_task: BatchTask, error: str):\n        \"\"\"错误信号包装器\"\"\"\n        batch_task.status = BatchTaskStatus.FAILED\n        batch_task.error_message = error\n        self.task_error.emit(batch_task.file_path, error)\n\n    def _on_finished_wrapper(self, batch_task: BatchTask, task=None):\n        \"\"\"完成信号包装器\"\"\"\n        batch_task.status = BatchTaskStatus.COMPLETED\n        batch_task.progress = 100\n        self.task_completed.emit(batch_task.file_path)\n        if batch_task.current_thread in self.threads:\n            self.threads.remove(batch_task.current_thread)\n\n    def _handle_transcribe_task(self, batch_task: BatchTask):\n        # self.max_concurrent_tasks = 3\n        task = self.factory.create_transcribe_task(batch_task.file_path)\n        thread = TranscriptThread(task)\n        batch_task.current_thread = thread\n\n        # 保存线程引用\n        self.threads.append(thread)\n\n        thread.progress.connect(  # type: ignore\n            partial(self._on_progress_wrapper, batch_task)  # type: ignore\n        )\n        thread.error.connect(  # type: ignore\n            partial(self._on_error_wrapper, batch_task)  # type: ignore\n        )\n        thread.finished.connect(  # type: ignore\n            partial(self._on_finished_wrapper, batch_task)  # type: ignore\n        )\n\n        thread.start()\n\n    def _handle_subtitle_task(self, batch_task: BatchTask):\n        logger.info(f\"开始处理字幕任务: {batch_task.file_path}\")\n\n        task = self.factory.create_subtitle_task(batch_task.file_path)\n        thread = SubtitleThread(task)\n        batch_task.current_thread = thread\n\n        # 保存线程引用\n        self.threads.append(thread)\n\n        thread.progress.connect(  # type: ignore\n            partial(self._on_progress_wrapper, batch_task)  # type: ignore\n        )\n        thread.error.connect(  # type: ignore\n            partial(self._on_error_wrapper, batch_task)  # type: ignore\n        )\n        thread.finished.connect(  # type: ignore\n            partial(self._on_finished_wrapper, batch_task)  # type: ignore\n        )\n\n        thread.start()\n\n    def _handle_trans_sub_task(self, batch_task: BatchTask):\n        trans_task = self.factory.create_transcribe_task(\n            batch_task.file_path, need_next_task=True\n        )\n        thread = TranscriptThread(trans_task)\n        batch_task.current_thread = thread\n        self.current_tasks[batch_task.file_path] = batch_task\n\n        # 保存线程引用\n        self.threads.append(thread)\n\n        thread.progress.connect(\n            partial(self._on_trans_sub_progress_wrapper, batch_task)\n        )\n        thread.error.connect(partial(self._on_error_wrapper, batch_task))\n        thread.finished.connect(\n            partial(self._on_trans_sub_finished_wrapper, batch_task)\n        )\n\n        thread.start()\n\n    def _on_trans_sub_progress_wrapper(\n        self, batch_task: BatchTask, progress: int, message: str\n    ):\n        \"\"\"转录+字幕任务进度包装器\"\"\"\n        progress = progress // 2  # 转录占50%进度\n        self.task_progress.emit(batch_task.file_path, progress, message)\n\n    def _on_trans_sub_finished_wrapper(\n        self, batch_task: BatchTask, task: TranscribeTask\n    ):\n        \"\"\"转录+字幕任务转录完成包装器\"\"\"\n        if batch_task.current_thread in self.threads:\n            self.threads.remove(batch_task.current_thread)\n\n        # 创建字幕任务\n        if not task.output_path:\n            raise ValueError(\"Task output_path is None\")\n        subtitle_task = self.factory.create_subtitle_task(\n            task.output_path, batch_task.file_path, need_next_task=True\n        )\n        thread = SubtitleThread(subtitle_task)\n        batch_task.current_thread = thread\n        self.current_tasks[batch_task.file_path] = batch_task\n\n        # 保存线程引用\n        self.threads.append(thread)\n\n        thread.progress.connect(\n            partial(self._on_trans_sub_subtitle_progress_wrapper, batch_task)\n        )\n        thread.error.connect(partial(self._on_error_wrapper, batch_task))\n        thread.finished.connect(partial(self._on_finished_wrapper, batch_task))\n\n        thread.start()\n\n    def _on_trans_sub_subtitle_progress_wrapper(\n        self, batch_task: BatchTask, progress: int, message: str\n    ):\n        \"\"\"转录+字幕任务字幕进度包装器\"\"\"\n        progress = 50 + progress // 2  # 字幕处理占后50%进度\n        self.task_progress.emit(batch_task.file_path, progress, message)\n\n    def _handle_full_process_task(self, batch_task: BatchTask):\n        # 首先创建转录任务\n        trans_task = self.factory.create_transcribe_task(\n            batch_task.file_path, need_next_task=True\n        )\n        thread = TranscriptThread(trans_task)\n        batch_task.current_thread = thread\n\n        # 保存线程引用\n        self.threads.append(thread)\n\n        thread.progress.connect(partial(self.on_full_process_progress, batch_task))\n        thread.error.connect(partial(self._on_error_wrapper, batch_task))\n        thread.finished.connect(partial(self.on_full_process_finished, batch_task))\n\n        thread.start()\n\n    def on_full_process_progress(\n        self, batch_task: BatchTask, progress: int, message: str\n    ):\n        \"\"\"处理全流程任务的转录进度\"\"\"\n        if batch_task.status == BatchTaskStatus.RUNNING:\n            progress_value = progress // 3  # 转录占33%进度\n            self.task_progress.emit(batch_task.file_path, progress_value, message)\n\n    def on_full_process_finished(self, batch_task: BatchTask, task: TranscribeTask):\n        \"\"\"处理转录完成后开始字幕任务\"\"\"\n        if batch_task.current_thread in self.threads:\n            self.threads.remove(batch_task.current_thread)\n\n        # 转录完成后创建字幕任务\n        if not task.output_path:\n            raise ValueError(\"Task output_path is None\")\n        subtitle_task = self.factory.create_subtitle_task(\n            task.output_path,\n            batch_task.file_path,\n            need_next_task=True,\n        )\n        thread = SubtitleThread(subtitle_task)\n        batch_task.current_thread = thread\n\n        # 保存线程引用\n        self.threads.append(thread)\n\n        thread.progress.connect(\n            partial(self.on_full_process_subtitle_progress, batch_task)\n        )\n        thread.error.connect(partial(self._on_error_wrapper, batch_task))\n        thread.finished.connect(\n            partial(self.on_full_process_subtitle_finished, batch_task)\n        )\n\n        thread.start()\n\n    def on_full_process_subtitle_progress(\n        self, batch_task: BatchTask, progress: int, message: str\n    ):\n        \"\"\"处理全流程任务中字幕部分的进度\"\"\"\n        if batch_task.status == BatchTaskStatus.RUNNING:\n            progress_value = 33 + progress // 3  # 字幕处理占中间33%进度\n            self.task_progress.emit(batch_task.file_path, progress_value, message)\n\n    def on_full_process_subtitle_finished(\n        self, batch_task: BatchTask, video_path: str, subtitle_path: str\n    ):\n        \"\"\"处理字幕完成后开始视频合成任务\"\"\"\n        if batch_task.current_thread in self.threads:\n            self.threads.remove(batch_task.current_thread)\n\n        # 字幕完成后创建视频合成任务\n        synthesis_task = self.factory.create_synthesis_task(video_path, subtitle_path)\n        thread = VideoSynthesisThread(synthesis_task)\n        batch_task.current_thread = thread\n\n        # 保存线程引用\n        self.threads.append(thread)\n\n        thread.progress.connect(\n            partial(self.on_full_process_synthesis_progress, batch_task)\n        )\n        thread.error.connect(partial(self._on_error_wrapper, batch_task))\n        thread.finished.connect(partial(self._on_finished_wrapper, batch_task))\n\n        thread.start()\n\n    def on_full_process_synthesis_progress(\n        self, batch_task: BatchTask, progress: int, message: str\n    ):\n        \"\"\"处理全流程任务中视频合成部分的进度\"\"\"\n        if batch_task.status == BatchTaskStatus.RUNNING:\n            progress_value = 66 + progress // 3  # 视频合成占最后34%进度\n            self.task_progress.emit(batch_task.file_path, progress_value, message)\n\n    def stop_task(self, file_path: str):\n        if file_path in self.current_tasks:\n            task = self.current_tasks[file_path]\n            if task.current_thread:\n                if hasattr(task.current_thread, \"stop\"):\n                    task.current_thread.stop()  # type: ignore\n            del self.current_tasks[file_path]\n            # 从队列中移除任务\n            with self.task_queue.mutex:\n                self.task_queue.queue.clear()\n\n    def stop_all(self):\n        self.is_running = False\n        # 停止所有线程\n        for thread in self.threads:\n            if hasattr(thread, \"stop\"):\n                thread.stop()  # type: ignore\n            thread.wait()  # 等待线程结束\n        self.threads.clear()\n        self.current_tasks.clear()\n        # 清空任务队列\n        with self.task_queue.mutex:\n            self.task_queue.queue.clear()\n"
  },
  {
    "path": "app/thread/file_download_thread.py",
    "content": "import shutil\nimport subprocess\nfrom abc import ABC, abstractmethod\nfrom pathlib import Path\n\nimport requests\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\nfrom app.config import CACHE_PATH\nfrom app.core.utils.logger import setup_logger\nfrom app.core.utils.platform_utils import get_subprocess_kwargs\n\nlogger = setup_logger(\"download_thread\")\n\n\nclass BaseDownloader(ABC):\n    \"\"\"下载器基类\"\"\"\n\n    def __init__(self, url: str, save_path: Path, progress_callback):\n        self.url = url\n        self.save_path = save_path\n        self.progress_callback = progress_callback\n        self._cancelled = False\n\n    @abstractmethod\n    def download(self) -> bool:\n        \"\"\"执行下载，返回是否成功\"\"\"\n        pass\n\n    def cancel(self):\n        \"\"\"取消下载\"\"\"\n        self._cancelled = True\n\n\nclass Aria2Downloader(BaseDownloader):\n    \"\"\"aria2c 多线程下载器\"\"\"\n\n    def __init__(self, url: str, save_path: Path, progress_callback):\n        super().__init__(url, save_path, progress_callback)\n        self.process = None\n\n    @staticmethod\n    def is_available() -> bool:\n        \"\"\"检查 aria2c 是否可用\"\"\"\n        return shutil.which(\"aria2c\") is not None\n\n    def download(self) -> bool:\n        temp_dir = CACHE_PATH / \"download_cache\"\n        temp_dir.mkdir(parents=True, exist_ok=True)\n        temp_file = temp_dir / self.save_path.name\n\n        cmd = [\n            \"aria2c\",\n            \"--no-conf\",\n            \"--show-console-readout=false\",\n            \"--summary-interval=1\",\n            \"--max-connection-per-server=2\",\n            \"--split=2\",\n            \"--connect-timeout=10\",\n            \"--timeout=10\",\n            \"--max-tries=2\",\n            \"--retry-wait=1\",\n            \"--continue=true\",\n            \"--auto-file-renaming=false\",\n            \"--allow-overwrite=true\",\n            \"--check-certificate=false\",\n            f\"--dir={temp_dir}\",\n            f\"--out={temp_file.name}\",\n            self.url,\n        ]\n\n        subprocess_args = {\n            \"stdout\": subprocess.PIPE,\n            \"stderr\": subprocess.PIPE,\n            \"universal_newlines\": True,\n            \"encoding\": \"utf-8\",\n            **get_subprocess_kwargs(),\n        }\n\n        logger.info(f\"使用 aria2c 下载: {self.url}\")\n        self.process = subprocess.Popen(cmd, **subprocess_args)\n\n        while True:\n            if self._cancelled:\n                self.process.terminate()\n                return False\n\n            if self.process.poll() is not None:\n                break\n\n            line = self.process.stdout.readline()\n            self._parse_progress(line)\n\n        if self.process.returncode == 0:\n            self.save_path.parent.mkdir(parents=True, exist_ok=True)\n            shutil.move(str(temp_file), self.save_path)\n            return True\n        else:\n            error = self.process.stderr.read()\n            logger.error(f\"aria2c 下载失败: {error}\")\n            return False\n\n    def _parse_progress(self, line: str):\n        \"\"\"解析 aria2c 输出格式: [#40ca1b 2.4MiB/74MiB(3%) CN:2 DL:3.9MiB ETA:18s]\"\"\"\n        if \"[#\" not in line or \"]\" not in line:\n            return\n\n        try:\n            progress_part = line.split(\"(\")[1].split(\")\")[0]\n            percent = float(progress_part.strip(\"%\"))\n\n            speed = \"0\"\n            eta = \"\"\n            if \"DL:\" in line:\n                speed = line.split(\"DL:\")[1].split()[0]\n            if \"ETA:\" in line:\n                eta = line.split(\"ETA:\")[1].split(\"]\")[0]\n\n            status = f\"速度: {speed}/s, 剩余: {eta}\"\n            self.progress_callback(percent, status)\n        except Exception:\n            pass\n\n    def cancel(self):\n        super().cancel()\n        if self.process:\n            self.process.terminate()\n            self.process.wait()\n\n\nclass RequestsDownloader(BaseDownloader):\n    \"\"\"Python requests 下载器（回退方案）\"\"\"\n\n    CHUNK_SIZE = 8192\n\n    def download(self) -> bool:\n        logger.info(f\"使用 requests 下载: {self.url}\")\n        self.progress_callback(0, \"正在连接...\")\n\n        try:\n            response = requests.get(self.url, stream=True, timeout=30)\n            response.raise_for_status()\n\n            total_size = int(response.headers.get(\"content-length\", 0))\n            downloaded = 0\n\n            self.save_path.parent.mkdir(parents=True, exist_ok=True)\n            temp_file = self.save_path.with_suffix(\".tmp\")\n\n            with open(temp_file, \"wb\") as f:\n                for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):\n                    if self._cancelled:\n                        temp_file.unlink(missing_ok=True)\n                        return False\n\n                    f.write(chunk)\n                    downloaded += len(chunk)\n\n                    if total_size > 0:\n                        percent = (downloaded / total_size) * 100\n                        speed = self._format_size(downloaded)\n                        status = f\"已下载: {speed} / {self._format_size(total_size)}\"\n                        self.progress_callback(percent, status)\n\n            # 下载完成后重命名\n            shutil.move(str(temp_file), self.save_path)\n            return True\n\n        except requests.RequestException as e:\n            logger.error(f\"requests 下载失败: {e}\")\n            return False\n\n    @staticmethod\n    def _format_size(bytes_size: int) -> str:\n        \"\"\"格式化文件大小\"\"\"\n        size = float(bytes_size)\n        for unit in [\"B\", \"KB\", \"MB\", \"GB\"]:\n            if size < 1024:\n                return f\"{size:.1f}{unit}\"\n            size /= 1024\n        return f\"{size:.1f}TB\"\n\n\nclass FileDownloadThread(QThread):\n    \"\"\"文件下载线程\"\"\"\n\n    progress = pyqtSignal(float, str)\n    finished = pyqtSignal()\n    error = pyqtSignal(str)\n\n    def __init__(self, url: str, save_path: str):\n        super().__init__()\n        self.url = url\n        self.save_path = Path(save_path)\n        self.downloader: BaseDownloader | None = None\n\n    def run(self):\n        try:\n            self.progress.emit(0, self.tr(\"正在连接...\"))\n\n            # 选择下载器：优先 aria2c，否则回退到 requests\n            if Aria2Downloader.is_available():\n                self.downloader = Aria2Downloader(\n                    self.url, self.save_path, self._on_progress\n                )\n            else:\n                logger.info(\"aria2c 不可用，使用 requests 下载\")\n                self.downloader = RequestsDownloader(\n                    self.url, self.save_path, self._on_progress\n                )\n\n            success = self.downloader.download()\n\n            if success:\n                self.finished.emit()\n            else:\n                self.error.emit(self.tr(\"下载失败\"))\n\n        except Exception as e:\n            logger.exception(\"下载异常\")\n            self.error.emit(str(e))\n\n    def _on_progress(self, percent: float, status: str):\n        \"\"\"进度回调\"\"\"\n        self.progress.emit(percent, status)\n\n    def stop(self):\n        \"\"\"停止下载\"\"\"\n        if self.downloader:\n            self.downloader.cancel()\n"
  },
  {
    "path": "app/thread/modelscope_download_thread.py",
    "content": "import io\nimport logging\nimport sys\nfrom typing import Callable\n\nfrom modelscope.hub.callback import ProgressCallback\nfrom modelscope.hub.snapshot_download import snapshot_download\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\n\nclass SuppressOutput:\n    \"\"\"上下文管理器：抑制 stdout/stderr 和 modelscope 日志\"\"\"\n\n    def __enter__(self):\n        self._stdout = sys.stdout\n        self._stderr = sys.stderr\n        sys.stdout = io.StringIO()\n        sys.stderr = io.StringIO()\n        self._loggers: dict[str, int] = {}\n        for name in [\"modelscope\", \"tqdm\"]:\n            logger = logging.getLogger(name)\n            self._loggers[name] = logger.level\n            logger.setLevel(logging.CRITICAL)\n        return self\n\n    def __exit__(self, *args):\n        sys.stdout = self._stdout\n        sys.stderr = self._stderr\n        for name, level in self._loggers.items():\n            logging.getLogger(name).setLevel(level)\n\n\ndef create_progress_callback_class(\n    progress_callback: Callable[[int, str], None],\n) -> type[ProgressCallback]:\n    \"\"\"创建一个自定义的 ProgressCallback 类，用于接收下载进度\"\"\"\n\n    class CustomProgressCallback(ProgressCallback):\n        def __init__(self, filename: str, file_size: int):\n            super().__init__(filename, file_size)\n            self.downloaded = 0\n\n        def update(self, size: int):\n            self.downloaded += size\n            if self.file_size > 0:\n                percentage = min(int(self.downloaded * 100 / self.file_size), 99)\n                progress_callback(percentage, f\"{self.filename}: {percentage}%\")\n\n        def end(self):\n            pass\n\n    return CustomProgressCallback\n\n\nclass ModelscopeDownloadThread(QThread):\n    progress = pyqtSignal(int, str)\n    error = pyqtSignal(str)\n\n    def __init__(self, model_id: str, save_path: str):\n        super().__init__()\n        self.model_id = model_id\n        self.save_path = save_path\n\n    def run(self):\n        try:\n            self.progress.emit(0, self.tr(\"开始下载...\"))\n\n            callback_class = create_progress_callback_class(self.progress.emit)\n\n            with SuppressOutput():\n                snapshot_download(\n                    self.model_id,\n                    local_dir=self.save_path,\n                    progress_callbacks=[callback_class],\n                )\n\n            self.progress.emit(100, self.tr(\"下载完成\"))\n\n        except Exception as e:\n            self.error.emit(str(e))\n\n\nif __name__ == \"__main__\":\n    import sys\n\n    from PyQt5.QtCore import QCoreApplication\n\n    app = QCoreApplication(sys.argv)\n    model_id = \"pengzhendong/faster-whisper-tiny\"\n    save_path = r\"models/faster-whisper-tiny\"\n    downloader = ModelscopeDownloadThread(model_id, save_path)\n\n    def on_progress(percentage, message):\n        print(f\"进度: {message}\")\n\n    def on_error(error_msg):\n        print(f\"错误: {error_msg}\")\n        app.quit()\n\n    def on_finished():\n        print(\"下载完成！\")\n        app.quit()\n\n    downloader.progress.connect(on_progress)\n    downloader.error.connect(on_error)\n    downloader.finished.connect(on_finished)\n\n    print(f\"开始下载模型 {model_id}\")\n    downloader.start()\n\n    sys.exit(app.exec_())\n"
  },
  {
    "path": "app/thread/subtitle_pipeline_thread.py",
    "content": "import datetime\n\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\nfrom app.core.entities import (\n    FullProcessTask,\n    SubtitleTask,\n    SynthesisTask,\n    TranscribeTask,\n)\nfrom app.core.utils.logger import setup_logger\n\nfrom .subtitle_thread import SubtitleThread\nfrom .transcript_thread import TranscriptThread\nfrom .video_synthesis_thread import VideoSynthesisThread\n\nlogger = setup_logger(\"subtitle_pipeline_thread\")\n\n\nclass SubtitlePipelineThread(QThread):\n    \"\"\"字幕处理全流程线程，包含:\n    1. 转录生成字幕\n    2. 字幕优化/翻译\n    3. 视频合成\n    \"\"\"\n\n    progress = pyqtSignal(int, str)  # 进度值, 进度描述\n    finished = pyqtSignal(FullProcessTask)\n    error = pyqtSignal(str)\n\n    def __init__(self, task: FullProcessTask):\n        super().__init__()\n        self.task = task\n        self.has_error = False\n\n    def run(self):\n        try:\n\n            def handle_error(error_msg):\n                logger.error(\"pipeline 发生错误: %s\", error_msg)\n                self.has_error = True\n                self.error.emit(error_msg)\n\n            # 1. 转录生成字幕\n            self.task.started_at = datetime.datetime.now()\n            logger.info(f\"\\n{self.task.transcribe_config.print_config()}\")\n            logger.info(f\"\\n{self.task.subtitle_config.print_config()}\")\n            if self.task.synthesis_config:\n                logger.info(f\"\\n{self.task.synthesis_config.print_config()}\")\n            self.progress.emit(0, self.tr(\"开始转录\"))\n\n            # 创建转录任务\n            transcribe_task = TranscribeTask(\n                file_path=self.task.file_path,\n                transcribe_config=self.task.transcribe_config,\n                need_next_task=True,\n                queued_at=self.task.queued_at,\n                started_at=self.task.started_at,\n                completed_at=self.task.completed_at,\n            )\n            transcript_thread = TranscriptThread(transcribe_task)\n            transcript_thread.progress.connect(\n                lambda value, msg: self.progress.emit(int(value * 0.4), msg)\n            )\n            transcript_thread.error.connect(handle_error)\n            transcript_thread.run()\n\n            if self.has_error:\n                logger.info(\"转录过程中发生错误，终止流程\")\n                return\n\n            # 2. 字幕优化/翻译\n            # self.task.status = Task.Status.OPTIMIZING\n            self.progress.emit(40, self.tr(\"开始优化字幕\"))\n\n            # 创建字幕任务\n            subtitle_task = SubtitleTask(\n                subtitle_path=transcribe_task.output_path or \"\",\n                video_path=self.task.file_path,\n                output_path=self.task.output_path,\n                subtitle_config=self.task.subtitle_config,\n                need_next_task=True,\n                queued_at=self.task.queued_at,\n                started_at=self.task.started_at,\n                completed_at=self.task.completed_at,\n            )\n            optimization_thread = SubtitleThread(subtitle_task)\n            optimization_thread.progress.connect(\n                lambda value, msg: self.progress.emit(int(40 + value * 0.2), msg)\n            )\n            optimization_thread.error.connect(handle_error)\n            optimization_thread.run()\n\n            if self.has_error:\n                logger.info(\"字幕优化过程中发生错误，终止流程\")\n                return\n\n            # 3. 视频合成\n            # self.task.status = Task.Status.GENERATING\n            self.progress.emit(80, self.tr(\"开始合成视频\"))\n\n            # 创建合成任务\n            synthesis_task = SynthesisTask(\n                video_path=self.task.file_path,\n                subtitle_path=subtitle_task.output_path,\n                output_path=self.task.output_path,\n                synthesis_config=self.task.synthesis_config,\n                queued_at=self.task.queued_at,\n                started_at=self.task.started_at,\n                completed_at=self.task.completed_at,\n            )\n            synthesis_thread = VideoSynthesisThread(synthesis_task)\n            synthesis_thread.progress.connect(\n                lambda value, msg: self.progress.emit(int(70 + value * 0.3), msg)\n            )\n            synthesis_thread.error.connect(handle_error)\n            synthesis_thread.run()\n\n            if self.has_error:\n                logger.info(\"视频合成过程中发生错误，终止流程\")\n                return\n\n            # self.task.status = FullProcessTask.Status.COMPLETED  # type: ignore\n            logger.info(\"处理完成\")\n            self.progress.emit(100, self.tr(\"处理完成\"))\n            self.finished.emit(self.task)\n\n        except Exception as e:\n            # self.task.status = FullProcessTask.Status.FAILED  # type: ignore\n            logger.exception(\"处理失败: %s\", str(e))\n            self.error.emit(str(e))\n"
  },
  {
    "path": "app/thread/subtitle_thread.py",
    "content": "import os\nfrom pathlib import Path\nfrom typing import List, Optional\n\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\nfrom app.core.asr.asr_data import ASRData\nfrom app.core.entities import (\n    SubtitleConfig,\n    SubtitleLayoutEnum,\n    SubtitleProcessData,\n    SubtitleTask,\n    TranslatorServiceEnum,\n)\nfrom app.core.llm.check_llm import check_llm_connection\nfrom app.core.llm.context import clear_task_context, set_task_context, update_stage\nfrom app.core.optimize.optimize import SubtitleOptimizer\nfrom app.core.split.split import SubtitleSplitter\nfrom app.core.translate import (\n    BingTranslator,\n    DeepLXTranslator,\n    GoogleTranslator,\n    LLMTranslator,\n)\nfrom app.core.utils.logger import setup_logger\n\n# 配置日志\nlogger = setup_logger(\"subtitle_optimization_thread\")\n\n\nclass SubtitleThread(QThread):\n    finished = pyqtSignal(str, str)\n    progress = pyqtSignal(int, str)\n    update = pyqtSignal(dict)\n    update_all = pyqtSignal(dict)\n    error = pyqtSignal(str)\n\n    def __init__(self, task: SubtitleTask):\n        super().__init__()\n        self.task: SubtitleTask = task\n        self.subtitle_length = 0\n        self.finished_subtitle_length = 0\n        self.custom_prompt_text = \"\"\n        self.optimizer = None\n\n    def set_custom_prompt_text(self, text: str):\n        self.custom_prompt_text = text\n\n    def _setup_llm_config(self) -> Optional[SubtitleConfig]:\n        \"\"\"设置API配置，返回SubtitleConfig\"\"\"\n        if (\n            self.task.subtitle_config.base_url\n            and self.task.subtitle_config.api_key\n            and self.task.subtitle_config.llm_model\n        ):\n            success, message = check_llm_connection(\n                self.task.subtitle_config.base_url,\n                self.task.subtitle_config.api_key,\n                self.task.subtitle_config.llm_model,\n            )\n            if not success:\n                raise Exception(f\"{self.tr('LLM API 测试失败: ')}{message or ''}\")\n            # 设置环境变量\n            if self.task.subtitle_config.base_url:\n                os.environ[\"OPENAI_BASE_URL\"] = self.task.subtitle_config.base_url\n            if self.task.subtitle_config.api_key:\n                os.environ[\"OPENAI_API_KEY\"] = self.task.subtitle_config.api_key\n            return self.task.subtitle_config\n        else:\n            raise Exception(self.tr(\"LLM API 未配置, 请检查LLM配置\"))\n\n    def run(self):\n        # 设置任务上下文\n        task_file = (\n            Path(self.task.video_path) if self.task.video_path else Path(self.task.subtitle_path)\n        )\n        set_task_context(\n            task_id=self.task.task_id,\n            file_name=task_file.name,\n            stage=\"subtitle\",\n        )\n\n        try:\n            logger.info(f\"\\n{self.task.subtitle_config.print_config()}\")\n\n            # 字幕文件路径检查、对断句字幕路径进行定义\n            subtitle_path = self.task.subtitle_path\n            assert subtitle_path is not None, self.tr(\"字幕文件路径为空\")\n\n            subtitle_config = self.task.subtitle_config\n            assert subtitle_config is not None, self.tr(\"字幕配置为空\")\n\n            asr_data = ASRData.from_subtitle_file(subtitle_path)\n\n            # 1. 分割成字词级时间戳（对于非断句字幕且开启分割选项）\n            if subtitle_config.need_split and not asr_data.is_word_timestamp():\n                asr_data.split_to_word_segments()\n                self.update_all.emit(asr_data.to_json())\n\n            # 验证 LLM 配置\n            if self.need_llm(subtitle_config, asr_data):\n                self.progress.emit(2, self.tr(\"开始验证 LLM 配置...\"))\n                subtitle_config = self._setup_llm_config()\n\n            # 2. 重新断句（对于字词级字幕）\n            if asr_data.is_word_timestamp():\n                update_stage(\"split\")\n                self.progress.emit(5, self.tr(\"字幕断句...\"))\n                logger.info(\"正在字幕断句...\")\n                splitter = SubtitleSplitter(\n                    thread_num=subtitle_config.thread_num,\n                    model=subtitle_config.llm_model,\n                    max_word_count_cjk=subtitle_config.max_word_count_cjk,\n                    max_word_count_english=subtitle_config.max_word_count_english,\n                )\n                asr_data = splitter.split_subtitle(asr_data)\n                self.update_all.emit(asr_data.to_json())\n\n            # 3. 优化字幕\n            context_info = f'The subtitles below are from a file named \"{task_file}\". Use this context to improve accuracy if needed.\\n'\n            custom_prompt = context_info + (subtitle_config.custom_prompt_text or \"\") + \"\\n\"\n            self.subtitle_length = len(asr_data.segments)\n\n            if subtitle_config.need_optimize:\n                update_stage(\"optimize\")\n                self.progress.emit(0, self.tr(\"优化字幕...\"))\n                logger.info(\"正在优化字幕...\")\n                self.finished_subtitle_length = 0\n                if not subtitle_config.llm_model:\n                    raise Exception(self.tr(\"LLM 模型未配置\"))\n                optimizer = SubtitleOptimizer(\n                    thread_num=subtitle_config.thread_num,\n                    batch_num=subtitle_config.batch_size,\n                    model=subtitle_config.llm_model,\n                    custom_prompt=custom_prompt or \"\",\n                    update_callback=self.callback,\n                )\n                asr_data = optimizer.optimize_subtitle(asr_data)\n                asr_data.remove_punctuation()\n                self.update_all.emit(asr_data.to_json())\n\n            # 4. 翻译字幕\n            if subtitle_config.need_translate:\n                update_stage(\"translate\")\n                self.progress.emit(0, self.tr(\"翻译字幕...\"))\n                logger.info(\"正在翻译字幕...\")\n                self.finished_subtitle_length = 0\n                translator_service = subtitle_config.translator_service\n\n                if not subtitle_config.target_language:\n                    raise Exception(self.tr(\"目标语言未配置\"))\n\n                if translator_service == TranslatorServiceEnum.OPENAI:\n                    if not subtitle_config.llm_model:\n                        raise Exception(self.tr(\"LLM 模型未配置\"))\n                    translator = LLMTranslator(\n                        thread_num=subtitle_config.thread_num,\n                        batch_num=subtitle_config.batch_size,\n                        target_language=subtitle_config.target_language,\n                        model=subtitle_config.llm_model,\n                        custom_prompt=custom_prompt or \"\",\n                        is_reflect=subtitle_config.need_reflect,\n                        update_callback=self.callback,\n                    )\n                elif translator_service == TranslatorServiceEnum.GOOGLE:\n                    translator = GoogleTranslator(\n                        thread_num=subtitle_config.thread_num,\n                        batch_num=5,\n                        target_language=subtitle_config.target_language,\n                        timeout=20,\n                        update_callback=self.callback,\n                    )\n                elif translator_service == TranslatorServiceEnum.BING:\n                    translator = BingTranslator(\n                        thread_num=subtitle_config.thread_num,\n                        batch_num=10,\n                        target_language=subtitle_config.target_language,\n                        update_callback=self.callback,\n                    )\n                elif translator_service == TranslatorServiceEnum.DEEPLX:\n                    os.environ[\"DEEPLX_ENDPOINT\"] = subtitle_config.deeplx_endpoint or \"\"\n                    translator = DeepLXTranslator(\n                        thread_num=subtitle_config.thread_num,\n                        batch_num=5,\n                        target_language=subtitle_config.target_language,\n                        timeout=20,\n                        update_callback=self.callback,\n                    )\n                else:\n                    raise Exception(self.tr(f\"不支持的翻译服务: {translator_service}\"))\n\n                asr_data = translator.translate_subtitle(asr_data)\n\n                # 移除末尾标点符号\n                asr_data.remove_punctuation()\n                self.update_all.emit(asr_data.to_json())\n\n                # 保存翻译结果(单语、双语)\n                if self.task.need_next_task and self.task.video_path:\n                    for layout in SubtitleLayoutEnum:\n                        save_path = str(\n                            Path(self.task.subtitle_path).parent\n                            / f\"{Path(self.task.video_path).stem}-{layout.value}.srt\"\n                        )\n                        asr_data.save(\n                            save_path=save_path,\n                            ass_style=subtitle_config.subtitle_style or \"\",\n                            layout=layout,\n                        )\n                        logger.info(f\"翻译字幕保存到：{save_path}\")\n\n            # 5. 保存字幕\n            asr_data.save(\n                save_path=self.task.output_path or \"\",\n                ass_style=subtitle_config.subtitle_style or \"\",\n                layout=subtitle_config.subtitle_layout or SubtitleLayoutEnum.ONLY_TRANSLATE,\n            )\n            logger.info(f\"字幕保存到 {self.task.output_path}\")\n\n            # 6. 文件移动与清理\n            if self.task.need_next_task and self.task.video_path:\n                # 保存srt/ass文件到视频目录（对于全流程任务）\n                save_srt_path = (\n                    Path(self.task.video_path).parent / f\"{Path(self.task.video_path).stem}.srt\"\n                )\n                asr_data.to_srt(\n                    save_path=str(save_srt_path),\n                    layout=subtitle_config.subtitle_layout,\n                )\n                save_ass_path = (\n                    Path(self.task.video_path).parent / f\"{Path(self.task.video_path).stem}.ass\"\n                )\n                asr_data.to_ass(\n                    save_path=str(save_ass_path),\n                    layout=subtitle_config.subtitle_layout,\n                    style_str=subtitle_config.subtitle_style,\n                )\n\n            self.progress.emit(100, self.tr(\"优化完成\"))\n            logger.info(\"优化完成\")\n            self.finished.emit(self.task.video_path, self.task.output_path)\n\n        except Exception as e:\n            logger.exception(f\"字幕处理失败: {str(e)}\")\n            self.error.emit(str(e))\n            self.progress.emit(100, self.tr(\"字幕处理失败\"))\n        finally:\n            clear_task_context()\n\n    def need_llm(self, subtitle_config: SubtitleConfig, asr_data: ASRData):\n        return (\n            subtitle_config.need_optimize\n            or asr_data.is_word_timestamp()\n            or (\n                subtitle_config.need_translate\n                and subtitle_config.translator_service\n                not in [\n                    TranslatorServiceEnum.DEEPLX,\n                    TranslatorServiceEnum.BING,\n                    TranslatorServiceEnum.GOOGLE,\n                ]\n            )\n        )\n\n    def callback(self, result: List[SubtitleProcessData]):\n        self.finished_subtitle_length += len(result)\n        # 简单计算当前进度（0-100%）\n        progress = min(int((self.finished_subtitle_length / self.subtitle_length) * 100), 100)\n        self.progress.emit(progress, self.tr(\"{0}% 处理字幕\").format(progress))\n        # 转换为字典格式供UI使用\n        result_dict = {\n            str(data.index): data.translated_text or data.optimized_text or data.original_text\n            for data in result\n        }\n        self.update.emit(result_dict)\n\n    def stop(self):\n        \"\"\"停止所有处理\"\"\"\n        try:\n            # 先停止优化器\n            if hasattr(self, \"optimizer\") and self.optimizer:\n                try:\n                    self.optimizer.stop()  # type: ignore\n                except Exception as e:\n                    logger.error(f\"停止优化器时出错：{str(e)}\")\n\n            # 终止线程\n            self.terminate()\n            # 等待最多3秒\n            if not self.wait(3000):\n                logger.warning(\"线程未能在3秒内正常停止\")\n\n            # 发送进度信号\n            self.progress.emit(100, self.tr(\"已终止\"))\n\n        except Exception as e:\n            logger.error(f\"停止线程时出错：{str(e)}\")\n            self.progress.emit(100, self.tr(\"终止时发生错误\"))\n"
  },
  {
    "path": "app/thread/transcript_thread.py",
    "content": "import datetime\nimport tempfile\nfrom pathlib import Path\n\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\nfrom app.core.asr import transcribe\nfrom app.core.entities import TranscribeOutputFormatEnum, TranscribeTask\nfrom app.core.utils.logger import setup_logger\nfrom app.core.utils.video_utils import video2audio\n\nlogger = setup_logger(\"transcript_thread\")\n\n\nclass TranscriptThread(QThread):\n    finished = pyqtSignal(TranscribeTask)\n    progress = pyqtSignal(int, str)\n    error = pyqtSignal(str)\n\n    def __init__(self, task: TranscribeTask):\n        super().__init__()\n        self.task = task\n\n    def run(self):\n        try:\n            self.task.started_at = datetime.datetime.now()\n            logger.info(f\"\\n{self.task.transcribe_config.print_config()}\")\n\n            self._validate_task()\n\n            # 检查是否已下载字幕文件\n            if self._check_downloaded_subtitle():\n                return\n\n            self._perform_transcription()\n\n        except Exception as e:\n            logger.exception(\"转录过程中发生错误: %s\", str(e))\n            self.error.emit(str(e))\n            self.progress.emit(100, self.tr(\"转录失败\"))\n\n    def _validate_task(self):\n        \"\"\"验证任务配置\"\"\"\n        if not self.task.file_path:\n            raise ValueError(self.tr(\"文件路径为空\"))\n\n        video_path = Path(self.task.file_path)\n        if not video_path.exists():\n            logger.error(f\"视频文件不存在：{video_path}\")\n            raise ValueError(self.tr(\"视频文件不存在\"))\n\n        if not self.task.transcribe_config:\n            raise ValueError(self.tr(\"转录配置为空\"))\n\n        if not self.task.output_path:\n            raise ValueError(self.tr(\"输出路径为空\"))\n\n    def _check_downloaded_subtitle(self) -> bool:\n        \"\"\"检查是否存在下载的字幕文件\"\"\"\n        if not (self.task.need_next_task and self.task.file_path):\n            return False\n\n        subtitle_dir = Path(self.task.file_path).parent / \"subtitle\"\n        if not subtitle_dir.exists():\n            return False\n\n        downloaded_subtitles = list(subtitle_dir.glob(\"【下载字幕】*\"))\n        if not downloaded_subtitles:\n            return False\n\n        subtitle_file = downloaded_subtitles[0]\n        self.task.output_path = str(subtitle_file)\n        logger.info(f\"字幕文件已下载，跳过转录。找到下载的字幕文件：{subtitle_file}\")\n        self.progress.emit(100, self.tr(\"字幕已下载\"))\n        self.finished.emit(self.task)\n        return True\n\n    def _perform_transcription(self):\n        \"\"\"执行转录流程\"\"\"\n        assert self.task.file_path is not None\n        assert self.task.transcribe_config is not None\n        assert self.task.output_path is not None\n\n        video_path = Path(self.task.file_path)\n\n        self.progress.emit(5, self.tr(\"转换音频中\"))\n        logger.info(\"开始转换音频\")\n\n        # 创建临时音频文件（delete=False 避免 Windows 权限问题）\n        temp_audio_file = tempfile.NamedTemporaryFile(suffix=\".wav\", delete=False)\n        temp_audio_path = temp_audio_file.name\n        temp_audio_file.close()  # 立即关闭文件句柄，让 ffmpeg 可以写入\n\n        try:\n            # 转换音频文件\n            # 获取选中的音轨索引（如果有）\n            audio_track_index = self.task.selected_audio_track_index\n            is_success = video2audio(\n                str(video_path),\n                output=temp_audio_path,\n                audio_track_index=audio_track_index,\n            )\n            if not is_success:\n                logger.error(\"音频转换失败\")\n                raise RuntimeError(self.tr(\"音频转换失败\"))\n\n            self.progress.emit(20, self.tr(\"语音转录中\"))\n            logger.info(\"开始语音转录\")\n\n            # 进行转录\n            asr_data = transcribe(\n                temp_audio_path,\n                self.task.transcribe_config,\n                callback=self.progress_callback,\n            )\n\n            # 保存字幕文件（根据配置的输出格式）\n            output_path = Path(self.task.output_path)\n            output_format_enum = self.task.transcribe_config.output_format\n            base_path = output_path.with_suffix(\"\")\n            \n            # 根据选择的格式导出\n            if output_format_enum == TranscribeOutputFormatEnum.ALL:\n                formats_to_export = [\n                    fmt.value.lower() \n                    for fmt in TranscribeOutputFormatEnum \n                    if fmt != TranscribeOutputFormatEnum.ALL\n                ]\n            else:\n                formats_to_export = [output_format_enum.value.lower()]\n            \n            if self.task.need_next_task:\n                formats_to_export.append(TranscribeOutputFormatEnum.SRT.value.lower())\n            formats_to_export = list(set(formats_to_export))\n            \n            # 保存字幕文件\n            for fmt in formats_to_export:\n                save_path = f\"{base_path}.{fmt}\"\n                asr_data.save(save_path)\n                logger.info(\"%s 字幕文件已保存到: %s\", fmt.upper(), save_path)\n\n            self.progress.emit(100, self.tr(\"转录完成\"))\n            self.finished.emit(self.task)\n        finally:\n            Path(temp_audio_path).unlink(missing_ok=True)\n\n    def progress_callback(self, value, message):\n        progress = min(20 + (value * 0.8), 100)\n        self.progress.emit(int(progress), message)\n"
  },
  {
    "path": "app/thread/version_checker_thread.py",
    "content": "# coding: utf-8\nimport hashlib\nfrom datetime import datetime\n\nimport requests\nfrom PyQt5.QtCore import QObject, QVersionNumber, pyqtSignal\n\nfrom app.config import VERSION\nfrom app.core.utils.cache import get_version_state_cache\nfrom app.core.utils.logger import setup_logger\n\nlogger = setup_logger(\"version_checker\")\n\n\nclass VersionChecker(QObject):\n    \"\"\"Version checker\"\"\"\n\n    newVersionAvailable = pyqtSignal(str, bool, str, str)\n    announcementAvailable = pyqtSignal(str)\n    checkCompleted = pyqtSignal()\n\n    def __init__(self):\n        super().__init__()\n        self.current_version = VERSION\n        self.latest_version = VERSION\n        self.update_info = \"\"\n        self.update_required = False\n        self.download_url = \"\"\n        self.announcement = {}\n\n        self.cache = get_version_state_cache()\n\n    def get_latest_version_info(self) -> dict:\n        \"\"\"Get latest version information\"\"\"\n        url = \"https://vc.bkfeng.top/api/version\"\n        headers = {\"app_version\": VERSION}\n\n        try:\n            response = requests.get(url, timeout=10, headers=headers)\n            response.raise_for_status()\n            data = response.json()\n            # data = {\n            #     \"latest_version\": \"v1.4.0\",\n            #     \"update_required\": True,\n            #     \"update_info\": \"更新内容\",\n            #     \"download_url\": \"https://github.com/WEIFENG2333/VideoCaptioner/releases/latest\",\n            #     \"announcement\": {\n            #         \"enabled\": True,\n            #         \"content\": \"公告内容211\",\n            #         \"start_date\": \"2025-01-01\",\n            #         \"end_date\": \"2025-12-30\",\n            #     },\n            # }\n\n            self.latest_version = data.get(\"latest_version\", self.current_version)\n            self.update_required = data.get(\"update_required\", False)\n            self.update_info = data.get(\"update_info\", \"\")\n            self.download_url = data.get(\"download_url\", \"\")\n            self.announcement = data.get(\"announcement\", {})\n\n            logger.info(\"Successfully fetched version info: %s\", self.latest_version)\n            return data\n\n        except requests.RequestException:\n            return {}\n\n    def has_new_version(self) -> bool:\n        \"\"\"Check if new version is available\"\"\"\n        try:\n            latest_ver = self.latest_version.lstrip(\"v\")\n            current_ver = self.current_version.lstrip(\"v\")\n\n            latest_ver_num = QVersionNumber.fromString(latest_ver)\n            current_ver_num = QVersionNumber.fromString(current_ver)\n\n            if latest_ver_num > current_ver_num:\n                logger.info(\n                    \"New version found: %s (current: %s)\",\n                    self.latest_version,\n                    self.current_version,\n                )\n                self.newVersionAvailable.emit(\n                    self.latest_version,\n                    self.update_required,\n                    self.update_info,\n                    self.download_url,\n                )\n                return True\n\n        except Exception as e:\n            logger.error(\"Version comparison failed: %s\", str(e))\n\n        return False\n\n    def check_announcement(self) -> None:\n        \"\"\"Check and show announcement\"\"\"\n        ann = self.announcement\n        if not ann.get(\"enabled\", False):\n            return\n\n        content = ann.get(\"content\", \"\")\n        if not content:\n            return\n\n        announcement_id = (\n            hashlib.sha256(content.encode(\"utf-8\")).hexdigest()\n            + \"_\"\n            + datetime.today().strftime(\"%Y-%m-%d\")\n        )\n\n        settings_key = f\"announcement/shown_{announcement_id}\"\n        if self.cache.get(settings_key, default=False):\n            return\n\n        start_date_str = ann.get(\"start_date\")\n        end_date_str = ann.get(\"end_date\")\n        if not start_date_str or not end_date_str:\n            return\n\n        try:\n            start_date = datetime.strptime(start_date_str, \"%Y-%m-%d\").date()\n            end_date = datetime.strptime(end_date_str, \"%Y-%m-%d\").date()\n            today = datetime.today().date()\n\n            if start_date <= today <= end_date:\n                self.cache.set(settings_key, True, expire=30 * 60 * 24)\n                self.announcementAvailable.emit(content)\n\n        except ValueError as e:\n            logger.error(\"Announcement date format error: %s\", str(e))\n\n    def check_new_version_announcement(self) -> None:\n        \"\"\"Check new version announcement\"\"\"\n        if self.latest_version != self.current_version:\n            return\n\n        version_key = f\"version/shown_{self.latest_version}\"\n\n        if not self.cache.get(version_key, default=False):\n            self.cache.set(version_key, True)\n\n            update_announcement = (\n                f\"Welcome to VideoCaptioner {self.current_version}\\n\\n\"\n                f\"What's new:\\n{self.update_info}\"\n            )\n            self.announcementAvailable.emit(update_announcement)\n\n    def perform_check(self) -> None:\n        \"\"\"Perform version and announcement check\"\"\"\n        try:\n            version_data = self.get_latest_version_info()\n            if not version_data:\n                return\n            self.has_new_version()\n            self.check_new_version_announcement()\n            self.check_announcement()\n            self.checkCompleted.emit()\n        except Exception:\n            logger.exception(\"Version and announcement check failed\")\n"
  },
  {
    "path": "app/thread/video_download_thread.py",
    "content": "import os\nimport re\nfrom pathlib import Path\n\nimport requests\nimport yt_dlp\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\nfrom app.config import APPDATA_PATH\nfrom app.core.utils.logger import setup_logger\n\nlogger = setup_logger(\"video_download_thread\")\n\n\nclass VideoDownloadThread(QThread):\n    \"\"\"视频下载线程类\"\"\"\n\n    finished = pyqtSignal(\n        str\n    )  # 发送下载完成的信号(视频路径, 字幕路径, 缩略图路径, 视频信息)\n    progress = pyqtSignal(int, str)  # 发送下载进度的信号\n    error = pyqtSignal(str)  # 发送错误信息的信号\n\n    def __init__(self, url: str, work_dir: str):\n        super().__init__()\n        self.url = url\n        self.work_dir = work_dir\n\n    def run(self):\n        try:\n            video_file_path, subtitle_file_path, thumbnail_file_path, info_dict = (\n                self.download()\n            )\n            self.finished.emit(video_file_path)\n        except Exception as e:\n            logger.exception(\"下载视频失败: %s\", str(e))\n            self.error.emit(str(e))\n\n    def progress_hook(self, d):\n        \"\"\"下载进度回调函数\"\"\"\n        if d[\"status\"] == \"downloading\":\n            percent = d[\"_percent_str\"]\n            speed = d[\"_speed_str\"]\n\n            # 提取百分比和速度的纯文本\n            clean_percent = (\n                percent.replace(\"\\x1b[0;94m\", \"\")\n                .replace(\"\\x1b[0m\", \"\")\n                .strip()\n                .replace(\"%\", \"\")\n            )\n            clean_speed = speed.replace(\"\\x1b[0;32m\", \"\").replace(\"\\x1b[0m\", \"\").strip()\n\n            self.progress.emit(\n                int(float(clean_percent)),\n                f\"下载进度: {clean_percent}%  速度: {clean_speed}\",\n            )\n\n    def sanitize_filename(self, name: str, replacement: str = \"_\") -> str:\n        \"\"\"清理文件名中不允许的字符\"\"\"\n        # 定义不允许的字符\n        forbidden_chars = r'<>:\"/\\\\|?*'\n\n        # 替换不允许的字符\n        sanitized = re.sub(f\"[{re.escape(forbidden_chars)}]\", replacement, name)\n\n        # 移除控制字符\n        sanitized = re.sub(r\"[\\0-\\31]\", \"\", sanitized)\n\n        # 去除文件名末尾的空格和点\n        sanitized = sanitized.rstrip(\" .\")\n\n        # 限制文件名长度\n        max_length = 255\n        if len(sanitized) > max_length:\n            base, ext = os.path.splitext(sanitized)\n            base_max_length = max_length - len(ext)\n            sanitized = base[:base_max_length] + ext\n\n        # 处理Windows保留名称\n        windows_reserved_names = {\n            \"CON\",\n            \"PRN\",\n            \"AUX\",\n            \"NUL\",\n            \"COM1\",\n            \"COM2\",\n            \"COM3\",\n            \"COM4\",\n            \"COM5\",\n            \"COM6\",\n            \"COM7\",\n            \"COM8\",\n            \"COM9\",\n            \"LPT1\",\n            \"LPT2\",\n            \"LPT3\",\n            \"LPT4\",\n            \"LPT5\",\n            \"LPT6\",\n            \"LPT7\",\n            \"LPT8\",\n            \"LPT9\",\n        }\n        name_without_ext = os.path.splitext(sanitized)[0].upper()\n        if name_without_ext in windows_reserved_names:\n            sanitized = f\"{sanitized}_\"\n\n        # 如果文件名为空，返回默认名称\n        if not sanitized:\n            sanitized = \"default_filename\"\n\n        return sanitized\n\n    def download(self, need_subtitle: bool = True, need_thumbnail: bool = False):\n        \"\"\"下载视频\"\"\"\n        logger.info(\"开始下载视频: %s\", self.url)\n\n        # 初始化 ydl 选项\n        initial_ydl_opts = {\n            \"outtmpl\": {\n                \"default\": \"%(title).200s.%(ext)s\",  # 限制文件名最长200个字符\n                \"subtitle\": \"【下载字幕】.%(ext)s\",\n                \"thumbnail\": \"thumbnail\",\n            },\n            \"format\": \"bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best\",  # 优先下载mp4格式\n            \"progress_hooks\": [self.progress_hook],  # 下载进度钩子\n            \"quiet\": True,  # 禁用日志输出\n            \"no_warnings\": True,  # 禁用警告信息\n            \"noprogress\": True,\n            \"writeautomaticsub\": need_subtitle,  # 下载自动生成的字幕\n            \"writethumbnail\": need_thumbnail,  # 下载缩略图\n            \"thumbnail_format\": \"jpg\",  # 指定缩略图的格式\n        }\n\n        # 检查 cookies 文件\n        cookiefile_path = APPDATA_PATH / \"cookies.txt\"\n        if cookiefile_path.exists():\n            logger.info(f\"使用cookiefile: {cookiefile_path}\")\n            initial_ydl_opts[\"cookiefile\"] = str(cookiefile_path)\n\n        with yt_dlp.YoutubeDL(initial_ydl_opts) as ydl:\n            # 提取视频信息（不下载）\n            info_dict = ydl.extract_info(self.url, download=False)\n\n            # 设置动态下载文件夹为视频标题\n            video_title = self.sanitize_filename(info_dict.get(\"title\", \"MyVideo\"))\n            video_work_dir = Path(self.work_dir) / self.sanitize_filename(video_title)\n            subtitle_language = info_dict.get(\"language\", None)\n            if subtitle_language:\n                subtitle_language = subtitle_language.lower().split(\"-\")[0]\n\n            try:\n                subtitle_download_link = None\n                automatic_captions = info_dict.get(\"automatic_captions\")\n                if automatic_captions and subtitle_language:\n                    for lang_code in automatic_captions:\n                        if lang_code.startswith(subtitle_language):\n                            subtitle_download_link = automatic_captions[lang_code][-1][\n                                \"url\"\n                            ]\n                            break\n            except Exception:\n                subtitle_download_link = None\n\n            # 设置 yt-dlp 下载选项\n            ydl_opts = {\n                \"paths\": {\n                    \"home\": str(video_work_dir),\n                    \"subtitle\": str(video_work_dir / \"subtitle\"),\n                    \"thumbnail\": str(video_work_dir),\n                },\n            }\n            # 更新 yt-dlp 的配置\n            ydl.params.update(ydl_opts)\n\n            # 使用 process_info 进行下载\n            ydl.process_info(info_dict)\n\n            # 获取视频文件路径\n            video_file_path = Path(ydl.prepare_filename(info_dict))\n            if video_file_path.exists():\n                video_file_path = str(video_file_path)\n            else:\n                video_file_path = None\n\n            # 获取字幕文件路径\n            subtitle_file_path = None\n            for file in video_work_dir.glob(\"**/【下载字幕】*\"):\n                file_path = str(file)\n                if subtitle_language and subtitle_language not in file_path:\n                    logger.info(\n                        \"字幕语言错误，重新下载字幕: %s\", subtitle_download_link\n                    )\n                    os.remove(file_path)\n                    if subtitle_download_link:\n                        response = requests.get(subtitle_download_link)\n                        file_path = (\n                            video_work_dir\n                            / \"subtitle\"\n                            / f\"【下载字幕】{subtitle_language}.vtt\"\n                        )\n                        if res := response.text:\n                            with open(file_path, \"w\", encoding=\"utf-8\") as f:\n                                f.write(res)\n                            subtitle_file_path = file_path\n                else:\n                    subtitle_file_path = file_path\n                break\n\n            # 获取缩略图文件路径\n            thumbnail_file_path = None\n            for file in video_work_dir.glob(\"**/thumbnail*\"):\n                thumbnail_file_path = str(file)\n                break\n\n            logger.info(f\"视频下载完成: {video_file_path}\")\n            logger.info(f\"字幕文件路径: {subtitle_file_path}\")\n            return video_file_path, subtitle_file_path, thumbnail_file_path, info_dict\n"
  },
  {
    "path": "app/thread/video_info_thread.py",
    "content": "import tempfile\nfrom pathlib import Path\n\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\nfrom app.core.entities import VideoInfo\nfrom app.core.utils.logger import setup_logger\nfrom app.core.utils.video_utils import get_video_info\n\nlogger = setup_logger(\"video_info_thread\")\n\n\nclass VideoInfoThread(QThread):\n    finished = pyqtSignal(VideoInfo)\n    error = pyqtSignal(str)\n\n    def __init__(self, file_path):\n        super().__init__()\n        self.file_path = file_path\n\n    def run(self):\n        try:\n            # 生成缩略图到临时文件\n            temp_dir = tempfile.gettempdir()\n            file_name = Path(self.file_path).stem\n            thumbnail_path = f\"{temp_dir}/{file_name}_thumbnail.jpg\"\n\n            # 使用统一的 get_video_info 函数\n            video_info = get_video_info(self.file_path, thumbnail_path=thumbnail_path)\n\n            if video_info:\n                self.finished.emit(video_info)\n            else:\n                self.error.emit(\"无法获取媒体文件信息，请确保文件格式正确\")\n\n        except Exception as e:\n            logger.exception(\"获取视频信息时出错\")\n            self.error.emit(str(e))\n"
  },
  {
    "path": "app/thread/video_synthesis_thread.py",
    "content": "import datetime\nimport tempfile\nfrom pathlib import Path\n\nfrom PyQt5.QtCore import QThread, pyqtSignal\n\nfrom app.core.asr.asr_data import ASRData\nfrom app.core.entities import SynthesisTask\nfrom app.core.utils.logger import setup_logger\nfrom app.core.utils.video_utils import add_subtitles, add_subtitles_with_style\n\nlogger = setup_logger(\"video_synthesis_thread\")\n\n\nclass VideoSynthesisThread(QThread):\n    finished = pyqtSignal(SynthesisTask)\n    progress = pyqtSignal(int, str)\n    error = pyqtSignal(str)\n\n    def __init__(self, task: SynthesisTask):\n        super().__init__()\n        self.task = task\n        logger.debug(f\"初始化 VideoSynthesisThread，任务: {self.task}\")\n\n    def run(self):\n        try:\n            self.task.started_at = datetime.datetime.now()\n            config = self.task.synthesis_config\n            logger.info(f\"\\n{config.print_config()}\")\n\n            video_file = self.task.video_path\n            subtitle_file = self.task.subtitle_path\n            output_path = self.task.output_path\n\n            if not config.need_video:\n                logger.info(\"不需要合成视频，跳过\")\n                self.progress.emit(100, self.tr(\"合成完成\"))\n                self.finished.emit(self.task)\n                return\n\n            logger.info(f\"开始合成视频: {video_file}\")\n            self.progress.emit(5, self.tr(\"正在合成\"))\n\n            if not video_file:\n                raise ValueError(self.tr(\"视频路径为空\"))\n            if not subtitle_file:\n                raise ValueError(self.tr(\"字幕路径为空\"))\n            if not output_path:\n                raise ValueError(self.tr(\"输出路径为空\"))\n\n            video_quality = config.video_quality\n            crf = video_quality.get_crf()\n            preset = video_quality.get_preset()\n\n            # 读取字幕数据\n            asr_data = ASRData.from_subtitle_file(subtitle_file)\n\n            if config.soft_subtitle:\n                # 软字幕：转为 SRT 后内嵌\n                with tempfile.NamedTemporaryFile(\n                    mode=\"w\",\n                    suffix=\".srt\",\n                    delete=False,\n                    encoding=\"utf-8\",\n                    prefix=\"VideoCaptioner_soft_\",\n                ) as f:\n                    srt_content = asr_data.to_srt(layout=config.subtitle_layout)\n                    f.write(srt_content)\n                    temp_srt_path = f.name\n\n                try:\n                    add_subtitles(\n                        video_file,\n                        temp_srt_path,\n                        output_path,\n                        crf=crf,\n                        preset=preset,\n                        soft_subtitle=True,\n                        progress_callback=self.progress_callback,\n                    )\n                finally:\n                    Path(temp_srt_path).unlink(missing_ok=True)\n\n            else:\n                # 硬字幕：使用样式配置渲染\n                add_subtitles_with_style(\n                    video_path=video_file,\n                    asr_data=asr_data,\n                    output_path=output_path,\n                    render_mode=config.render_mode,\n                    subtitle_layout=config.subtitle_layout,\n                    ass_style=config.ass_style,\n                    rounded_style=config.rounded_style,\n                    crf=crf,\n                    preset=preset,\n                    progress_callback=self.progress_callback,\n                )\n\n            self.progress.emit(100, self.tr(\"合成完成\"))\n            logger.info(f\"视频合成完成，保存路径: {output_path}\")\n            self.finished.emit(self.task)\n\n        except Exception as e:\n            logger.exception(f\"视频合成失败: {e}\")\n            self.error.emit(str(e))\n            self.progress.emit(100, self.tr(\"视频合成失败\"))\n\n    def progress_callback(self, value, message):\n        progress = int(5 + int(value) / 100 * 95)\n        logger.debug(f\"合成进度: {progress}% - {message}\")\n        self.progress.emit(progress, str(progress) + \"% \" + message)\n"
  },
  {
    "path": "app/view/batch_process_interface.py",
    "content": "import os\n\nfrom PyQt5.QtCore import Qt, QUrl\nfrom PyQt5.QtGui import QColor, QDesktopServices, QFont\nfrom PyQt5.QtWidgets import (\n    QFileDialog,\n    QHBoxLayout,\n    QHeaderView,\n    QSizePolicy,\n    QTableWidget,\n    QTableWidgetItem,\n    QVBoxLayout,\n    QWidget,\n)\nfrom qfluentwidgets import (\n    Action,\n    ComboBox,\n    InfoBar,\n    InfoBarPosition,\n    ProgressBar,\n    PushButton,\n    RoundMenu,\n    TableWidget,\n)\nfrom qfluentwidgets import (\n    FluentIcon as FIF,\n)\n\nfrom app.core.constant import (\n    INFOBAR_DURATION_INFO,\n    INFOBAR_DURATION_SUCCESS,\n    INFOBAR_DURATION_WARNING,\n)\nfrom app.core.entities import (\n    BatchTaskStatus,\n    BatchTaskType,\n    SupportedAudioFormats,\n    SupportedSubtitleFormats,\n    SupportedVideoFormats,\n)\nfrom app.thread.batch_process_thread import (\n    BatchProcessThread,\n    BatchTask,\n)\n\n\nclass BatchProcessInterface(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent=parent)\n        self.setObjectName(\"batchProcessInterface\")\n        self.setWindowTitle(self.tr(\"批量处理\"))\n        self.setAcceptDrops(True)\n        self.batch_thread = BatchProcessThread()\n\n        self.init_ui()\n        self.setup_connections()\n\n    def init_ui(self):\n        # 创建主布局\n        main_layout = QVBoxLayout(self)\n        main_layout.setContentsMargins(16, 16, 16, 16)\n        main_layout.setSpacing(8)\n\n        # 顶部控制区域\n        top_layout = QHBoxLayout()\n        top_layout.setSpacing(8)\n\n        # 任务类型选择\n        self.task_type_combo = ComboBox()\n        self.task_type_combo.addItems([str(task_type) for task_type in BatchTaskType])\n        self.task_type_combo.setCurrentText(str(BatchTaskType.FULL_PROCESS))\n\n        # 任务类型说明\n        self.task_type_descriptions = {\n            str(BatchTaskType.TRANSCRIBE): self.tr(\"仅进行语音识别，生成字幕文件\"),\n            str(BatchTaskType.SUBTITLE): self.tr(\"对已有字幕进行分割、优化或翻译\"),\n            str(BatchTaskType.TRANS_SUB): self.tr(\"先转录再处理字幕，不合成视频\"),\n            str(BatchTaskType.FULL_PROCESS): self.tr(\"转录 → 字幕处理 → 合成视频\"),\n        }\n\n        # 控制按钮\n        self.add_file_btn = PushButton(self.tr(\"添加文件\"), icon=FIF.ADD)\n        self.start_all_btn = PushButton(self.tr(\"开始处理\"), icon=FIF.PLAY)\n        self.clear_btn = PushButton(self.tr(\"清空列表\"), icon=FIF.DELETE)\n\n        # 添加到顶部布局\n        top_layout.addWidget(self.task_type_combo)\n        top_layout.addWidget(self.add_file_btn)\n        top_layout.addWidget(self.clear_btn)\n\n        top_layout.addStretch()\n        top_layout.addWidget(self.start_all_btn)\n\n        # 创建任务表格\n        self.task_table = TableWidget()\n        self.task_table.setColumnCount(3)\n        self.task_table.setHorizontalHeaderLabels([\"文件名\", \"进度\", \"状态\"])\n\n        # 设置表格样式\n        self.task_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)\n        self.task_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Fixed)\n        self.task_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed)\n        self.task_table.setColumnWidth(1, 250)  # 进度条列宽\n        self.task_table.setColumnWidth(2, 160)  # 状态列宽\n\n        # 设置行高\n        self.task_table.verticalHeader().setDefaultSectionSize(40)  # 设置默认行高\n\n        # 设置表格边框\n        self.task_table.setBorderVisible(True)\n        self.task_table.setBorderRadius(12)\n\n        # 设置表格不可编辑\n        self.task_table.setEditTriggers(QTableWidget.NoEditTriggers)\n\n        # 设置表格大小策略\n        self.task_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)\n        self.task_table.setMinimumHeight(300)  # 设置最小高度\n\n        # 连接双击信号\n        self.task_table.doubleClicked.connect(self.on_table_double_clicked)\n\n        # 添加到主布局\n        main_layout.addLayout(top_layout)\n        main_layout.addWidget(self.task_table)\n\n        # 连接信号\n        self.add_file_btn.clicked.connect(self.on_add_file_clicked)\n        self.start_all_btn.clicked.connect(self.start_all_tasks)\n        self.clear_btn.clicked.connect(self.clear_tasks)\n        self.task_type_combo.currentTextChanged.connect(self.on_task_type_changed)\n\n    def setup_connections(self):\n        # 批处理线程信号连接\n        self.batch_thread.task_progress.connect(self.update_task_progress)\n        self.batch_thread.task_error.connect(self.on_task_error)\n        self.batch_thread.task_completed.connect(self.on_task_completed)\n\n        # 表格右键菜单\n        self.task_table.setContextMenuPolicy(Qt.CustomContextMenu)  # type: ignore\n        self.task_table.customContextMenuRequested.connect(self.show_context_menu)\n\n    def on_add_file_clicked(self):\n        task_type = self.task_type_combo.currentText()\n        file_filter = \"\"\n        if task_type in [\n            BatchTaskType.TRANSCRIBE,\n            BatchTaskType.TRANS_SUB,\n            BatchTaskType.FULL_PROCESS,\n        ]:\n            # 获取所有支持的音视频格式\n            audio_formats = [f\"*.{fmt.value}\" for fmt in SupportedAudioFormats]\n            video_formats = [f\"*.{fmt.value}\" for fmt in SupportedVideoFormats]\n            formats = audio_formats + video_formats\n            file_filter = f\"音视频文件 ({' '.join(formats)})\"\n        elif task_type == BatchTaskType.SUBTITLE:\n            # 获取所有支持的字幕格式\n            subtitle_formats = [f\"*.{fmt.value}\" for fmt in SupportedSubtitleFormats]\n            file_filter = f\"字幕文件 ({' '.join(subtitle_formats)})\"\n\n        files, _ = QFileDialog.getOpenFileNames(self, \"选择文件\", \"\", file_filter)\n        if files:\n            self.add_files(files)\n\n    def dragEnterEvent(self, event):\n        if event.mimeData().hasUrls():\n            event.accept()\n        else:\n            event.ignore()\n\n    def dropEvent(self, event):\n        files = [url.toLocalFile() for url in event.mimeData().urls()]\n        self.add_files(files)\n\n    def add_files(self, file_paths):\n        task_type = BatchTaskType(self.task_type_combo.currentText())\n\n        # 检查文件是否存在并收集不存在的文件\n        non_existent_files = []\n        valid_files = []\n        for file_path in file_paths:\n            if not os.path.exists(file_path):\n                non_existent_files.append(os.path.basename(file_path))\n            else:\n                valid_files.append(file_path)\n\n        # 如果有不存在的文件，显示警告\n        if non_existent_files:\n            InfoBar.warning(\n                title=\"文件不存在\",\n                content=f\"以下文件不存在：\\n{', '.join(non_existent_files)}\",\n                duration=INFOBAR_DURATION_WARNING,\n                position=InfoBarPosition.TOP,\n                parent=self,\n            )\n\n        # 如果没有有效文件，直接返回\n        if not valid_files:\n            return\n\n        # 对有效文件按文件名排序\n        valid_files.sort(key=lambda x: os.path.basename(x).lower())\n\n        # 如果表格为空，自动检测文件类型并设置任务类型\n        if self.task_table.rowCount() == 0 and self.task_type_combo.currentIndex() == 0:\n            first_file = valid_files[0].lower()\n            is_subtitle = any(\n                first_file.endswith(f\".{fmt.value}\") for fmt in SupportedSubtitleFormats\n            )\n            if is_subtitle:\n                self.task_type_combo.setCurrentText(str(BatchTaskType.SUBTITLE))\n                task_type = BatchTaskType.SUBTITLE\n            # elif is_media:\n            #     self.task_type_combo.setCurrentText(str(BatchTaskType.FULL_PROCESS))\n            #     task_type = BatchTaskType.FULL_PROCESS\n\n        # 过滤文件类型\n        valid_files = self.filter_files(valid_files, task_type)\n\n        if not valid_files:\n            InfoBar.warning(\n                title=\"无效文件\",\n                content=\"请选择正确的文件类型\",\n                duration=INFOBAR_DURATION_WARNING,\n                position=InfoBarPosition.TOP,\n                parent=self,\n            )\n            return\n\n        for file_path in valid_files:\n            # 检查是否已存在相同任务\n            exists = False\n            for row in range(self.task_table.rowCount()):\n                if self.task_table.item(row, 0).toolTip() == file_path:\n                    exists = True\n                    InfoBar.warning(\n                        title=\"任务已存在\",\n                        content=\"任务已存在\",\n                        duration=INFOBAR_DURATION_WARNING,\n                        position=InfoBarPosition.TOP_RIGHT,\n                        parent=self,\n                    )\n                    break\n\n            if not exists:\n                self.add_task_to_table(file_path)\n\n    def filter_files(self, file_paths, task_type: BatchTaskType):\n        valid_extensions = {}\n\n        # 根据任务类型设置有效的扩展名\n        if task_type in [\n            BatchTaskType.TRANSCRIBE,\n            BatchTaskType.TRANS_SUB,\n            BatchTaskType.FULL_PROCESS,\n        ]:\n            valid_extensions = {f\".{fmt.value}\" for fmt in SupportedAudioFormats} | {\n                f\".{fmt.value}\" for fmt in SupportedVideoFormats\n            }\n        elif task_type == BatchTaskType.SUBTITLE:\n            valid_extensions = {f\".{fmt.value}\" for fmt in SupportedSubtitleFormats}\n\n        return [\n            f\n            for f in file_paths\n            if any(f.lower().endswith(ext) for ext in valid_extensions)\n        ]\n\n    def add_task_to_table(self, file_path):\n        row = self.task_table.rowCount()\n        self.task_table.insertRow(row)\n\n        # 文件名\n        file_name = QTableWidgetItem(os.path.basename(file_path))\n        file_name.setToolTip(file_path)\n        self.task_table.setItem(row, 0, file_name)\n\n        # 进度条\n        progress_bar = ProgressBar()\n        progress_bar.setRange(0, 100)\n        progress_bar.setValue(0)\n        progress_bar.setFixedHeight(18)\n        self.task_table.setCellWidget(row, 1, progress_bar)\n\n        # 状态\n        status = QTableWidgetItem(str(BatchTaskStatus.WAITING))\n        status.setTextAlignment(Qt.AlignCenter)  # type: ignore\n        status.setForeground(Qt.gray)  # type: ignore  # 设置字体颜色为灰色\n        font = QFont()\n        font.setBold(True)\n        status.setFont(font)\n        self.task_table.setItem(row, 2, status)\n\n    def show_context_menu(self, pos):\n        row = self.task_table.rowAt(pos.y())\n        if row < 0:\n            return\n\n        menu = RoundMenu(parent=self)\n        file_path = self.task_table.item(row, 0).toolTip()\n        status = self.task_table.item(row, 2).text()\n\n        start_action = Action(FIF.PLAY, \"开始\")\n        start_action.triggered.connect(lambda: self.start_task(file_path))\n        menu.addAction(start_action)\n\n        cancel_action = Action(FIF.CLOSE, \"取消\")\n        cancel_action.triggered.connect(lambda: self.cancel_task(file_path))\n        menu.addAction(cancel_action)\n\n        menu.addSeparator()\n        open_folder_action = Action(FIF.FOLDER, \"打开输出文件夹\")\n        open_folder_action.triggered.connect(lambda: self.open_output_folder(file_path))\n        menu.addAction(open_folder_action)\n\n        if status != str(BatchTaskStatus.WAITING):\n            start_action.setEnabled(False)\n\n        menu.exec_(self.task_table.viewport().mapToGlobal(pos))\n\n    def open_output_folder(self, file_path: str):\n        # 根据任务类型和文件路径确定输出文件夹\n        task_type = BatchTaskType(self.task_type_combo.currentText())\n        file_dir = os.path.dirname(file_path)\n\n        if task_type == BatchTaskType.FULL_PROCESS:\n            # 对于全流程任务，输出在视频同目录下\n            output_dir = file_dir\n        else:\n            # 其他任务输出在文件同目录下\n            output_dir = file_dir\n\n        # 打开文件夹\n        QDesktopServices.openUrl(QUrl.fromLocalFile(output_dir))\n\n    def update_task_progress(self, file_path: str, progress: int, status: str):\n        for row in range(self.task_table.rowCount()):\n            if self.task_table.item(row, 0).toolTip() == file_path:\n                # 更新进度条\n                progress_bar = self.task_table.cellWidget(row, 1)\n                progress_bar.setValue(progress)\n                # 更新状态\n                self.task_table.item(row, 2).setText(status)\n                break\n\n    def on_task_error(self, file_path: str, error: str):\n        for row in range(self.task_table.rowCount()):\n            if self.task_table.item(row, 0).toolTip() == file_path:\n                status_item = self.task_table.item(row, 2)\n                status_item.setText(str(BatchTaskStatus.FAILED))\n                status_item.setToolTip(error)\n                break\n\n    def on_task_completed(self, file_path: str):\n        for row in range(self.task_table.rowCount()):\n            if self.task_table.item(row, 0).toolTip() == file_path:\n                self.task_table.item(row, 2).setText(str(BatchTaskStatus.COMPLETED))\n                self.task_table.item(row, 2).setForeground(QColor(\"#13A10E\"))\n                break\n\n    def start_all_tasks(self):\n        # 检查是否有任务\n        if self.task_table.rowCount() == 0:\n            InfoBar.warning(\n                title=\"无任务\",\n                content=\"请先添加需要处理的文件\",\n                duration=INFOBAR_DURATION_WARNING,\n                position=InfoBarPosition.TOP,\n                parent=self,\n            )\n            return\n\n        # 检查是否有等待处理的任务\n        waiting_tasks = 0\n        for row in range(self.task_table.rowCount()):\n            if self.task_table.item(row, 2).text() == str(BatchTaskStatus.WAITING):\n                waiting_tasks += 1\n\n        if waiting_tasks == 0:\n            InfoBar.warning(\n                title=\"无待处理任务\",\n                content=\"所有任务已经在处理或已完成\",\n                duration=INFOBAR_DURATION_WARNING,\n                position=InfoBarPosition.TOP,\n                parent=self,\n            )\n            return\n\n        # 显示开始处理的提示\n        InfoBar.success(\n            title=self.tr(\"开始处理\"),\n            content=f\"开始处理 {waiting_tasks} 个任务\",\n            duration=INFOBAR_DURATION_SUCCESS,\n            position=InfoBarPosition.TOP,\n            parent=self,\n        )\n        # 开始处理任务\n        for row in range(self.task_table.rowCount()):\n            file_path = self.task_table.item(row, 0).toolTip()\n            status = self.task_table.item(row, 2).text()\n            if status == str(BatchTaskStatus.WAITING):\n                task_type = BatchTaskType(self.task_type_combo.currentText())\n                batch_task = BatchTask(file_path, task_type)\n                self.batch_thread.add_task(batch_task)\n\n    def start_task(self, file_path: str):\n        # 显示开始处理的提示\n        file_name = os.path.basename(file_path)\n        InfoBar.success(\n            title=self.tr(\"开始处理\"),\n            content=f\"开始处理文件：{file_name}\",\n            duration=INFOBAR_DURATION_SUCCESS,\n            position=InfoBarPosition.TOP,\n            parent=self,\n        )\n\n        # 创建并添加单个任务\n        task_type = BatchTaskType(self.task_type_combo.currentText())\n        batch_task = BatchTask(file_path, task_type)\n        self.batch_thread.add_task(batch_task)\n\n    def cancel_task(self, file_path: str):\n        self.batch_thread.stop_task(file_path)\n        # 从表格中移除任务\n        for row in range(self.task_table.rowCount()):\n            if self.task_table.item(row, 0).toolTip() == file_path:\n                self.task_table.removeRow(row)\n                break\n\n    def clear_tasks(self):\n        self.batch_thread.stop_all()\n        self.task_table.setRowCount(0)\n\n    def on_task_type_changed(self, task_type: str):\n        # 显示任务类型说明\n        description = self.task_type_descriptions.get(task_type, \"\")\n        if description:\n            InfoBar.info(\n                title=task_type,\n                content=description,\n                duration=INFOBAR_DURATION_INFO,\n                position=InfoBarPosition.BOTTOM,\n                parent=self,\n            )\n        # 清空当前任务列表\n        self.clear_tasks()\n\n    def closeEvent(self, event):\n        self.batch_thread.stop_all()\n        super().closeEvent(event)\n\n    def on_table_double_clicked(self, index):\n        \"\"\"处理表格双击事件\"\"\"\n        row = index.row()\n        file_path = self.task_table.item(row, 0).toolTip()\n        self.open_output_folder(file_path)\n"
  },
  {
    "path": "app/view/home_interface.py",
    "content": "from typing import Optional\n\nfrom PyQt5.QtWidgets import QSizePolicy, QStackedWidget, QVBoxLayout, QWidget\nfrom qfluentwidgets import SegmentedWidget\n\nfrom app.core.llm.context import generate_task_id\nfrom app.core.task_factory import TaskFactory\nfrom app.view.subtitle_interface import SubtitleInterface\nfrom app.view.task_creation_interface import TaskCreationInterface\nfrom app.view.transcription_interface import TranscriptionInterface\nfrom app.view.video_synthesis_interface import VideoSynthesisInterface\n\n\nclass HomeInterface(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self._current_task_id: Optional[str] = None  # 当前流程的任务 ID\n\n        # 设置对象名称和样式\n        self.setObjectName(\"HomeInterface\")\n        self.setStyleSheet(\n            \"\"\"\n            HomeInterface{background: white}\n        \"\"\"\n        )\n\n        # 创建分段控件和堆叠控件\n        self.pivot = SegmentedWidget(self)\n        self.pivot.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)\n\n        self.stackedWidget = QStackedWidget(self)\n        self.vBoxLayout = QVBoxLayout(self)\n\n        # 添加子界面\n        self.task_creation_interface = TaskCreationInterface(self)\n        self.transcription_interface = TranscriptionInterface(self)\n        self.subtitle_optimization_interface = SubtitleInterface(self)\n        self.video_synthesis_interface = VideoSynthesisInterface(self)\n\n        self.addSubInterface(\n            self.task_creation_interface, \"TaskCreationInterface\", self.tr(\"任务创建\")\n        )\n        self.addSubInterface(\n            self.transcription_interface, \"TranscriptionInterface\", self.tr(\"语音转录\")\n        )\n        self.addSubInterface(\n            self.subtitle_optimization_interface,\n            \"SubtitleInterface\",\n            self.tr(\"字幕优化与翻译\"),\n        )\n        self.addSubInterface(\n            self.video_synthesis_interface,\n            \"VideoSynthesisInterface\",\n            self.tr(\"字幕视频合成\"),\n        )\n\n        self.vBoxLayout.addWidget(self.pivot)\n        self.vBoxLayout.addWidget(self.stackedWidget)\n        self.vBoxLayout.setContentsMargins(30, 10, 30, 30)\n\n        self.stackedWidget.currentChanged.connect(self.onCurrentIndexChanged)\n        self.stackedWidget.setCurrentWidget(self.task_creation_interface)\n        self.pivot.setCurrentItem(\"TaskCreationInterface\")\n\n        self.task_creation_interface.finished.connect(self.switch_to_transcription)\n        self.transcription_interface.finished.connect(\n            self.switch_to_subtitle_optimization\n        )\n        self.subtitle_optimization_interface.finished.connect(\n            self.switch_to_video_synthesis\n        )\n\n    def switch_to_transcription(self, file_path):\n        # 流程开始，生成新的 task_id\n        self._current_task_id = generate_task_id()\n\n        transcribe_task = TaskFactory.create_transcribe_task(\n            file_path, need_next_task=True, task_id=self._current_task_id\n        )\n        self.transcription_interface.set_task(transcribe_task)\n        self.transcription_interface.process()\n        self.stackedWidget.setCurrentWidget(self.transcription_interface)\n        self.pivot.setCurrentItem(\"TranscriptionInterface\")\n\n    def switch_to_subtitle_optimization(self, file_path, video_path):\n        # 继续使用同一个 task_id\n        subtitle_task = TaskFactory.create_subtitle_task(\n            file_path, video_path, need_next_task=True, task_id=self._current_task_id\n        )\n        self.subtitle_optimization_interface.set_task(subtitle_task)\n        self.subtitle_optimization_interface.process()\n        self.stackedWidget.setCurrentWidget(self.subtitle_optimization_interface)\n        self.pivot.setCurrentItem(\"SubtitleInterface\")\n\n    def switch_to_video_synthesis(self, video_path, subtitle_path):\n        # 继续使用同一个 task_id，流程结束后清空\n        synthesis_task = TaskFactory.create_synthesis_task(\n            video_path, subtitle_path, need_next_task=True, task_id=self._current_task_id\n        )\n        self._current_task_id = None  # 流程结束\n        self.video_synthesis_interface.set_task(synthesis_task)\n        self.video_synthesis_interface.process()\n        self.stackedWidget.setCurrentWidget(self.video_synthesis_interface)\n        self.pivot.setCurrentItem(\"VideoSynthesisInterface\")\n\n    def addSubInterface(self, widget, objectName, text):\n        # 添加子界面到堆叠控件和分段控件\n        widget.setObjectName(objectName)\n        self.stackedWidget.addWidget(widget)\n        self.pivot.addItem(\n            routeKey=objectName,\n            text=text,\n            onClick=lambda: self.stackedWidget.setCurrentWidget(widget),\n        )\n\n    def onCurrentIndexChanged(self, index):\n        # 当堆叠控件的当前索引改变时，更新分段控件的当前项\n        widget = self.stackedWidget.widget(index)\n        if widget:\n            self.pivot.setCurrentItem(widget.objectName())\n\n    def closeEvent(self, event):\n        # 关闭事件，关闭所有子界面\n        self.task_creation_interface.close()\n        self.transcription_interface.close()\n        self.subtitle_optimization_interface.close()\n        self.video_synthesis_interface.close()\n        super().closeEvent(event)\n"
  },
  {
    "path": "app/view/llm_logs_interface.py",
    "content": "\"\"\"LLM 请求日志查看界面\"\"\"\n\nimport json\nfrom typing import Any, Dict, List\n\nfrom PyQt5.QtCore import QFileSystemWatcher, Qt\nfrom PyQt5.QtWidgets import (\n    QApplication,\n    QHBoxLayout,\n    QHeaderView,\n    QTableWidgetItem,\n    QVBoxLayout,\n    QWidget,\n)\nfrom qfluentwidgets import (\n    BodyLabel,\n    CaptionLabel,\n    InfoBar,\n    InfoBarPosition,\n    MessageBox,\n    MessageBoxBase,\n    PillPushButton,\n    PlainTextEdit,\n    PushButton,\n    SearchLineEdit,\n    SubtitleLabel,\n    TableWidget,\n    ToolButton,\n    setCustomStyleSheet,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.config import LLM_LOG_FILE, LOG_PATH\n\nPAGE_SIZE = 50\n\n\nclass LogDetailDialog(MessageBoxBase):\n    \"\"\"日志详情对话框\"\"\"\n\n    def __init__(self, log_entry: Dict[str, Any], parent=None):\n        super().__init__(parent)\n        self.log_entry = log_entry\n        self._setup_ui()\n\n    def _setup_ui(self):\n        self.titleLabel = SubtitleLabel(self.tr(\"请求详情\"))\n        self.viewLayout.addWidget(self.titleLabel)\n\n        # 提取信息\n        time_str = self.log_entry.get(\"time\", \"\")\n        model = self.log_entry.get(\"request\", {}).get(\"model\", \"未知\")\n        duration = self.log_entry.get(\"duration_ms\", 0) / 1000\n        stage = self.log_entry.get(\"stage\", \"\") or \"-\"\n\n        usage = self.log_entry.get(\"response\", {}).get(\"usage\", {})\n        prompt_tokens = usage.get(\"prompt_tokens\", 0)\n        completion_tokens = usage.get(\"completion_tokens\", 0)\n\n        # 顶部信息栏\n        info_row = QHBoxLayout()\n        info_row.setSpacing(8)\n        info_row.setContentsMargins(0, 0, 0, 8)\n\n        # 用 PillPushButton 展示各项信息（禁用点击）\n        items = [\n            time_str,\n            stage,\n            model,\n            f\"{duration:.1f}s\",\n            f\"input token: {prompt_tokens}\",\n            f\"output token: {completion_tokens}\",\n        ]\n        for text in items:\n            if text:\n                pill = PillPushButton(str(text))\n                pill.setCheckable(False)\n                pill.setEnabled(False)\n                pill.setFixedHeight(24)\n                info_row.addWidget(pill)\n\n        info_row.addStretch()\n        self.viewLayout.addLayout(info_row)\n\n        # Request\n        self.viewLayout.addWidget(SubtitleLabel(\"Request\"))\n        self.request_edit = PlainTextEdit()\n        self.request_edit.setReadOnly(True)\n        self.request_edit.setMinimumHeight(180)\n        request_text = json.dumps(\n            self.log_entry.get(\"request\", {}), indent=2, ensure_ascii=False\n        )\n        self.request_edit.setPlainText(request_text)\n        self.viewLayout.addWidget(self.request_edit)\n\n        # Response\n        self.viewLayout.addWidget(SubtitleLabel(\"Response\"))\n        self.response_edit = PlainTextEdit()\n        self.response_edit.setReadOnly(True)\n        self.response_edit.setMinimumHeight(180)\n        response_text = json.dumps(\n            self.log_entry.get(\"response\", {}), indent=2, ensure_ascii=False\n        )\n        self.response_edit.setPlainText(response_text)\n        self.viewLayout.addWidget(self.response_edit)\n\n        # 底部按钮：替换默认按钮\n        self.yesButton.setText(self.tr(\"关闭\"))\n        self.cancelButton.hide()  # type: ignore\n\n        copy_req_btn = PushButton(FIF.COPY, self.tr(\"复制请求\"))\n        copy_req_btn.clicked.connect(self._copy_request)\n        self.buttonLayout.insertWidget(0, copy_req_btn)  # type: ignore\n\n        copy_resp_btn = PushButton(FIF.COPY, self.tr(\"复制响应\"))\n        copy_resp_btn.clicked.connect(self._copy_response)\n        self.buttonLayout.insertWidget(1, copy_resp_btn)  # type: ignore\n\n        self.widget.setMinimumWidth(700)\n\n    def _copy_request(self):\n        text = json.dumps(\n            self.log_entry.get(\"request\", {}), indent=2, ensure_ascii=False\n        )\n        clipboard = QApplication.clipboard()\n        if clipboard:\n            clipboard.setText(text)\n        InfoBar.success(\n            title=\"\",\n            content=self.tr(\"已复制\"),\n            parent=self,\n            position=InfoBarPosition.TOP,\n            duration=1500,\n        )\n\n    def _copy_response(self):\n        text = json.dumps(\n            self.log_entry.get(\"response\", {}), indent=2, ensure_ascii=False\n        )\n        clipboard = QApplication.clipboard()\n        if clipboard:\n            clipboard.setText(text)\n        InfoBar.success(\n            title=\"\",\n            content=self.tr(\"已复制\"),\n            parent=self,\n            position=InfoBarPosition.TOP,\n            duration=1500,\n        )\n\n\nclass LLMLogsInterface(QWidget):\n    \"\"\"LLM 请求日志界面\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setObjectName(\"llmLogsInterface\")\n        self.setWindowTitle(self.tr(\"LLM 请求日志\"))\n\n        self.all_logs: List[Dict[str, Any]] = []\n        self.filtered_logs: List[Dict[str, Any]] = []\n        self.current_page = 0\n\n        self._setup_ui()\n        self._connect_signals()\n        self._load_logs()\n        self._setup_file_watcher()\n\n    def _setup_ui(self):\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setContentsMargins(20, 20, 20, 20)\n        self.main_layout.setSpacing(12)\n\n        self._setup_toolbar()\n        self._setup_table()\n        self._setup_footer()\n\n    def _setup_toolbar(self):\n        toolbar = QHBoxLayout()\n        toolbar.setSpacing(10)\n\n        self.search_edit = SearchLineEdit()\n        self.search_edit.setPlaceholderText(self.tr(\"搜索任务ID、文件名、模型...\"))\n        self.search_edit.setFixedWidth(280)\n        toolbar.addWidget(self.search_edit)\n\n        toolbar.addStretch()\n\n        self.refresh_btn = PushButton(FIF.SYNC, self.tr(\"刷新\"))\n        toolbar.addWidget(self.refresh_btn)\n\n        self.clear_btn = PushButton(FIF.DELETE, self.tr(\"清空日志\"))\n        toolbar.addWidget(self.clear_btn)\n\n        self.main_layout.addLayout(toolbar)\n\n    def _setup_table(self):\n        self.table = TableWidget()\n        self.table.setColumnCount(7)\n        self.table.setHorizontalHeaderLabels(\n            [\n                self.tr(\"时间\"),\n                self.tr(\"任务ID\"),\n                self.tr(\"文件\"),\n                self.tr(\"阶段\"),\n                self.tr(\"模型\"),\n                self.tr(\"耗时\"),\n                self.tr(\"Tokens\"),\n            ]\n        )\n\n        header = self.table.horizontalHeader()\n        if header:\n            header.setSectionResizeMode(0, QHeaderView.Fixed)\n            header.setSectionResizeMode(1, QHeaderView.Fixed)\n            header.setSectionResizeMode(2, QHeaderView.Stretch)  # 文件 - 自适应\n            header.setSectionResizeMode(3, QHeaderView.Fixed)\n            header.setSectionResizeMode(4, QHeaderView.Stretch)  # 模型 - 自适应\n            header.setSectionResizeMode(5, QHeaderView.Fixed)\n            header.setSectionResizeMode(6, QHeaderView.Fixed)\n\n        self.table.setColumnWidth(0, 130)  # 时间\n        self.table.setColumnWidth(1, 100)  # 任务ID\n        self.table.setColumnWidth(3, 90)  # 阶段\n        self.table.setColumnWidth(5, 70)  # 耗时\n        self.table.setColumnWidth(6, 70)  # Tokens\n\n        v_header = self.table.verticalHeader()\n        if v_header:\n            v_header.setVisible(False)\n\n        self.table.setEditTriggers(self.table.NoEditTriggers)\n        self.table.setSelectionBehavior(self.table.SelectRows)\n        self.table.setSelectionMode(self.table.SingleSelection)\n        self.table.setBorderVisible(True)\n        self.table.setBorderRadius(8)\n\n        # 减少单元格内边距，让文字显示更多\n        qss = \"QTableView::item { padding-left: 8px; padding-right: 8px; }\"\n        setCustomStyleSheet(self.table, qss, qss)\n\n        self.main_layout.addWidget(self.table)\n\n    def _setup_footer(self):\n        \"\"\"底部：记录数 + 提示 + 分页\"\"\"\n        footer = QHBoxLayout()\n        footer.setSpacing(15)\n\n        # 记录数\n        self.status_label = BodyLabel(self.tr(\"共 0 条\"))\n        footer.addWidget(self.status_label)\n\n        # 双击提示\n        hint_label = CaptionLabel(self.tr(\"双击查看详情\"))\n        hint_label.setStyleSheet(\"color: gray;\")\n        footer.addWidget(hint_label)\n\n        footer.addStretch()\n\n        # 右侧：分页\n        self.prev_btn = ToolButton(FIF.LEFT_ARROW)\n        self.prev_btn.setEnabled(False)\n        footer.addWidget(self.prev_btn)\n\n        self.page_label = BodyLabel(\"1 / 1\")\n        footer.addWidget(self.page_label)\n\n        self.next_btn = ToolButton(FIF.RIGHT_ARROW)\n        self.next_btn.setEnabled(False)\n        footer.addWidget(self.next_btn)\n\n        self.main_layout.addLayout(footer)\n\n    def _connect_signals(self):\n        self.refresh_btn.clicked.connect(self._on_refresh_clicked)\n        self.clear_btn.clicked.connect(self._clear_logs)\n        self.search_edit.textChanged.connect(self._filter_logs)\n        self.table.doubleClicked.connect(self._show_detail)\n        self.prev_btn.clicked.connect(self._prev_page)\n        self.next_btn.clicked.connect(self._next_page)\n\n    def _setup_file_watcher(self):\n        \"\"\"设置文件监控，日志文件变化时自动刷新\"\"\"\n        self.file_watcher = QFileSystemWatcher(self)\n        if LLM_LOG_FILE.exists():\n            self.file_watcher.addPath(str(LLM_LOG_FILE))\n        # 同时监控目录，以便检测文件创建\n        self.file_watcher.addPath(str(LOG_PATH))\n        self.file_watcher.fileChanged.connect(self._on_file_changed)\n        self.file_watcher.directoryChanged.connect(self._on_dir_changed)\n\n    def _on_file_changed(self, path: str):\n        \"\"\"日志文件内容变化时自动刷新\"\"\"\n        self._load_logs()\n        # 文件变化后可能需要重新添加监控\n        if LLM_LOG_FILE.exists() and str(LLM_LOG_FILE) not in self.file_watcher.files():\n            self.file_watcher.addPath(str(LLM_LOG_FILE))\n\n    def _on_dir_changed(self, path: str):\n        \"\"\"目录变化时检查日志文件是否创建\"\"\"\n        if LLM_LOG_FILE.exists() and str(LLM_LOG_FILE) not in self.file_watcher.files():\n            self.file_watcher.addPath(str(LLM_LOG_FILE))\n            self._load_logs()\n\n    def _on_refresh_clicked(self):\n        \"\"\"手动刷新按钮点击\"\"\"\n        self._load_logs()\n        InfoBar.success(\n            title=\"\",\n            content=self.tr(\"刷新成功\"),\n            parent=self,\n            position=InfoBarPosition.TOP,\n            duration=1000,\n        )\n\n    def _load_logs(self):\n        \"\"\"加载日志文件\"\"\"\n        self.all_logs = []\n\n        if not LLM_LOG_FILE.exists():\n            self._update_table()\n            return\n\n        try:\n            with open(LLM_LOG_FILE, \"r\", encoding=\"utf-8\") as f:\n                for line in f:\n                    line = line.strip()\n                    if line:\n                        try:\n                            self.all_logs.append(json.loads(line))\n                        except json.JSONDecodeError:\n                            continue\n        except Exception as e:\n            InfoBar.error(\n                title=self.tr(\"错误\"),\n                content=str(e),\n                parent=self,\n                position=InfoBarPosition.TOP,\n                duration=3000,\n            )\n            return\n\n        self.all_logs.reverse()\n        self._filter_logs()\n\n    def _filter_logs(self):\n        \"\"\"根据搜索词过滤日志\"\"\"\n        search_text = self.search_edit.text().lower()\n\n        if not search_text:\n            self.filtered_logs = self.all_logs.copy()\n        else:\n            self.filtered_logs = []\n            for log in self.all_logs:\n                model = log.get(\"request\", {}).get(\"model\", \"\").lower()\n                task_id = log.get(\"task_id\", \"\").lower()\n                file_name = log.get(\"file_name\", \"\").lower()\n                stage = log.get(\"stage\", \"\").lower()\n                messages = json.dumps(log.get(\"request\", {}).get(\"messages\", []))\n                response = json.dumps(log.get(\"response\", {}))\n\n                if (\n                    search_text in model\n                    or search_text in task_id\n                    or search_text in file_name\n                    or search_text in stage\n                    or search_text in messages.lower()\n                    or search_text in response.lower()\n                ):\n                    self.filtered_logs.append(log)\n\n        self.current_page = 0\n        self._update_table()\n\n    def _update_table(self):\n        \"\"\"更新表格显示\"\"\"\n        self.table.setRowCount(0)\n\n        total_pages = max(1, (len(self.filtered_logs) + PAGE_SIZE - 1) // PAGE_SIZE)\n        start_idx = self.current_page * PAGE_SIZE\n        end_idx = min(start_idx + PAGE_SIZE, len(self.filtered_logs))\n\n        for log in self.filtered_logs[start_idx:end_idx]:\n            row = self.table.rowCount()\n            self.table.insertRow(row)\n\n            # 时间（不显示年份：MM-DD HH:MM:SS）\n            time_str = log.get(\"time\", \"\")\n            if time_str and len(time_str) > 5:\n                time_str = time_str[5:]  # 去掉 \"YYYY-\"\n            self.table.setItem(row, 0, self._create_item(time_str))\n\n            # 任务ID\n            task_id = log.get(\"task_id\", \"\") or \"-\"\n            self.table.setItem(row, 1, self._create_item(task_id))\n\n            # 文件\n            file_name = log.get(\"file_name\", \"\") or \"-\"\n            self.table.setItem(row, 2, self._create_item(file_name, align_left=True))\n\n            # 阶段\n            stage = log.get(\"stage\", \"\") or \"-\"\n            self.table.setItem(row, 3, self._create_item(stage))\n\n            # 模型\n            model = log.get(\"request\", {}).get(\"model\", \"未知\")\n            self.table.setItem(row, 4, self._create_item(model))\n\n            # 耗时\n            duration = log.get(\"duration_ms\", 0) / 1000\n            self.table.setItem(row, 5, self._create_item(f\"{duration:.1f}s\"))\n\n            # 总 Tokens\n            usage = log.get(\"response\", {}).get(\"usage\", {})\n            total_tokens = usage.get(\"total_tokens\", 0)\n            if not total_tokens:\n                total_tokens = usage.get(\"prompt_tokens\", 0) + usage.get(\n                    \"completion_tokens\", 0\n                )\n            self.table.setItem(row, 6, self._create_item(str(total_tokens)))\n\n        # 更新分页和统计\n        self.page_label.setText(f\"{self.current_page + 1} / {total_pages}\")\n        self.prev_btn.setEnabled(self.current_page > 0)\n        self.next_btn.setEnabled(self.current_page < total_pages - 1)\n        self.status_label.setText(f\"共 {len(self.filtered_logs)} 条\")\n\n    def _create_item(self, text: str, align_left: bool = False) -> QTableWidgetItem:\n        \"\"\"创建表格项\"\"\"\n        item = QTableWidgetItem(text)\n        if align_left:\n            item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)  # type: ignore\n        else:\n            item.setTextAlignment(Qt.AlignCenter)  # type: ignore\n        return item\n\n    def _show_detail(self, index):\n        \"\"\"显示日志详情\"\"\"\n        actual_idx = self.current_page * PAGE_SIZE + index.row()\n        if 0 <= actual_idx < len(self.filtered_logs):\n            dialog = LogDetailDialog(self.filtered_logs[actual_idx], self)\n            dialog.exec()\n\n    def _prev_page(self):\n        if self.current_page > 0:\n            self.current_page -= 1\n            self._update_table()\n\n    def _next_page(self):\n        total_pages = (len(self.filtered_logs) + PAGE_SIZE - 1) // PAGE_SIZE\n        if self.current_page < total_pages - 1:\n            self.current_page += 1\n            self._update_table()\n\n    def _clear_logs(self):\n        \"\"\"清空日志\"\"\"\n        w = MessageBox(\n            self.tr(\"确认清空\"),\n            self.tr(\"确定要清空所有日志吗？此操作不可恢复。\"),\n            self,\n        )\n        if w.exec():\n            try:\n                if LLM_LOG_FILE.exists():\n                    LLM_LOG_FILE.unlink()\n                self.all_logs = []\n                self.filtered_logs = []\n                self._update_table()\n                InfoBar.success(\n                    title=\"\",\n                    content=self.tr(\"日志已清空\"),\n                    parent=self,\n                    position=InfoBarPosition.TOP,\n                    duration=2000,\n                )\n            except Exception as e:\n                InfoBar.error(\n                    title=self.tr(\"错误\"),\n                    content=str(e),\n                    parent=self,\n                    position=InfoBarPosition.TOP,\n                    duration=3000,\n                )\n"
  },
  {
    "path": "app/view/log_window.py",
    "content": "import os\nimport platform\nimport subprocess\n\nfrom PyQt5.QtCore import Qt, QTimer\nfrom PyQt5.QtGui import QTextCursor\nfrom PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget\nfrom qfluentwidgets import FluentStyleSheet, PushButton, TextEdit, isDarkTheme\n\nfrom app.config import LOG_PATH, RESOURCE_PATH\n\n\nclass LogWindow(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setWindowTitle(\"日志查看器\")\n        self.resize(800, 600)\n\n        FluentStyleSheet.FLUENT_WINDOW.apply(self)\n\n        theme = \"dark\" if isDarkTheme() else \"light\"\n        with open(\n            RESOURCE_PATH / \"assets\" / \"qss\" / theme / \"demo.qss\", encoding=\"utf-8\"\n        ) as f:\n            self.setStyleSheet(f.read())\n\n        # 设置为非模态对话框\n        self.setWindowModality(Qt.NonModal)  # type: ignore\n        # 设置窗口标志\n        self.setWindowFlags(\n            Qt.Window  # type: ignore  # 让窗口成为独立窗口\n            | Qt.WindowCloseButtonHint  # type: ignore  # 添加关闭按钮\n            | Qt.WindowMinMaxButtonsHint  # type: ignore  # 添加最小化最大化按钮\n        )\n        # 创建主布局\n        layout = QVBoxLayout(self)\n\n        # 创建顶部按钮布局\n        top_layout = QHBoxLayout()\n        self.open_folder_btn = PushButton(\"打开日志文件夹\", self)\n        self.open_folder_btn.clicked.connect(self.open_log_folder)\n        top_layout.addWidget(self.open_folder_btn)\n        top_layout.addStretch()\n        layout.addLayout(top_layout)\n\n        # 创建文本编辑器用于显示日志\n        self.log_text = TextEdit(self)\n        self.log_text.setReadOnly(True)\n        layout.addWidget(self.log_text)\n\n        # 设置定时器用于更新日志\n        self.timer = QTimer(self)\n        self.timer.timeout.connect(self.update_log)\n        self.timer.start(500)  # 每2秒更新一次\n\n        # 获取日志文件路径并打开文件\n        self.log_path = LOG_PATH / \"app.log\"\n        try:\n            self.log_file = open(self.log_path, \"r\", encoding=\"utf-8\")\n            self.load_last_lines(20480)\n            self.log_text.moveCursor(QTextCursor.End)\n            self.log_text.insertPlainText(f\"\\n{'=' * 25}以上是历史日志{'=' * 25}\\n\\n\")\n        except Exception as e:\n            self.log_file = None\n            self.log_text.setPlainText(f\"打开日志文件失败: {str(e)}\")\n\n        # 添加文件大小跟踪\n        self.last_position = self.log_file.tell()\n        self.max_lines = 100  # 最多显示100行\n\n        self.auto_scroll = True  # 添加自动滚动标志\n\n        # 监听滚动条变化\n        self.log_text.verticalScrollBar().valueChanged.connect(self.on_scroll_changed)\n\n        # # 初始加载日志\n        # self.update_log()\n\n    def load_last_lines(self, read_size):\n        \"\"\"加载文件最后的内容\n        Args:\n            read_size: 要读取的字节数，比如102400表示读取最后100KB\n        \"\"\"\n        try:\n            # 移动到文件末尾\n            self.log_file.seek(0, 2)\n            file_size = self.log_file.tell()\n\n            # 向前读取指定大小或整个文件\n            read_size = min(read_size, file_size)\n\n            # 从文件开头读取以确保不会破坏UTF-8编码\n            self.log_file.seek(0)\n            content = self.log_file.read()\n\n            # 只保留最后一部分内容\n            if len(content) > read_size:\n                content = content[-read_size:]\n                # 找到第一个完整的行\n                newline_pos = content.find(\"\\n\")\n                if newline_pos != -1:\n                    content = content[newline_pos + 1 :]\n\n            self.last_position = self.log_file.tell()\n            self.log_text.moveCursor(QTextCursor.End)\n            self.log_text.setPlainText(content)\n\n            # 滚动到底部\n            self.log_text.verticalScrollBar().setValue(\n                self.log_text.verticalScrollBar().maximum()\n            )\n\n        except Exception as e:\n            self.log_text.setPlainText(f\"读取日志文件失败: {str(e)}\")\n\n    # def closeEvent(self, event):\n    #     # 关闭窗口时同时关闭文件和定时器\n    #     self.timer.stop()\n    #     if self.log_file:\n    #         self.log_file.close()\n    #     event.accept()\n\n    def on_scroll_changed(self, value):\n        \"\"\"监听滚动条变化\"\"\"\n        scrollbar = self.log_text.verticalScrollBar()\n        max_value = scrollbar.maximum()\n        self.auto_scroll = value <= max_value and value >= max_value * 0.85\n\n    def update_log(self):\n        \"\"\"更新日志内容\"\"\"\n        if not self.log_file:\n            return\n\n        try:\n            # 移动到上次读取的位置\n            self.log_file.seek(self.last_position)\n            new_content = self.log_file.read()\n\n            if new_content:\n                # 按行分割内容\n                lines = new_content.splitlines(True)  # keepends=True 保留换行符\n                for line in lines:\n                    self.log_text.moveCursor(QTextCursor.End)\n                    self.log_text.insertPlainText(line)\n                    # time.sleep(0.02)\n                    self.log_text.repaint()\n\n                self.last_position = self.log_file.tell()\n\n                if self.auto_scroll:\n                    self.log_text.verticalScrollBar().setValue(\n                        self.log_text.verticalScrollBar().maximum()\n                    )\n\n        except Exception as e:\n            self.log_text.setPlainText(f\"读取日志文件出错: {str(e)}\")\n\n    def open_log_folder(self):\n        \"\"\"打开日志文件所在文件夹\"\"\"\n        if platform.system() == \"Windows\":\n            os.startfile(str(LOG_PATH))  # type: ignore\n        elif platform.system() == \"Darwin\":  # macOS\n            subprocess.run([\"open\", str(LOG_PATH)])\n        else:  # Linux\n            subprocess.run([\"xdg-open\", str(LOG_PATH)])\n"
  },
  {
    "path": "app/view/main_window.py",
    "content": "import atexit\nimport os\nimport shutil\n\nimport psutil\nfrom PyQt5.QtCore import QSize, QThread, QUrl\nfrom PyQt5.QtGui import QDesktopServices, QIcon\nfrom PyQt5.QtWidgets import QApplication\nfrom qfluentwidgets import FluentIcon as FIF\nfrom qfluentwidgets import (\n    FluentWindow,\n    InfoBar,\n    InfoBarPosition,\n    MessageBox,\n    NavigationItemPosition,\n    SplashScreen,\n)\n\nfrom app.common.config import cfg\nfrom app.components.DonateDialog import DonateDialog\nfrom app.config import ASSETS_PATH, GITHUB_REPO_URL\nfrom app.core.constant import INFOBAR_DURATION_FOREVER\nfrom app.thread.version_checker_thread import VersionChecker\nfrom app.view.batch_process_interface import BatchProcessInterface\nfrom app.view.home_interface import HomeInterface\nfrom app.view.llm_logs_interface import LLMLogsInterface\nfrom app.view.setting_interface import SettingInterface\nfrom app.view.subtitle_style_interface import SubtitleStyleInterface\n\nLOGO_PATH = ASSETS_PATH / \"logo.png\"\n\n\nclass MainWindow(FluentWindow):\n    def __init__(self):\n        super().__init__()\n        self.initWindow()\n\n        # 创建子界面\n        self.homeInterface = HomeInterface(self)\n        self.settingInterface = SettingInterface(self)\n        self.subtitleStyleInterface = SubtitleStyleInterface(self)\n        self.batchProcessInterface = BatchProcessInterface(self)\n        self.llmLogsInterface = LLMLogsInterface(self)\n\n        # 初始化版本检查器\n        self.versionChecker = VersionChecker()\n        self.versionChecker.newVersionAvailable.connect(self.onNewVersion)\n        self.versionChecker.announcementAvailable.connect(self.onAnnouncement)\n\n        self.versionThread = QThread()\n        self.versionChecker.moveToThread(self.versionThread)\n        self.versionThread.started.connect(self.versionChecker.perform_check)\n        self.versionThread.start()\n\n        # 初始化导航界面\n        self.initNavigation()\n        self.splashScreen.finish()\n\n        # 检查系统依赖\n        self._check_ffmpeg()\n\n        # 注册退出处理， 清理进程\n        atexit.register(self.stop)\n\n    def initNavigation(self):\n        \"\"\"初始化导航栏\"\"\"\n        # 添加导航项\n        self.addSubInterface(self.homeInterface, FIF.HOME, self.tr(\"主页\"))\n        self.addSubInterface(self.batchProcessInterface, FIF.VIDEO, self.tr(\"批量处理\"))\n        self.addSubInterface(self.subtitleStyleInterface, FIF.FONT, self.tr(\"字幕样式\"))\n        self.addSubInterface(self.llmLogsInterface, FIF.HISTORY, self.tr(\"请求日志\"))\n\n        self.navigationInterface.addSeparator()\n\n        # 在底部添加自定义小部件\n        self.navigationInterface.addItem(\n            routeKey=\"avatar\",\n            text=\"GitHub\",\n            icon=FIF.GITHUB,\n            onClick=self.onGithubDialog,\n            position=NavigationItemPosition.BOTTOM,\n        )\n        self.addSubInterface(\n            self.settingInterface,\n            FIF.SETTING,\n            self.tr(\"Settings\"),\n            NavigationItemPosition.BOTTOM,\n        )\n\n        # 设置默认界面\n        self.switchTo(self.homeInterface)\n\n    def switchTo(self, interface):\n        if interface.windowTitle():\n            self.setWindowTitle(interface.windowTitle())\n        else:\n            self.setWindowTitle(self.tr(\"卡卡字幕助手 -- VideoCaptioner\"))\n        self.stackedWidget.setCurrentWidget(interface, popOut=False)\n\n    def initWindow(self):\n        \"\"\"初始化窗口\"\"\"\n        self.resize(1050, 800)\n        self.setMinimumWidth(700)\n        self.setWindowIcon(QIcon(str(LOGO_PATH)))\n        self.setWindowTitle(self.tr(\"卡卡字幕助手 -- VideoCaptioner\"))\n\n        self.setMicaEffectEnabled(cfg.get(cfg.micaEnabled))\n\n        # 创建启动画面\n        self.splashScreen = SplashScreen(self.windowIcon(), self)\n        self.splashScreen.setIconSize(QSize(106, 106))\n        self.splashScreen.raise_()\n\n        # 设置窗口位置, 居中\n        desktop = QApplication.desktop().availableGeometry()\n        w, h = desktop.width(), desktop.height()\n        self.move(w // 2 - self.width() // 2, h // 2 - self.height() // 2)\n\n        self.show()\n        QApplication.processEvents()\n\n    def onGithubDialog(self):\n        \"\"\"打开GitHub\"\"\"\n        w = MessageBox(\n            self.tr(\"GitHub信息\"),\n            self.tr(\n                \"VideoCaptioner 由本人在课余时间独立开发完成，目前托管在GitHub上，欢迎Star和Fork。项目诚然还有很多地方需要完善，遇到软件的问题或者BUG欢迎提交Issue。\\n\\n https://github.com/WEIFENG2333/VideoCaptioner\"\n            ),\n            self,\n        )\n        w.yesButton.setText(self.tr(\"打开 GitHub\"))\n        w.cancelButton.setText(self.tr(\"支持作者\"))\n        if w.exec():\n            QDesktopServices.openUrl(QUrl(GITHUB_REPO_URL))\n        else:\n            # 点击\"支持作者\"按钮时打开捐赠对话框\n            donate_dialog = DonateDialog(self)\n            donate_dialog.exec_()\n\n    def onNewVersion(self, version, update_required, update_info, download_url):\n        \"\"\"新版本提示\"\"\"\n        if update_required:\n            title = \"发现新版本, 需要更新\"\n            content = f\"发现新版本 {version}\\n\\n\" f\"更新内容：\\n{update_info}\"\n        else:\n            title = \"发现新版本\"\n            content = f\"发现新版本 {version}\\n\\n{update_info}\"\n\n        w = MessageBox(title, content, self)\n        w.yesButton.setText(\"立即更新\")\n        w.cancelButton.setText(\"稍后再说\")\n\n        if w.exec() or update_required:\n            QDesktopServices.openUrl(QUrl(download_url))\n\n        if update_required:\n            self.homeInterface.setEnabled(False)\n            self.batchProcessInterface.setEnabled(False)\n            InfoBar.error(\n                title=\"需要更新\",\n                content=self.tr(\"当前版本部分功能已被禁用。请尽快更新。\"),\n                isClosable=False,\n                position=InfoBarPosition.BOTTOM,\n                duration=-1,\n                parent=self,\n            )\n\n    def onAnnouncement(self, content):\n        \"\"\"显示公告\"\"\"\n        w = MessageBox(\"公告\", content, self)\n        w.yesButton.setText(\"我知道了\")\n        w.cancelButton.hide()\n        w.exec()\n\n    def resizeEvent(self, e):\n        super().resizeEvent(e)\n        if hasattr(self, \"splashScreen\"):\n            self.splashScreen.resize(self.size())\n\n    def closeEvent(self, event):\n        # 关闭所有子界面\n        # self.homeInterface.close()\n        # self.batchProcessInterface.close()\n        # self.subtitleStyleInterface.close()\n        # self.settingInterface.close()\n        super().closeEvent(event)\n\n        # 强制退出应用程序\n        QApplication.quit()\n\n        # 确保所有线程和进程都被终止 要是一些错误退出就不会处理了。\n        # import os\n        # os._exit(0)\n\n    def stop(self):\n        # 找到 FFmpeg 进程并关闭\n        process = psutil.Process(os.getpid())\n        for child in process.children(recursive=True):\n            child.kill()\n\n    def _check_ffmpeg(self):\n        \"\"\"检查 FFmpeg 是否已安装\"\"\"\n        if shutil.which(\"ffmpeg\") is None:\n            InfoBar.warning(\n                self.tr(\"FFmpeg 未安装\"),\n                self.tr(\"软件处理音视频文件时需要 FFmpeg，请先安装\"),\n                duration=INFOBAR_DURATION_FOREVER,\n                position=InfoBarPosition.BOTTOM,\n                parent=self,\n            )\n"
  },
  {
    "path": "app/view/setting_interface.py",
    "content": "import webbrowser\n\nfrom PyQt5.QtCore import Qt, QThread, QUrl, pyqtSignal\nfrom PyQt5.QtGui import QDesktopServices\nfrom PyQt5.QtWidgets import QFileDialog, QLabel, QWidget\nfrom qfluentwidgets import (\n    ComboBoxSettingCard,\n    CustomColorSettingCard,\n    ExpandLayout,\n    HyperlinkCard,\n    InfoBar,\n    OptionsSettingCard,\n    PrimaryPushSettingCard,\n    PushSettingCard,\n    RangeSettingCard,\n    ScrollArea,\n    SettingCardGroup,\n    SwitchSettingCard,\n    setTheme,\n    setThemeColor,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.common.signal_bus import signalBus\nfrom app.components.EditComboBoxSettingCard import EditComboBoxSettingCard\nfrom app.components.LineEditSettingCard import LineEditSettingCard\nfrom app.config import AUTHOR, FEEDBACK_URL, HELP_URL, RELEASE_URL, VERSION, YEAR\nfrom app.core.constant import (\n    INFOBAR_DURATION_ERROR,\n    INFOBAR_DURATION_SUCCESS,\n    INFOBAR_DURATION_WARNING,\n)\nfrom app.core.entities import LLMServiceEnum, TranscribeModelEnum, TranslatorServiceEnum\nfrom app.core.llm import check_whisper_connection\nfrom app.core.llm.check_llm import check_llm_connection, get_available_models\nfrom app.core.utils.cache import disable_cache, enable_cache\n\n\nclass SettingInterface(ScrollArea):\n    \"\"\"设置界面\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent=parent)\n        self.setWindowTitle(self.tr(\"设置\"))\n        self.scrollWidget = QWidget()\n        self.expandLayout = ExpandLayout(self.scrollWidget)\n        self.settingLabel = QLabel(self.tr(\"设置\"), self)\n\n        # 初始化所有设置组\n        self.__initGroups()\n        # 初始化所有配置卡片\n        self.__initCards()\n        # 初始化界面\n        self.__initWidget()\n        # 初始化布局\n        self.__initLayout()\n        # 连接信号和槽\n        self.__connectSignalToSlot()\n\n    def __initGroups(self):\n        \"\"\"初始化所有设置组\"\"\"\n        # 转录配置组\n        self.transcribeGroup = SettingCardGroup(self.tr(\"转录配置\"), self.scrollWidget)\n        # LLM配置组\n        self.llmGroup = SettingCardGroup(self.tr(\"LLM配置\"), self.scrollWidget)\n        # 翻译服务组\n        self.translate_serviceGroup = SettingCardGroup(\n            self.tr(\"翻译服务\"), self.scrollWidget\n        )\n        # 翻译与优化组\n        self.translateGroup = SettingCardGroup(self.tr(\"翻译与优化\"), self.scrollWidget)\n        # 字幕合成配置组\n        self.subtitleGroup = SettingCardGroup(\n            self.tr(\"字幕合成配置\"), self.scrollWidget\n        )\n        # 保存配置组\n        self.saveGroup = SettingCardGroup(self.tr(\"保存配置\"), self.scrollWidget)\n        # 个性化组\n        self.personalGroup = SettingCardGroup(self.tr(\"个性化\"), self.scrollWidget)\n        # 关于组\n        self.aboutGroup = SettingCardGroup(self.tr(\"关于\"), self.scrollWidget)\n\n    def __initCards(self):\n        \"\"\"初始化所有配置卡片\"\"\"\n\n        # ASR 服务配置卡片\n        self.__createASRServiceCards()\n\n        # LLM配置卡片\n        self.__createLLMServiceCards()\n\n        # 翻译配置卡片\n        self.__createTranslateServiceCards()\n\n        # 翻译与优化配置卡片\n        self.subtitleCorrectCard = SwitchSettingCard(\n            FIF.EDIT,\n            self.tr(\"字幕校正\"),\n            self.tr(\"字幕处理过程是否对生成的字幕错别字、名词等进行校正\"),\n            cfg.need_optimize,\n            self.translateGroup,\n        )\n        self.subtitleTranslateCard = SwitchSettingCard(\n            FIF.LANGUAGE,\n            self.tr(\"字幕翻译\"),\n            self.tr(\"字幕处理过程是否对生成的字幕进行翻译\"),\n            cfg.need_translate,\n            self.translateGroup,\n        )\n        self.targetLanguageCard = ComboBoxSettingCard(\n            cfg.target_language,\n            FIF.LANGUAGE,\n            self.tr(\"目标语言\"),\n            self.tr(\"选择翻译字幕的目标语言\"),\n            texts=[lang.value for lang in cfg.target_language.validator.options],  # type: ignore\n            parent=self.translateGroup,\n        )\n\n        # 字幕合成配置卡片\n        self.subtitleStyleCard = HyperlinkCard(\n            \"\",\n            self.tr(\"修改\"),\n            FIF.FONT,\n            self.tr(\"字幕样式\"),\n            self.tr(\"选择字幕的样式（颜色、大小、字体等）\"),\n            self.subtitleGroup,\n        )\n        self.subtitleLayoutCard = HyperlinkCard(\n            \"\",\n            self.tr(\"修改\"),\n            FIF.FONT,\n            self.tr(\"字幕布局\"),\n            self.tr(\"选择字幕的布局（单语、双语）\"),\n            self.subtitleGroup,\n        )\n        self.needVideoCard = SwitchSettingCard(\n            FIF.VIDEO,\n            self.tr(\"需要合成视频\"),\n            self.tr(\"开启时触发合成视频，关闭时跳过\"),\n            cfg.need_video,\n            self.subtitleGroup,\n        )\n        self.softSubtitleCard = SwitchSettingCard(\n            FIF.FONT,\n            self.tr(\"软字幕\"),\n            self.tr(\"开启时字幕可在播放器中关闭或调整，关闭时字幕烧录到视频画面上\"),\n            cfg.soft_subtitle,\n            self.subtitleGroup,\n        )\n        self.videoQualityCard = ComboBoxSettingCard(\n            cfg.video_quality,\n            FIF.SPEED_HIGH,\n            self.tr(\"视频合成质量\"),\n            self.tr(\"硬字幕视频合成时的质量等级（质量越高文件越大，编码时间越长）\"),\n            texts=[quality.value for quality in cfg.video_quality.validator.options],  # type: ignore\n            parent=self.subtitleGroup,\n        )\n\n        # 保存配置卡片\n        self.savePathCard = PushSettingCard(\n            self.tr(\"工作文件夹\"),\n            FIF.SAVE,\n            self.tr(\"工作目录路径\"),\n            cfg.get(cfg.work_dir),\n            self.saveGroup,\n        )\n\n        # 个性化配置卡片\n        self.cacheEnabledCard = SwitchSettingCard(\n            FIF.HISTORY,\n            self.tr(\"启用缓存\"),\n            self.tr(\"相同配置下会复用之前的 ASR 和 LLM 结果；关闭缓存后每次重新生成\"),\n            cfg.cache_enabled,\n            self.personalGroup,\n        )\n        self.themeCard = OptionsSettingCard(\n            cfg.themeMode,\n            FIF.BRUSH,\n            self.tr(\"应用主题\"),\n            self.tr(\"更改应用程序的外观\"),\n            texts=[self.tr(\"浅色\"), self.tr(\"深色\"), self.tr(\"使用系统设置\")],\n            parent=self.personalGroup,\n        )\n        self.themeColorCard = CustomColorSettingCard(\n            cfg.themeColor,\n            FIF.PALETTE,\n            self.tr(\"主题颜色\"),\n            self.tr(\"更改应用程序的主题颜色\"),\n            self.personalGroup,\n        )\n        self.zoomCard = OptionsSettingCard(\n            cfg.dpiScale,\n            FIF.ZOOM,\n            self.tr(\"界面缩放\"),\n            self.tr(\"更改小部件和字体的大小\"),\n            texts=[\"100%\", \"125%\", \"150%\", \"175%\", \"200%\", self.tr(\"使用系统设置\")],\n            parent=self.personalGroup,\n        )\n        self.languageCard = ComboBoxSettingCard(\n            cfg.language,\n            FIF.LANGUAGE,\n            self.tr(\"语言\"),\n            self.tr(\"设置您偏好的界面语言\"),\n            texts=[\"简体中文\", \"繁體中文\", \"English\", self.tr(\"使用系统设置\")],\n            parent=self.personalGroup,\n        )\n\n        # 关于卡片\n        self.helpCard = HyperlinkCard(\n            HELP_URL,\n            self.tr(\"打开帮助页面\"),\n            FIF.HELP,\n            self.tr(\"帮助\"),\n            self.tr(\"发现新功能并了解有关VideoCaptioner的使用技巧\"),\n            self.aboutGroup,\n        )\n        self.feedbackCard = PrimaryPushSettingCard(\n            self.tr(\"提供反馈\"),\n            FIF.FEEDBACK,\n            self.tr(\"提供反馈\"),\n            self.tr(\"提供反馈帮助我们改进VideoCaptioner\"),\n            self.aboutGroup,\n        )\n        self.aboutCard = PrimaryPushSettingCard(\n            self.tr(\"检查更新\"),\n            FIF.INFO,\n            self.tr(\"关于\"),\n            \"© \"\n            + self.tr(\"版权所有\")\n            + f\" {YEAR}, {AUTHOR}. \"\n            + self.tr(\"版本\")\n            + \" \"\n            + VERSION,\n            self.aboutGroup,\n        )\n\n        # 添加卡片到对应的组\n        self.translateGroup.addSettingCard(self.subtitleCorrectCard)\n        self.translateGroup.addSettingCard(self.subtitleTranslateCard)\n        self.translateGroup.addSettingCard(self.targetLanguageCard)\n\n        self.subtitleGroup.addSettingCard(self.subtitleStyleCard)\n        self.subtitleGroup.addSettingCard(self.subtitleLayoutCard)\n        self.subtitleGroup.addSettingCard(self.needVideoCard)\n        self.subtitleGroup.addSettingCard(self.softSubtitleCard)\n        self.subtitleGroup.addSettingCard(self.videoQualityCard)\n\n        self.saveGroup.addSettingCard(self.savePathCard)\n        self.saveGroup.addSettingCard(self.cacheEnabledCard)\n\n        self.personalGroup.addSettingCard(self.themeCard)\n        self.personalGroup.addSettingCard(self.themeColorCard)\n        self.personalGroup.addSettingCard(self.zoomCard)\n        self.personalGroup.addSettingCard(self.languageCard)\n\n        self.aboutGroup.addSettingCard(self.helpCard)\n        self.aboutGroup.addSettingCard(self.feedbackCard)\n        self.aboutGroup.addSettingCard(self.aboutCard)\n\n    def __createLLMServiceCards(self):\n        \"\"\"创建LLM服务相关的配置卡片\"\"\"\n        # 服务选择卡片\n        self.llmServiceCard = ComboBoxSettingCard(\n            cfg.llm_service,\n            FIF.ROBOT,\n            self.tr(\"LLM 提供商\"),\n            self.tr(\"选择大模型提供商，用于字幕断句、优化、翻译\"),\n            texts=[service.value for service in cfg.llm_service.validator.options],  # type: ignore\n            parent=self.llmGroup,\n        )\n        self.llmServiceCard.comboBox.setMinimumWidth(150)\n\n        # 创建OPENAI官方API链接卡片\n        self.openaiOfficialApiCard = HyperlinkCard(\n            \"https://api.videocaptioner.cn/register?aff=UrLB\",\n            self.tr(\"访问\"),\n            FIF.DEVELOPER_TOOLS,\n            self.tr(\"VideoCaptioner 官方API\"),\n            self.tr(\"集成多种大语言模型，支持高并发字幕优化、翻译\"),\n            self.llmGroup,\n        )\n        # 默认隐藏\n        self.openaiOfficialApiCard.setVisible(False)\n\n        # 定义每个服务的配置\n        service_configs = {\n            LLMServiceEnum.OPENAI: {\n                \"prefix\": \"openai\",\n                \"api_key_cfg\": cfg.openai_api_key,\n                \"api_base_cfg\": cfg.openai_api_base,\n                \"model_cfg\": cfg.openai_model,\n                \"default_base\": \"https://api.openai.com/v1\",\n                \"default_models\": [\n                    \"gemini-2.5-pro\",\n                    \"gpt-5\",\n                    \"claude-sonnet-4-5-20250929\",\n                    \"gemini-2.5-flash\",\n                    \"claude-haiku-4-5-20251001\",\n                ],\n            },\n            LLMServiceEnum.SILICON_CLOUD: {\n                \"prefix\": \"silicon_cloud\",\n                \"api_key_cfg\": cfg.silicon_cloud_api_key,\n                \"api_base_cfg\": cfg.silicon_cloud_api_base,\n                \"model_cfg\": cfg.silicon_cloud_model,\n                \"default_base\": \"https://api.siliconflow.cn/v1\",\n                \"default_models\": [\n                    \"moonshotai/Kimi-K2-Instruct-0905\",\n                    \"deepseek-ai/DeepSeek-V3\",\n                ],\n            },\n            LLMServiceEnum.DEEPSEEK: {\n                \"prefix\": \"deepseek\",\n                \"api_key_cfg\": cfg.deepseek_api_key,\n                \"api_base_cfg\": cfg.deepseek_api_base,\n                \"model_cfg\": cfg.deepseek_model,\n                \"default_base\": \"https://api.deepseek.com/v1\",\n                \"default_models\": [\"deepseek-chat\", \"deepseek-reasoner\"],\n            },\n            LLMServiceEnum.OLLAMA: {\n                \"prefix\": \"ollama\",\n                \"api_key_cfg\": cfg.ollama_api_key,\n                \"api_base_cfg\": cfg.ollama_api_base,\n                \"model_cfg\": cfg.ollama_model,\n                \"default_base\": \"http://localhost:11434/v1\",\n                \"default_models\": [\"qwen3:8b\"],\n            },\n            LLMServiceEnum.LM_STUDIO: {\n                \"prefix\": \"LM Studio\",\n                \"api_key_cfg\": cfg.lm_studio_api_key,\n                \"api_base_cfg\": cfg.lm_studio_api_base,\n                \"model_cfg\": cfg.lm_studio_model,\n                \"default_base\": \"http://localhost:1234/v1\",\n                \"default_models\": [\"qwen3:8b\"],\n            },\n            LLMServiceEnum.GEMINI: {\n                \"prefix\": \"gemini\",\n                \"api_key_cfg\": cfg.gemini_api_key,\n                \"api_base_cfg\": cfg.gemini_api_base,\n                \"model_cfg\": cfg.gemini_model,\n                \"default_base\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n                \"default_models\": [\n                    \"gemini-2.5-pro\",\n                    \"gemini-2.5-flash\",\n                    \"gemini-2.0-flash-lite\",\n                ],\n            },\n            LLMServiceEnum.CHATGLM: {\n                \"prefix\": \"chatglm\",\n                \"api_key_cfg\": cfg.chatglm_api_key,\n                \"api_base_cfg\": cfg.chatglm_api_base,\n                \"model_cfg\": cfg.chatglm_model,\n                \"default_base\": \"https://open.bigmodel.cn/api/paas/v4\",\n                \"default_models\": [\"glm-4-plus\", \"glm-4-air-250414\", \"glm-4-flash\"],\n            },\n        }\n\n        # 创建服务配置映射\n        self.llm_service_configs = {}\n\n        # 为每个服务创建配置卡片\n        for service, config in service_configs.items():\n            prefix = config[\"prefix\"]\n\n            # 创建API Key卡片\n            api_key_card = LineEditSettingCard(\n                config[\"api_key_cfg\"],\n                FIF.FINGERPRINT,\n                self.tr(\"API Key\"),\n                self.tr(f\"输入您的 {service.value} API Key\"),\n                \"sk-\" if service != LLMServiceEnum.OLLAMA else \"\",\n                self.llmGroup,\n            )\n            setattr(self, f\"{prefix}_api_key_card\", api_key_card)\n\n            # 创建Base URL卡片\n            api_base_card = LineEditSettingCard(\n                config[\"api_base_cfg\"],\n                FIF.LINK,\n                self.tr(\"Base URL\"),\n                self.tr(f\"输入 {service.value} Base URL\"),\n                config[\"default_base\"],\n                self.llmGroup,\n            )\n            setattr(self, f\"{prefix}_api_base_card\", api_base_card)\n\n            # 设置只读状态：只有 OpenAI、Ollama、LM Studio 可以编辑 Base URL\n            if service not in [\n                LLMServiceEnum.OPENAI,\n                LLMServiceEnum.OLLAMA,\n                LLMServiceEnum.LM_STUDIO,\n            ]:\n                api_base_card.lineEdit.setReadOnly(True)\n\n            # 创建模型选择卡片\n            model_card = EditComboBoxSettingCard(\n                config[\"model_cfg\"],\n                FIF.ROBOT,  # type: ignore\n                self.tr(\"模型\"),\n                self.tr(f\"选择 {service.value} 模型\"),\n                config[\"default_models\"],\n                self.llmGroup,\n            )\n            setattr(self, f\"{prefix}_model_card\", model_card)\n\n            # 存储服务配置\n            cards = [api_key_card, api_base_card, model_card]\n\n            self.llm_service_configs[service] = {\n                \"cards\": cards,\n                \"api_base\": api_base_card,\n                \"api_key\": api_key_card,\n                \"model\": model_card,\n            }\n\n        # 创建检查连接卡片\n        self.checkLLMConnectionCard = PushSettingCard(\n            self.tr(\"检查连接\"),\n            FIF.LINK,\n            self.tr(\"检查 LLM 连接\"),\n            self.tr(\"点击检查 API 连接是否正常，并获取模型列表\"),\n            self.llmGroup,\n        )\n\n        # 初始化显示状态\n        self.__onLLMServiceChanged(self.llmServiceCard.comboBox.currentText())\n\n    def __createASRServiceCards(self):\n        \"\"\"创建 Whisper API 配置卡片\"\"\"\n        # 转录配置卡片\n        self.transcribeModelCard = ComboBoxSettingCard(\n            cfg.transcribe_model,\n            FIF.MICROPHONE,\n            self.tr(\"转录模型\"),\n            self.tr(\"语音转换文字要使用的语音识别服务\"),\n            texts=[model.value for model in cfg.transcribe_model.validator.options],  # type: ignore\n            parent=self.transcribeGroup,\n        )\n        self.transcribeModelCard.comboBox.setMinimumWidth(150)\n\n        # API Base URL\n        self.whisperApiBaseCard = LineEditSettingCard(\n            cfg.whisper_api_base,\n            FIF.LINK,\n            self.tr(\"Whisper API Base URL\"),\n            self.tr(\"输入 Whisper API Base URL\"),\n            \"https://api.openai.com/v1\",\n            self.transcribeGroup,\n        )\n\n        # API Key\n        self.whisperApiKeyCard = LineEditSettingCard(\n            cfg.whisper_api_key,\n            FIF.FINGERPRINT,\n            self.tr(\"Whisper API Key\"),\n            self.tr(\"输入 Whisper API Key\"),\n            \"sk-\",\n            self.transcribeGroup,\n        )\n\n        # 模型选择\n        self.whisperApiModelCard = EditComboBoxSettingCard(\n            cfg.whisper_api_model,\n            FIF.ROBOT,  # type: ignore\n            self.tr(\"Whisper 模型\"),\n            self.tr(\"选择 Whisper 模型\"),\n            [\n                \"whisper-1\",\n                \"whisper-large-v3-turbo\",\n            ],\n            self.transcribeGroup,\n        )\n\n        # 测试连接按钮\n        self.checkWhisperConnectionCard = PushSettingCard(\n            self.tr(\"测试 Whisper 连接\"),\n            FIF.CONNECT,\n            self.tr(\"测试 Whisper API 连接\"),\n            self.tr(\"点击测试 API 连接是否正常\"),\n            self.transcribeGroup,\n        )\n\n        # 默认隐藏 Whisper API 配置卡片（仅在选择 Whisper API 时显示）\n        self.whisperApiBaseCard.setVisible(False)\n        self.whisperApiKeyCard.setVisible(False)\n        self.whisperApiModelCard.setVisible(False)\n        self.checkWhisperConnectionCard.setVisible(False)\n\n    def __createTranslateServiceCards(self):\n        \"\"\"创建翻译服务相关的配置卡片\"\"\"\n        # 翻译服务选择卡片\n        self.translatorServiceCard = ComboBoxSettingCard(\n            cfg.translator_service,\n            FIF.ROBOT,\n            self.tr(\"翻译服务\"),\n            self.tr(\"选择翻译服务\"),\n            texts=[\n                service.value\n                for service in cfg.translator_service.validator.options  # type: ignore\n            ],\n            parent=self.translate_serviceGroup,\n        )\n        self.translatorServiceCard.comboBox.setMinimumWidth(150)\n\n        # 反思翻译开关\n        self.needReflectTranslateCard = SwitchSettingCard(\n            FIF.EDIT,\n            self.tr(\"需要反思翻译\"),\n            self.tr(\"启用反思翻译可以提高翻译质量，但耗费更多时间和token\"),\n            cfg.need_reflect_translate,\n            self.translate_serviceGroup,\n        )\n\n        # DeepLx端点配置\n        self.deeplxEndpointCard = LineEditSettingCard(\n            cfg.deeplx_endpoint,\n            FIF.LINK,\n            self.tr(\"DeepLx 后端\"),\n            self.tr(\"输入 DeepLx 的后端地址(开启deeplx翻译时必填)\"),\n            \"https://api.deeplx.org/translate\",\n            self.translate_serviceGroup,\n        )\n\n        # 批处理大小配置\n        self.batchSizeCard = RangeSettingCard(\n            cfg.batch_size,\n            FIF.ALIGNMENT,\n            self.tr(\"批处理大小\"),\n            self.tr(\"每批处理字幕的数量，建议为 10 的倍数\"),\n            parent=self.translate_serviceGroup,\n        )\n\n        # 线程数配置\n        self.threadNumCard = RangeSettingCard(\n            cfg.thread_num,\n            FIF.SPEED_HIGH,\n            self.tr(\"线程数\"),\n            self.tr(\n                \"请求并行处理的数量，模型服务商允许的情况下建议尽可能大，数值越大速度越快\"\n            ),\n            parent=self.translate_serviceGroup,\n        )\n\n        # 添加卡片到翻译服务组\n        self.translate_serviceGroup.addSettingCard(self.translatorServiceCard)\n        self.translate_serviceGroup.addSettingCard(self.needReflectTranslateCard)\n        self.translate_serviceGroup.addSettingCard(self.deeplxEndpointCard)\n        self.translate_serviceGroup.addSettingCard(self.batchSizeCard)\n        self.translate_serviceGroup.addSettingCard(self.threadNumCard)\n\n        # 初始化显示状态\n        self.__onTranslatorServiceChanged(\n            self.translatorServiceCard.comboBox.currentText()\n        )\n\n    def __initWidget(self):\n        self.resize(1000, 800)\n        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)  # type: ignore\n        self.setViewportMargins(0, 80, 0, 20)\n        self.setWidget(self.scrollWidget)\n        self.setWidgetResizable(True)\n        self.setObjectName(\"settingInterface\")\n\n        # 初始化样式表\n        self.scrollWidget.setObjectName(\"scrollWidget\")\n        self.settingLabel.setObjectName(\"settingLabel\")\n\n        # 初始化转录模型配置卡片的显示状态\n        self.__onTranscribeModelChanged(self.transcribeModelCard.comboBox.currentText())\n\n        # 初始化翻译服务配置卡片的显示状态\n        self.__onTranslatorServiceChanged(\n            self.translatorServiceCard.comboBox.currentText()\n        )\n\n        self.setStyleSheet(\n            \"\"\"        \n            SettingInterface, #scrollWidget {\n                background-color: transparent;\n            }\n            QScrollArea {\n                border: none;\n                background-color: transparent;\n            }\n            QLabel#settingLabel {\n                font: 33px 'Microsoft YaHei';\n                background-color: transparent;\n                color: white;\n            }\n        \"\"\"\n        )\n\n    def __initLayout(self):\n        \"\"\"初始化布局\"\"\"\n        self.settingLabel.move(36, 30)\n\n        # 添加转录配置卡片\n        self.transcribeGroup.addSettingCard(self.transcribeModelCard)\n        # 添加 Whisper API 配置卡片\n        self.transcribeGroup.addSettingCard(self.whisperApiBaseCard)\n        self.transcribeGroup.addSettingCard(self.whisperApiKeyCard)\n        self.transcribeGroup.addSettingCard(self.whisperApiModelCard)\n        self.transcribeGroup.addSettingCard(self.checkWhisperConnectionCard)\n\n        # 添加LLM配置卡片\n        self.llmGroup.addSettingCard(self.llmServiceCard)\n        # 添加OPENAI官方API链接卡片\n        self.llmGroup.addSettingCard(self.openaiOfficialApiCard)\n        for config in self.llm_service_configs.values():\n            for card in config[\"cards\"]:\n                self.llmGroup.addSettingCard(card)\n        self.llmGroup.addSettingCard(self.checkLLMConnectionCard)\n\n        # 将所有组添加到布局\n        self.expandLayout.setSpacing(28)\n        self.expandLayout.setContentsMargins(36, 10, 36, 0)\n        self.expandLayout.addWidget(self.transcribeGroup)\n        self.expandLayout.addWidget(self.llmGroup)\n        self.expandLayout.addWidget(self.translate_serviceGroup)\n        self.expandLayout.addWidget(self.translateGroup)\n        self.expandLayout.addWidget(self.subtitleGroup)\n        self.expandLayout.addWidget(self.saveGroup)\n        self.expandLayout.addWidget(self.personalGroup)\n        self.expandLayout.addWidget(self.aboutGroup)\n\n    def __connectSignalToSlot(self):\n        \"\"\"连接信号与槽\"\"\"\n        cfg.appRestartSig.connect(self.__showRestartTooltip)\n\n        # LLM服务切换\n        self.llmServiceCard.comboBox.currentTextChanged.connect(\n            self.__onLLMServiceChanged\n        )\n\n        # 翻译服务切换\n        self.translatorServiceCard.comboBox.currentTextChanged.connect(\n            self.__onTranslatorServiceChanged\n        )\n\n        # 转录模型切换\n        self.transcribeModelCard.comboBox.currentTextChanged.connect(\n            self.__onTranscribeModelChanged\n        )\n\n        # 检查 LLM 连接\n        self.checkLLMConnectionCard.clicked.connect(self.checkLLMConnection)\n\n        # 检查 Whisper 连接\n        self.checkWhisperConnectionCard.clicked.connect(self.checkWhisperConnection)\n\n        # 保存路径\n        self.savePathCard.clicked.connect(self.__onsavePathCardClicked)\n\n        # 字幕样式修改跳转\n        self.subtitleStyleCard.linkButton.clicked.connect(\n            lambda: self.window().switchTo(self.window().subtitleStyleInterface)  # type: ignore\n        )\n        self.subtitleLayoutCard.linkButton.clicked.connect(\n            lambda: self.window().switchTo(self.window().subtitleStyleInterface)  # type: ignore\n        )\n\n        # 个性化\n        self.cacheEnabledCard.checkedChanged.connect(self.__onCacheEnabledChanged)\n        self.themeCard.optionChanged.connect(lambda ci: setTheme(cfg.get(ci)))\n        self.themeColorCard.colorChanged.connect(setThemeColor)\n\n        # 反馈\n        self.feedbackCard.clicked.connect(\n            lambda: QDesktopServices.openUrl(QUrl(FEEDBACK_URL))  # type: ignore\n        )\n\n        # 关于\n        self.aboutCard.clicked.connect(self.checkUpdate)\n\n        # 全局 signalBus\n        self.transcribeModelCard.comboBox.currentTextChanged.connect(\n            signalBus.transcription_model_changed\n        )\n        self.subtitleCorrectCard.checkedChanged.connect(\n            signalBus.subtitle_optimization_changed\n        )\n        self.subtitleTranslateCard.checkedChanged.connect(\n            signalBus.subtitle_translation_changed\n        )\n        self.targetLanguageCard.comboBox.currentTextChanged.connect(\n            signalBus.target_language_changed\n        )\n        self.softSubtitleCard.checkedChanged.connect(signalBus.soft_subtitle_changed)\n        self.needVideoCard.checkedChanged.connect(signalBus.need_video_changed)\n        self.videoQualityCard.comboBox.currentTextChanged.connect(\n            signalBus.video_quality_changed\n        )\n\n    def __showRestartTooltip(self):\n        \"\"\"显示重启提示\"\"\"\n        InfoBar.success(\n            self.tr(\"更新成功\"),\n            self.tr(\"配置将在重启后生效\"),\n            duration=INFOBAR_DURATION_SUCCESS,\n            parent=self,\n        )\n\n    def __onsavePathCardClicked(self):\n        \"\"\"处理保存路径卡片点击事件\"\"\"\n        folder = QFileDialog.getExistingDirectory(self, self.tr(\"选择文件夹\"), \"./\")\n        if not folder or cfg.get(cfg.work_dir) == folder:\n            return\n        cfg.set(cfg.work_dir, folder)\n        self.savePathCard.setContent(folder)\n\n    def __onCacheEnabledChanged(self, is_enabled: bool):\n        \"\"\"处理缓存开关变化\"\"\"\n        if is_enabled:\n            enable_cache()\n            InfoBar.success(\n                self.tr(\"缓存已启用\"),\n                self.tr(\"ASR、翻译等操作将优先使用缓存\"),\n                duration=INFOBAR_DURATION_SUCCESS,\n                parent=self,\n            )\n        else:\n            disable_cache()\n            InfoBar.warning(\n                self.tr(\"缓存已禁用\"),\n                self.tr(\"所有操作将重新生成，不使用缓存（建议开启缓存）\"),\n                duration=INFOBAR_DURATION_WARNING,\n                parent=self,\n            )\n\n    def checkLLMConnection(self):\n        \"\"\"检查 LLM 连接\"\"\"\n        # 保存当前滚动位置\n        scroll_position = self.verticalScrollBar().value()\n\n        # 获取当前选中的服务\n        current_service = LLMServiceEnum(self.llmServiceCard.comboBox.currentText())\n\n        # 获取服务配置\n        service_config = self.llm_service_configs.get(current_service)\n        if not service_config:\n            return\n\n        api_base = (\n            service_config[\"api_base\"].lineEdit.text()\n            if service_config[\"api_base\"]\n            else \"\"\n        )\n        api_key = (\n            service_config[\"api_key\"].lineEdit.text()\n            if service_config[\"api_key\"]\n            else \"\"\n        )\n        model = (\n            service_config[\"model\"].comboBox.currentText()\n            if service_config[\"model\"]\n            else \"\"\n        )\n\n        # 禁用检查按钮，显示加载状态\n        self.checkLLMConnectionCard.button.setEnabled(False)\n        self.checkLLMConnectionCard.button.setText(self.tr(\"正在检查...\"))\n\n        # 立即恢复滚动位置（防止按钮状态改变导致的自动滚动）\n        self.verticalScrollBar().setValue(scroll_position)\n\n        # 创建并启动线程\n        self.connection_thread = LLMConnectionThread(api_base, api_key, model)\n        self.connection_thread.finished.connect(self.onConnectionCheckFinished)\n        self.connection_thread.error.connect(self.onConnectionCheckError)\n        self.connection_thread.start()\n\n    def onConnectionCheckError(self, message):\n        \"\"\"处理连接检查错误事件\"\"\"\n        self.checkLLMConnectionCard.button.setEnabled(True)\n        self.checkLLMConnectionCard.button.setText(self.tr(\"检查连接\"))\n        InfoBar.error(\n            self.tr(\"LLM 连接测试错误\"),\n            message,\n            duration=INFOBAR_DURATION_ERROR,\n            parent=self,\n        )\n\n    def onConnectionCheckFinished(self, is_success, message, models):\n        \"\"\"处理连接检查完成事件\"\"\"\n        self.checkLLMConnectionCard.button.setEnabled(True)\n        self.checkLLMConnectionCard.button.setText(self.tr(\"检查连接\"))\n\n        # 获取当前服务\n        current_service = LLMServiceEnum(self.llmServiceCard.comboBox.currentText())\n\n        if models:\n            # 更新当前服务的模型列表\n            service_config = self.llm_service_configs.get(current_service)\n            if service_config and service_config[\"model\"]:\n                temp = service_config[\"model\"].comboBox.currentText()\n                service_config[\"model\"].setItems(models)\n                service_config[\"model\"].comboBox.setCurrentText(temp)\n\n            InfoBar.success(\n                self.tr(\"获取模型列表成功:\"),\n                self.tr(\"一共\") + str(len(models)) + self.tr(\"个模型\"),\n                duration=INFOBAR_DURATION_SUCCESS,\n                parent=self,\n            )\n        if not is_success:\n            InfoBar.error(\n                self.tr(\"LLM 连接测试错误\"),\n                message,\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n        else:\n            InfoBar.success(\n                self.tr(\"LLM 连接测试成功\"),\n                message,\n                duration=INFOBAR_DURATION_SUCCESS,\n                parent=self,\n            )\n\n    def checkUpdate(self):\n        webbrowser.open(RELEASE_URL)\n\n    def __onLLMServiceChanged(self, service):\n        \"\"\"处理LLM服务切换事件\"\"\"\n        current_service = LLMServiceEnum(service)\n\n        # 隐藏所有卡片\n        for config in self.llm_service_configs.values():\n            for card in config[\"cards\"]:\n                card.setVisible(False)\n\n        # 隐藏OPENAI官方API链接卡片\n        self.openaiOfficialApiCard.setVisible(False)\n\n        # 显示选中服务的卡片\n        if current_service in self.llm_service_configs:\n            for card in self.llm_service_configs[current_service][\"cards\"]:\n                card.setVisible(True)\n\n            # 为OLLAMA和LM_STUDIO设置默认API Key\n            service_config = self.llm_service_configs[current_service]\n            if current_service == LLMServiceEnum.OLLAMA and service_config[\"api_key\"]:\n                # 如果API Key为空，设置默认值\"ollama\"\n                if not service_config[\"api_key\"].lineEdit.text():\n                    service_config[\"api_key\"].lineEdit.setText(\"ollama\")\n            if (\n                current_service == LLMServiceEnum.LM_STUDIO\n                and service_config[\"api_key\"]\n            ):\n                # 如果API Key为空，设置默认值 \"lm-studio\"\n                if not service_config[\"api_key\"].lineEdit.text():\n                    service_config[\"api_key\"].lineEdit.setText(\"lm-studio\")\n\n            # 如果是OPENAI服务，显示官方API链接卡片\n            if current_service == LLMServiceEnum.OPENAI:\n                self.openaiOfficialApiCard.setVisible(True)\n\n        # 更新布局\n        self.llmGroup.adjustSize()\n        self.expandLayout.update()\n\n    def __onTranslatorServiceChanged(self, service):\n        openai_cards = [\n            self.needReflectTranslateCard,\n            self.batchSizeCard,\n        ]\n        deeplx_cards = [self.deeplxEndpointCard]\n\n        all_cards = openai_cards + deeplx_cards\n        for card in all_cards:\n            card.setVisible(False)\n\n        # 根据选择的服务显示相应的配置卡片\n        if service in [TranslatorServiceEnum.DEEPLX.value]:\n            for card in deeplx_cards:\n                card.setVisible(True)\n        elif service in [TranslatorServiceEnum.OPENAI.value]:\n            for card in openai_cards:\n                card.setVisible(True)\n\n        # 更新布局\n        self.translate_serviceGroup.adjustSize()\n        self.expandLayout.update()\n\n    def __onTranscribeModelChanged(self, model_name):\n        \"\"\"处理转录模型切换事件\"\"\"\n        # Whisper API 配置卡片\n        whisper_api_cards = [\n            self.whisperApiBaseCard,\n            self.whisperApiKeyCard,\n            self.whisperApiModelCard,\n            self.checkWhisperConnectionCard,\n        ]\n\n        # 根据选择的模型显示/隐藏 Whisper API 配置\n        is_whisper_api = model_name == TranscribeModelEnum.WHISPER_API.value\n        for card in whisper_api_cards:\n            card.setVisible(is_whisper_api)\n\n        # 更新布局\n        self.transcribeGroup.adjustSize()\n        self.expandLayout.update()\n\n    def checkWhisperConnection(self):\n        \"\"\"检查 Whisper API 连接\"\"\"\n        # 保存当前滚动位置\n        scroll_position = self.verticalScrollBar().value()\n\n        # 获取配置\n        base_url = self.whisperApiBaseCard.lineEdit.text().strip()\n        api_key = self.whisperApiKeyCard.lineEdit.text().strip()\n        model = self.whisperApiModelCard.comboBox.currentText().strip()\n\n        # 验证必填字段\n        if not base_url:\n            InfoBar.warning(\n                self.tr(\"配置不完整\"),\n                self.tr(\"请输入 Whisper API Base URL\"),\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n            return\n\n        if not api_key:\n            InfoBar.warning(\n                self.tr(\"配置不完整\"),\n                self.tr(\"请输入 Whisper API Key\"),\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n            return\n\n        if not model:\n            InfoBar.warning(\n                self.tr(\"配置不完整\"),\n                self.tr(\"请输入 Whisper 模型名称\"),\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n            return\n\n        # 禁用按钮，显示加载状态\n        self.checkWhisperConnectionCard.button.setEnabled(False)\n        self.checkWhisperConnectionCard.button.setText(self.tr(\"正在测试...\"))\n\n        # 立即恢复滚动位置（防止按钮状态改变导致的自动滚动）\n        self.verticalScrollBar().setValue(scroll_position)\n\n        # 创建并启动测试线程\n        self.whisper_connection_thread = WhisperConnectionThread(\n            base_url, api_key, model\n        )\n        self.whisper_connection_thread.finished.connect(\n            self.onWhisperConnectionCheckFinished\n        )\n        self.whisper_connection_thread.error.connect(self.onWhisperConnectionCheckError)\n        self.whisper_connection_thread.start()\n\n    def onWhisperConnectionCheckFinished(self, success, result):\n        \"\"\"处理 Whisper 连接检查完成事件\"\"\"\n        # 恢复按钮状态\n        self.checkWhisperConnectionCard.button.setEnabled(True)\n        self.checkWhisperConnectionCard.button.setText(self.tr(\"测试 Whisper 连接\"))\n\n        if success:\n            InfoBar.success(\n                self.tr(\"连接成功\"),\n                self.tr(\"Whisper API 连接成功！\\n转录结果:\") + result,\n                duration=INFOBAR_DURATION_SUCCESS,\n                parent=self,\n            )\n        else:\n            InfoBar.error(\n                self.tr(\"连接失败\"),\n                self.tr(f\"Whisper API 连接失败！\\n{result}\"),\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n\n    def onWhisperConnectionCheckError(self, message):\n        \"\"\"处理 Whisper 连接检查错误事件\"\"\"\n        # 恢复按钮状态\n        self.checkWhisperConnectionCard.button.setEnabled(True)\n        self.checkWhisperConnectionCard.button.setText(self.tr(\"测试 Whisper 连接\"))\n\n        InfoBar.error(\n            self.tr(\"测试错误\"),\n            message,\n            duration=INFOBAR_DURATION_ERROR,\n            parent=self,\n        )\n\n\nclass WhisperConnectionThread(QThread):\n    \"\"\"Whisper API 连接测试线程\"\"\"\n\n    finished = pyqtSignal(bool, str)\n    error = pyqtSignal(str)\n\n    def __init__(self, base_url, api_key, model):\n        super().__init__()\n        self.base_url = base_url\n        self.api_key = api_key\n        self.model = model\n\n    def run(self):\n        \"\"\"执行连接测试\"\"\"\n        try:\n            success, result = check_whisper_connection(\n                self.base_url, self.api_key, self.model\n            )\n            self.finished.emit(success, result)\n        except Exception as e:\n            self.error.emit(str(e))\n\n\nclass LLMConnectionThread(QThread):\n    finished = pyqtSignal(bool, str, list)\n    error = pyqtSignal(str)\n\n    def __init__(self, api_base, api_key, model):\n        super().__init__()\n        self.api_base = api_base\n        self.api_key = api_key\n        self.model = model\n\n    def run(self):\n        \"\"\"检查 LLM 连接并获取模型列表\"\"\"\n        try:\n            is_success, message = check_llm_connection(\n                self.api_base, self.api_key, self.model\n            )\n            models = get_available_models(self.api_base, self.api_key)\n            self.finished.emit(is_success, message, models)\n        except Exception as e:\n            self.error.emit(str(e))\n"
  },
  {
    "path": "app/view/subtitle_interface.py",
    "content": "# -*- coding: utf-8 -*-\nimport json\nimport os\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional, Union\n\nfrom PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, QTime, pyqtSignal\nfrom PyQt5.QtGui import QCloseEvent, QColor, QDragEnterEvent, QDropEvent, QKeyEvent\nfrom PyQt5.QtWidgets import (\n    QAbstractItemView,\n    QApplication,\n    QFileDialog,\n    QHBoxLayout,\n    QHeaderView,\n    QVBoxLayout,\n    QWidget,\n)\nfrom qfluentwidgets import (\n    Action,\n    BodyLabel,\n    CommandBar,\n    InfoBar,\n    InfoBarPosition,\n    MessageBoxBase,\n    PrimaryPushButton,\n    ProgressBar,\n    PushButton,\n    RoundMenu,\n    TableView,\n    TextEdit,\n    TransparentDropDownPushButton,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.common.signal_bus import signalBus\nfrom app.components.SubtitleSettingDialog import SubtitleSettingDialog\nfrom app.config import SUBTITLE_STYLE_PATH\nfrom app.core.asr.asr_data import ASRData\nfrom app.core.constant import (\n    INFOBAR_DURATION_ERROR,\n    INFOBAR_DURATION_INFO,\n    INFOBAR_DURATION_SUCCESS,\n    INFOBAR_DURATION_WARNING,\n)\nfrom app.core.entities import (\n    OutputSubtitleFormatEnum,\n    SubtitleLayoutEnum,\n    SubtitleTask,\n    SupportedSubtitleFormats,\n)\nfrom app.core.subtitle import get_subtitle_style\nfrom app.core.task_factory import TaskFactory\nfrom app.core.translate.types import TargetLanguage\nfrom app.core.utils.platform_utils import open_folder\nfrom app.thread.subtitle_thread import SubtitleThread\n\n\nclass SubtitleTableModel(QAbstractTableModel):\n    def __init__(self, data: Union[str, Dict[str, Any]] = \"\"):\n        super().__init__()\n        self._data: Dict[str, Any] = {}\n        if isinstance(data, str):\n            self.load_data(data)\n        else:\n            self._data = data\n\n    def load_data(self, data: str):\n        \"\"\"加载字幕数据\"\"\"\n        try:\n            self._data = json.loads(data)\n            self.layoutChanged.emit()\n        except json.JSONDecodeError:\n            pass\n\n    def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:  # type: ignore\n        if not index.isValid() or not self._data:\n            return None\n\n        row = index.row()\n        col = index.column()\n        segment = self._data.get(str(row + 1))\n\n        if not segment:\n            return None\n\n        if role == Qt.DisplayRole or role == Qt.EditRole:  # type: ignore\n            if col == 0:\n                return (\n                    QTime(0, 0)\n                    .addMSecs(segment[\"start_time\"])\n                    .toString(\"hh:mm:ss.zzz\")[:-2]\n                )\n            elif col == 1:\n                return (\n                    QTime(0, 0)\n                    .addMSecs(segment[\"end_time\"])\n                    .toString(\"hh:mm:ss.zzz\")[:-2]\n                )\n            elif col == 2:\n                return segment[\"original_subtitle\"]\n            elif col == 3:\n                return segment[\"translated_subtitle\"]\n        elif role == Qt.TextAlignmentRole:  # type: ignore\n            if col in [0, 1]:\n                return Qt.AlignCenter  # type: ignore\n        return None\n\n    def setData(self, index: QModelIndex, value: Any, role: int = Qt.EditRole) -> bool:  # type: ignore\n        if not index.isValid() or not self._data:\n            return False\n\n        if role == Qt.EditRole:  # type: ignore\n            row = index.row()\n            col = index.column()\n            segment = self._data.get(str(row + 1))\n\n            if not segment:\n                return False\n\n            if col == 2:\n                segment[\"original_subtitle\"] = value\n            elif col == 3:\n                segment[\"translated_subtitle\"] = value\n            else:\n                return False\n\n            self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole])  # type: ignore\n            return True\n        return False\n\n    def headerData(\n        self,\n        section: int,\n        orientation: Qt.Orientation,\n        role: int = Qt.DisplayRole,  # type: ignore\n    ) -> Any:  # type: ignore\n        if role == Qt.DisplayRole:  # type: ignore\n            if orientation == Qt.Horizontal:  # type: ignore\n                return [\n                    self.tr(\"开始时间\"),\n                    self.tr(\"结束时间\"),\n                    self.tr(\"字幕内容\"),\n                    (\n                        self.tr(\"翻译字幕\")\n                        if cfg.need_translate.value\n                        else self.tr(\"优化字幕\")\n                    ),\n                ][section]\n            elif orientation == Qt.Vertical:  # type: ignore\n                return str(section + 1)  # 显示行号\n        elif role == Qt.TextAlignmentRole:  # type: ignore\n            return Qt.AlignCenter  # type: ignore  # 居中对齐\n        return None\n\n    def rowCount(self, parent: Optional[QModelIndex] = None) -> int:\n        return len(self._data)\n\n    def columnCount(self, parent: Optional[QModelIndex] = None) -> int:\n        return 4\n\n    def flags(self, index: QModelIndex) -> Qt.ItemFlags:\n        if not index.isValid():\n            return Qt.NoItemFlags  # type: ignore\n        if index.column() in [2, 3]:\n            return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable  # type: ignore\n        return Qt.ItemIsEnabled | Qt.ItemIsSelectable  # type: ignore\n\n    def update_data(self, new_data: Dict[str, str]) -> None:\n        \"\"\"更新字幕数据\"\"\"\n        updated_rows = set()\n\n        # 更新内部数据\n        for key, value in new_data.items():\n            if key in self._data:\n                self._data[key][\"translated_subtitle\"] = value\n                row = list(self._data.keys()).index(key)\n                updated_rows.add(row)\n\n        # 如果有更新，发出dataChanged信号\n        if updated_rows:\n            min_row = min(updated_rows)\n            max_row = max(updated_rows)\n            top_left = self.index(min_row, 2)\n            bottom_right = self.index(max_row, 3)\n            self.dataChanged.emit(top_left, bottom_right, [Qt.DisplayRole, Qt.EditRole])  # type: ignore\n\n    def update_all(self, data: Dict[str, Any]) -> None:\n        \"\"\"更新所有数据\"\"\"\n        self._data = data\n        self.layoutChanged.emit()\n\n\nclass SubtitleInterface(QWidget):\n    finished = pyqtSignal(str, str)\n\n    def __init__(self, parent: Optional[QWidget] = None):\n        super().__init__(parent)\n        self.setAcceptDrops(True)\n        self.task: Optional[SubtitleTask] = None\n        self.subtitle_path: Optional[str] = None\n        self.custom_prompt_text: str = cfg.custom_prompt_text.value\n        self.setAttribute(Qt.WA_DeleteOnClose)  # type: ignore\n        self._init_ui()\n        self._setup_signals()\n        self._update_prompt_button_style()\n        self.set_values()\n\n    def _init_ui(self):\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setObjectName(\"main_layout\")\n        self.main_layout.setSpacing(20)\n\n        self._setup_top_layout()\n        self._setup_subtitle_table()\n        self._setup_bottom_layout()\n\n    def set_values(self):\n        self.layout_button.setText(\n            cfg.subtitle_layout.value.value\n        )  # Get enum's string value\n        self.translate_button.setChecked(cfg.need_translate.value)\n        self.optimize_button.setChecked(cfg.need_optimize.value)\n        self.target_language_button.setText(cfg.target_language.value.value)\n        self.target_language_button.setEnabled(cfg.need_translate.value)\n\n    def _setup_top_layout(self):\n        # 创建水平布局\n        top_layout = QHBoxLayout()\n\n        # 创建命令栏\n        self.command_bar = CommandBar(self)\n        self.command_bar.setToolButtonStyle(\n            Qt.ToolButtonTextBesideIcon  # type: ignore\n        )  # 设置图标和文字并排显示\n        top_layout.addWidget(self.command_bar, 1)  # 设置stretch为1，使其尽可能占用空间\n\n        # 创建保存按钮的下拉菜单\n        save_menu = RoundMenu(parent=self)\n        save_menu.view.setMaxVisibleItems(8)  # 设置菜单最大高度\n        for format in OutputSubtitleFormatEnum:\n            action = Action(text=format.value)\n            action.triggered.connect(\n                lambda checked, f=format.value: self.on_save_format_clicked(f)\n            )\n            save_menu.addAction(action)\n\n        # 添加保存按钮(带下拉菜单)\n        save_button = TransparentDropDownPushButton(self.tr(\"保存\"), self, FIF.SAVE)\n        save_button.setMenu(save_menu)\n        save_button.setFixedHeight(34)\n        self.command_bar.addWidget(save_button)\n\n        # 添加字幕排布下拉按钮\n        self.layout_button = TransparentDropDownPushButton(\n            self.tr(\"字幕排布\"), self, FIF.LAYOUT\n        )\n        self.layout_button.setFixedHeight(34)\n        self.layout_button.setMinimumWidth(125)\n        self.layout_menu = RoundMenu(parent=self)\n        for layout in [\"译文在上\", \"原文在上\", \"仅译文\", \"仅原文\"]:\n            action = Action(text=layout)\n            action.triggered.connect(\n                lambda checked, layout_value=layout: signalBus.subtitle_layout_changed.emit(\n                    layout_value\n                )\n            )\n            self.layout_menu.addAction(action)\n        self.layout_button.setMenu(self.layout_menu)\n        self.command_bar.addWidget(self.layout_button)\n\n        self.command_bar.addSeparator()\n\n        # 添加字幕优化按钮\n        self.optimize_button = Action(\n            FIF.EDIT,\n            self.tr(\"字幕校正\"),\n            triggered=self.on_subtitle_optimization_changed,\n            checkable=True,\n        )\n        self.command_bar.addAction(self.optimize_button)\n\n        # 添加字幕翻译按钮\n        self.translate_button = Action(\n            FIF.LANGUAGE,\n            self.tr(\"字幕翻译\"),\n            triggered=self.on_subtitle_translation_changed,\n            checkable=True,\n        )\n        self.command_bar.addAction(self.translate_button)\n\n        # 添加翻译语言选择\n        self.target_language_button = TransparentDropDownPushButton(\n            self.tr(\"翻译语言\"), self, FIF.LANGUAGE\n        )\n        self.target_language_button.setFixedHeight(34)\n        self.target_language_button.setMinimumWidth(125)\n        self.target_language_menu = RoundMenu(parent=self)\n        self.target_language_menu.setMaxVisibleItems(10)\n        for lang in TargetLanguage:\n            action = Action(text=lang.value)\n            action.triggered.connect(\n                lambda checked, lang_value=lang.value: signalBus.target_language_changed.emit(\n                    lang_value\n                )\n            )\n            self.target_language_menu.addAction(action)\n        self.target_language_button.setMenu(self.target_language_menu)\n\n        self.command_bar.addWidget(self.target_language_button)\n\n        self.command_bar.addSeparator()\n\n        # 添加文稿提示按钮\n        self.prompt_button = Action(\n            FIF.DOCUMENT, self.tr(\"Prompt\"), triggered=self.show_prompt_dialog\n        )\n        self.command_bar.addAction(self.prompt_button)\n\n        # 添加设置按钮\n        self.command_bar.addAction(\n            Action(FIF.SETTING, \"\", triggered=self.show_subtitle_settings)\n        )\n\n        # 添加视频播放按钮\n        # self.command_bar.addAction(Action(FIF.VIDEO, \"\", triggered=self.show_video_player))\n\n        # 添加打开文件夹按钮\n        self.command_bar.addAction(\n            Action(FIF.FOLDER, \"\", triggered=self.on_open_folder_clicked)\n        )\n\n        self.command_bar.addSeparator()\n\n        # 添加文件选择按钮\n        self.command_bar.addAction(\n            Action(FIF.FOLDER_ADD, \"\", triggered=self.on_file_select)\n        )\n\n        # 添加开始按钮到水平布局\n        self.start_button = PrimaryPushButton(self.tr(\"开始\"), self, icon=FIF.PLAY)\n        self.start_button.clicked.connect(\n            lambda: self.start_subtitle_optimization(need_create_task=True)\n        )\n        self.start_button.setFixedHeight(34)\n        top_layout.addWidget(self.start_button)\n\n        self.main_layout.addLayout(top_layout)\n\n    def _setup_subtitle_table(self):\n        self.subtitle_table = TableView(self)\n        self.model = SubtitleTableModel(\"\")\n        self.subtitle_table.setModel(self.model)\n        self.subtitle_table.setBorderVisible(True)\n        self.subtitle_table.setBorderRadius(8)\n        self.subtitle_table.setWordWrap(True)\n        self.subtitle_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)\n        self.subtitle_table.horizontalHeader().setSectionResizeMode(\n            0, QHeaderView.Fixed\n        )\n        self.subtitle_table.horizontalHeader().setSectionResizeMode(\n            1, QHeaderView.Fixed\n        )\n        self.subtitle_table.setColumnWidth(0, 120)\n        self.subtitle_table.setColumnWidth(1, 120)\n\n        # 配置垂直表头\n        self.subtitle_table.verticalHeader().setVisible(True)  # 显示垂直表头\n        self.subtitle_table.verticalHeader().setDefaultAlignment(\n            Qt.AlignCenter  # type: ignore\n        )  # 居中对齐\n        self.subtitle_table.verticalHeader().setDefaultSectionSize(50)  # 行高\n        self.subtitle_table.verticalHeader().setMinimumWidth(20)  # 设置最小宽度\n\n        self.subtitle_table.setEditTriggers(\n            QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed  # type: ignore\n        )\n        self.subtitle_table.clicked.connect(self.on_subtitle_clicked)\n        # 添加右键菜单支持\n        self.subtitle_table.setContextMenuPolicy(Qt.CustomContextMenu)  # type: ignore\n        self.subtitle_table.customContextMenuRequested.connect(self.show_context_menu)\n        self.main_layout.addWidget(self.subtitle_table)\n\n    def _setup_bottom_layout(self):\n        self.bottom_layout = QHBoxLayout()\n        self.progress_bar = ProgressBar(self)\n        self.status_label = BodyLabel(self.tr(\"请拖入字幕文件\"), self)\n        self.status_label.setMinimumWidth(100)\n        self.status_label.setAlignment(Qt.AlignCenter)  # type: ignore\n\n        # 添加取消按钮\n        self.cancel_button = PushButton(self.tr(\"取消\"), self, icon=FIF.CANCEL)\n        self.cancel_button.hide()  # 初始隐藏\n        self.cancel_button.clicked.connect(self.cancel_optimization)\n\n        self.bottom_layout.addWidget(self.progress_bar, 1)\n        self.bottom_layout.addWidget(self.status_label)\n        self.bottom_layout.addWidget(self.cancel_button)\n        self.main_layout.addLayout(self.bottom_layout)\n\n    def _setup_signals(self) -> None:\n        signalBus.subtitle_layout_changed.connect(self.on_subtitle_layout_changed)\n        signalBus.target_language_changed.connect(self.on_target_language_changed)\n        signalBus.subtitle_optimization_changed.connect(\n            self.on_subtitle_optimization_changed\n        )\n        signalBus.subtitle_translation_changed.connect(\n            self.on_subtitle_translation_changed\n        )\n        # self.subtitle_setting_button.clicked.connect(self.show_subtitle_settings)\n        # self.video_player_button.clicked.connect(self.show_video_player)\n\n    def show_prompt_dialog(self) -> None:\n        dialog = PromptDialog(self)\n        if dialog.exec_():\n            self.custom_prompt_text = cfg.custom_prompt_text.value\n            self._update_prompt_button_style()\n\n    def _update_prompt_button_style(self) -> None:\n        if self.custom_prompt_text.strip():\n            green_icon = FIF.DOCUMENT.colored(\n                QColor(76, 255, 165), QColor(76, 255, 165)\n            )\n            self.prompt_button.setIcon(green_icon)\n        else:\n            self.prompt_button.setIcon(FIF.DOCUMENT)\n\n    def set_task(self, task: SubtitleTask) -> None:\n        \"\"\"设置任务并更新UI\"\"\"\n        if hasattr(self, \"subtitle_optimization_thread\"):\n            self.subtitle_optimization_thread.stop()  # type: ignore\n        self.start_button.setEnabled(True)\n        self.task = task\n        self.subtitle_path = task.subtitle_path\n        self.update_info(task)\n\n    def update_info(self, task: SubtitleTask) -> None:\n        \"\"\"更新页面信息\"\"\"\n        if not self.task:\n            return\n        original_subtitle_save_path = Path(str(self.task.subtitle_path))\n        asr_data = ASRData.from_subtitle_file(str(original_subtitle_save_path))\n        self.model._data = asr_data.to_json()\n        self.model.layoutChanged.emit()\n        self.status_label.setText(self.tr(\"已加载文件\"))\n\n    def start_subtitle_optimization(self, need_create_task: bool = True) -> None:\n        # 检查是否有任务\n        if not self.subtitle_path:\n            InfoBar.warning(\n                self.tr(\"警告\"),\n                self.tr(\"请先加载字幕文件\"),\n                duration=INFOBAR_DURATION_WARNING,\n                parent=self,\n            )\n            return\n        self.start_button.setEnabled(False)\n        self.progress_bar.resume()\n        self.progress_bar.reset()\n        self.cancel_button.show()\n\n        if need_create_task:\n            self.task = TaskFactory.create_subtitle_task(file_path=self.subtitle_path)\n        if self.task:\n            self.subtitle_optimization_thread = SubtitleThread(self.task)\n        self.subtitle_optimization_thread.finished.connect(\n            self.on_subtitle_optimization_finished\n        )\n        self.subtitle_optimization_thread.progress.connect(\n            self.on_subtitle_optimization_progress\n        )\n        self.subtitle_optimization_thread.update.connect(self.update_data)\n        self.subtitle_optimization_thread.update_all.connect(self.update_all)\n        self.subtitle_optimization_thread.error.connect(\n            self.on_subtitle_optimization_error\n        )\n        self.subtitle_optimization_thread.set_custom_prompt_text(\n            self.custom_prompt_text\n        )\n        self.subtitle_optimization_thread.start()\n        InfoBar.info(\n            self.tr(\"开始优化\"),\n            self.tr(\"开始优化字幕\"),\n            duration=INFOBAR_DURATION_INFO,\n            parent=self,\n        )\n\n    def process(self) -> None:\n        \"\"\"主处理函数\"\"\"\n        # 检查是否有任务\n        self.start_subtitle_optimization(need_create_task=False)\n\n    def on_subtitle_optimization_finished(\n        self, video_path: str, output_path: str\n    ) -> None:\n        self.start_button.setEnabled(True)\n        self.cancel_button.hide()\n        self.progress_bar.setValue(100)\n        if self.task and self.task.need_next_task:\n            self.finished.emit(video_path, output_path)\n        InfoBar.success(\n            self.tr(\"优化完成\"),\n            self.tr(\"优化完成字幕...\"),\n            duration=INFOBAR_DURATION_SUCCESS,\n            position=InfoBarPosition.BOTTOM,\n            parent=self.parent(),\n        )\n\n    def on_subtitle_optimization_error(self, error: str) -> None:\n        self.start_button.setEnabled(True)\n        self.cancel_button.hide()  # 隐藏取消按钮\n        self.progress_bar.error()\n        InfoBar.error(\n            self.tr(\"优化失败\"),\n            self.tr(error),\n            duration=INFOBAR_DURATION_ERROR,\n            parent=self,\n        )\n\n    def on_subtitle_optimization_progress(self, value: int, status: str) -> None:\n        self.progress_bar.setValue(value)\n        self.status_label.setText(status)\n\n    def update_data(self, data):\n        self.model.update_data(data)\n\n    def update_all(self, data):\n        self.model.update_all(data)\n\n    def remove_widget(self) -> None:\n        \"\"\"隐藏顶部开始按钮和底部进度条\"\"\"\n        self.start_button.hide()\n        for i in range(self.bottom_layout.count()):\n            item = self.bottom_layout.itemAt(i)\n            if item:\n                widget = item.widget()\n                if widget:\n                    widget.hide()\n\n    def on_file_select(self) -> None:\n        # 构建文件过滤器\n        subtitle_formats = \" \".join(\n            f\"*.{fmt.value}\" for fmt in SupportedSubtitleFormats\n        )\n        filter_str = f\"{self.tr('字幕文件')} ({subtitle_formats})\"\n\n        file_path, _ = QFileDialog.getOpenFileName(\n            self, self.tr(\"选择字幕文件\"), \"\", filter_str\n        )\n        if file_path:\n            self.subtitle_path = file_path\n            self.load_subtitle_file(file_path)\n\n    def on_save_format_clicked(self, format: str) -> None:\n        \"\"\"处理保存格式的选择\"\"\"\n        if not self.subtitle_path:\n            InfoBar.warning(\n                self.tr(\"警告\"),\n                self.tr(\"请先加载字幕文件\"),\n                duration=INFOBAR_DURATION_WARNING,\n                parent=self,\n            )\n            return\n\n        # 获取保存路径\n        default_name = Path(self.subtitle_path).stem\n        file_path, _ = QFileDialog.getSaveFileName(\n            self,\n            self.tr(\"保存字幕文件\"),\n            default_name,  # 使用原文件名作为默认名\n            f\"{self.tr('字幕文件')} (*.{format})\",\n        )\n        if not file_path:\n            return\n\n        try:\n            # 转换并保存字幕\n            asr_data = ASRData.from_json(self.model._data)\n            layout = cfg.subtitle_layout.value\n\n            if file_path.endswith(\".ass\"):\n                style_str = get_subtitle_style(cfg.subtitle_style_name.value)\n                asr_data.to_ass(style_str, layout, file_path)\n            else:\n                asr_data.save(file_path, layout=layout)\n            InfoBar.success(\n                self.tr(\"保存成功\"),\n                self.tr(\"字幕已保存至:\") + file_path,\n                duration=INFOBAR_DURATION_SUCCESS,\n                parent=self,\n            )\n        except Exception as e:\n            InfoBar.error(\n                self.tr(\"保存失败\"),\n                self.tr(\"保存字幕文件失败: \") + str(e),\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n\n    def on_open_folder_clicked(self) -> None:\n        \"\"\"打开文件夹按钮点击事件\"\"\"\n        if not self.task:\n            InfoBar.warning(\n                self.tr(\"警告\"),\n                self.tr(\"请先加载字幕文件\"),\n                duration=INFOBAR_DURATION_WARNING,\n                parent=self,\n            )\n            return\n        if not self.task:\n            return\n        if self.task.output_path:\n            output_path = Path(self.task.output_path)\n            target_dir = str(\n                output_path.parent\n                if output_path.exists()\n                else Path(self.task.subtitle_path).parent\n            )\n        else:\n            target_dir = str(Path(self.task.subtitle_path).parent)\n        open_folder(target_dir)\n\n    def load_subtitle_file(self, file_path: str) -> None:\n        self.subtitle_path = file_path\n        asr_data = ASRData.from_subtitle_file(file_path)\n        self.model._data = asr_data.to_json()\n        self.model.layoutChanged.emit()\n        self.status_label.setText(self.tr(\"已加载文件\"))\n\n    def dragEnterEvent(self, event: QDragEnterEvent) -> None:\n        event.accept() if event.mimeData().hasUrls() else event.ignore()\n\n    def dropEvent(self, event: QDropEvent) -> None:\n        files = [u.toLocalFile() for u in event.mimeData().urls()]\n        for file_path in files:\n            if not os.path.isfile(file_path):\n                continue\n\n            file_ext = os.path.splitext(file_path)[1][1:].lower()\n\n            # 检查文件格式是否支持\n            supported_formats = {fmt.value for fmt in SupportedSubtitleFormats}\n            is_supported = file_ext in supported_formats\n\n            if is_supported:\n                self.load_subtitle_file(file_path)\n                InfoBar.success(\n                    self.tr(\"导入成功\"),\n                    self.tr(\"成功导入\") + os.path.basename(file_path),\n                    duration=INFOBAR_DURATION_SUCCESS,\n                    position=InfoBarPosition.BOTTOM,\n                    parent=self,\n                )\n                break\n            else:\n                InfoBar.error(\n                    self.tr(\"格式错误\") + file_ext,\n                    self.tr(\"支持的字幕格式:\") + str(supported_formats),\n                    duration=INFOBAR_DURATION_ERROR,\n                    parent=self,\n                )\n        event.accept()\n\n    def closeEvent(self, event: QCloseEvent) -> None:\n        if hasattr(self, \"subtitle_optimization_thread\"):\n            self.subtitle_optimization_thread.stop()  # type: ignore\n        super().closeEvent(event)\n\n    def show_subtitle_settings(self) -> None:\n        \"\"\"显示字幕设置对话框\"\"\"\n        dialog = SubtitleSettingDialog(self.window())\n        dialog.exec_()\n\n    def show_video_player(self) -> None:\n        \"\"\"显示视频播放器窗口\"\"\"\n        # 创建视频播放器窗口（延迟导入，因为vlc是可选依赖）\n        from app.components.MyVideoWidget import MyVideoWidget\n\n        self.video_player = MyVideoWidget()\n        self.video_player.resize(800, 600)\n\n        def signal_update() -> None:\n            if not self.model._data:\n                return\n            ass_style_name = cfg.subtitle_style_name.value\n            ass_style_path = SUBTITLE_STYLE_PATH / f\"{ass_style_name}.txt\"\n            if ass_style_path.exists():\n                subtitle_style_srt = ass_style_path.read_text(encoding=\"utf-8\")\n            else:\n                subtitle_style_srt = None\n            temp_srt_path = os.path.join(tempfile.gettempdir(), \"temp_subtitle.ass\")\n            asr_data = ASRData.from_json(self.model._data)\n            asr_data.save(\n                temp_srt_path,\n                layout=cfg.subtitle_layout.value,\n                ass_style=subtitle_style_srt or \"\",\n            )\n            signalBus.add_subtitle(temp_srt_path)\n\n        # 如果有字幕文件,则添加字幕\n        signal_update()\n\n        signalBus.subtitle_layout_changed.connect(signal_update)\n        self.model.dataChanged.connect(signal_update)\n        self.model.layoutChanged.connect(signal_update)\n\n        # 如果有关联的视频文件,则自动加载\n        # Note: SubtitleTask doesn't have file_path attribute\n        # if self.task and hasattr(self.task, \"file_path\") and self.task.file_path:\n        #     self.video_player.setVideo(QUrl.fromLocalFile(self.task.file_path))\n\n        self.video_player.show()\n        self.video_player.play()\n\n    def on_subtitle_clicked(self, index: QModelIndex) -> None:\n        row = index.row()\n        item = list(self.model._data.values())[row]\n        start_time = item[\"start_time\"]  # 毫秒\n        end_time = (\n            item[\"end_time\"] - 50\n            if item[\"end_time\"] - 50 > start_time\n            else item[\"end_time\"]\n        )\n        signalBus.play_video_segment(start_time, end_time)\n\n    def show_context_menu(self, pos) -> None:\n        \"\"\"显示右键菜单\"\"\"\n        menu = RoundMenu(parent=self)\n\n        # 获取选中的行\n        indexes = self.subtitle_table.selectedIndexes()\n        if not indexes:\n            return\n\n        # 获取唯一的行号\n        rows = sorted(set(index.row() for index in indexes))\n        if not rows:\n            return\n\n        # 添加菜单项\n        # retranslate_action = Action(FIF.SYNC, self.tr(\"重新翻译\"))\n        merge_action = Action(FIF.LINK, self.tr(\"合并\"))  # 添加快捷键提示\n        # menu.addAction(retranslate_action)\n        menu.addAction(merge_action)\n        merge_action.setShortcut(\"Ctrl+M\")  # 设置快捷键\n\n        # 设置动作状态\n        # retranslate_action.setEnabled(cfg.need_translate.value)\n        merge_action.setEnabled(len(rows) > 1)\n\n        # 连接动作信号\n        # retranslate_action.triggered.connect(lambda: self.retranslate_selected_rows(rows))\n        merge_action.triggered.connect(lambda: self.merge_selected_rows(rows))\n\n        # 显示菜单\n        menu.exec(self.subtitle_table.viewport().mapToGlobal(pos))\n\n    def merge_selected_rows(self, rows: List[int]) -> None:\n        \"\"\"合并选中的字幕行\"\"\"\n        if not rows or len(rows) < 2:\n            return\n\n        # 获取选中行的数据\n        data = self.model._data\n        data_list = list(data.values())\n\n        # 获取第一行和最后一行的时间戳\n        first_row = data_list[rows[0]]\n        last_row = data_list[rows[-1]]\n        start_time = first_row[\"start_time\"]\n        end_time = last_row[\"end_time\"]\n\n        # 合并字幕内容\n        original_subtitles = []\n        translated_subtitles = []\n        for row in rows:\n            item = data_list[row]\n            original_subtitles.append(item[\"original_subtitle\"])\n            translated_subtitles.append(item[\"translated_subtitle\"])\n\n        merged_original = \" \".join(original_subtitles)\n        merged_translated = \" \".join(translated_subtitles)\n\n        # 创建新的合并后的字幕项\n        merged_item = {\n            \"start_time\": start_time,\n            \"end_time\": end_time,\n            \"original_subtitle\": merged_original,\n            \"translated_subtitle\": merged_translated,\n        }\n\n        # 获取所有需要保留的键\n        keys = list(data.keys())\n        preserved_keys = keys[: rows[0]] + keys[rows[-1] + 1 :]\n\n        # 创建新的数据字典\n        new_data = {}\n        for i, key in enumerate(preserved_keys):\n            if i == rows[0]:\n                new_key = f\"{len(new_data) + 1}\"\n                new_data[new_key] = merged_item\n            new_key = f\"{len(new_data) + 1}\"\n            new_data[new_key] = data[key]\n\n        # 如果合并的是最后几行，需要确保合并项被添加\n        if rows[0] >= len(preserved_keys):\n            new_key = f\"{len(new_data) + 1}\"\n            new_data[new_key] = merged_item\n\n        # 更新模型数据\n        self.model.update_all(new_data)\n\n        # 显示成功提示\n        InfoBar.success(\n            self.tr(\"合并成功\"),\n            self.tr(\"已成功合并选中的字幕行\"),\n            duration=INFOBAR_DURATION_SUCCESS,\n            parent=self,\n        )\n\n    def keyPressEvent(self, event: QKeyEvent) -> None:\n        \"\"\"处理键盘事件\"\"\"\n        # 处理 Ctrl+M 快捷键\n        if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_M:  # type: ignore\n            indexes = self.subtitle_table.selectedIndexes()\n            if indexes:\n                rows = sorted(set(index.row() for index in indexes))\n                if len(rows) > 1:\n                    self.merge_selected_rows(rows)\n            event.accept()\n        else:\n            super().keyPressEvent(event)\n\n    def cancel_optimization(self) -> None:\n        \"\"\"取消字幕校正\"\"\"\n        if hasattr(self, \"subtitle_optimization_thread\"):\n            self.subtitle_optimization_thread.stop()  # type: ignore\n            self.start_button.setEnabled(True)\n            self.cancel_button.hide()\n            self.progress_bar.resume()  # 恢复正常状态\n            self.progress_bar.setValue(0)\n            self.status_label.setText(self.tr(\"已取消校正\"))\n            InfoBar.warning(\n                self.tr(\"已取消\"),\n                self.tr(\"字幕校正已取消\"),\n                duration=INFOBAR_DURATION_WARNING,\n                parent=self,\n            )\n\n    def on_target_language_changed(self, language: str) -> None:\n        \"\"\"处理翻译语言变更\"\"\"\n        for lang in TargetLanguage:\n            if lang.value == language:\n                self.target_language_button.setText(lang.value)\n                cfg.set(cfg.target_language, lang)\n                break\n\n    def on_subtitle_optimization_changed(self, checked: bool) -> None:\n        \"\"\"处理字幕优化开关变更\"\"\"\n        cfg.set(cfg.need_optimize, checked)\n        self.optimize_button.setChecked(checked)\n\n    def on_subtitle_translation_changed(self, checked: bool) -> None:\n        \"\"\"处理字幕翻译开关变更\"\"\"\n        cfg.set(cfg.need_translate, checked)\n        self.translate_button.setChecked(checked)\n        # 控制翻译语言选择按钮的启用状态\n        self.target_language_button.setEnabled(checked)\n\n    def on_subtitle_layout_changed(self, layout: str) -> None:\n        \"\"\"处理字幕排布变更\"\"\"\n        layout_enum = SubtitleLayoutEnum(layout)  # Convert string to enum\n        cfg.set(cfg.subtitle_layout, layout_enum)\n        self.layout_button.setText(layout)\n\n\nclass PromptDialog(MessageBoxBase):\n    def __init__(self, parent: Optional[QWidget] = None):\n        super().__init__(parent)\n        self.setup_ui()\n        self.setWindowTitle(self.tr(\"文稿提示\"))\n        # 连接按钮点击事件\n        self.yesButton.clicked.connect(self.save_prompt)\n\n    def setup_ui(self) -> None:\n        self.titleLabel = BodyLabel(self.tr(\"文稿提示\"), self)\n\n        # 添加文本编辑框\n        self.text_edit = TextEdit(self)\n        self.text_edit.setPlaceholderText(\n            self.tr(\n                \"请输入文稿提示（辅助校正字幕和翻译）\\n\\n\"\n                \"支持以下内容:\\n\"\n                \"1. 术语表 - 专业术语、人名、特定词语的修正对照表\\n\"\n                \"示例:\\n机器学习->Machine Learning\\n马斯克->Elon Musk\\n打call->应援\\n\\n\"\n                \"2. 原字幕文稿 - 视频的原有文稿或相关内容\\n\"\n                \"示例: 完整的演讲稿、课程讲义等\\n\\n\"\n                \"3. 修正要求 - 内容相关的具体修正要求\\n\"\n                \"示例: 统一人称代词、规范专业术语等\\n\\n\"\n                \"注意: 使用小型LLM模型时建议控制文稿在1千字内。对于不同字幕文件,请使用与该字幕相关的文稿提示。\"\n            )\n        )\n        self.text_edit.setText(cfg.custom_prompt_text.value)\n\n        self.text_edit.setMinimumWidth(420)\n        self.text_edit.setMinimumHeight(380)\n\n        # 添加到布局\n        self.viewLayout.addWidget(self.titleLabel)\n        self.viewLayout.addWidget(self.text_edit)\n        self.viewLayout.setSpacing(10)\n\n        # 设置按钮文本\n        self.yesButton.setText(self.tr(\"确定\"))\n        self.cancelButton.setText(self.tr(\"取消\"))\n\n    def get_prompt(self) -> str:\n        return self.text_edit.toPlainText()\n\n    def save_prompt(self) -> None:\n        # 在点击确定按钮时保存提示文本到配置\n        prompt_text = self.text_edit.toPlainText()\n        cfg.set(cfg.custom_prompt_text, prompt_text)\n\n\nif __name__ == \"__main__\":\n    QApplication.setHighDpiScaleFactorRoundingPolicy(\n        Qt.HighDpiScaleFactorRoundingPolicy.PassThrough  # type: ignore\n    )\n    QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)  # type: ignore\n    QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)  # type: ignore\n\n    app = QApplication(sys.argv)\n    window = SubtitleInterface()\n    window.show()\n    sys.exit(app.exec_())\n"
  },
  {
    "path": "app/view/subtitle_style_interface.py",
    "content": "import json\nfrom pathlib import Path\nfrom typing import Optional, Tuple\n\nfrom PIL import ImageFont\nfrom PyQt5.QtCore import Qt, QThread, pyqtSignal\nfrom PyQt5.QtGui import QColor, QFontDatabase\nfrom PyQt5.QtWidgets import QFileDialog, QHBoxLayout, QVBoxLayout, QWidget\nfrom qfluentwidgets import (\n    BodyLabel,\n    CardWidget,\n    ImageLabel,\n    InfoBar,\n    InfoBarPosition,\n    LineEdit,\n    MessageBoxBase,\n    PushSettingCard,\n    ScrollArea,\n    SettingCardGroup,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.common.signal_bus import signalBus\nfrom app.components.MySettingCard import (\n    ColorSettingCard,\n    ComboBoxSettingCard,\n    DoubleSpinBoxSettingCard,\n    SpinBoxSettingCard,\n)\nfrom app.config import ASSETS_PATH, SUBTITLE_STYLE_PATH\nfrom app.core.constant import INFOBAR_DURATION_SUCCESS, INFOBAR_DURATION_WARNING\nfrom app.core.entities import SubtitleLayoutEnum, SubtitleRenderModeEnum\nfrom app.core.subtitle import get_builtin_fonts, render_ass_preview, render_preview\nfrom app.core.subtitle.styles import RoundedBgStyle\nfrom app.core.utils.platform_utils import open_folder\n\nPERVIEW_TEXTS = {\n    \"长文本\": (\n        \"This is a long text for testing subtitle preview, text wrapping, and style settings.\",\n        \"这是一段用于测试字幕预览、自动换行以及样式设置的较长文本内容。\",\n    ),\n    \"中文本\": (\n        \"Welcome to apply for the prestigious South China Normal University!\",\n        \"欢迎报考百年名校华南师范大学\",\n    ),\n    \"短文本\": (\"Elementary school students know this\", \"小学二年级的都知道\"),\n}\n\nDEFAULT_BG_LANDSCAPE = {\n    \"path\": ASSETS_PATH / \"default_bg_landscape.png\",\n    \"width\": 1280,\n    \"height\": 720,\n}\nDEFAULT_BG_PORTRAIT = {\n    \"path\": ASSETS_PATH / \"default_bg_portrait.png\",\n    \"width\": 480,\n    \"height\": 852,\n}\n\n\nclass AssPreviewThread(QThread):\n    \"\"\"ASS 样式预览线程\"\"\"\n\n    previewReady = pyqtSignal(str)\n\n    def __init__(\n        self,\n        preview_text: Tuple[str, Optional[str]],\n        style_str: str,\n        bg_image_path: str,\n        width: Optional[int] = None,\n        height: Optional[int] = None,\n    ):\n        super().__init__()\n        self.preview_text = preview_text\n        self.width = width\n        self.height = height\n        self.style_str = style_str\n        self.bg_image_path = bg_image_path\n\n    def run(self):\n        preview_path = render_ass_preview(\n            style_str=self.style_str,\n            preview_text=self.preview_text,\n            bg_image_path=self.bg_image_path,\n            width=self.width,\n            height=self.height,\n        )\n        self.previewReady.emit(preview_path)\n\n\nclass RoundedBgPreviewThread(QThread):\n    \"\"\"圆角背景预览线程\"\"\"\n\n    previewReady = pyqtSignal(str)\n\n    def __init__(\n        self,\n        style: RoundedBgStyle,\n        preview_text: Tuple[str, Optional[str]],\n        width: Optional[int] = None,\n        height: Optional[int] = None,\n        bg_image_path: Optional[str] = None,\n    ):\n        super().__init__()\n        self.primary_text = preview_text[0]\n        self.secondary_text = preview_text[1] or \"\"\n        self.width = width\n        self.height = height\n        self.style = style\n        self.bg_image_path = bg_image_path\n\n    def run(self):\n        preview_path = render_preview(\n            primary_text=self.primary_text,\n            secondary_text=self.secondary_text,\n            width=self.width,\n            height=self.height,\n            style=self.style,\n            bg_image_path=self.bg_image_path,\n        )\n        self.previewReady.emit(preview_path)\n\n\nclass SubtitleStyleInterface(QWidget):\n    def __init__(self, parent=None):\n        super().__init__(parent=parent)\n        self.setObjectName(\"SubtitleStyleInterface\")\n        self.setWindowTitle(self.tr(\"字幕样式配置\"))\n        self.setAcceptDrops(True)  # 启用拖放功能\n\n        # 创建主布局\n        self.hBoxLayout = QHBoxLayout(self)\n\n        # 初始化界面组件\n        self._initSettingsArea()\n        self._initPreviewArea()\n        self._initSettingCards()\n        self._initLayout()\n        self._initStyle()\n\n        # 控制是否触发样式变更回调（加载样式时禁用）\n        self._loading_style = False\n\n        # 设置初始值,加载样式\n        self.__setValues()\n\n        # 连接信号\n        self.connectSignals()\n\n    def _initSettingsArea(self):\n        \"\"\"初始化左侧设置区域\"\"\"\n        self.settingsScrollArea = ScrollArea()\n        self.settingsScrollArea.setFixedWidth(350)\n        self.settingsWidget = QWidget()\n        self.settingsLayout = QVBoxLayout(self.settingsWidget)\n        self.settingsScrollArea.setWidget(self.settingsWidget)\n        self.settingsScrollArea.setWidgetResizable(True)\n\n        # 创建设置组 - 通用\n        self.layoutGroup = SettingCardGroup(self.tr(\"字幕排布\"), self.settingsWidget)\n\n        # ASS 样式设置组\n        self.assPrimaryGroup = SettingCardGroup(\n            self.tr(\"主字幕样式\"), self.settingsWidget\n        )\n        self.assSecondaryGroup = SettingCardGroup(\n            self.tr(\"副字幕样式\"), self.settingsWidget\n        )\n\n        # 圆角背景设置组\n        self.roundedBgGroup = SettingCardGroup(\n            self.tr(\"圆角背景样式\"), self.settingsWidget\n        )\n\n        # 预览设置组\n        self.previewGroup = SettingCardGroup(self.tr(\"预览设置\"), self.settingsWidget)\n\n    def _initPreviewArea(self):\n        \"\"\"初始化右侧预览区域\"\"\"\n        self.previewCard = CardWidget()\n        self.previewLayout = QVBoxLayout(self.previewCard)\n        self.previewLayout.setSpacing(16)\n\n        # 顶部预览区域\n        self.previewTopWidget = QWidget()\n        self.previewTopWidget.setFixedHeight(430)\n        self.previewTopLayout = QVBoxLayout(self.previewTopWidget)\n\n        self.previewLabel = BodyLabel(self.tr(\"预览效果\"))\n        self.previewImage = ImageLabel()\n        self.previewImage.setAlignment(Qt.AlignCenter)  # type: ignore\n        self.previewTopLayout.addWidget(self.previewImage, 0, Qt.AlignCenter)  # type: ignore\n        self.previewTopLayout.setAlignment(Qt.AlignVCenter)  # type: ignore\n\n        # 底部控件区域\n        self.previewBottomWidget = QWidget()\n        self.previewBottomLayout = QVBoxLayout(self.previewBottomWidget)\n\n        self.styleNameComboBox = ComboBoxSettingCard(\n            FIF.VIEW,  # type: ignore\n            self.tr(\"选择样式\"),\n            self.tr(\"选择已保存的字幕样式\"),\n            texts=[],  # type: ignore\n        )\n\n        self.newStyleButton = PushSettingCard(\n            self.tr(\"新建样式\"),\n            FIF.ADD,\n            self.tr(\"新建样式\"),\n            self.tr(\"基于当前样式新建预设\"),\n        )\n\n        self.openStyleFolderButton = PushSettingCard(\n            self.tr(\"打开样式文件夹\"),\n            FIF.FOLDER,\n            self.tr(\"打开样式文件夹\"),\n            self.tr(\"在文件管理器中打开样式文件夹\"),\n        )\n\n        self.previewBottomLayout.addWidget(self.styleNameComboBox)\n        self.previewBottomLayout.addWidget(self.newStyleButton)\n        self.previewBottomLayout.addWidget(self.openStyleFolderButton)\n\n        self.previewLayout.addWidget(self.previewTopWidget)\n        self.previewLayout.addWidget(self.previewBottomWidget)\n        self.previewLayout.addStretch(1)\n\n    def _initSettingCards(self):\n        \"\"\"初始化所有设置卡片\"\"\"\n        # 渲染模式切换\n        self.renderModeCard = ComboBoxSettingCard(\n            FIF.BRUSH,  # type: ignore\n            self.tr(\"渲染模式\"),\n            self.tr(\"选择字幕渲染方式\"),\n            texts=[e.value for e in SubtitleRenderModeEnum],\n        )\n\n        # 字幕排布设置\n        self.layoutCard = ComboBoxSettingCard(\n            FIF.ALIGNMENT,  # type: ignore\n            self.tr(\"字幕排布\"),\n            self.tr(\"设置主字幕和副字幕的显示方式\"),\n            texts=[\"译文在上\", \"原文在上\", \"仅译文\", \"仅原文\"],\n        )\n\n        # ASS 模式 - 垂直间距\n        self.assVerticalSpacingCard = SpinBoxSettingCard(\n            FIF.ALIGNMENT,  # type: ignore\n            self.tr(\"垂直间距\"),\n            self.tr(\"设置字幕的垂直间距\"),\n            minimum=8,\n            maximum=10000,\n        )\n\n        # ASS 模式 - 主字幕样式\n        self.assPrimaryFontCard = ComboBoxSettingCard(\n            FIF.FONT,  # type: ignore\n            self.tr(\"主字幕字体\"),\n            self.tr(\"设置主字幕的字体\"),\n        )\n\n        self.assPrimarySizeCard = SpinBoxSettingCard(\n            FIF.FONT_SIZE,  # type: ignore\n            self.tr(\"主字幕字号\"),\n            self.tr(\"设置主字幕的大小\"),\n            minimum=8,\n            maximum=1000,\n        )\n\n        self.assPrimarySpacingCard = DoubleSpinBoxSettingCard(\n            FIF.ALIGNMENT,  # type: ignore\n            self.tr(\"主字幕间距\"),\n            self.tr(\"设置主字幕的字符间距\"),\n            minimum=0.0,\n            maximum=10.0,\n            decimals=1,\n        )\n\n        self.assPrimaryColorCard = ColorSettingCard(\n            QColor(255, 255, 255),\n            FIF.PALETTE,  # type: ignore\n            self.tr(\"主字幕颜色\"),\n            self.tr(\"设置主字幕的颜色\"),\n        )\n\n        self.assPrimaryOutlineColorCard = ColorSettingCard(\n            QColor(0, 0, 0),\n            FIF.PALETTE,  # type: ignore\n            self.tr(\"主字幕边框颜色\"),\n            self.tr(\"设置主字幕的边框颜色\"),\n        )\n\n        self.assPrimaryOutlineSizeCard = DoubleSpinBoxSettingCard(\n            FIF.ZOOM,  # type: ignore\n            self.tr(\"主字幕边框大小\"),\n            self.tr(\"设置主字幕的边框粗细\"),\n            minimum=0.0,\n            maximum=10.0,\n            decimals=1,\n        )\n\n        # ASS 模式 - 副字幕样式\n        self.assSecondaryFontCard = ComboBoxSettingCard(\n            FIF.FONT,  # type: ignore\n            self.tr(\"副字幕字体\"),\n            self.tr(\"设置副字幕的字体\"),\n        )\n\n        self.assSecondarySizeCard = SpinBoxSettingCard(\n            FIF.FONT_SIZE,  # type: ignore\n            self.tr(\"副字幕字号\"),\n            self.tr(\"设置副字幕的大小\"),\n            minimum=8,\n            maximum=1000,\n        )\n\n        self.assSecondarySpacingCard = DoubleSpinBoxSettingCard(\n            FIF.ALIGNMENT,  # type: ignore\n            self.tr(\"副字幕间距\"),\n            self.tr(\"设置副字幕的字符间距\"),\n            minimum=0.0,\n            maximum=50.0,\n            decimals=1,\n        )\n\n        self.assSecondaryColorCard = ColorSettingCard(\n            QColor(255, 255, 255),\n            FIF.PALETTE,  # type: ignore\n            self.tr(\"副字幕颜色\"),\n            self.tr(\"设置副字幕的颜色\"),\n        )\n\n        self.assSecondaryOutlineColorCard = ColorSettingCard(\n            QColor(0, 0, 0),\n            FIF.PALETTE,  # type: ignore\n            self.tr(\"副字幕边框颜色\"),\n            self.tr(\"设置副字幕的边框颜色\"),\n        )\n\n        self.assSecondaryOutlineSizeCard = DoubleSpinBoxSettingCard(\n            FIF.ZOOM,  # type: ignore\n            self.tr(\"副字幕边框大小\"),\n            self.tr(\"设置副字幕的边框粗细\"),\n            minimum=0.0,\n            maximum=50.0,\n            decimals=1,\n        )\n\n        # 圆角背景样式设置\n        self.roundedFontCard = ComboBoxSettingCard(\n            FIF.FONT,  # type: ignore\n            self.tr(\"字体\"),\n            self.tr(\"设置字幕字体\"),\n        )\n\n        self.roundedFontSizeCard = SpinBoxSettingCard(\n            FIF.FONT_SIZE,  # type: ignore\n            self.tr(\"字体大小\"),\n            self.tr(\"设置字幕字体大小\"),\n            minimum=16,\n            maximum=120,\n        )\n\n        self.roundedTextColorCard = ColorSettingCard(\n            QColor(255, 255, 255),\n            FIF.PALETTE,  # type: ignore\n            self.tr(\"文字颜色\"),\n            self.tr(\"设置字幕文字颜色\"),\n        )\n\n        self.roundedBgColorCard = ColorSettingCard(\n            QColor(25, 25, 25, 200),\n            FIF.PALETTE,  # type: ignore\n            self.tr(\"背景颜色\"),\n            self.tr(\"设置圆角矩形背景颜色\"),\n            enableAlpha=True,\n        )\n\n        self.roundedCornerRadiusCard = SpinBoxSettingCard(\n            FIF.ZOOM,  # type: ignore\n            self.tr(\"圆角半径\"),\n            self.tr(\"设置背景圆角大小\"),\n            minimum=0,\n            maximum=50,\n        )\n\n        self.roundedPaddingHCard = SpinBoxSettingCard(\n            FIF.ALIGNMENT,  # type: ignore\n            self.tr(\"水平内边距\"),\n            self.tr(\"文字与背景边缘的水平距离\"),\n            minimum=4,\n            maximum=100,\n        )\n\n        self.roundedPaddingVCard = SpinBoxSettingCard(\n            FIF.ALIGNMENT,  # type: ignore\n            self.tr(\"垂直内边距\"),\n            self.tr(\"文字与背景边缘的垂直距离\"),\n            minimum=4,\n            maximum=50,\n        )\n\n        self.roundedMarginBottomCard = SpinBoxSettingCard(\n            FIF.ALIGNMENT,  # type: ignore\n            self.tr(\"底部边距\"),\n            self.tr(\"字幕距视频底部的距离\"),\n            minimum=20,\n            maximum=300,\n        )\n\n        self.roundedLineSpacingCard = SpinBoxSettingCard(\n            FIF.ALIGNMENT,  # type: ignore\n            self.tr(\"行间距\"),\n            self.tr(\"双语字幕的行间距\"),\n            minimum=0,\n            maximum=50,\n        )\n\n        self.roundedLetterSpacingCard = SpinBoxSettingCard(\n            FIF.FONT,  # type: ignore\n            self.tr(\"字符间距\"),\n            self.tr(\"每个字符之间的额外间距\"),\n            minimum=0,\n            maximum=20,\n            step=1,\n        )\n\n        # 预览设置\n        self.previewTextCard = ComboBoxSettingCard(\n            FIF.MESSAGE,  # type: ignore\n            self.tr(\"预览文字\"),\n            self.tr(\"设置预览显示的文字内容\"),\n            texts=list(PERVIEW_TEXTS.keys()),\n            parent=self.previewGroup,\n        )\n\n        self.orientationCard = ComboBoxSettingCard(\n            FIF.LAYOUT,  # type: ignore\n            self.tr(\"预览方向\"),\n            self.tr(\"设置预览图片的显示方向\"),\n            texts=[\"横屏\", \"竖屏\"],\n            parent=self.previewGroup,\n        )\n\n        self.previewImageCard = PushSettingCard(\n            self.tr(\"选择图片\"),\n            FIF.PHOTO,\n            self.tr(\"预览背景\"),\n            self.tr(\"选择预览使用的背景图片\"),\n            parent=self.previewGroup,\n        )\n\n    def _initLayout(self):\n        \"\"\"初始化布局\"\"\"\n        # 通用设置\n        self.layoutGroup.addSettingCard(self.renderModeCard)\n        self.layoutGroup.addSettingCard(self.layoutCard)\n        self.layoutGroup.addSettingCard(self.assVerticalSpacingCard)\n\n        # ASS 样式卡片\n        self.assPrimaryGroup.addSettingCard(self.assPrimaryFontCard)\n        self.assPrimaryGroup.addSettingCard(self.assPrimarySizeCard)\n        self.assPrimaryGroup.addSettingCard(self.assPrimarySpacingCard)\n        self.assPrimaryGroup.addSettingCard(self.assPrimaryColorCard)\n        self.assPrimaryGroup.addSettingCard(self.assPrimaryOutlineColorCard)\n        self.assPrimaryGroup.addSettingCard(self.assPrimaryOutlineSizeCard)\n\n        self.assSecondaryGroup.addSettingCard(self.assSecondaryFontCard)\n        self.assSecondaryGroup.addSettingCard(self.assSecondarySizeCard)\n        self.assSecondaryGroup.addSettingCard(self.assSecondarySpacingCard)\n        self.assSecondaryGroup.addSettingCard(self.assSecondaryColorCard)\n        self.assSecondaryGroup.addSettingCard(self.assSecondaryOutlineColorCard)\n        self.assSecondaryGroup.addSettingCard(self.assSecondaryOutlineSizeCard)\n\n        # 圆角背景卡片\n        self.roundedBgGroup.addSettingCard(self.roundedFontCard)\n        self.roundedBgGroup.addSettingCard(self.roundedFontSizeCard)\n        self.roundedBgGroup.addSettingCard(self.roundedTextColorCard)\n        self.roundedBgGroup.addSettingCard(self.roundedBgColorCard)\n        self.roundedBgGroup.addSettingCard(self.roundedCornerRadiusCard)\n        self.roundedBgGroup.addSettingCard(self.roundedPaddingHCard)\n        self.roundedBgGroup.addSettingCard(self.roundedPaddingVCard)\n        self.roundedBgGroup.addSettingCard(self.roundedMarginBottomCard)\n        self.roundedBgGroup.addSettingCard(self.roundedLineSpacingCard)\n        self.roundedBgGroup.addSettingCard(self.roundedLetterSpacingCard)\n\n        # 预览设置\n        self.previewGroup.addSettingCard(self.previewTextCard)\n        self.previewGroup.addSettingCard(self.orientationCard)\n        self.previewGroup.addSettingCard(self.previewImageCard)\n\n        # 添加组到布局\n        self.settingsLayout.addWidget(self.layoutGroup)\n        self.settingsLayout.addWidget(self.assPrimaryGroup)\n        self.settingsLayout.addWidget(self.assSecondaryGroup)\n        self.settingsLayout.addWidget(self.roundedBgGroup)\n        self.settingsLayout.addWidget(self.previewGroup)\n        self.settingsLayout.addStretch(1)\n\n        # 添加左右两侧到主布局\n        self.hBoxLayout.addWidget(self.settingsScrollArea)\n        self.hBoxLayout.addWidget(self.previewCard)\n\n    def _initStyle(self):\n        \"\"\"初始化样式\"\"\"\n        self.settingsWidget.setObjectName(\"settingsWidget\")\n        self.setStyleSheet(\n            \"\"\"\n            SubtitleStyleInterface, #settingsWidget {\n                background-color: transparent;\n            }\n            QScrollArea {\n                border: none;\n                background-color: transparent;\n            }\n        \"\"\"\n        )\n\n    def __setValues(self):\n        \"\"\"设置初始值\"\"\"\n        # 设置渲染模式\n        self.renderModeCard.comboBox.setCurrentText(\n            cfg.subtitle_render_mode.value.value\n        )\n\n        # 设置字幕排布\n        self.layoutCard.comboBox.setCurrentText(cfg.subtitle_layout.value.value)\n\n        # 设置字幕样式\n        self.styleNameComboBox.comboBox.setCurrentText(cfg.get(cfg.subtitle_style_name))\n\n        # 获取字体列表（内置字体 + 系统字体）\n        builtin_fonts = get_builtin_fonts()\n        builtin_font_names = [f[\"name\"] for f in builtin_fonts]\n\n        fontDatabase = QFontDatabase()\n        fontFamilies = fontDatabase.families()\n\n        # 过滤系统字体：\n        # 1. 排除私有字体（以 . 开头）\n        # 2. 排除已有的内置字体\n        # 3. 只保留 PIL 能实际加载的字体（用于圆角背景渲染）\n        system_fonts = []\n        for font_name in fontFamilies:\n            if font_name.startswith(\".\") or font_name in builtin_font_names:\n                continue\n            # 测试 PIL 是否能加载此字体\n            try:\n                ImageFont.truetype(font_name, 12)  # 测试用小尺寸\n                system_fonts.append(font_name)\n            except (OSError, IOError):\n                # PIL 无法加载，跳过此字体\n                pass\n\n        # 合并字体列表：内置字体在最前面\n        all_fonts = builtin_font_names + sorted(system_fonts)\n\n        # ASS 模式字体\n        self.assPrimaryFontCard.addItems(all_fonts)\n        self.assSecondaryFontCard.addItems(all_fonts)\n        self.assPrimaryFontCard.comboBox.setMaxVisibleItems(12)\n        self.assSecondaryFontCard.comboBox.setMaxVisibleItems(12)\n\n        # 圆角背景模式字体\n        self.roundedFontCard.addItems(all_fonts)\n        self.roundedFontCard.comboBox.setMaxVisibleItems(12)\n\n        # 设置圆角背景模式的初始值\n        self.roundedFontSizeCard.spinBox.setValue(cfg.get(cfg.rounded_bg_font_size))\n        self.roundedCornerRadiusCard.spinBox.setValue(\n            cfg.get(cfg.rounded_bg_corner_radius)\n        )\n        self.roundedPaddingHCard.spinBox.setValue(cfg.get(cfg.rounded_bg_padding_h))\n        self.roundedPaddingVCard.spinBox.setValue(cfg.get(cfg.rounded_bg_padding_v))\n        self.roundedMarginBottomCard.spinBox.setValue(\n            cfg.get(cfg.rounded_bg_margin_bottom)\n        )\n        self.roundedLineSpacingCard.spinBox.setValue(\n            cfg.get(cfg.rounded_bg_line_spacing)\n        )\n        self.roundedLetterSpacingCard.spinBox.setValue(\n            cfg.get(cfg.rounded_bg_letter_spacing)\n        )\n\n        # 设置颜色\n        text_color = cfg.get(cfg.rounded_bg_text_color)\n        self.roundedTextColorCard.setColor(QColor(text_color))\n        bg_color = cfg.get(cfg.rounded_bg_color)\n        self.roundedBgColorCard.setColor(self._parseRgbaHex(bg_color))\n\n        # 加载样式列表（根据当前模式）\n        self._refreshStyleList()\n\n        # 根据当前渲染模式显示/隐藏设置组\n        self._updateVisibleGroups()\n\n    def connectSignals(self):\n        \"\"\"连接所有设置变更的信号到预览更新函数\"\"\"\n        # 渲染模式切换\n        self.renderModeCard.currentTextChanged.connect(self.onRenderModeChanged)\n\n        # 字幕排布（通用设置）\n        self.layoutCard.currentTextChanged.connect(self.updatePreview)\n        self.layoutCard.currentTextChanged.connect(\n            lambda: cfg.set(\n                cfg.subtitle_layout,\n                SubtitleLayoutEnum(self.layoutCard.comboBox.currentText()),\n            )\n        )\n        # ASS 模式 - 垂直间距\n        self.assVerticalSpacingCard.spinBox.valueChanged.connect(\n            self.onAssSettingChanged\n        )\n\n        # ASS 模式 - 主字幕样式\n        self.assPrimaryFontCard.currentTextChanged.connect(self.onAssSettingChanged)\n        self.assPrimarySizeCard.spinBox.valueChanged.connect(self.onAssSettingChanged)\n        self.assPrimarySpacingCard.spinBox.valueChanged.connect(\n            self.onAssSettingChanged\n        )\n        self.assPrimaryColorCard.colorChanged.connect(self.onAssSettingChanged)\n        self.assPrimaryOutlineColorCard.colorChanged.connect(self.onAssSettingChanged)\n        self.assPrimaryOutlineSizeCard.spinBox.valueChanged.connect(\n            self.onAssSettingChanged\n        )\n\n        # ASS 模式 - 副字幕样式\n        self.assSecondaryFontCard.currentTextChanged.connect(self.onAssSettingChanged)\n        self.assSecondarySizeCard.spinBox.valueChanged.connect(self.onAssSettingChanged)\n        self.assSecondarySpacingCard.spinBox.valueChanged.connect(\n            self.onAssSettingChanged\n        )\n        self.assSecondaryColorCard.colorChanged.connect(self.onAssSettingChanged)\n        self.assSecondaryOutlineColorCard.colorChanged.connect(self.onAssSettingChanged)\n        self.assSecondaryOutlineSizeCard.spinBox.valueChanged.connect(\n            self.onAssSettingChanged\n        )\n\n        # 圆角背景样式信号\n        self.roundedFontCard.currentTextChanged.connect(self.onRoundedBgSettingChanged)\n        self.roundedFontSizeCard.spinBox.valueChanged.connect(\n            self.onRoundedBgSettingChanged\n        )\n        self.roundedTextColorCard.colorChanged.connect(self.onRoundedBgSettingChanged)\n        self.roundedBgColorCard.colorChanged.connect(self.onRoundedBgSettingChanged)\n        self.roundedCornerRadiusCard.spinBox.valueChanged.connect(\n            self.onRoundedBgSettingChanged\n        )\n        self.roundedPaddingHCard.spinBox.valueChanged.connect(\n            self.onRoundedBgSettingChanged\n        )\n        self.roundedPaddingVCard.spinBox.valueChanged.connect(\n            self.onRoundedBgSettingChanged\n        )\n        self.roundedMarginBottomCard.spinBox.valueChanged.connect(\n            self.onRoundedBgSettingChanged\n        )\n        self.roundedLineSpacingCard.spinBox.valueChanged.connect(\n            self.onRoundedBgSettingChanged\n        )\n        self.roundedLetterSpacingCard.spinBox.valueChanged.connect(\n            self.onRoundedBgSettingChanged\n        )\n\n        # 预览设置（通用设置）\n        self.previewTextCard.currentTextChanged.connect(self.updatePreview)\n        self.orientationCard.currentTextChanged.connect(self.onOrientationChanged)\n        self.previewImageCard.clicked.connect(self.selectPreviewImage)\n\n        # 连接样式切换信号\n        self.styleNameComboBox.currentTextChanged.connect(self.loadStyle)\n        self.newStyleButton.clicked.connect(self.createNewStyle)\n        self.openStyleFolderButton.clicked.connect(self.on_open_style_folder_clicked)\n\n        # 连接字幕排布信号\n        self.layoutCard.comboBox.currentTextChanged.connect(\n            signalBus.subtitle_layout_changed\n        )\n        signalBus.subtitle_layout_changed.connect(self.on_subtitle_layout_changed)\n\n        # 连接渲染模式信号（从视频合成界面同步）\n        signalBus.subtitle_render_mode_changed.connect(self.on_render_mode_changed_external)\n\n    def on_open_style_folder_clicked(self):\n        \"\"\"打开样式文件夹\"\"\"\n        open_folder(str(SUBTITLE_STYLE_PATH))\n\n    def on_subtitle_layout_changed(self, layout: str):\n        layout_enum = SubtitleLayoutEnum(layout)\n        cfg.subtitle_layout.value = layout_enum\n        self.layoutCard.setCurrentText(layout)\n\n    def on_render_mode_changed_external(self, mode_text: str):\n        \"\"\"处理外部渲染模式变更（从视频合成界面同步）\"\"\"\n        # 避免信号循环：阻断信号后再更新\n        self.renderModeCard.comboBox.blockSignals(True)\n        self.renderModeCard.comboBox.setCurrentText(mode_text)\n        self.renderModeCard.comboBox.blockSignals(False)\n        # 手动触发 UI 更新\n        self._updateVisibleGroups()\n        self._refreshStyleList()\n        self.updatePreview()\n\n    def onRenderModeChanged(self):\n        \"\"\"渲染模式切换（本界面触发）\"\"\"\n        mode_text = self.renderModeCard.comboBox.currentText()\n        mode = SubtitleRenderModeEnum(mode_text)\n        cfg.set(cfg.subtitle_render_mode, mode)\n        # 断开自身监听，避免信号回传导致重复执行\n        signalBus.subtitle_render_mode_changed.disconnect(self.on_render_mode_changed_external)\n        signalBus.subtitle_render_mode_changed.emit(mode_text)\n        signalBus.subtitle_render_mode_changed.connect(self.on_render_mode_changed_external)\n        self._updateVisibleGroups()\n        self._refreshStyleList()\n        self.updatePreview()\n\n    def onRoundedBgSettingChanged(self):\n        \"\"\"圆角背景设置变更\"\"\"\n        if self._loading_style:\n            return\n\n        # 保存圆角背景配置\n        cfg.set(cfg.rounded_bg_font_name, self.roundedFontCard.comboBox.currentText())\n        cfg.set(cfg.rounded_bg_font_size, self.roundedFontSizeCard.spinBox.value())\n        cfg.set(\n            cfg.rounded_bg_corner_radius, self.roundedCornerRadiusCard.spinBox.value()\n        )\n        cfg.set(cfg.rounded_bg_padding_h, self.roundedPaddingHCard.spinBox.value())\n        cfg.set(cfg.rounded_bg_padding_v, self.roundedPaddingVCard.spinBox.value())\n        cfg.set(\n            cfg.rounded_bg_margin_bottom, self.roundedMarginBottomCard.spinBox.value()\n        )\n        cfg.set(\n            cfg.rounded_bg_line_spacing, self.roundedLineSpacingCard.spinBox.value()\n        )\n        cfg.set(\n            cfg.rounded_bg_letter_spacing, self.roundedLetterSpacingCard.spinBox.value()\n        )\n\n        # 保存颜色\n        text_color = self.roundedTextColorCard.colorPicker.color.name()\n        cfg.set(cfg.rounded_bg_text_color, text_color)\n        bg_color = self.roundedBgColorCard.colorPicker.color\n        bg_color_hex = f\"#{bg_color.red():02x}{bg_color.green():02x}{bg_color.blue():02x}{bg_color.alpha():02x}\"\n        cfg.set(cfg.rounded_bg_color, bg_color_hex)\n\n        # 自动保存当前样式\n        current_style = self.styleNameComboBox.comboBox.currentText()\n        if current_style:\n            self.saveStyle(current_style)\n\n        self.updatePreview()\n\n    def _updateVisibleGroups(self):\n        \"\"\"根据渲染模式显示/隐藏设置组\"\"\"\n        mode_text = self.renderModeCard.comboBox.currentText()\n        is_ass_mode = mode_text == SubtitleRenderModeEnum.ASS_STYLE.value\n\n        # ASS 样式设置组\n        self.assVerticalSpacingCard.setVisible(is_ass_mode)\n        self.assPrimaryGroup.setVisible(is_ass_mode)\n        self.assSecondaryGroup.setVisible(is_ass_mode)\n\n        # 圆角背景设置组\n        self.roundedBgGroup.setVisible(not is_ass_mode)\n\n    def _getStyleFileExtension(self) -> str:\n        \"\"\"获取当前模式的样式文件扩展名\"\"\"\n        mode = self._getCurrentRenderMode()\n        return \".txt\" if mode == SubtitleRenderModeEnum.ASS_STYLE else \".json\"\n\n    def _refreshStyleList(self):\n        \"\"\"根据当前渲染模式刷新样式列表\"\"\"\n        ext = self._getStyleFileExtension()\n        pattern = f\"*{ext}\"\n\n        # 阻断信号，避免 addItems/setCurrentText 重复触发 loadStyle\n        self.styleNameComboBox.comboBox.blockSignals(True)\n\n        # 清空现有列表\n        self.styleNameComboBox.comboBox.clear()\n\n        # 获取样式文件\n        style_files = [f.stem for f in SUBTITLE_STYLE_PATH.glob(pattern)]\n\n        # 确保有默认样式\n        if \"default\" not in style_files:\n            style_files.insert(0, \"default\")\n            self.saveStyle(\"default\")\n        else:\n            style_files.insert(0, style_files.pop(style_files.index(\"default\")))\n\n        self.styleNameComboBox.comboBox.addItems(style_files)\n\n        # 加载默认样式或配置中保存的样式\n        subtitle_style_name = cfg.get(cfg.subtitle_style_name)\n        if subtitle_style_name in style_files:\n            self.styleNameComboBox.comboBox.setCurrentText(subtitle_style_name)\n        else:\n            self.styleNameComboBox.comboBox.setCurrentText(style_files[0])\n            subtitle_style_name = style_files[0]\n\n        # 恢复信号\n        self.styleNameComboBox.comboBox.blockSignals(False)\n\n        # 只调用一次 loadStyle\n        self.loadStyle(subtitle_style_name)\n\n    def _getCurrentRenderMode(self) -> SubtitleRenderModeEnum:\n        \"\"\"获取当前渲染模式\"\"\"\n        mode_text = self.renderModeCard.comboBox.currentText()\n        return SubtitleRenderModeEnum(mode_text)\n\n    def _parseRgbaHex(self, hex_color: str) -> QColor:\n        \"\"\"解析 #RRGGBBAA 格式的颜色\"\"\"\n        hex_color = hex_color.lstrip(\"#\")\n        if len(hex_color) == 8:\n            r = int(hex_color[0:2], 16)\n            g = int(hex_color[2:4], 16)\n            b = int(hex_color[4:6], 16)\n            a = int(hex_color[6:8], 16)\n            return QColor(r, g, b, a)\n        elif len(hex_color) == 6:\n            return QColor(f\"#{hex_color}\")\n        return QColor(25, 25, 25, 200)  # 默认值\n\n    def onOrientationChanged(self):\n        \"\"\"当预览方向改变时调用\"\"\"\n        orientation = self.orientationCard.comboBox.currentText()\n        preview_image = (\n            DEFAULT_BG_LANDSCAPE if orientation == \"横屏\" else DEFAULT_BG_PORTRAIT\n        )\n        cfg.set(cfg.subtitle_preview_image, str(Path(preview_image[\"path\"])))\n        self.updatePreview()\n\n    def onAssSettingChanged(self):\n        \"\"\"ASS 样式设置变更\"\"\"\n        if self._loading_style:\n            return\n\n        self.updatePreview()\n        current_style = self.styleNameComboBox.comboBox.currentText()\n        if current_style:\n            self.saveStyle(current_style)\n        else:\n            self.saveStyle(\"default\")\n\n    def selectPreviewImage(self):\n        \"\"\"选择预览背景图片\"\"\"\n        file_path, _ = QFileDialog.getOpenFileName(\n            self,\n            self.tr(\"选择背景图片\"),\n            \"\",\n            self.tr(\"图片文件\") + \" (*.png *.jpg *.jpeg)\",\n        )\n        if file_path:\n            cfg.set(cfg.subtitle_preview_image, file_path)\n            self.updatePreview()\n\n    def generateAssStyles(self) -> str:\n        \"\"\"生成 ASS 样式字符串（固定720P分辨率）\"\"\"\n        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\"\n\n        # 垂直间距\n        vertical_spacing = self.assVerticalSpacingCard.spinBox.value()\n\n        # 主字幕样式\n        primary_font = self.assPrimaryFontCard.comboBox.currentText()\n        primary_size = self.assPrimarySizeCard.spinBox.value()\n\n        # 颜色转换为 ASS 格式 (AABBGGRR)\n        primary_color_hex = self.assPrimaryColorCard.colorPicker.color.name()\n        primary_outline_hex = self.assPrimaryOutlineColorCard.colorPicker.color.name()\n        primary_color = f\"&H00{primary_color_hex[5:7]}{primary_color_hex[3:5]}{primary_color_hex[1:3]}\"\n        primary_outline_color = f\"&H00{primary_outline_hex[5:7]}{primary_outline_hex[3:5]}{primary_outline_hex[1:3]}\"\n        primary_spacing = self.assPrimarySpacingCard.spinBox.value()\n        primary_outline_size = self.assPrimaryOutlineSizeCard.spinBox.value()\n\n        # 副字幕样式\n        secondary_font = self.assSecondaryFontCard.comboBox.currentText()\n        secondary_size = self.assSecondarySizeCard.spinBox.value()\n\n        secondary_color_hex = self.assSecondaryColorCard.colorPicker.color.name()\n        secondary_outline_hex = (\n            self.assSecondaryOutlineColorCard.colorPicker.color.name()\n        )\n        secondary_color = f\"&H00{secondary_color_hex[5:7]}{secondary_color_hex[3:5]}{secondary_color_hex[1:3]}\"\n        secondary_outline_color = f\"&H00{secondary_outline_hex[5:7]}{secondary_outline_hex[3:5]}{secondary_outline_hex[1:3]}\"\n        secondary_spacing = self.assSecondarySpacingCard.spinBox.value()\n        secondary_outline_size = self.assSecondaryOutlineSizeCard.spinBox.value()\n\n        # 生成样式字符串\n        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\"\n        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\"\n\n        return f\"[V4+ Styles]\\n{style_format}\\n{primary_style}\\n{secondary_style}\"\n\n    def updatePreview(self):\n        \"\"\"更新预览图片\"\"\"\n        # 获取预览文本\n        main_text, sub_text = PERVIEW_TEXTS[self.previewTextCard.comboBox.currentText()]\n\n        # 字幕布局\n        layout = self.layoutCard.comboBox.currentText()\n        if layout == \"译文在上\":\n            main_text, sub_text = sub_text, main_text\n        elif layout == \"原文在上\":\n            main_text, sub_text = main_text, sub_text\n        elif layout == \"仅译文\":\n            main_text, sub_text = sub_text, None\n        elif layout == \"仅原文\":\n            main_text, sub_text = main_text, None\n\n        # 获取预览方向和背景\n        orientation = self.orientationCard.comboBox.currentText()\n        default_preview = (\n            DEFAULT_BG_LANDSCAPE if orientation == \"横屏\" else DEFAULT_BG_PORTRAIT\n        )\n\n        # 获取背景图片路径\n        user_bg_path = cfg.get(cfg.subtitle_preview_image)\n        if user_bg_path and Path(user_bg_path).exists():\n            path = user_bg_path\n        else:\n            path = default_preview[\"path\"]\n\n        # 根据渲染模式创建不同的预览线程（不传入尺寸，由渲染层自动从图片获取）\n        render_mode = self._getCurrentRenderMode()\n\n        if render_mode == SubtitleRenderModeEnum.ROUNDED_BG:\n            # 圆角背景模式（样式720P基准，由渲染层自动缩放）\n            bg_color = self.roundedBgColorCard.colorPicker.color\n            bg_color_hex = f\"#{bg_color.red():02x}{bg_color.green():02x}{bg_color.blue():02x}{bg_color.alpha():02x}\"\n\n            style = RoundedBgStyle(\n                font_name=self.roundedFontCard.comboBox.currentText(),\n                font_size=self.roundedFontSizeCard.spinBox.value(),\n                bg_color=bg_color_hex,\n                text_color=self.roundedTextColorCard.colorPicker.color.name(),\n                corner_radius=self.roundedCornerRadiusCard.spinBox.value(),\n                padding_h=self.roundedPaddingHCard.spinBox.value(),\n                padding_v=self.roundedPaddingVCard.spinBox.value(),\n                margin_bottom=self.roundedMarginBottomCard.spinBox.value(),\n                line_spacing=self.roundedLineSpacingCard.spinBox.value(),\n                letter_spacing=self.roundedLetterSpacingCard.spinBox.value(),\n            )\n\n            self.preview_thread = RoundedBgPreviewThread(\n                preview_text=(main_text, sub_text),\n                style=style,\n                bg_image_path=str(path),\n            )\n        else:\n            # ASS 样式模式（样式720P基准，由渲染层自动缩放）\n            style_str = self.generateAssStyles()\n            self.preview_thread = AssPreviewThread(\n                preview_text=(main_text, sub_text),\n                style_str=style_str,\n                bg_image_path=str(path),\n            )\n\n        self.preview_thread.previewReady.connect(self.onPreviewReady)\n        self.preview_thread.start()\n\n    def onPreviewReady(self, preview_path):\n        \"\"\"预览图片生成完成的回调\"\"\"\n        self.previewImage.setImage(preview_path)\n        self.updatePreviewImage()\n\n    def updatePreviewImage(self):\n        \"\"\"更新预览图片\"\"\"\n        height = int(self.previewTopWidget.height() * 0.98)\n        width = int(self.previewTopWidget.width() * 0.98)\n        self.previewImage.scaledToWidth(width)\n        if self.previewImage.height() > height:\n            self.previewImage.scaledToHeight(height)\n        self.previewImage.setBorderRadius(8, 8, 8, 8)\n\n    def resizeEvent(self, event):\n        super().resizeEvent(event)\n        self.updatePreviewImage()\n\n    def showEvent(self, event):\n        \"\"\"窗口显示事件\"\"\"\n        super().showEvent(event)\n        self.updatePreviewImage()\n\n    def loadStyle(self, style_name):\n        \"\"\"加载指定样式（根据当前渲染模式加载对应格式）\"\"\"\n        ext = self._getStyleFileExtension()\n        style_path = SUBTITLE_STYLE_PATH / f\"{style_name}{ext}\"\n\n        if not style_path.exists():\n            return\n\n        self._loading_style = True\n\n        mode = self._getCurrentRenderMode()\n        if mode == SubtitleRenderModeEnum.ROUNDED_BG:\n            self._loadRoundedBgStyle(style_path)\n        else:\n            self._loadAssStyle(style_path)\n\n        cfg.set(cfg.subtitle_style_name, style_name)\n        self._loading_style = False\n        self.updatePreview()\n\n        InfoBar.success(\n            title=self.tr(\"成功\"),\n            content=self.tr(\"已加载样式 \") + style_name,\n            orient=Qt.Horizontal,  # type: ignore\n            isClosable=True,\n            position=InfoBarPosition.TOP,\n            duration=INFOBAR_DURATION_SUCCESS,\n            parent=self,\n        )\n\n    def _loadAssStyle(self, style_path: Path):\n        \"\"\"加载 ASS 样式 (.txt)\"\"\"\n        with open(style_path, \"r\", encoding=\"utf-8\") as f:\n            style_content = f.read()\n\n        for line in style_content.split(\"\\n\"):\n            if line.startswith(\"Style: Default\"):\n                parts = line.split(\",\")\n                self.assPrimaryFontCard.setCurrentText(parts[1])\n                self.assPrimarySizeCard.spinBox.setValue(int(parts[2]))\n                self.assVerticalSpacingCard.spinBox.setValue(int(parts[21]))\n\n                primary_color = parts[3].strip()\n                if primary_color.startswith(\"&H\"):\n                    color_hex = primary_color[2:]\n                    a, b, g, r = (\n                        int(color_hex[0:2], 16),\n                        int(color_hex[2:4], 16),\n                        int(color_hex[4:6], 16),\n                        int(color_hex[6:8], 16),\n                    )\n                    self.assPrimaryColorCard.setColor(QColor(r, g, b, a))\n\n                outline_color = parts[5].strip()\n                if outline_color.startswith(\"&H\"):\n                    color_hex = outline_color[2:]\n                    a, b, g, r = (\n                        int(color_hex[0:2], 16),\n                        int(color_hex[2:4], 16),\n                        int(color_hex[4:6], 16),\n                        int(color_hex[6:8], 16),\n                    )\n                    self.assPrimaryOutlineColorCard.setColor(QColor(r, g, b, a))\n\n                self.assPrimarySpacingCard.spinBox.setValue(float(parts[13]))\n                self.assPrimaryOutlineSizeCard.spinBox.setValue(float(parts[16]))\n\n            elif line.startswith(\"Style: Secondary\"):\n                parts = line.split(\",\")\n\n                self.assSecondaryFontCard.setCurrentText(parts[1])\n                self.assSecondarySizeCard.spinBox.setValue(int(parts[2]))\n\n                secondary_color = parts[3].strip()\n                if secondary_color.startswith(\"&H\"):\n                    color_hex = secondary_color[2:]\n                    a, b, g, r = (\n                        int(color_hex[0:2], 16),\n                        int(color_hex[2:4], 16),\n                        int(color_hex[4:6], 16),\n                        int(color_hex[6:8], 16),\n                    )\n                    self.assSecondaryColorCard.setColor(QColor(r, g, b, a))\n\n                outline_color = parts[5].strip()\n                if outline_color.startswith(\"&H\"):\n                    color_hex = outline_color[2:]\n                    a, b, g, r = (\n                        int(color_hex[0:2], 16),\n                        int(color_hex[2:4], 16),\n                        int(color_hex[4:6], 16),\n                        int(color_hex[6:8], 16),\n                    )\n                    self.assSecondaryOutlineColorCard.setColor(QColor(r, g, b, a))\n\n                self.assSecondarySpacingCard.spinBox.setValue(float(parts[13]))\n                self.assSecondaryOutlineSizeCard.spinBox.setValue(float(parts[16]))\n\n    def _loadRoundedBgStyle(self, style_path: Path):\n        \"\"\"加载圆角背景样式 (.json)\"\"\"\n        with open(style_path, \"r\", encoding=\"utf-8\") as f:\n            data = json.load(f)\n\n        if \"font_name\" in data:\n            self.roundedFontCard.setCurrentText(data[\"font_name\"])\n        if \"font_size\" in data:\n            self.roundedFontSizeCard.spinBox.setValue(data[\"font_size\"])\n        if \"text_color\" in data:\n            self.roundedTextColorCard.setColor(QColor(data[\"text_color\"]))\n        if \"bg_color\" in data:\n            self.roundedBgColorCard.setColor(self._parseRgbaHex(data[\"bg_color\"]))\n        if \"corner_radius\" in data:\n            self.roundedCornerRadiusCard.spinBox.setValue(data[\"corner_radius\"])\n        if \"padding_h\" in data:\n            self.roundedPaddingHCard.spinBox.setValue(data[\"padding_h\"])\n        if \"padding_v\" in data:\n            self.roundedPaddingVCard.spinBox.setValue(data[\"padding_v\"])\n        if \"margin_bottom\" in data:\n            self.roundedMarginBottomCard.spinBox.setValue(data[\"margin_bottom\"])\n        if \"line_spacing\" in data:\n            self.roundedLineSpacingCard.spinBox.setValue(data[\"line_spacing\"])\n        if \"letter_spacing\" in data:\n            self.roundedLetterSpacingCard.spinBox.setValue(data[\"letter_spacing\"])\n\n    def createNewStyle(self):\n        \"\"\"创建新样式\"\"\"\n        dialog = StyleNameDialog(self)\n        if dialog.exec():\n            style_name = dialog.nameLineEdit.text().strip()\n            if not style_name:\n                return\n\n            # 检查是否已存在同名样式\n            ext = self._getStyleFileExtension()\n            if (SUBTITLE_STYLE_PATH / f\"{style_name}{ext}\").exists():\n                InfoBar.warning(\n                    title=self.tr(\"警告\"),\n                    content=self.tr(\"样式 \") + style_name + self.tr(\" 已存在\"),\n                    orient=Qt.Horizontal,  # type: ignore\n                    isClosable=True,\n                    position=InfoBarPosition.TOP,\n                    duration=INFOBAR_DURATION_WARNING,\n                    parent=self,\n                )\n                return\n\n            # 保存新样式\n            self.saveStyle(style_name)\n\n            # 更新样式列表并选中新样式\n            self.styleNameComboBox.addItem(style_name)\n            self.styleNameComboBox.comboBox.setCurrentText(style_name)\n\n            InfoBar.success(\n                title=self.tr(\"成功\"),\n                content=self.tr(\"已创建新样式 \") + style_name,\n                orient=Qt.Horizontal,  # type: ignore\n                isClosable=True,\n                position=InfoBarPosition.TOP,\n                duration=INFOBAR_DURATION_SUCCESS,\n                parent=self,\n            )\n\n    def saveStyle(self, style_name):\n        \"\"\"保存样式（根据当前渲染模式保存对应格式）\"\"\"\n        SUBTITLE_STYLE_PATH.mkdir(parents=True, exist_ok=True)\n\n        mode = self._getCurrentRenderMode()\n        ext = self._getStyleFileExtension()\n        style_path = SUBTITLE_STYLE_PATH / f\"{style_name}{ext}\"\n\n        if mode == SubtitleRenderModeEnum.ROUNDED_BG:\n            self._saveRoundedBgStyle(style_path)\n        else:\n            self._saveAssStyle(style_path)\n\n    def _saveAssStyle(self, style_path: Path):\n        \"\"\"保存 ASS 样式 (.txt)\"\"\"\n        style_content = self.generateAssStyles()\n        with open(style_path, \"w\", encoding=\"utf-8\") as f:\n            f.write(style_content)\n\n    def _saveRoundedBgStyle(self, style_path: Path):\n        \"\"\"保存圆角背景样式 (.json)\"\"\"\n        bg_color = self.roundedBgColorCard.colorPicker.color\n        bg_color_hex = f\"#{bg_color.red():02x}{bg_color.green():02x}{bg_color.blue():02x}{bg_color.alpha():02x}\"\n\n        data = {\n            \"font_name\": self.roundedFontCard.comboBox.currentText(),\n            \"font_size\": self.roundedFontSizeCard.spinBox.value(),\n            \"text_color\": self.roundedTextColorCard.colorPicker.color.name(),\n            \"bg_color\": bg_color_hex,\n            \"corner_radius\": self.roundedCornerRadiusCard.spinBox.value(),\n            \"padding_h\": self.roundedPaddingHCard.spinBox.value(),\n            \"padding_v\": self.roundedPaddingVCard.spinBox.value(),\n            \"margin_bottom\": self.roundedMarginBottomCard.spinBox.value(),\n            \"line_spacing\": self.roundedLineSpacingCard.spinBox.value(),\n            \"letter_spacing\": self.roundedLetterSpacingCard.spinBox.value(),\n        }\n        with open(style_path, \"w\", encoding=\"utf-8\") as f:\n            json.dump(data, f, ensure_ascii=False, indent=2)\n\n    def dragEnterEvent(self, event):\n        \"\"\"拖入事件：检查是否为图片文件\"\"\"\n        if event.mimeData().hasUrls():\n            # 检查是否有图片文件\n            for url in event.mimeData().urls():\n                file_path = url.toLocalFile()\n                if file_path.lower().endswith((\".png\", \".jpg\", \".jpeg\")):\n                    event.accept()\n                    return\n        event.ignore()\n\n    def dropEvent(self, event):\n        \"\"\"放下事件：将图片设置为预览背景\"\"\"\n        files = [u.toLocalFile() for u in event.mimeData().urls()]\n        for file_path in files:\n            # 检查是否为图片文件\n            if file_path.lower().endswith((\".png\", \".jpg\", \".jpeg\")):\n                # 设置为预览背景\n                cfg.set(cfg.subtitle_preview_image, file_path)\n                # 更新预览\n                self.updatePreview()\n                # 显示成功提示\n                InfoBar.success(\n                    title=self.tr(\"成功\"),\n                    content=self.tr(\"已设置预览背景：\") + Path(file_path).name,\n                    orient=Qt.Horizontal,  # type: ignore\n                    isClosable=True,\n                    position=InfoBarPosition.TOP,\n                    duration=INFOBAR_DURATION_SUCCESS,\n                    parent=self,\n                )\n                break  # 只处理第一个图片文件\n\n\nclass StyleNameDialog(MessageBoxBase):\n    \"\"\"样式名称输入对话框\"\"\"\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.titleLabel = BodyLabel(self.tr(\"新建样式\"), self)\n        self.nameLineEdit = LineEdit(self)\n\n        self.nameLineEdit.setPlaceholderText(self.tr(\"输入样式名称\"))\n        self.nameLineEdit.setClearButtonEnabled(True)\n\n        # 添加控件到布局\n        self.viewLayout.addWidget(self.titleLabel)\n        self.viewLayout.addWidget(self.nameLineEdit)\n\n        # 设置按钮文本\n        self.yesButton.setText(self.tr(\"确定\"))\n        self.cancelButton.setText(self.tr(\"取消\"))\n\n        self.widget.setMinimumWidth(350)\n        self.yesButton.setDisabled(True)\n        self.nameLineEdit.textChanged.connect(self._validateInput)\n\n    def _validateInput(self, text):\n        self.yesButton.setEnabled(bool(text.strip()))\n"
  },
  {
    "path": "app/view/task_creation_interface.py",
    "content": "# -*- coding: utf-8 -*-\nimport os\nimport sys\nfrom urllib.parse import urlparse\n\nfrom PyQt5.QtCore import QStandardPaths, Qt, pyqtSignal\nfrom PyQt5.QtGui import QPixmap\nfrom PyQt5.QtWidgets import (\n    QApplication,\n    QFileDialog,\n    QHBoxLayout,\n    QLabel,\n    QVBoxLayout,\n    QWidget,\n)\nfrom qfluentwidgets import (\n    BodyLabel,\n    FluentIcon,\n    HyperlinkButton,\n    InfoBar,\n    InfoBarPosition,\n    LineEdit,\n    ProgressBar,\n    ToolButton,\n)\n\nfrom app.common.config import cfg\nfrom app.components.DonateDialog import DonateDialog\nfrom app.config import APPDATA_PATH, ASSETS_PATH, VERSION\nfrom app.core.constant import (\n    INFOBAR_DURATION_ERROR,\n    INFOBAR_DURATION_INFO,\n    INFOBAR_DURATION_SUCCESS,\n    INFOBAR_DURATION_WARNING,\n)\nfrom app.core.entities import (\n    SupportedAudioFormats,\n    SupportedVideoFormats,\n)\nfrom app.thread.video_download_thread import VideoDownloadThread\nfrom app.view.log_window import LogWindow\n\nLOGO_PATH = ASSETS_PATH / \"logo.png\"\n\n\nclass TaskCreationInterface(QWidget):\n    \"\"\"\n    任务创建界面类，用于创建和配置任务。\n    \"\"\"\n\n    finished = pyqtSignal(str)  # 该信号用于在任务创建完成后通知主窗口\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.task = None\n        self.log_window = None\n\n        self.setObjectName(\"TaskCreationInterface\")\n        self.setAttribute(Qt.WA_StyledBackground, True)  # type: ignore\n        self.setAcceptDrops(True)\n\n        self.setup_ui()\n        self.setup_values()\n        self.setup_signals()\n\n    def setup_ui(self):\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setObjectName(\"main_layout\")\n        self.main_layout.setSpacing(50)\n        self.main_layout.addSpacing(120)\n        self.setup_logo()\n        self.setup_search_layout()\n        self.setup_status_layout()\n        self.setup_info_label()\n\n    def setup_logo(self):\n        self.logo_label = QLabel(self)\n        self.logo_pixmap = QPixmap(str(LOGO_PATH))\n        self.logo_pixmap = self.logo_pixmap.scaled(\n            150,\n            150,\n            Qt.AspectRatioMode.KeepAspectRatio,\n            Qt.SmoothTransformation,  # type: ignore\n        )\n\n        self.logo_label.setPixmap(self.logo_pixmap)\n        self.logo_label.setAlignment(Qt.AlignCenter)  # type: ignore\n        self.main_layout.addWidget(self.logo_label)\n        self.main_layout.addSpacing(10)\n\n    def setup_search_layout(self):\n        self.search_layout = QHBoxLayout()\n        self.search_layout.setContentsMargins(80, 0, 80, 0)\n        self.search_input = LineEdit(self)\n        self.search_input.setPlaceholderText(self.tr(\"请拖拽文件或输入视频URL\"))\n        self.search_input.setFixedHeight(40)\n        self.search_input.setClearButtonEnabled(True)\n        self.search_input.focusOutEvent = lambda e: super(\n            LineEdit, self.search_input\n        ).focusOutEvent(e)\n        self.search_input.paintEvent = lambda e: super(\n            LineEdit, self.search_input\n        ).paintEvent(e)\n        self.search_input.setStyleSheet(\n            self.search_input.styleSheet()\n            + \"\"\"\n            QLineEdit {\n                border-radius: 18px;\n                padding: 0 20px;\n                background-color: transparent;\n                border: 1px solid rgba(255,255, 255, 0.08);\n            }\n            QLineEdit:focus[transparent=true] {\n                border: 1px solid rgba(47,141, 99, 0.48);\n            }\n            \n        \"\"\"\n        )\n        self.start_button = ToolButton(FluentIcon.FOLDER, self)\n        self.start_button.setFixedSize(40, 40)\n        self.start_button.setStyleSheet(\n            self.start_button.styleSheet()\n            + \"\"\"\n            QToolButton {\n                border-radius: 20px;\n                background-color: #2F8D63;\n            }\n            QToolButton:hover {\n                background-color: #2E805C;\n            }\n            QToolButton:pressed {\n                background-color: #2E905C;\n            }\n        \"\"\"\n        )\n        self.search_layout.addWidget(self.search_input)\n        self.search_layout.addWidget(self.start_button)\n        self.search_layout.setSpacing(10)\n        self.main_layout.addLayout(self.search_layout)\n        self.main_layout.addSpacing(100)\n\n    def setup_status_layout(self):\n        self.status_layout = QVBoxLayout()\n        self.status_layout.setContentsMargins(50, 0, 30, 5)\n        self.status_layout.setAlignment(Qt.AlignBottom | Qt.AlignHCenter)  # type: ignore\n        self.status_label = BodyLabel(self.tr(\"准备就绪\"), self)\n        self.status_label.setStyleSheet(\"font-size: 14px; color: #888888;\")\n        self.status_layout.addWidget(self.status_label, 0, Qt.AlignCenter)  # type: ignore\n        self.progress_bar = ProgressBar(self)\n        self.status_label.hide()\n        self.progress_bar.hide()\n        self.progress_bar.setFixedWidth(300)\n        self.status_layout.addWidget(self.progress_bar, 0, Qt.AlignCenter)  # type: ignore\n\n        self.main_layout.addStretch(1)\n        self.main_layout.addLayout(self.status_layout)\n\n    def setup_info_label(self):\n        # 创建底部容器\n        bottom_container = QWidget()\n        bottom_layout = QHBoxLayout(bottom_container)\n        bottom_layout.setContentsMargins(0, 0, 0, 0)\n\n        # 创建日志按钮\n        self.log_button = HyperlinkButton(url=\"\", text=self.tr(\"查看日志\"), parent=self)\n        self.log_button.setStyleSheet(\n            self.log_button.styleSheet()\n            + \"\"\"\n            QPushButton {\n                font-size: 12px;\n                color: #2F8D63;\n                text-decoration: underline;\n            }\n        \"\"\"\n        )\n\n        # 创建捐助按钮\n        self.donate_button = HyperlinkButton(url=\"\", text=self.tr(\"捐助\"), parent=self)\n        self.donate_button.setStyleSheet(\n            self.donate_button.styleSheet()\n            + \"\"\"\n            QPushButton {\n                font-size: 12px;\n                color: #2F8D63;\n                text-decoration: underline;\n            }\n        \"\"\"\n        )\n\n        # 添加版权信息标签\n        self.info_label = BodyLabel(\n            self.tr(f\"©VideoCaptioner {VERSION} • By Weifeng\"), self\n        )\n        self.info_label.setAlignment(Qt.AlignCenter)  # type: ignore\n        self.info_label.setStyleSheet(\"font-size: 12px; color: #888888;\")\n\n        # 将组件添加到底部布局\n        bottom_layout.addStretch()\n        bottom_layout.addWidget(self.info_label)\n        bottom_layout.addWidget(self.log_button)\n        bottom_layout.addWidget(self.donate_button)\n        bottom_layout.addStretch()\n\n        self.main_layout.addStretch()\n        self.main_layout.addWidget(bottom_container)\n\n    def setup_signals(self):\n        self.start_button.clicked.connect(self.on_start_clicked)\n        self.search_input.textChanged.connect(self.on_search_input_changed)\n        self.log_button.clicked.connect(self.show_log_window)\n        self.donate_button.clicked.connect(self.show_donate_dialog)\n\n    def setup_values(self):\n        self.search_input.setText(\"\")\n\n    def on_start_clicked(self):\n        if self.start_button._icon == FluentIcon.FOLDER:\n            desktop_path = QStandardPaths.writableLocation(\n                QStandardPaths.DesktopLocation\n            )\n            file_dialog = QFileDialog()\n\n            # 构建文件过滤器\n            video_formats = \" \".join(f\"*.{fmt.value}\" for fmt in SupportedVideoFormats)\n            audio_formats = \" \".join(f\"*.{fmt.value}\" for fmt in SupportedAudioFormats)\n            filter_str = f\"{self.tr('媒体文件')} ({video_formats} {audio_formats});;{self.tr('视频文件')} ({video_formats});;{self.tr('音频文件')} ({audio_formats})\"\n\n            file_path, _ = file_dialog.getOpenFileName(\n                self, self.tr(\"选择媒体文件\"), desktop_path, filter_str\n            )\n            if file_path:\n                self.search_input.setText(file_path)\n            return\n\n        self.process()\n\n    def on_search_input_changed(self):\n        if self.search_input.text():\n            self.start_button.setIcon(FluentIcon.PLAY)\n        else:\n            self.start_button.setIcon(FluentIcon.FOLDER)\n\n    def dragEnterEvent(self, event):\n        event.accept() if event.mimeData().hasUrls() else event.ignore()\n\n    def dropEvent(self, event):\n        files = [u.toLocalFile() for u in event.mimeData().urls()]\n        for file_path in files:\n            if not os.path.isfile(file_path):\n                continue\n\n            file_ext = os.path.splitext(file_path)[1][1:].lower()\n\n            # 检查文件格式是否支持\n            supported_formats = {fmt.value for fmt in SupportedVideoFormats} | {\n                fmt.value for fmt in SupportedAudioFormats\n            }\n            is_supported = file_ext in supported_formats\n\n            if is_supported:\n                self.search_input.setText(file_path)\n                self.status_label.setText(self.tr(\"导入成功\"))\n                InfoBar.success(\n                    self.tr(\"导入成功\"),\n                    self.tr(\"导入媒体文件成功\"),\n                    duration=INFOBAR_DURATION_SUCCESS,\n                    parent=self,\n                )\n                break\n            else:\n                InfoBar.error(\n                    self.tr(\"格式错误\") + file_ext,\n                    self.tr(\"不支持该文件格式\"),\n                    duration=INFOBAR_DURATION_ERROR,\n                    parent=self,\n                )\n\n    def create_task(self):\n        search_input = self.search_input.text()\n        if os.path.isfile(search_input):\n            self._process_file(search_input)\n        elif self._is_valid_url(search_input):\n            self._process_url(search_input)\n        else:\n            InfoBar.error(\n                self.tr(\"错误\"),\n                self.tr(\"请输入有效的文件路径或视频URL\"),\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n\n    def _is_valid_url(self, url):\n        try:\n            result = urlparse(url)\n            return result.scheme in (\"http\", \"https\") and bool(result.netloc)\n        except ValueError:\n            return False\n\n    def _process_file(self, file_path):\n        self.finished.emit(file_path)\n\n    def _process_url(self, url):\n        # 检测 cookies.txt 文件\n        cookiefile_path = APPDATA_PATH / \"cookies.txt\"\n        if not cookiefile_path.exists():\n            InfoBar.warning(\n                self.tr(\"警告\"),\n                self.tr(\"建议根据文档配置cookies.txt文件，以可以下载高清视频\"),\n                duration=INFOBAR_DURATION_WARNING,\n                parent=self,\n            )\n\n        # 创建视频下载线程\n        self.video_download_thread = VideoDownloadThread(url, str(cfg.work_dir.value))\n        self.video_download_thread.finished.connect(self.on_video_download_finished)\n        self.video_download_thread.progress.connect(self.on_create_task_progress)\n        self.video_download_thread.error.connect(self.on_create_task_error)\n        self.video_download_thread.start()\n\n        InfoBar.info(\n            self.tr(\"开始下载\"),\n            self.tr(\"开始下载视频...\"),\n            duration=INFOBAR_DURATION_INFO,\n            parent=self,\n        )\n\n    def on_video_download_finished(self, video_file_path):\n        \"\"\"视频下载完成的回调函数\"\"\"\n        if video_file_path:\n            self.finished.emit(video_file_path)\n            InfoBar.success(\n                self.tr(\"下载成功\"),\n                self.tr(\"视频下载完成，开始自动处理...\"),\n                duration=INFOBAR_DURATION_SUCCESS,\n                position=InfoBarPosition.BOTTOM,\n                parent=self.parent(),\n            )\n        else:\n            InfoBar.error(\n                self.tr(\"错误\"),\n                self.tr(\"视频下载失败\"),\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n\n    def on_create_task_progress(self, value, status):\n        self.progress_bar.show()\n        self.status_label.show()\n        self.progress_bar.setValue(value)\n        self.status_label.setText(status)\n\n    def on_create_task_error(self, error):\n        InfoBar.error(\n            self.tr(\"错误\"),\n            self.tr(error),\n            duration=INFOBAR_DURATION_ERROR,\n            parent=self,\n        )\n\n    def set_task(self, task):\n        self.task = task\n        self.update_info()\n\n    def update_info(self):\n        if self.task:\n            self.search_input.setText(self.task.file_path)\n\n    def process(self):\n        search_input = self.search_input.text()\n\n        if os.path.isfile(search_input):\n            self._process_file(search_input)\n        elif self._is_valid_url(search_input):\n            self._process_url(search_input)\n        else:\n            InfoBar.error(\n                self.tr(\"错误\"),\n                self.tr(\"请输入音视频文件路径或URL\"),\n                duration=INFOBAR_DURATION_ERROR,\n                parent=self,\n            )\n\n    def show_log_window(self):\n        \"\"\"显示日志窗口\"\"\"\n        if self.log_window is None:\n            self.log_window = LogWindow()\n        if self.log_window.isHidden():\n            self.log_window.show()\n        else:\n            self.log_window.activateWindow()\n\n    def show_donate_dialog(self):\n        \"\"\"显示捐助窗口\"\"\"\n        donate_dialog = DonateDialog(self)\n        donate_dialog.exec_()\n\n\nif __name__ == \"__main__\":\n    QApplication.setHighDpiScaleFactorRoundingPolicy(\n        Qt.HighDpiScaleFactorRoundingPolicy.PassThrough\n    )\n    QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)  # type: ignore\n    QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)  # type: ignore\n\n    app = QApplication(sys.argv)\n    window = TaskCreationInterface()\n    window.show()\n    sys.exit(app.exec_())\n"
  },
  {
    "path": "app/view/transcription_interface.py",
    "content": "# -*- coding: utf-8 -*-\n\nimport datetime\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom PyQt5.QtCore import QStandardPaths, Qt, pyqtSignal\nfrom PyQt5.QtGui import QFont, QPixmap\nfrom PyQt5.QtWidgets import (\n    QApplication,\n    QFileDialog,\n    QHBoxLayout,\n    QLabel,\n    QVBoxLayout,\n    QWidget,\n)\nfrom qfluentwidgets import (\n    Action,\n    BodyLabel,\n    CardWidget,\n    CommandBar,\n    FluentIcon,\n    InfoBar,\n    InfoBarPosition,\n    PillPushButton,\n    PrimaryPushButton,\n    ProgressRing,\n    PushButton,\n    RoundMenu,\n    TransparentDropDownPushButton,\n    setFont,\n)\n\nfrom app.common.config import cfg\nfrom app.common.signal_bus import signalBus\nfrom app.components.transcription_setting_card import TranscriptionSettingCard\nfrom app.components.TranscriptionSettingDialog import TranscriptionSettingDialog\nfrom app.config import RESOURCE_PATH\nfrom app.core.constant import (\n    INFOBAR_DURATION_ERROR,\n    INFOBAR_DURATION_SUCCESS,\n    INFOBAR_DURATION_WARNING,\n)\nfrom app.core.entities import (\n    SupportedAudioFormats,\n    SupportedVideoFormats,\n    TranscribeModelEnum,\n    TranscribeTask,\n    VideoInfo,\n)\nfrom app.core.task_factory import TaskFactory\nfrom app.core.utils.platform_utils import get_available_transcribe_models, open_folder\nfrom app.thread.transcript_thread import TranscriptThread\nfrom app.thread.video_info_thread import VideoInfoThread\n\nDEFAULT_THUMBNAIL_PATH = RESOURCE_PATH / \"assets\" / \"default_thumbnail.jpg\"\n\n\nclass VideoInfoCard(CardWidget):\n    finished = pyqtSignal(TranscribeTask)\n\n    def __init__(self, parent: Optional[QWidget] = None):\n        super().__init__(parent)\n        self.setup_ui()\n        self.setup_signals()\n        self.task: Optional[TranscribeTask] = None\n        self.video_info: Optional[VideoInfo] = None\n        self.transcription_interface = parent\n        self.selected_audio_track_index = 0  # 默认选择第一条音轨\n\n    def setup_ui(self) -> None:\n        self.setFixedHeight(150)\n        self.main_layout = QHBoxLayout(self)\n        self.main_layout.setContentsMargins(20, 15, 20, 15)\n        self.main_layout.setSpacing(20)\n\n        self.setup_thumbnail()\n        self.setup_info_layout()\n        self.setup_button_layout()\n\n    def setup_thumbnail(self) -> None:\n        default_thumbnail_path = os.path.join(DEFAULT_THUMBNAIL_PATH)\n\n        self.video_thumbnail = QLabel(self)\n        self.video_thumbnail.setFixedSize(208, 117)\n        self.video_thumbnail.setStyleSheet(\"background-color: #1E1F22;\")\n        self.video_thumbnail.setAlignment(Qt.AlignCenter)  # type: ignore\n        pixmap = QPixmap(default_thumbnail_path).scaled(\n            self.video_thumbnail.size(),\n            Qt.AspectRatioMode.KeepAspectRatio,\n            Qt.SmoothTransformation,  # type: ignore\n        )\n        self.video_thumbnail.setPixmap(pixmap)\n        self.main_layout.addWidget(self.video_thumbnail, 0, Qt.AlignLeft)  # type: ignore\n\n    def setup_info_layout(self) -> None:\n        self.info_layout = QVBoxLayout()\n        self.info_layout.setContentsMargins(3, 8, 3, 8)\n        self.info_layout.setSpacing(10)\n\n        self.video_title = BodyLabel(self.tr(\"请拖入音频或视频文件\"), self)\n        self.video_title.setFont(QFont(\"Microsoft YaHei\", 14, QFont.Bold))\n        self.video_title.setWordWrap(True)\n        self.info_layout.addWidget(self.video_title, alignment=Qt.AlignTop)  # type: ignore\n\n        self.details_layout = QHBoxLayout()\n        self.details_layout.setSpacing(15)\n\n        self.resolution_info = self.create_pill_button(self.tr(\"画质\"), 110)\n        self.file_size_info = self.create_pill_button(self.tr(\"文件大小\"), 110)\n        self.duration_info = self.create_pill_button(self.tr(\"时长\"), 100)\n        self.audio_track_button = self.create_pill_button(self.tr(\"音轨\"), 100)\n        self.audio_track_button.hide()  # 默认隐藏，只在多音轨时显示\n\n        self.progress_ring = ProgressRing(self)\n        self.progress_ring.setFixedSize(20, 20)\n        self.progress_ring.setStrokeWidth(4)\n        self.progress_ring.hide()\n\n        self.details_layout.addWidget(self.resolution_info)\n        self.details_layout.addWidget(self.file_size_info)\n        self.details_layout.addWidget(self.duration_info)\n        self.details_layout.addWidget(self.audio_track_button)\n        self.details_layout.addWidget(self.progress_ring)\n        self.details_layout.addStretch(1)\n        self.info_layout.addLayout(self.details_layout)\n        self.main_layout.addLayout(self.info_layout)  # type: ignore\n\n    def create_pill_button(self, text: str, width: int) -> PillPushButton:\n        button = PillPushButton(text, self)\n        button.setCheckable(False)\n        setFont(button, 11)\n        # button.setFixedWidth(width)\n        button.setMinimumWidth(50)\n        return button\n\n    def setup_button_layout(self) -> None:\n        self.button_layout = QVBoxLayout()\n        self.open_folder_button = PushButton(self.tr(\"打开文件夹\"), self)\n        self.start_button = PrimaryPushButton(self.tr(\"开始转录\"), self)\n        self.button_layout.addWidget(self.open_folder_button)\n        self.button_layout.addWidget(self.start_button)\n\n        self.start_button.setDisabled(True)\n\n        button_widget = QWidget()\n        button_widget.setLayout(self.button_layout)\n        button_widget.setFixedWidth(130)\n        self.main_layout.addWidget(button_widget)  # type: ignore\n\n    def update_info(self, video_info: VideoInfo) -> None:\n        \"\"\"更新视频信息显示\"\"\"\n        self.reset_ui()\n        self.video_info = video_info\n\n        self.video_title.setText(video_info.file_name.rsplit(\".\", 1)[0])\n        self.resolution_info.setText(\n            self.tr(\"画质: \") + f\"{video_info.width}x{video_info.height}\"\n        )\n        file_size_mb = os.path.getsize(video_info.file_path) / 1024 / 1024\n        self.file_size_info.setText(self.tr(\"大小: \") + f\"{file_size_mb:.1f} MB\")\n        duration = datetime.timedelta(seconds=int(video_info.duration_seconds))\n        self.duration_info.setText(self.tr(\"时长: \") + f\"{duration}\")\n\n        # 更新音轨选择按钮\n        self.update_audio_tracks(video_info)\n\n        if self.transcription_interface and self.transcription_interface.is_processing:  # type: ignore\n            self.start_button.setEnabled(False)\n        else:\n            self.start_button.setEnabled(True)\n        self.update_thumbnail(video_info.thumbnail_path)\n\n    def update_audio_tracks(self, video_info: VideoInfo) -> None:\n        \"\"\"更新音轨选择按钮\"\"\"\n        audio_streams = video_info.audio_streams\n\n        if len(audio_streams) > 1:\n            # 多音轨，显示选择按钮，默认选择第一条音轨（数组索引0）\n            self.selected_audio_track_index = 0\n            self.update_audio_track_button_text(audio_streams, 0)\n\n            # 创建下拉菜单\n            menu = RoundMenu(parent=self)\n            for i, stream in enumerate(audio_streams):\n                lang = stream.language\n\n                # 构建菜单项文本（使用序号 i+1）\n                text = self.tr(\"音轨\") + str(i + 1)\n                if lang:\n                    text += f\" ({lang})\"\n\n                action = Action(text)\n                action.triggered.connect(\n                    lambda checked, array_idx=i, streams=audio_streams: self.on_audio_track_selected(\n                        array_idx, streams\n                    )\n                )\n                menu.addAction(action)\n\n            # 绑定菜单到按钮\n            self.audio_track_button.clicked.connect(\n                lambda: menu.exec(\n                    self.audio_track_button.mapToGlobal(\n                        self.audio_track_button.rect().bottomLeft()\n                    )\n                )\n            )\n            self.audio_track_button.show()\n        else:\n            self.audio_track_button.hide()\n            self.selected_audio_track_index = 0\n\n    def update_audio_track_button_text(\n        self, audio_streams: list, array_index: int\n    ) -> None:\n        \"\"\"更新音轨按钮显示文本\n\n        Args:\n            audio_streams: 音轨列表\n            array_index: 数组索引（0, 1, 2...）\n        \"\"\"\n        if array_index < len(audio_streams):\n            stream = audio_streams[array_index]\n            lang = stream.language\n            text = f\"{self.tr('音轨')} {array_index + 1}\"\n            if lang:\n                text += f\" ({lang})\"\n            self.audio_track_button.setText(text)\n\n    def on_audio_track_selected(self, array_index: int, audio_streams: list) -> None:\n        \"\"\"音轨选择事件处理\n\n        Args:\n            array_index: 数组索引（0, 1, 2...），用于 UI 显示和 ffmpeg -map 0:a:N\n            audio_streams: 音轨列表\n        \"\"\"\n        self.selected_audio_track_index = array_index  # 保存数组索引，传给 ffmpeg\n        self.update_audio_track_button_text(audio_streams, array_index)\n\n    def update_thumbnail(self, thumbnail_path):\n        \"\"\"更新视频缩略图\"\"\"\n        if not Path(thumbnail_path).exists():\n            thumbnail_path = RESOURCE_PATH / \"assets\" / \"audio-thumbnail.png\"\n\n        pixmap = QPixmap(str(thumbnail_path)).scaled(\n            self.video_thumbnail.size(),\n            Qt.AspectRatioMode.KeepAspectRatio,\n            Qt.SmoothTransformation,  # type: ignore\n        )\n        self.video_thumbnail.setPixmap(pixmap)\n\n    def setup_signals(self) -> None:\n        self.start_button.clicked.connect(self.on_start_button_clicked)\n        self.open_folder_button.clicked.connect(self.on_open_folder_clicked)\n\n    def on_start_button_clicked(self):\n        \"\"\"开始转录按钮点击事件\"\"\"\n        self.progress_ring.setValue(0)\n        self.progress_ring.show()\n        self.start_button.setDisabled(True)\n        self.start_transcription()\n\n    def on_open_folder_clicked(self):\n        \"\"\"打开文件夹按钮点击事件\"\"\"\n        if self.task and self.task.output_path:\n            original_subtitle_save_path = Path(str(self.task.output_path))\n            target_dir = str(\n                original_subtitle_save_path.parent\n                if original_subtitle_save_path.exists()\n                else Path(str(self.task.file_path)).parent\n            )\n            open_folder(target_dir)\n        else:\n            InfoBar.warning(\n                self.tr(\"警告\"),\n                self.tr(\"没有可用的字幕文件夹\"),\n                duration=INFOBAR_DURATION_WARNING,\n                parent=self,\n            )\n\n    def start_transcription(self, need_create_task=True):\n        \"\"\"开始转录过程\"\"\"\n        self.transcription_interface.is_processing = True  # type: ignore\n        self.start_button.setEnabled(False)\n\n        if need_create_task:\n            self.task = TaskFactory.create_transcribe_task(self.video_info.file_path)\n\n        if not self.task:\n            return\n\n        # 将选中的音轨索引作为临时属性传递给 task\n        self.task.selected_audio_track_index = self.selected_audio_track_index  # type: ignore\n\n        self.transcript_thread = TranscriptThread(self.task)\n        self.transcript_thread.finished.connect(self.on_transcript_finished)\n        self.transcript_thread.progress.connect(self.on_transcript_progress)\n        self.transcript_thread.error.connect(self.on_transcript_error)\n        self.transcript_thread.start()\n\n    def on_transcript_progress(self, value, message):\n        \"\"\"更新转录进度\"\"\"\n        self.start_button.setText(message)\n        self.progress_ring.setValue(value)\n\n    def on_transcript_error(self, error):\n        \"\"\"处理转录错误\"\"\"\n        self.transcription_interface.is_processing = False  # type: ignore\n        self.start_button.setEnabled(True)\n        self.start_button.setText(self.tr(\"重新转录\"))\n        self.progress_ring.hide()\n        InfoBar.error(\n            self.tr(\"转录失败\"),\n            self.tr(error),\n            duration=INFOBAR_DURATION_ERROR,\n            parent=self.parent().parent(),\n        )\n\n    def on_transcript_finished(self, task):\n        \"\"\"转录完成处理\"\"\"\n        self.start_button.setEnabled(True)\n        self.start_button.setText(self.tr(\"转录完成\"))\n        self.progress_ring.hide()\n        self.finished.emit(task)\n\n    def reset_ui(self):\n        \"\"\"重置UI状态\"\"\"\n        self.start_button.setDisabled(False)\n        self.start_button.setText(self.tr(\"开始转录\"))\n        self.progress_ring.setValue(0)\n        self.progress_ring.hide()\n\n    def set_task(self, task):\n        \"\"\"设置任务并更新UI\"\"\"\n        self.task = task\n        self.reset_ui()\n\n    def stop(self):\n        if hasattr(self, \"transcript_thread\"):\n            self.transcript_thread.terminate()\n\n\nclass TranscriptionInterface(QWidget):\n    \"\"\"转录界面类,用于显示视频信息和转录进度\"\"\"\n\n    finished = pyqtSignal(str, str)\n\n    def __init__(self, parent: Optional[QWidget] = None):\n        super().__init__(parent)\n        self.setAttribute(Qt.WA_StyledBackground, True)  # type: ignore\n        self.setAcceptDrops(True)\n        self.task: Optional[TranscribeTask] = None\n        self.is_processing: bool = False\n\n        self._init_ui()\n        self._setup_signals()\n        self._set_value()\n\n    def _init_ui(self) -> None:\n        \"\"\"初始化UI\"\"\"\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setObjectName(\"main_layout\")\n        self.main_layout.setSpacing(20)\n\n        # 添加命令栏\n        self._setup_command_bar()\n\n        self.video_info_card = VideoInfoCard(self)\n        self.main_layout.addWidget(self.video_info_card)\n\n        # 添加转录设置卡片\n        self.transcription_setting_card = TranscriptionSettingCard(self)\n        self.main_layout.addWidget(self.transcription_setting_card)\n\n    def _setup_command_bar(self):\n        \"\"\"设置命令栏\"\"\"\n        self.command_bar = CommandBar(self)\n        self.command_bar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)  # type: ignore\n        self.command_bar.setFixedHeight(40)\n\n        # 添加打开文件按钮\n        self.open_file_action = Action(FluentIcon.FOLDER, self.tr(\"打开文件\"))\n        self.open_file_action.triggered.connect(self._on_file_select)\n        self.command_bar.addAction(self.open_file_action)\n\n        self.command_bar.addSeparator()\n\n        # 添加转录模型选择按钮\n        self.model_button = TransparentDropDownPushButton(\n            self.tr(\"转录模型\"), self, FluentIcon.MICROPHONE\n        )\n        self.model_button.setFixedHeight(34)\n        self.model_button.setMinimumWidth(180)\n\n        self.model_menu = RoundMenu(parent=self)\n        # 只显示当前平台可用的模型（macOS 上不显示 FasterWhisper）\n        available_models = get_available_transcribe_models()\n        for model in available_models:\n            if (\n                model == TranscribeModelEnum.WHISPER_API\n                or model == TranscribeModelEnum.BIJIAN\n                or model == TranscribeModelEnum.JIANYING\n            ):\n                self.model_menu.addActions(\n                    [\n                        Action(FluentIcon.GLOBE, model.value),\n                    ]\n                )\n            else:\n                self.model_menu.addActions(\n                    [\n                        Action(FluentIcon.ROBOT, model.value),\n                    ]\n                )\n        self.model_button.setMenu(self.model_menu)\n        self.command_bar.addWidget(self.model_button)\n\n        self.command_bar.addSeparator()\n\n        # 添加输出设置按钮\n        self.command_bar.addAction(\n            Action(FluentIcon.SETTING, \"\", triggered=self._show_output_settings)\n        )\n\n        self.main_layout.addWidget(self.command_bar)\n\n    def _setup_signals(self) -> None:\n        \"\"\"设置信号连接\"\"\"\n        self.video_info_card.finished.connect(self._on_transcript_finished)\n\n        # 设置模型选择菜单的信号连接\n        for action in self.model_menu.actions():\n            action.triggered.connect(\n                lambda checked, text=action.text(): self.on_transcription_model_changed(\n                    text\n                )\n            )\n\n        # 全局信号连接\n        signalBus.transcription_model_changed.connect(\n            self.on_transcription_model_changed\n        )\n\n    def _show_output_settings(self):\n        \"\"\"显示转录设置对话框\"\"\"\n        dialog = TranscriptionSettingDialog(self.window())\n        dialog.exec_()\n\n    def _set_value(self) -> None:\n        \"\"\"设置转录模型\"\"\"\n        model_name = cfg.get(cfg.transcribe_model).value\n        # self.model_button.setText(self.tr(model_name))\n        self.on_transcription_model_changed(model_name)\n\n    def on_transcription_model_changed(self, model_name: str):\n        \"\"\"处理转录模型改变\"\"\"\n        self.model_button.setText(self.tr(model_name))\n        self.transcription_setting_card.on_model_changed(model_name)\n        for model in TranscribeModelEnum:\n            if model.value == model_name:\n                cfg.set(cfg.transcribe_model, model)\n                break\n\n    def _on_transcript_finished(self, task: TranscribeTask):\n        \"\"\"转录完成处理\"\"\"\n        self.is_processing = False\n        if task.need_next_task:\n            self.finished.emit(task.output_path, task.file_path)\n\n            InfoBar.success(\n                self.tr(\"转录完成\"),\n                self.tr(\"开始字幕优化...\"),\n                duration=INFOBAR_DURATION_SUCCESS,\n                position=InfoBarPosition.BOTTOM,\n                parent=self.parent(),\n            )\n\n    def _on_file_select(self):\n        \"\"\"文件选择处理\"\"\"\n        desktop_path = QStandardPaths.writableLocation(QStandardPaths.DesktopLocation)\n        file_dialog = QFileDialog()\n\n        video_formats = \" \".join(f\"*.{fmt.value}\" for fmt in SupportedVideoFormats)\n        audio_formats = \" \".join(f\"*.{fmt.value}\" for fmt in SupportedAudioFormats)\n        filter_str = f\"{self.tr('媒体文件')} ({video_formats} {audio_formats});;{self.tr('视频文件')} ({video_formats});;{self.tr('音频文件')} ({audio_formats})\"\n\n        file_path, _ = file_dialog.getOpenFileName(\n            self, self.tr(\"选择媒体文件\"), desktop_path, filter_str\n        )\n        if file_path:\n            self.update_info(file_path)\n\n    def update_info(self, file_path):\n        \"\"\"设置UI\"\"\"\n        self.video_info_thread = VideoInfoThread(file_path)\n        self.video_info_thread.finished.connect(self.video_info_card.update_info)\n        self.video_info_thread.error.connect(self._on_video_info_error)\n        self.video_info_thread.start()\n\n    def _on_video_info_error(self, error_msg):\n        \"\"\"处理视频信息提取错误\"\"\"\n        self.is_processing = False\n        InfoBar.error(\n            self.tr(\"错误\"),\n            self.tr(error_msg),\n            duration=INFOBAR_DURATION_ERROR,\n            parent=self,\n        )\n\n    def set_task(self, task: TranscribeTask) -> None:\n        \"\"\"设置任务并更新UI\"\"\"\n        self.task = task\n        self.video_info_card.set_task(self.task)\n        self.update_info(self.task.file_path)\n\n    def process(self):\n        \"\"\"主处理函数\"\"\"\n        self.is_processing = True\n        self.video_info_card.start_transcription(need_create_task=False)\n\n    def dragEnterEvent(self, event):\n        \"\"\"拖拽进入事件处理\"\"\"\n        event.accept() if event.mimeData().hasUrls() else event.ignore()\n\n    def dropEvent(self, event):\n        \"\"\"拖拽放下事件处理\"\"\"\n        if self.is_processing:\n            InfoBar.warning(\n                self.tr(\"警告\"),\n                self.tr(\"正在处理中，请等待当前任务完成\"),\n                duration=INFOBAR_DURATION_WARNING,\n                parent=self,\n            )\n            return\n\n        files = [u.toLocalFile() for u in event.mimeData().urls()]\n        for file_path in files:\n            if not os.path.isfile(file_path):\n                continue\n\n            file_ext = os.path.splitext(file_path)[1][1:].lower()\n\n            # 检查文件格式是否支持\n            supported_formats = {fmt.value for fmt in SupportedVideoFormats} | {\n                fmt.value for fmt in SupportedAudioFormats\n            }\n            is_supported = file_ext in supported_formats\n\n            if is_supported:\n                self.update_info(file_path)\n                InfoBar.success(\n                    self.tr(\"导入成功\"),\n                    self.tr(\"开始语音转文字\"),\n                    duration=INFOBAR_DURATION_SUCCESS,\n                    parent=self,\n                )\n                break\n            else:\n                InfoBar.error(\n                    self.tr(\"格式错误\") + file_ext,\n                    self.tr(\"请拖入音频或视频文件\"),\n                    duration=INFOBAR_DURATION_ERROR,\n                    parent=self,\n                )\n\n    def closeEvent(self, event):\n        self.video_info_card.stop()\n        super().closeEvent(event)\n\n\nif __name__ == \"__main__\":\n    QApplication.setHighDpiScaleFactorRoundingPolicy(\n        Qt.HighDpiScaleFactorRoundingPolicy.PassThrough\n    )\n    QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)  # type: ignore\n    QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)  # type: ignore\n\n    app = QApplication(sys.argv)\n    window = TranscriptionInterface()\n    window.show()\n    sys.exit(app.exec_())\n"
  },
  {
    "path": "app/view/video_synthesis_interface.py",
    "content": "# -*- coding: utf-8 -*-\n\nimport os\nimport sys\nfrom pathlib import Path\n\nfrom PyQt5.QtCore import Qt, pyqtSignal\nfrom PyQt5.QtGui import QDropEvent\nfrom PyQt5.QtWidgets import QApplication, QFileDialog, QHBoxLayout, QVBoxLayout, QWidget\nfrom qfluentwidgets import (\n    Action,\n    BodyLabel,\n    CardWidget,\n    CommandBar,\n    InfoBar,\n    InfoBarPosition,\n    LineEdit,\n    PrimaryPushButton,\n    ProgressBar,\n    PushButton,\n    RoundMenu,\n    ToolTipFilter,\n    ToolTipPosition,\n    TransparentDropDownPushButton,\n)\nfrom qfluentwidgets import FluentIcon as FIF\n\nfrom app.common.config import cfg\nfrom app.common.signal_bus import signalBus\nfrom app.core.constant import (\n    INFOBAR_DURATION_ERROR,\n    INFOBAR_DURATION_SUCCESS,\n    INFOBAR_DURATION_WARNING,\n)\nfrom app.core.entities import (\n    SubtitleRenderModeEnum,\n    SupportedSubtitleFormats,\n    SupportedVideoFormats,\n    SynthesisTask,\n    VideoQualityEnum,\n)\nfrom app.core.task_factory import TaskFactory\nfrom app.core.utils.platform_utils import open_folder\nfrom app.thread.video_synthesis_thread import VideoSynthesisThread\n\n\nclass VideoSynthesisInterface(QWidget):\n    finished = pyqtSignal()\n\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self.setObjectName(\"VideoSynthesisInterface\")\n        self.setAttribute(Qt.WA_StyledBackground, True)  # type: ignore\n        self.setAcceptDrops(True)  # 启用拖放功能\n        self.setup_ui()\n        self.setup_style()\n        self.set_value()\n        self.setup_signals()\n        self.task = None\n\n        self.installEventFilter(ToolTipFilter(self, 100, ToolTipPosition.BOTTOM))\n\n    def setup_ui(self):\n        self.main_layout = QVBoxLayout(self)\n        self.main_layout.setSpacing(20)\n\n        # 创建顶部布局\n        top_layout = QHBoxLayout()\n\n        # 添加顶部命令栏\n        self.command_bar = CommandBar(self)\n        self.command_bar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)  # type: ignore\n        top_layout.addWidget(self.command_bar, 1)  # 设置stretch为1，使其尽可能占用空间\n\n        # 设置命令栏\n        self._setup_command_bar()\n\n        # 添加开始合成按钮到水平布局\n        self.synthesize_button = PrimaryPushButton(\n            self.tr(\"开始合成\"), self, icon=FIF.PLAY\n        )\n        self.synthesize_button.setFixedHeight(34)\n        top_layout.addWidget(self.synthesize_button)\n\n        self.main_layout.addLayout(top_layout)\n\n        # 配置卡片\n        self.config_card = CardWidget(self)\n        self.config_layout = QVBoxLayout(self.config_card)\n        self.config_layout.setContentsMargins(20, 20, 20, 20)\n        self.config_layout.setSpacing(20)\n\n        # 字幕文件选择\n        self.subtitle_layout = QHBoxLayout()\n        self.subtitle_layout.setSpacing(15)\n        self.subtitle_label = BodyLabel(self.tr(\"字幕文件\"), self)\n        self.subtitle_input = LineEdit(self)\n        self.subtitle_input.setPlaceholderText(self.tr(\"选择或者拖拽字幕文件\"))\n        self.subtitle_input.setAcceptDrops(True)  # 启用拖放\n        self.subtitle_button = PushButton(self.tr(\"浏览\"))\n        self.subtitle_layout.addWidget(self.subtitle_label)\n        self.subtitle_layout.addWidget(self.subtitle_input)\n        self.subtitle_layout.addWidget(self.subtitle_button)\n        self.config_layout.addLayout(self.subtitle_layout)\n\n        # 视频文件选择\n        self.video_layout = QHBoxLayout()\n        self.video_layout.setSpacing(15)\n        self.video_label = BodyLabel(self.tr(\"视频文件\"), self)\n        self.video_input = LineEdit(self)\n        self.video_input.setPlaceholderText(self.tr(\"选择或者拖拽视频文件\"))\n        self.video_input.setAcceptDrops(True)  # 启用拖放\n        self.video_button = PushButton(self.tr(\"浏览\"))\n        self.video_layout.addWidget(self.video_label)\n        self.video_layout.addWidget(self.video_input)\n        self.video_layout.addWidget(self.video_button)\n        self.config_layout.addLayout(self.video_layout)\n\n        self.main_layout.addWidget(self.config_card)\n\n        self.main_layout.addStretch(1)\n\n        # 底部进度条和状态信息\n        self.bottom_layout = QHBoxLayout()\n        self.progress_bar = ProgressBar(self)\n        self.status_label = BodyLabel(self.tr(\"就绪\"), self)\n        self.status_label.setMinimumWidth(100)  # 设置最小宽度\n        self.status_label.setAlignment(Qt.AlignCenter)  # type: ignore  # 设置文本居中对齐\n        self.bottom_layout.addWidget(self.progress_bar, 1)  # 进度条使用剩余空间\n        self.bottom_layout.addWidget(self.status_label)  # 状态标签使用固定宽度\n        self.main_layout.addLayout(self.bottom_layout)\n\n    def _setup_command_bar(self):\n        \"\"\"设置顶部命令栏\"\"\"\n        # 添加软字幕选项\n        self.soft_subtitle_action = Action(\n            FIF.FONT,\n            self.tr(\"软字幕\"),\n            triggered=self.on_soft_subtitle_action_triggered,\n            checkable=True,\n        )\n        self.soft_subtitle_action.setToolTip(self.tr(\"使用软字幕嵌入视频\"))\n        self.command_bar.addAction(self.soft_subtitle_action)\n\n        # 添加分隔符\n        self.command_bar.addSeparator()\n\n        # 添加使用样式开关\n        self.use_style_action = Action(\n            FIF.PALETTE,\n            self.tr(\"使用样式\"),\n            triggered=self.on_use_style_action_triggered,\n            checkable=True,\n        )\n        self.use_style_action.setToolTip(self.tr(\"启用字幕样式渲染\"))\n        self.command_bar.addAction(self.use_style_action)\n\n        self.command_bar.addSeparator()\n\n        # 添加渲染模式下拉按钮\n        self.render_mode_button = TransparentDropDownPushButton(\n            self.tr(\"渲染模式\"), self, FIF.FONT_SIZE\n        )\n        self.render_mode_button.setFixedHeight(34)\n        self.render_mode_button.setMinimumWidth(140)\n        self.render_mode_menu = RoundMenu(parent=self)\n        for mode in SubtitleRenderModeEnum:\n            action = Action(text=mode.value)\n            action.triggered.connect(\n                lambda checked, m=mode.value: self.on_render_mode_changed(m)\n            )\n            self.render_mode_menu.addAction(action)\n        self.render_mode_button.setMenu(self.render_mode_menu)\n        self.command_bar.addWidget(self.render_mode_button)\n\n        self.command_bar.addSeparator()\n\n        # 添加视频质量选择下拉按钮\n        self.video_quality_button = TransparentDropDownPushButton(\n            self.tr(\"视频质量\"), self, FIF.SPEED_HIGH\n        )\n        self.video_quality_button.setFixedHeight(34)\n        self.video_quality_button.setMinimumWidth(125)\n        self.video_quality_menu = RoundMenu(parent=self)\n        for quality in VideoQualityEnum:\n            action = Action(text=quality.value)\n            action.triggered.connect(\n                lambda checked, q=quality.value: self.on_video_quality_action_changed(q)\n            )\n            self.video_quality_menu.addAction(action)\n        self.video_quality_button.setMenu(self.video_quality_menu)\n        self.command_bar.addWidget(self.video_quality_button)\n\n        # 添加分隔符\n        self.command_bar.addSeparator()\n\n        # 添加是否合成视频选项\n        self.need_video_action = Action(\n            FIF.VIDEO,\n            self.tr(\"合成视频\"),\n            triggered=self.on_need_video_action_triggered,\n            checkable=True,\n        )\n        self.need_video_action.setToolTip(self.tr(\"是否生成新的视频文件\"))\n        self.command_bar.addAction(self.need_video_action)\n\n        self.command_bar.addSeparator()\n\n        # 添加打开文件夹按钮\n        folder_action = Action(FIF.FOLDER, \"\", triggered=self.open_video_folder)\n        folder_action.setToolTip(self.tr(\"打开输出文件夹\"))\n        self.command_bar.addAction(folder_action)\n\n    def setup_style(self):\n        self.subtitle_input.focusOutEvent = lambda e: super(\n            LineEdit, self.subtitle_input\n        ).focusOutEvent(e)\n        self.subtitle_input.paintEvent = lambda e: super(\n            LineEdit, self.subtitle_input\n        ).paintEvent(e)\n        self.subtitle_input.setStyleSheet(\n            self.subtitle_input.styleSheet()\n            + \"\"\"\n            QLineEdit {\n                border-radius: 15px;\n                padding: 0 20px;\n                background-color: transparent;\n                border: 1px solid rgba(255,255, 255, 0.08);\n            }\n            QLineEdit:focus[transparent=true] {\n                border: 1px solid rgba(47,141, 99, 0.48);\n            }\n        \"\"\"\n        )\n\n        self.video_input.focusOutEvent = lambda e: super(\n            LineEdit, self.video_input\n        ).focusOutEvent(e)\n        self.video_input.paintEvent = lambda e: super(\n            LineEdit, self.video_input\n        ).paintEvent(e)\n        self.video_input.setStyleSheet(\n            self.video_input.styleSheet()\n            + \"\"\"\n            QLineEdit {\n                border-radius: 15px;\n                padding: 0 20px;\n                background-color: transparent;\n                border: 1px solid rgba(255,255, 255, 0.08);\n            }\n            QLineEdit:focus[transparent=true] {\n                border: 1px solid rgba(47,141, 99, 0.48);\n            }\n        \"\"\"\n        )\n\n    def setup_signals(self):\n        # 文件选择相关信号\n        self.subtitle_button.clicked.connect(self.choose_subtitle_file)\n        self.video_button.clicked.connect(self.choose_video_file)\n\n        # 合成和文件夹相关信号\n        self.synthesize_button.clicked.connect(\n            lambda: self.start_video_synthesis(need_create_task=True)\n        )\n\n        # 全局 signalBus\n        signalBus.soft_subtitle_changed.connect(self.on_soft_subtitle_changed)\n        signalBus.need_video_changed.connect(self.on_need_video_changed)\n        signalBus.video_quality_changed.connect(self.on_video_quality_changed)\n        signalBus.use_subtitle_style_changed.connect(self.on_use_style_changed)\n        signalBus.subtitle_render_mode_changed.connect(self.on_render_mode_changed_external)\n\n    def set_value(self):\n        \"\"\"设置初始值\"\"\"\n        self.soft_subtitle_action.setChecked(cfg.soft_subtitle.value)\n        self.need_video_action.setChecked(cfg.need_video.value)\n        self.video_quality_button.setText(cfg.video_quality.value.value)\n\n        # 设置样式相关初始值\n        self.use_style_action.setChecked(cfg.use_subtitle_style.value)\n        self.render_mode_button.setText(cfg.subtitle_render_mode.value.value)\n        self._update_synthesis_controls_state()\n\n    def on_soft_subtitle_action_triggered(self, checked: bool):\n        \"\"\"处理软字幕按钮点击（更新配置+显示InfoBar）\"\"\"\n        cfg.set(cfg.soft_subtitle, checked)\n\n        # 显示说明信息\n        if checked:\n            # 开启软字幕时自动关闭使用样式\n            if self.use_style_action.isChecked():\n                self.use_style_action.setChecked(False)\n                cfg.set(cfg.use_subtitle_style, False)\n                self._update_style_controls_state()\n            InfoBar.info(\n                self.tr(\"开启软字幕\"),\n                self.tr(\"字幕作为独立轨道嵌入视频，不包含字幕样式\"),\n                duration=3000,\n                position=InfoBarPosition.BOTTOM,\n                parent=self,\n            )\n        else:\n            InfoBar.info(\n                self.tr(\"开启硬烧录字幕\"),\n                self.tr(\"字幕直接烧录到视频画面中，包含字幕样式\"),\n                duration=3000,\n                position=InfoBarPosition.BOTTOM,\n                parent=self,\n            )\n\n    def on_soft_subtitle_changed(self, checked: bool):\n        \"\"\"处理外部软字幕配置变更（仅更新UI状态）\"\"\"\n        self.soft_subtitle_action.setChecked(checked)\n\n    def on_need_video_action_triggered(self, checked: bool):\n        \"\"\"处理视频合成按钮点击（更新配置+显示InfoBar）\"\"\"\n        cfg.set(cfg.need_video, checked)\n        self._update_synthesis_controls_state()\n\n        # 显示说明信息\n        if checked:\n            InfoBar.info(\n                self.tr(\"开启视频合成\"),\n                self.tr(\"将进行视频与字幕的合成操作\"),\n                duration=3000,\n                position=InfoBarPosition.BOTTOM,\n                parent=self,\n            )\n        else:\n            InfoBar.info(\n                self.tr(\"关闭视频合成\"),\n                self.tr(\"仅生成字幕文件，不生成新的视频文件\"),\n                duration=3000,\n                position=InfoBarPosition.BOTTOM,\n                parent=self,\n            )\n\n    def on_need_video_changed(self, checked: bool):\n        \"\"\"处理外部视频合成配置变更（仅更新UI状态）\"\"\"\n        self.need_video_action.setChecked(checked)\n        self._update_synthesis_controls_state()\n\n    def on_video_quality_action_changed(self, quality_text: str):\n        \"\"\"处理质量选择\"\"\"\n        # 根据文本找到对应的枚举\n        quality_enum = None\n        for e in VideoQualityEnum:\n            if e.value == quality_text:\n                quality_enum = e\n                break\n\n        if quality_enum is None:\n            return\n\n        cfg.set(cfg.video_quality, quality_enum)\n        self.video_quality_button.setText(quality_text)\n\n    def on_video_quality_changed(self, quality_text: str):\n        \"\"\"处理外部质量配置变更（仅更新UI状态）\"\"\"\n        self.video_quality_button.setText(quality_text)\n\n    def on_use_style_action_triggered(self, checked: bool):\n        \"\"\"处理使用样式开关点击\"\"\"\n        cfg.set(cfg.use_subtitle_style, checked)\n        self._update_style_controls_state()\n\n        if checked:\n            # 启用样式时自动关闭软字幕\n            if self.soft_subtitle_action.isChecked():\n                self.soft_subtitle_action.setChecked(False)\n                cfg.set(cfg.soft_subtitle, False)\n            InfoBar.info(\n                self.tr(\"启用字幕样式\"),\n                self.tr(\"已自动切换为硬字幕渲染\"),\n                duration=3000,\n                position=InfoBarPosition.BOTTOM,\n                parent=self,\n            )\n        else:\n            InfoBar.info(\n                self.tr(\"关闭字幕样式\"),\n                self.tr(\"将使用默认字幕渲染\"),\n                duration=3000,\n                position=InfoBarPosition.BOTTOM,\n                parent=self,\n            )\n\n    def on_use_style_changed(self, checked: bool):\n        \"\"\"处理外部使用样式配置变更（仅更新 UI）\"\"\"\n        self.use_style_action.setChecked(checked)\n        self._update_style_controls_state()\n\n    def on_render_mode_changed(self, mode_text: str):\n        \"\"\"处理渲染模式选择（本界面触发）\"\"\"\n        mode_enum = None\n        for e in SubtitleRenderModeEnum:\n            if e.value == mode_text:\n                mode_enum = e\n                break\n        if mode_enum:\n            cfg.set(cfg.subtitle_render_mode, mode_enum)\n            self.render_mode_button.setText(mode_text)\n            signalBus.subtitle_render_mode_changed.emit(mode_text)\n\n    def on_render_mode_changed_external(self, mode_text: str):\n        \"\"\"处理外部渲染模式变更（仅更新 UI）\"\"\"\n        self.render_mode_button.setText(mode_text)\n\n    def _update_synthesis_controls_state(self):\n        \"\"\"更新所有合成相关控件的启用/禁用状态\"\"\"\n        need_video = self.need_video_action.isChecked()\n\n        # 合成视频关闭时，禁用所有相关选项\n        self.soft_subtitle_action.setEnabled(need_video)\n        self.use_style_action.setEnabled(need_video)\n        self.video_quality_button.setEnabled(need_video)\n\n        # 渲染模式按钮需要同时满足：合成视频开启 且 使用样式开启\n        self._update_style_controls_state()\n\n    def _update_style_controls_state(self):\n        \"\"\"更新样式相关控件的启用/禁用状态\"\"\"\n        need_video = self.need_video_action.isChecked()\n        use_style = self.use_style_action.isChecked()\n        # 渲染模式按钮：需要合成视频开启 且 使用样式开启\n        self.render_mode_button.setEnabled(need_video and use_style)\n\n    def choose_subtitle_file(self):\n        # 构建文件过滤器\n        subtitle_formats = \" \".join(\n            f\"*.{fmt.value}\" for fmt in SupportedSubtitleFormats\n        )\n        filter_str = f\"{self.tr('字幕文件')} ({subtitle_formats})\"\n\n        file_path, _ = QFileDialog.getOpenFileName(\n            self, self.tr(\"选择字幕文件\"), \"\", filter_str\n        )\n        if file_path:\n            self.subtitle_input.setText(file_path)\n\n    def choose_video_file(self):\n        # 构建文件过滤器\n        video_formats = \" \".join(f\"*.{fmt.value}\" for fmt in SupportedVideoFormats)\n        filter_str = f\"{self.tr('视频文件')} ({video_formats})\"\n\n        file_path, _ = QFileDialog.getOpenFileName(\n            self, self.tr(\"选择视频文件\"), \"\", filter_str\n        )\n        if file_path:\n            self.video_input.setText(file_path)\n\n    def create_task(self):\n        subtitle_file = self.subtitle_input.text()\n        video_file = self.video_input.text()\n        if not subtitle_file or not video_file:\n            InfoBar.error(\n                self.tr(\"错误\"),\n                self.tr(\"请选择字幕文件和视频文件\"),\n                duration=INFOBAR_DURATION_ERROR,\n                position=InfoBarPosition.TOP,\n                parent=self,\n            )\n            return None\n        return TaskFactory.create_synthesis_task(video_file, subtitle_file)\n\n    def set_task(self, task: SynthesisTask):\n        self.task = task\n        self.update_info()\n\n    def update_info(self):\n        if self.task:\n            self.video_input.setText(self.task.video_path)\n            self.subtitle_input.setText(self.task.subtitle_path)\n\n    def start_video_synthesis(self, need_create_task=True):\n        self.synthesize_button.setEnabled(False)\n        self.progress_bar.resume()\n        self.progress_bar.reset()\n        if need_create_task:\n            self.task = self.create_task()\n\n        if self.task:\n            self.video_synthesis_thread = VideoSynthesisThread(self.task)\n            self.video_synthesis_thread.finished.connect(\n                self.on_video_synthesis_finished\n            )\n            self.video_synthesis_thread.progress.connect(\n                self.on_video_synthesis_progress\n            )\n            self.video_synthesis_thread.error.connect(self.on_video_synthesis_error)\n            self.video_synthesis_thread.start()\n        else:\n            self.synthesize_button.setEnabled(True)\n\n    def process(self):\n        self.start_video_synthesis(need_create_task=False)\n\n    def on_video_synthesis_finished(self, task):\n        self.synthesize_button.setEnabled(True)\n        self.progress_bar.setValue(100)\n        self.open_video_folder()\n        InfoBar.success(\n            self.tr(\"成功\"),\n            self.tr(\"视频合成已完成\"),\n            duration=INFOBAR_DURATION_SUCCESS,\n            position=InfoBarPosition.TOP,\n            parent=self,\n        )\n\n    def on_video_synthesis_progress(self, progress, message):\n        self.progress_bar.setValue(progress)\n        self.status_label.setText(message)\n\n    def on_video_synthesis_error(self, error):\n        self.synthesize_button.setEnabled(True)\n        self.progress_bar.error()\n        InfoBar.error(\n            self.tr(\"错误\"),\n            str(error),\n            duration=INFOBAR_DURATION_ERROR,\n            position=InfoBarPosition.TOP,\n            parent=self,\n        )\n\n    def open_video_folder(self):\n        if self.task and self.task.output_path:\n            file_path = Path(self.task.output_path)\n            target_dir = str(\n                file_path.parent\n                if file_path.exists()\n                else (\n                    Path(str(self.task.video_path)).parent\n                    if self.task.video_path\n                    else file_path.parent\n                )\n            )\n            # Cross-platform folder opening\n            open_folder(target_dir)\n        else:\n            InfoBar.warning(\n                self.tr(\"警告\"),\n                self.tr(\"没有可用的视频文件夹\"),\n                duration=INFOBAR_DURATION_WARNING,\n                position=InfoBarPosition.TOP,\n                parent=self,\n            )\n\n    def dragEnterEvent(self, event):\n        \"\"\"拖拽进入事件处理\"\"\"\n        event.accept() if event.mimeData().hasUrls() else event.ignore()\n\n    def dropEvent(self, event: QDropEvent):\n        \"\"\"拖拽放下事件处理\"\"\"\n        files = [u.toLocalFile() for u in event.mimeData().urls()]\n        for file_path in files:\n            if not os.path.isfile(file_path):\n                continue\n\n            file_ext = os.path.splitext(file_path)[1][1:].lower()\n\n            # 检查文件格式是否支持\n            if file_ext in {fmt.value for fmt in SupportedSubtitleFormats}:\n                self.subtitle_input.setText(file_path)\n                InfoBar.success(\n                    self.tr(\"导入成功\"),\n                    self.tr(\"字幕文件已放入输入框\"),\n                    duration=INFOBAR_DURATION_SUCCESS,\n                    parent=self,\n                )\n                break\n            elif file_ext in {fmt.value for fmt in SupportedVideoFormats}:\n                self.video_input.setText(file_path)\n                InfoBar.success(\n                    self.tr(\"导入成功\"),\n                    self.tr(\"视频文件已输入框\"),\n                    duration=INFOBAR_DURATION_SUCCESS,\n                    parent=self,\n                )\n                break\n            else:\n                InfoBar.error(\n                    self.tr(\"格式错误\") + file_ext,\n                    self.tr(\"请拖入视频或者字幕文件\"),\n                    duration=INFOBAR_DURATION_ERROR,\n                    parent=self,\n                )\n\n\nif __name__ == \"__main__\":\n    QApplication.setHighDpiScaleFactorRoundingPolicy(\n        Qt.HighDpiScaleFactorRoundingPolicy.PassThrough\n    )\n    QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)  # type: ignore\n    QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)  # type: ignore\n\n    app = QApplication(sys.argv)\n    window = VideoSynthesisInterface()\n    window.resize(600, 400)  # 设置窗口大小\n    window.show()\n    sys.exit(app.exec_())\n"
  },
  {
    "path": "docs/.vitepress/config.mts",
    "content": "import { defineConfig } from 'vitepress'\n\nexport default defineConfig({\n  title: 'VideoCaptioner',\n  description: '基于大语言模型(LLM)的视频字幕处理助手，支持语音识别、字幕断句、优化、翻译全流程处理',\n  titleTemplate: ':title - VideoCaptioner',\n\n  lastUpdated: true,\n  cleanUrls: true,\n  ignoreDeadLinks: true,\n\n  // 多语言替代链接配置\n  transformHead({ pageData }) {\n    const canonicalUrl = `https://weifeng2333.github.io/VideoCaptioner/${pageData.relativePath}`\n      .replace(/index\\.md$/, '')\n      .replace(/\\.md$/, '')\n\n    const head: [string, Record<string, string>][] = [\n      ['link', { rel: 'canonical', href: canonicalUrl }]\n    ]\n\n    // 中英文页面互相引用\n    if (!pageData.relativePath.startsWith('en/')) {\n      // 中文页面指向英文版本\n      const enPath = `https://weifeng2333.github.io/VideoCaptioner/en/${pageData.relativePath}`\n        .replace(/index\\.md$/, '')\n        .replace(/\\.md$/, '')\n      head.push(\n        ['link', { rel: 'alternate', hreflang: 'en', href: enPath }],\n        ['link', { rel: 'alternate', hreflang: 'zh-CN', href: canonicalUrl }],\n        ['link', { rel: 'alternate', hreflang: 'x-default', href: canonicalUrl }]\n      )\n    } else {\n      // 英文页面指向中文版本\n      const zhPath = `https://weifeng2333.github.io/VideoCaptioner/${pageData.relativePath.replace('en/', '')}`\n        .replace(/index\\.md$/, '')\n        .replace(/\\.md$/, '')\n      head.push(\n        ['link', { rel: 'alternate', hreflang: 'zh-CN', href: zhPath }],\n        ['link', { rel: 'alternate', hreflang: 'en', href: canonicalUrl }],\n        ['link', { rel: 'alternate', hreflang: 'x-default', href: zhPath }]\n      )\n    }\n\n    return head\n  },\n\n  // SEO 优化配置\n  head: [\n    // Favicon 和 App Icons\n    ['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/logo.png' }],\n    ['link', { rel: 'apple-touch-icon', sizes: '180x180', href: '/logo.png' }],\n    ['link', { rel: 'mask-icon', href: '/logo.png', color: '#5f67ee' }],\n\n    // 主题颜色和 Web App 配置\n    ['meta', { name: 'theme-color', content: '#5f67ee' }],\n    ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }],\n    ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }],\n    ['meta', { name: 'msapplication-TileColor', content: '#5f67ee' }],\n\n    // 核心 SEO Meta 标签（中英文混合关键词，提升国际化搜索）\n    ['meta', { name: 'keywords', content: 'VideoCaptioner,video subtitles,AI subtitles,automatic captions,视频字幕生成,自动字幕工具,Whisper subtitles,LLM translation,字幕翻译,subtitle optimization,语音识别,speech recognition,字幕错别字优化,视频处理,video processing,开源字幕软件,open source subtitle tool,卡卡字幕助手' }],\n    ['meta', { name: 'author', content: 'WEIFENG' }],\n    ['meta', { name: 'viewport', content: 'width=device-width, initial-scale=1.0, viewport-fit=cover' }],\n\n    // 额外的搜索引擎指令\n    ['meta', { name: 'robots', content: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1' }],\n    ['meta', { name: 'googlebot', content: 'index, follow' }],\n    ['meta', { name: 'bingbot', content: 'index, follow' }],\n\n    // Open Graph（中文为主）\n    ['meta', { property: 'og:type', content: 'website' }],\n    ['meta', { property: 'og:locale', content: 'zh_CN' }],\n    ['meta', { property: 'og:locale:alternate', content: 'en_US' }],\n    ['meta', { property: 'og:title', content: 'VideoCaptioner - 基于LLM的智能视频字幕处理工具' }],\n    ['meta', { property: 'og:description', content: '免费开源的AI视频字幕处理助手。支持Whisper语音识别、LLM智能断句与翻译、多语言字幕生成。适用于YouTube、B站等平台，支持99种语言。' }],\n    ['meta', { property: 'og:site_name', content: 'VideoCaptioner' }],\n    ['meta', { property: 'og:url', content: 'https://weifeng2333.github.io/VideoCaptioner/' }],\n    ['meta', { property: 'og:image', content: 'https://weifeng2333.github.io/VideoCaptioner/logo.png' }],\n    ['meta', { property: 'og:image:width', content: '1200' }],\n    ['meta', { property: 'og:image:height', content: '630' }],\n    ['meta', { property: 'og:image:alt', content: 'VideoCaptioner Logo' }],\n\n    // Twitter Card（英文为主，面向国际用户）\n    ['meta', { name: 'twitter:card', content: 'summary_large_image' }],\n    ['meta', { name: 'twitter:site', content: '@VideoCaptioner' }],\n    ['meta', { name: 'twitter:creator', content: '@WEIFENG' }],\n    ['meta', { name: 'twitter:title', content: 'VideoCaptioner - AI-Powered Video Subtitle Tool' }],\n    ['meta', { name: 'twitter:description', content: 'Free & open-source AI subtitle tool powered by Whisper & LLM. Supports 99 languages with intelligent segmentation and translation.' }],\n    ['meta', { name: 'twitter:image', content: 'https://weifeng2333.github.io/VideoCaptioner/logo.png' }],\n    ['meta', { name: 'twitter:image:alt', content: 'VideoCaptioner - AI Video Subtitle Tool' }],\n\n    // 百度站长验证（需要时取消注释）\n    // ['meta', { name: 'baidu-site-verification', content: 'codeva-XXXXXXXX' }],\n\n    // Google 站长验证（需要时取消注释）\n    // ['meta', { name: 'google-site-verification', content: 'XXXXXXXXXXXXXXXXXXXXXXX' }],\n\n    // 增强的 JSON-LD 结构化数据（SoftwareApplication + Organization + WebSite）\n    ['script', { type: 'application/ld+json' }, JSON.stringify({\n      '@context': 'https://schema.org',\n      '@graph': [\n        {\n          '@type': 'SoftwareApplication',\n          '@id': 'https://weifeng2333.github.io/VideoCaptioner/#software',\n          name: 'VideoCaptioner',\n          alternateName: ['卡卡字幕助手', 'Video Captioner', 'AI Subtitle Tool'],\n          description: '基于大语言模型和Whisper的智能视频字幕处理工具，支持语音识别、智能断句、字幕优化和多语言翻译',\n          applicationCategory: 'MultimediaApplication',\n          operatingSystem: ['Windows 10', 'Windows 11', 'macOS 10.15+', 'Linux'],\n          softwareVersion: '1.4.0',\n          offers: {\n            '@type': 'Offer',\n            price: '0',\n            priceCurrency: 'USD'\n          },\n          author: {\n            '@type': 'Person',\n            name: 'WEIFENG',\n            url: 'https://github.com/WEIFENG2333'\n          },\n          aggregateRating: {\n            '@type': 'AggregateRating',\n            ratingValue: '4.8',\n            ratingCount: '150',\n            bestRating: '5',\n            worstRating: '1'\n          },\n          screenshot: 'https://h1.appinn.me/file/1731487405884_main.png',\n          url: 'https://weifeng2333.github.io/VideoCaptioner/',\n          downloadUrl: 'https://github.com/WEIFENG2333/VideoCaptioner/releases',\n          image: 'https://weifeng2333.github.io/VideoCaptioner/logo.png',\n          keywords: 'video subtitles, AI subtitles, Whisper, LLM, speech recognition, subtitle translation, 视频字幕, 自动字幕',\n          inLanguage: ['zh-CN', 'en-US'],\n          featureList: [\n            'Whisper语音识别',\n            'LLM智能断句',\n            '多语言翻译',\n            '字幕优化',\n            '批量处理',\n            '支持99种语言'\n          ]\n        },\n        {\n          '@type': 'WebSite',\n          '@id': 'https://weifeng2333.github.io/VideoCaptioner/#website',\n          url: 'https://weifeng2333.github.io/VideoCaptioner/',\n          name: 'VideoCaptioner Documentation',\n          description: 'VideoCaptioner 官方文档 - 视频字幕处理工具使用指南',\n          publisher: {\n            '@id': 'https://weifeng2333.github.io/VideoCaptioner/#organization'\n          },\n          inLanguage: ['zh-CN', 'en-US'],\n          potentialAction: {\n            '@type': 'SearchAction',\n            target: 'https://weifeng2333.github.io/VideoCaptioner/?q={search_term_string}',\n            'query-input': 'required name=search_term_string'\n          }\n        },\n        {\n          '@type': 'Organization',\n          '@id': 'https://weifeng2333.github.io/VideoCaptioner/#organization',\n          name: 'VideoCaptioner',\n          url: 'https://weifeng2333.github.io/VideoCaptioner/',\n          logo: {\n            '@type': 'ImageObject',\n            url: 'https://weifeng2333.github.io/VideoCaptioner/logo.png',\n            width: 200,\n            height: 200\n          },\n          sameAs: [\n            'https://github.com/WEIFENG2333/VideoCaptioner'\n          ]\n        }\n      ]\n    })]\n  ],\n\n  // Sitemap 生成配置\n  sitemap: {\n    hostname: 'https://weifeng2333.github.io/VideoCaptioner/',\n    transformItems(items) {\n      // 为不同类型页面设置不同的优先级和更新频率\n      return items.map(item => {\n        const url = item.url\n        // 首页最高优先级 (exact match for homepage)\n        if (url === 'https://weifeng2333.github.io/VideoCaptioner/' ||\n            url === 'https://weifeng2333.github.io/VideoCaptioner/en/') {\n          return { ...item, priority: 1.0, changefreq: 'daily' }\n        }\n        // 指南页面高优先级\n        else if (url.includes('/guide/')) {\n          return { ...item, priority: 0.8, changefreq: 'weekly' }\n        }\n        // 配置页面中等优先级\n        else if (url.includes('/config/')) {\n          return { ...item, priority: 0.6, changefreq: 'monthly' }\n        }\n        // 其他页面\n        else {\n          return { ...item, priority: 0.5, changefreq: 'monthly' }\n        }\n      })\n    }\n  },\n\n  themeConfig: {\n    logo: '/logo.png',\n\n    search: {\n      provider: 'local',\n      options: {\n        locales: {\n          zh: {\n            translations: {\n              button: {\n                buttonText: '搜索文档',\n                buttonAriaLabel: '搜索文档'\n              },\n              modal: {\n                noResultsText: '无法找到相关结果',\n                resetButtonTitle: '清除查询条件',\n                footer: {\n                  selectText: '选择',\n                  navigateText: '切换'\n                }\n              }\n            }\n          }\n        }\n      }\n    },\n\n    socialLinks: [\n      { icon: 'github', link: 'https://github.com/WEIFENG2333/VideoCaptioner' }\n    ]\n  },\n\n  locales: {\n    root: {\n      label: '简体中文',\n      lang: 'zh-CN',\n      themeConfig: {\n        nav: [\n          { text: '首页', link: '/' },\n          { text: '指南', link: '/guide/getting-started' },\n          { text: '配置', link: '/config/llm' },\n          { text: '开发', link: '/dev/architecture' }\n        ],\n\n        sidebar: {\n          '/guide/': [\n            {\n              text: '使用指南',\n              items: [\n                { text: '快速开始', link: '/guide/getting-started' },\n                { text: '快速示例', link: '/guide/quick-example' },\n                { text: 'LLM API 配置', link: '/guide/llm-config' },\n                { text: 'Cookie 配置', link: '/guide/cookies-config' },\n                { text: '基础配置', link: '/guide/configuration' },\n                { text: '工作流程', link: '/guide/workflow' },\n                { text: '常见问题', link: '/guide/faq' }\n              ]\n            },\n            {\n              text: '高级功能',\n              items: [\n                { text: '批量处理', link: '/guide/batch-processing' },\n                { text: '字幕样式', link: '/guide/subtitle-style' },\n                { text: '文稿匹配', link: '/guide/manuscript' }\n              ]\n            }\n          ],\n          '/config/': [\n            {\n              text: '配置指南',\n              items: [\n                { text: 'LLM 配置', link: '/config/llm' },\n                { text: '语音识别配置', link: '/config/asr' },\n                { text: '翻译配置', link: '/config/translator' },\n                { text: 'Cookie 配置', link: '/config/cookies' }\n              ]\n            }\n          ],\n          '/dev/': [\n            {\n              text: '开发文档',\n              items: [\n                { text: '架构设计', link: '/dev/architecture' },\n                { text: 'API 文档', link: '/dev/api' },\n                { text: '贡献指南', link: '/dev/contributing' }\n              ]\n            }\n          ]\n        },\n\n        editLink: {\n          pattern: 'https://github.com/WEIFENG2333/VideoCaptioner/edit/master/docs/:path',\n          text: '在 GitHub 上编辑此页'\n        },\n\n        footer: {\n          message: '基于 MIT 许可发布',\n          copyright: 'Copyright © 2024-present WEIFENG'\n        },\n\n        docFooter: {\n          prev: '上一页',\n          next: '下一页'\n        },\n\n        outline: {\n          label: '页面导航'\n        },\n\n        lastUpdated: {\n          text: '最后更新于',\n          formatOptions: {\n            dateStyle: 'short',\n            timeStyle: 'medium'\n          }\n        },\n\n        returnToTopLabel: '回到顶部',\n        sidebarMenuLabel: '菜单',\n        darkModeSwitchLabel: '主题',\n        lightModeSwitchTitle: '切换到浅色模式',\n        darkModeSwitchTitle: '切换到深色模式'\n      }\n    },\n\n    en: {\n      label: 'English',\n      lang: 'en-US',\n      link: '/en/',\n      themeConfig: {\n        nav: [\n          { text: 'Home', link: '/en/' },\n          { text: 'Guide', link: '/en/guide/getting-started' },\n          { text: 'Config', link: '/en/config/llm' },\n          { text: 'Dev', link: '/en/dev/architecture' }\n        ],\n\n        sidebar: {\n          '/en/guide/': [\n            {\n              text: 'User Guide',\n              items: [\n                { text: 'Getting Started', link: '/en/guide/getting-started' },\n                { text: 'Configuration', link: '/en/guide/configuration' },\n                { text: 'Workflow', link: '/en/guide/workflow' },\n                { text: 'FAQ', link: '/en/guide/faq' }\n              ]\n            },\n            {\n              text: 'Advanced Features',\n              items: [\n                { text: 'Batch Processing', link: '/en/guide/batch-processing' },\n                { text: 'Subtitle Style', link: '/en/guide/subtitle-style' },\n                { text: 'Manuscript Matching', link: '/en/guide/manuscript' }\n              ]\n            }\n          ],\n          '/en/config/': [\n            {\n              text: 'Configuration',\n              items: [\n                { text: 'LLM Configuration', link: '/en/config/llm' },\n                { text: 'ASR Configuration', link: '/en/config/asr' },\n                { text: 'Translation', link: '/en/config/translator' },\n                { text: 'Cookie Setup', link: '/en/config/cookies' }\n              ]\n            }\n          ],\n          '/en/dev/': [\n            {\n              text: 'Developer Docs',\n              items: [\n                { text: 'Architecture', link: '/en/dev/architecture' },\n                { text: 'API Reference', link: '/en/dev/api' },\n                { text: 'Contributing', link: '/en/dev/contributing' }\n              ]\n            }\n          ]\n        },\n\n        editLink: {\n          pattern: 'https://github.com/WEIFENG2333/VideoCaptioner/edit/master/docs/:path',\n          text: 'Edit this page on GitHub'\n        },\n\n        footer: {\n          message: 'Released under the MIT License',\n          copyright: 'Copyright © 2024-present WEIFENG'\n        }\n      }\n    }\n  }\n})\n"
  },
  {
    "path": "docs/.vitepress/theme/CustomHome.vue",
    "content": "<template>\n  <div class=\"custom-home\">\n    <!-- Hero Section -->\n    <section class=\"hero-section\">\n      <div class=\"hero-bg-decoration\"></div>\n      <div class=\"container\">\n        <div class=\"hero-content\">\n          <div class=\"hero-badge\">\n            <span class=\"badge-emoji\">🎬</span>\n            开源免费 · 用心打造\n          </div>\n          <h1 class=\"hero-title\">\n            给你的视频<br>\n            <span class=\"gradient-text\">一副好字幕</span>\n          </h1>\n          <p class=\"hero-description\">\n            不只是转录文字，更懂语义和语境<br>\n            让 AI 帮你把字幕做得像人工精修一样好\n          </p>\n          <div class=\"hero-stats-inline\">\n            <span class=\"stat-inline\">⚡ 4分钟处理14分钟视频</span>\n            <span class=\"stat-divider\">·</span>\n            <span class=\"stat-inline\">💰 成本不足 ¥0.01</span>\n            <span class=\"stat-divider\">·</span>\n            <span class=\"stat-inline\">🌍 支持99种语言</span>\n          </div>\n          <div class=\"hero-actions\">\n            <a href=\"/guide/getting-started\" class=\"btn-primary\">\n              <span>立即开始</span>\n              <span class=\"btn-arrow\">→</span>\n            </a>\n            <a href=\"https://github.com/WEIFENG2333/VideoCaptioner\" class=\"btn-secondary\">\n              <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n                <path d=\"M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z\"/>\n              </svg>\n              <span>看看源码</span>\n            </a>\n          </div>\n        </div>\n        <div class=\"hero-visual\">\n          <div class=\"visual-card\">\n            <div class=\"card-decoration\"></div>\n            <img src=\"https://h1.appinn.me/file/1731487405884_main.png\" alt=\"VideoCaptioner 界面预览\">\n            <div class=\"card-badge\">实时预览</div>\n          </div>\n        </div>\n      </div>\n    </section>\n\n    <!-- Stats Section -->\n    <section class=\"stats-section\">\n      <div class=\"container\">\n        <div class=\"stats-grid\">\n          <div class=\"stat-item\">\n            <div class=\"stat-number\">95%+</div>\n            <div class=\"stat-label\">识别准确度</div>\n          </div>\n          <div class=\"stat-item\">\n            <div class=\"stat-number\">99</div>\n            <div class=\"stat-label\">支持语言</div>\n          </div>\n          <div class=\"stat-item\">\n            <div class=\"stat-number\">4min</div>\n            <div class=\"stat-label\">处理14分钟视频</div>\n          </div>\n          <div class=\"stat-item\">\n            <div class=\"stat-number\">¥0.01</div>\n            <div class=\"stat-label\">单视频成本</div>\n          </div>\n        </div>\n      </div>\n    </section>\n\n    <!-- Features Section -->\n    <section class=\"features-section\">\n      <div class=\"container\">\n        <div class=\"section-header\">\n          <h2 class=\"section-title\">我们在乎的事情</h2>\n          <p class=\"section-subtitle\">这些功能，都是为了让你更轻松</p>\n        </div>\n        <div class=\"features-grid\">\n          <div class=\"feature-card\">\n            <div class=\"feature-icon-wrap\">\n              <div class=\"feature-icon\">⚡</div>\n            </div>\n            <h3 class=\"feature-title\">快，真的快</h3>\n            <p class=\"feature-desc\">14分钟视频只需4分钟处理。你去泡杯咖啡的功夫，字幕就好了</p>\n          </div>\n          <div class=\"feature-card\">\n            <div class=\"feature-icon-wrap\">\n              <div class=\"feature-icon\">🧠</div>\n            </div>\n            <h3 class=\"feature-title\">懂语义，不只是转录</h3>\n            <p class=\"feature-desc\">LLM 会帮你智能断句、纠正错别字、统一专业术语。就像有个助手在帮你</p>\n          </div>\n          <div class=\"feature-card\">\n            <div class=\"feature-icon-wrap\">\n              <div class=\"feature-icon\">🌍</div>\n            </div>\n            <h3 class=\"feature-title\">全球化不是梦</h3>\n            <p class=\"feature-desc\">99种语言识别，37种语言翻译。你的内容可以触达全世界</p>\n          </div>\n          <div class=\"feature-card\">\n            <div class=\"feature-icon-wrap\">\n              <div class=\"feature-icon\">💝</div>\n            </div>\n            <h3 class=\"feature-title\">完全免费，永久开源</h3>\n            <p class=\"feature-desc\">MIT 协议，代码透明。你的数据在本地，隐私完全掌控在自己手里</p>\n          </div>\n          <div class=\"feature-card\">\n            <div class=\"feature-icon-wrap\">\n              <div class=\"feature-icon\">🏠</div>\n            </div>\n            <h3 class=\"feature-title\">老电脑也能用</h3>\n            <p class=\"feature-desc\">不需要昂贵的显卡。有 CPU 就能跑，有 GPU 更快。云端和本地随你选</p>\n          </div>\n          <div class=\"feature-card\">\n            <div class=\"feature-icon-wrap\">\n              <div class=\"feature-icon\">🎨</div>\n            </div>\n            <h3 class=\"feature-title\">样式随心调</h3>\n            <p class=\"feature-desc\">科普风、新闻风、番剧风...各种模板任你挑。支持 SRT、ASS、VTT 格式</p>\n          </div>\n        </div>\n      </div>\n    </section>\n\n    <!-- CTA Section -->\n    <section class=\"cta-section\">\n      <div class=\"container\">\n        <div class=\"cta-content\">\n          <div class=\"cta-emoji\">✨</div>\n          <h2 class=\"cta-title\">试试看？不用担心，完全免费</h2>\n          <p class=\"cta-description\">\n            下载或者直接从源码运行都可以<br>\n            有问题随时在 GitHub 提 Issue，社区会帮你\n          </p>\n          <div class=\"cta-buttons\">\n            <a href=\"/guide/getting-started\" class=\"btn-cta-primary\">\n              <span>开始使用</span>\n              <span class=\"btn-arrow\">→</span>\n            </a>\n            <a href=\"/guide/faq\" class=\"btn-cta-secondary\">\n              <span>看看常见问题</span>\n            </a>\n          </div>\n        </div>\n      </div>\n    </section>\n  </div>\n</template>\n\n<script setup>\n</script>\n\n<style scoped>\n/* 绿色系配色 - 高对比度 */\n.custom-home {\n  --vc-green-primary: #10b981;\n  --vc-green-dark: #047857;\n  --vc-green-light: #6ee7b7;\n  --vc-text-strong: #1a1a1a;\n  --vc-text-body: #4b5563;\n  --vc-bg-soft: #f9fafb;\n}\n\n.dark .custom-home {\n  --vc-text-strong: #ffffff;\n  --vc-text-body: #d1d5db;\n  --vc-bg-soft: #1f2937;\n  --vc-green-primary: #34d399;\n  --vc-green-dark: #10b981;\n}\n\n/* Container */\n.container {\n  max-width: 1200px;\n  margin: 0 auto;\n  padding: 0 2rem;\n}\n\n/* Hero Section */\n.hero-section {\n  position: relative;\n  padding: 6rem 0 4rem;\n  overflow: hidden;\n  background: var(--vp-c-bg);\n}\n\n.hero-bg-decoration {\n  position: absolute;\n  top: -50%;\n  right: -20%;\n  width: 800px;\n  height: 800px;\n  background: radial-gradient(circle, rgba(16, 185, 129, 0.05) 0%, transparent 70%);\n  border-radius: 50%;\n  pointer-events: none;\n}\n\n.hero-section .container {\n  display: grid;\n  grid-template-columns: 1fr 1fr;\n  gap: 4rem;\n  align-items: center;\n  position: relative;\n  z-index: 1;\n}\n\n.hero-badge {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.5rem 1rem;\n  background: var(--vc-bg-soft);\n  color: var(--vc-green-dark);\n  border-radius: 100px;\n  font-size: 0.875rem;\n  font-weight: 600;\n  margin-bottom: 2rem;\n  border: 1.5px solid var(--vc-green-primary);\n}\n\n.dark .hero-badge {\n  background: rgba(16, 185, 129, 0.15);\n  color: var(--vc-green-light);\n  border-color: var(--vc-green-primary);\n}\n\n.badge-emoji {\n  font-size: 1.1rem;\n}\n\n.hero-title {\n  font-size: clamp(2.5rem, 5vw, 4rem);\n  font-weight: 800;\n  line-height: 1.15;\n  letter-spacing: -0.02em;\n  margin: 0 0 1.5rem;\n  color: var(--vc-text-strong);\n}\n\n.gradient-text {\n  color: var(--vc-green-primary);\n}\n\n.hero-description {\n  font-size: 1.15rem;\n  line-height: 1.8;\n  color: var(--vc-text-body);\n  margin: 0 0 1.5rem;\n}\n\n.hero-stats-inline {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 0.75rem;\n  margin: 0 0 2rem;\n  font-size: 0.9375rem;\n  color: var(--vc-text-body);\n}\n\n.stat-inline {\n  display: flex;\n  align-items: center;\n  gap: 0.25rem;\n  padding: 0.375rem 0.75rem;\n  background: var(--vc-bg-soft);\n  border-radius: 8px;\n}\n\n.stat-divider {\n  opacity: 0.3;\n}\n\n.hero-actions {\n  display: flex;\n  gap: 1rem;\n  flex-wrap: wrap;\n}\n\n.btn-primary, .btn-secondary {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.875rem 1.75rem;\n  font-size: 1rem;\n  font-weight: 600;\n  border-radius: 8px;\n  text-decoration: none;\n  transition: all 0.2s ease;\n}\n\n.btn-primary {\n  background: var(--vc-green-primary);\n  color: white;\n  border: none;\n}\n\n.btn-primary:hover {\n  background: var(--vc-green-dark);\n  transform: translateY(-1px);\n  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);\n}\n\n.btn-arrow {\n  transition: transform 0.2s ease;\n}\n\n.btn-primary:hover .btn-arrow {\n  transform: translateX(2px);\n}\n\n.btn-secondary {\n  background: var(--vp-c-bg);\n  color: var(--vc-text-body);\n  border: 1.5px solid var(--vp-c-divider);\n}\n\n.btn-secondary:hover {\n  border-color: var(--vc-green-primary);\n  color: var(--vc-green-primary);\n  background: var(--vc-bg-soft);\n}\n\n.hero-visual {\n  position: relative;\n}\n\n.visual-card {\n  position: relative;\n  border-radius: 16px;\n  overflow: hidden;\n  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12);\n  background: var(--vp-c-bg);\n  border: 1px solid var(--vp-c-divider);\n}\n\n.card-decoration {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  height: 4px;\n  background: linear-gradient(90deg, var(--vc-green-1), var(--vc-green-3));\n}\n\n.visual-card img {\n  width: 100%;\n  height: auto;\n  display: block;\n}\n\n.card-badge {\n  position: absolute;\n  top: 1rem;\n  right: 1rem;\n  padding: 0.375rem 0.75rem;\n  background: rgba(16, 185, 129, 0.9);\n  backdrop-filter: blur(8px);\n  color: white;\n  font-size: 0.75rem;\n  font-weight: 600;\n  border-radius: 6px;\n}\n\n/* Stats Section */\n.stats-section {\n  padding: 3rem 0;\n  border-top: 1px solid var(--vp-c-divider);\n  border-bottom: 1px solid var(--vp-c-divider);\n  background: var(--vc-bg-soft);\n}\n\n.stats-grid {\n  display: grid;\n  grid-template-columns: repeat(4, 1fr);\n  gap: 2rem;\n}\n\n.stat-item {\n  text-align: center;\n}\n\n.stat-number {\n  font-size: 2.5rem;\n  font-weight: 800;\n  color: var(--vc-green-primary);\n  line-height: 1;\n  margin-bottom: 0.5rem;\n}\n\n.stat-label {\n  font-size: 0.9375rem;\n  color: var(--vc-text-body);\n  font-weight: 500;\n}\n\n/* Features Section */\n.features-section {\n  padding: 5rem 0;\n  background: var(--vp-c-bg);\n}\n\n.section-header {\n  text-align: center;\n  margin-bottom: 3.5rem;\n}\n\n.section-title {\n  font-size: clamp(2rem, 4vw, 2.75rem);\n  font-weight: 800;\n  line-height: 1.2;\n  letter-spacing: -0.02em;\n  margin: 0 0 0.75rem;\n  color: var(--vc-text-strong);\n}\n\n.section-subtitle {\n  font-size: 1.125rem;\n  color: var(--vc-text-body);\n  margin: 0;\n}\n\n.features-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 2rem;\n}\n\n.feature-card {\n  padding: 2rem;\n  background: var(--vc-bg-soft);\n  border-radius: 12px;\n  border: 1px solid var(--vp-c-divider);\n  transition: all 0.2s ease;\n}\n\n.feature-card:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);\n  border-color: var(--vc-green-primary);\n}\n\n.feature-icon-wrap {\n  display: inline-flex;\n  padding: 0.75rem;\n  background: rgba(16, 185, 129, 0.1);\n  border-radius: 10px;\n  margin-bottom: 1.25rem;\n}\n\n.feature-icon {\n  font-size: 2rem;\n  line-height: 1;\n}\n\n.feature-title {\n  font-size: 1.25rem;\n  font-weight: 700;\n  margin: 0 0 0.75rem;\n  color: var(--vc-text-strong);\n}\n\n.feature-desc {\n  font-size: 0.9375rem;\n  line-height: 1.7;\n  color: var(--vc-text-body);\n  margin: 0;\n}\n\n/* CTA Section */\n.cta-section {\n  padding: 5rem 0;\n  background: linear-gradient(135deg, var(--vc-green-primary) 0%, var(--vc-green-dark) 100%);\n  color: white;\n  position: relative;\n  overflow: hidden;\n}\n\n.cta-section::before {\n  content: '';\n  position: absolute;\n  top: -50%;\n  right: -20%;\n  width: 600px;\n  height: 600px;\n  background: radial-gradient(circle, rgba(255, 255, 255, 0.08) 0%, transparent 70%);\n  border-radius: 50%;\n}\n\n.cta-content {\n  text-align: center;\n  position: relative;\n  z-index: 1;\n}\n\n.cta-emoji {\n  font-size: 3rem;\n  margin-bottom: 1rem;\n}\n\n.cta-title {\n  font-size: clamp(1.75rem, 4vw, 2.5rem);\n  font-weight: 800;\n  margin: 0 0 1rem;\n  letter-spacing: -0.01em;\n  color: white;\n}\n\n.cta-description {\n  font-size: 1.125rem;\n  margin: 0 0 2rem;\n  opacity: 0.95;\n  line-height: 1.7;\n  color: rgba(255, 255, 255, 0.9);\n}\n\n.cta-buttons {\n  display: flex;\n  gap: 1rem;\n  justify-content: center;\n  flex-wrap: wrap;\n}\n\n.btn-cta-primary, .btn-cta-secondary {\n  display: inline-flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 1rem 2rem;\n  font-size: 1rem;\n  font-weight: 600;\n  border-radius: 8px;\n  text-decoration: none;\n  transition: all 0.2s ease;\n}\n\n.btn-cta-primary {\n  background: white;\n  color: var(--vc-green-dark);\n  border: none;\n}\n\n.btn-cta-primary:hover {\n  transform: translateY(-1px);\n  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);\n}\n\n.btn-cta-primary:hover .btn-arrow {\n  transform: translateX(2px);\n}\n\n.btn-cta-secondary {\n  background: rgba(255, 255, 255, 0.1);\n  backdrop-filter: blur(10px);\n  color: white;\n  border: 1.5px solid rgba(255, 255, 255, 0.3);\n}\n\n.btn-cta-secondary:hover {\n  background: rgba(255, 255, 255, 0.2);\n  border-color: rgba(255, 255, 255, 0.5);\n}\n\n/* Responsive */\n@media (max-width: 960px) {\n  .hero-section .container {\n    grid-template-columns: 1fr;\n    gap: 3rem;\n  }\n\n  .hero-visual {\n    order: -1;\n  }\n\n  .stats-grid {\n    grid-template-columns: repeat(2, 1fr);\n  }\n\n  .features-grid {\n    grid-template-columns: 1fr;\n  }\n}\n\n@media (max-width: 640px) {\n  .hero-section {\n    padding: 4rem 0 3rem;\n  }\n\n  .stats-grid {\n    grid-template-columns: 1fr;\n    gap: 1.5rem;\n  }\n\n  .hero-stats-inline {\n    flex-direction: column;\n    align-items: flex-start;\n  }\n\n  .stat-divider {\n    display: none;\n  }\n\n  .cta-buttons {\n    flex-direction: column;\n    align-items: stretch;\n  }\n\n  .btn-cta-primary, .btn-cta-secondary {\n    width: 100%;\n    justify-content: center;\n  }\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/custom.css",
    "content": "/**\n * VideoCaptioner Custom Theme\n * Inspired by Anthropic's modern, elegant design\n */\n\n/* ===== Color Variables ===== */\n:root {\n  /* Primary Brand Colors - Purple Gradient */\n  --vc-c-brand-1: #667eea;\n  --vc-c-brand-2: #764ba2;\n  --vc-c-brand-3: #5a67d8;\n\n  /* Accent Colors */\n  --vc-c-accent: #d97757;\n  --vc-c-success: #43e97b;\n  --vc-c-info: #4facfe;\n\n  /* Light Theme */\n  --vp-c-brand-1: var(--vc-c-brand-1);\n  --vp-c-brand-2: var(--vc-c-brand-2);\n  --vp-c-brand-3: var(--vc-c-brand-3);\n\n  /* Refined spacing */\n  --vc-spacing-xs: 0.5rem;\n  --vc-spacing-sm: 1rem;\n  --vc-spacing-md: 2rem;\n  --vc-spacing-lg: 4rem;\n  --vc-spacing-xl: 6rem;\n\n  /* Smooth transitions */\n  --vc-transition-fast: 0.15s cubic-bezier(0.16, 1, 0.3, 1);\n  --vc-transition-base: 0.3s cubic-bezier(0.16, 1, 0.3, 1);\n  --vc-transition-slow: 0.5s cubic-bezier(0.16, 1, 0.3, 1);\n\n  /* Shadows */\n  --vc-shadow-sm: 0 2px 8px rgba(102, 126, 234, 0.08);\n  --vc-shadow-md: 0 8px 24px rgba(102, 126, 234, 0.12);\n  --vc-shadow-lg: 0 16px 48px rgba(102, 126, 234, 0.16);\n  --vc-shadow-xl: 0 24px 64px rgba(102, 126, 234, 0.2);\n}\n\n.dark {\n  --vc-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);\n  --vc-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);\n  --vc-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);\n  --vc-shadow-xl: 0 24px 64px rgba(0, 0, 0, 0.6);\n}\n\n/* ===== Hero Section - Anthropic Style ===== */\n.VPHero {\n  padding-top: var(--vc-spacing-xl) !important;\n  padding-bottom: var(--vc-spacing-xl) !important;\n}\n\n.VPHero .container {\n  max-width: 1280px;\n}\n\n.VPHero .name {\n  font-size: clamp(2.5rem, 5vw, 4rem) !important;\n  font-weight: 800 !important;\n  letter-spacing: -0.02em !important;\n  line-height: 1.1 !important;\n  background: linear-gradient(\n    135deg,\n    var(--vc-c-brand-1) 0%,\n    var(--vc-c-brand-2) 100%\n  );\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n  animation: fadeInUp 0.8s var(--vc-transition-base) backwards;\n}\n\n.VPHero .text {\n  font-size: clamp(1.5rem, 3vw, 2.5rem) !important;\n  font-weight: 600 !important;\n  letter-spacing: -0.01em !important;\n  line-height: 1.3 !important;\n  margin-top: var(--vc-spacing-sm) !important;\n  animation: fadeInUp 0.8s 0.1s var(--vc-transition-base) backwards;\n}\n\n.VPHero .tagline {\n  font-size: clamp(1rem, 2vw, 1.25rem) !important;\n  line-height: 1.6 !important;\n  margin-top: var(--vc-spacing-md) !important;\n  opacity: 0.8;\n  max-width: 800px;\n  animation: fadeInUp 0.8s 0.2s var(--vc-transition-base) backwards;\n}\n\n.VPHero .actions {\n  margin-top: var(--vc-spacing-md) !important;\n  animation: fadeInUp 0.8s 0.3s var(--vc-transition-base) backwards;\n}\n\n/* Hero Image Animation */\n.VPHero .VPImage {\n  animation:\n    float 6s ease-in-out infinite,\n    fadeIn 1s var(--vc-transition-base);\n}\n\n@keyframes float {\n  0%,\n  100% {\n    transform: translateY(0) rotate(0deg);\n  }\n  50% {\n    transform: translateY(-20px) rotate(2deg);\n  }\n}\n\n@keyframes fadeInUp {\n  from {\n    opacity: 0;\n    transform: translateY(30px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n/* ===== Buttons - Modern Style ===== */\n.VPButton {\n  font-weight: 600 !important;\n  padding: 0.875rem 2rem !important;\n  font-size: 1.0625rem !important;\n  border-radius: 12px !important;\n  transition: all var(--vc-transition-base) !important;\n  border: none !important;\n  position: relative;\n  overflow: hidden;\n}\n\n.VPButton.brand {\n  background: linear-gradient(\n    135deg,\n    var(--vc-c-brand-1) 0%,\n    var(--vc-c-brand-2) 100%\n  ) !important;\n  box-shadow: var(--vc-shadow-sm);\n}\n\n.VPButton.brand:hover {\n  transform: translateY(-2px);\n  box-shadow: var(--vc-shadow-md);\n}\n\n.VPButton.brand:active {\n  transform: translateY(0);\n}\n\n.VPButton.alt {\n  background: transparent !important;\n  border: 2px solid var(--vc-c-brand-1) !important;\n  color: var(--vc-c-brand-1) !important;\n}\n\n.VPButton.alt:hover {\n  background: rgba(102, 126, 234, 0.08) !important;\n  transform: translateY(-2px);\n}\n\n/* ===== Features - Card Grid ===== */\n.VPFeatures {\n  padding: var(--vc-spacing-xl) 0 !important;\n}\n\n.VPFeature {\n  border-radius: 16px !important;\n  border: 1px solid transparent !important;\n  padding: var(--vc-spacing-md) !important;\n  transition: all var(--vc-transition-base) !important;\n  background: var(--vp-c-bg-soft);\n  position: relative;\n  overflow: hidden;\n}\n\n.VPFeature::before {\n  content: \"\";\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  height: 3px;\n  background: linear-gradient(90deg, var(--vc-c-brand-1), var(--vc-c-brand-2));\n  opacity: 0;\n  transition: opacity var(--vc-transition-base);\n}\n\n.VPFeature:hover {\n  transform: translateY(-8px);\n  box-shadow: var(--vc-shadow-lg);\n  border-color: rgba(102, 126, 234, 0.2) !important;\n}\n\n.VPFeature:hover::before {\n  opacity: 1;\n}\n\n.VPFeature .icon {\n  font-size: 2.5rem !important;\n  line-height: 1 !important;\n  margin-bottom: var(--vc-spacing-sm) !important;\n  display: inline-block;\n  transition: transform var(--vc-transition-base);\n}\n\n.VPFeature:hover .icon {\n  transform: scale(1.1) rotate(5deg);\n}\n\n.VPFeature .title {\n  font-size: 1.25rem !important;\n  font-weight: 700 !important;\n  line-height: 1.4 !important;\n  margin-bottom: var(--vc-spacing-xs) !important;\n}\n\n.VPFeature .details {\n  font-size: 0.9375rem !important;\n  line-height: 1.7 !important;\n  opacity: 0.85;\n}\n\n/* ===== Content Area ===== */\n.vp-doc {\n  font-size: 1.0625rem;\n  line-height: 1.75;\n}\n\n.vp-doc h1,\n.vp-doc h2,\n.vp-doc h3 {\n  font-weight: 700;\n  letter-spacing: -0.01em;\n  line-height: 1.3;\n  position: relative;\n}\n\n.vp-doc h2 {\n  font-size: clamp(1.75rem, 3vw, 2.25rem);\n  margin-top: var(--vc-spacing-lg);\n  padding-top: var(--vc-spacing-md);\n  border-top: 1px solid var(--vp-c-divider);\n}\n\n.vp-doc h3 {\n  font-size: clamp(1.375rem, 2.5vw, 1.75rem);\n  margin-top: var(--vc-spacing-md);\n}\n\n/* ===== Links ===== */\n.vp-doc a {\n  color: var(--vc-c-brand-1);\n  text-decoration: none;\n  border-bottom: 1px solid transparent;\n  transition: all var(--vc-transition-fast);\n  font-weight: 500;\n}\n\n.vp-doc a:hover {\n  border-bottom-color: var(--vc-c-brand-1);\n  opacity: 0.85;\n}\n\n/* ===== Tables - Elegant Style ===== */\n.vp-doc table {\n  border-radius: 12px;\n  overflow: hidden;\n  box-shadow: var(--vc-shadow-sm);\n  border: 1px solid var(--vp-c-divider);\n}\n\n.vp-doc thead {\n  background: linear-gradient(\n    135deg,\n    rgba(102, 126, 234, 0.08),\n    rgba(118, 75, 162, 0.08)\n  );\n}\n\n.vp-doc th {\n  font-weight: 700;\n  text-align: left;\n  padding: 1rem 1.5rem !important;\n  font-size: 0.9375rem;\n  letter-spacing: 0.01em;\n}\n\n.vp-doc td {\n  padding: 1rem 1.5rem !important;\n}\n\n.vp-doc tbody tr {\n  transition: background-color var(--vc-transition-fast);\n}\n\n.vp-doc tbody tr:hover {\n  background-color: rgba(102, 126, 234, 0.03);\n}\n\n/* ===== Code Blocks ===== */\n.vp-doc div[class*=\"language-\"] {\n  border-radius: 12px;\n  box-shadow: var(--vc-shadow-sm);\n  margin: var(--vc-spacing-md) 0;\n  overflow: hidden;\n}\n\n.vp-doc div[class*=\"language-\"]:hover {\n  box-shadow: var(--vc-shadow-md);\n}\n\n/* ===== Images ===== */\n.vp-doc img {\n  border-radius: 12px;\n  transition: all var(--vc-transition-base);\n}\n\n.vp-doc img:hover {\n  transform: scale(1.01);\n  box-shadow: var(--vc-shadow-lg) !important;\n}\n\n/* ===== Mermaid Diagrams ===== */\n.vp-doc .mermaid {\n  background: linear-gradient(\n    135deg,\n    rgba(102, 126, 234, 0.03),\n    rgba(118, 75, 162, 0.03)\n  );\n  border-radius: 16px;\n  padding: var(--vc-spacing-md);\n  margin: var(--vc-spacing-md) 0;\n  border: 1px solid rgba(102, 126, 234, 0.1);\n}\n\n/* ===== Scrollbar ===== */\n::-webkit-scrollbar {\n  width: 10px;\n  height: 10px;\n}\n\n::-webkit-scrollbar-track {\n  background: var(--vp-c-bg);\n}\n\n::-webkit-scrollbar-thumb {\n  background: linear-gradient(135deg, var(--vc-c-brand-1), var(--vc-c-brand-2));\n  border-radius: 5px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  opacity: 0.8;\n}\n\n/* ===== Responsive Optimizations ===== */\n@media (max-width: 768px) {\n  .VPHero {\n    padding-top: var(--vc-spacing-lg) !important;\n    padding-bottom: var(--vc-spacing-lg) !important;\n  }\n\n  .VPFeature {\n    padding: var(--vc-spacing-sm) !important;\n  }\n\n  .vp-doc h2 {\n    margin-top: var(--vc-spacing-md);\n    padding-top: var(--vc-spacing-sm);\n  }\n}\n\n/* ===== Accessibility ===== */\n@media (prefers-reduced-motion: reduce) {\n  *,\n  *::before,\n  *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n  }\n\n  .VPHero .VPImage {\n    animation: none !important;\n  }\n}\n\n/* ===== Focus States ===== */\n.VPButton:focus-visible {\n  outline: 3px solid var(--vc-c-brand-1);\n  outline-offset: 3px;\n}\n\n.vp-doc a:focus-visible {\n  outline: 2px solid var(--vc-c-brand-1);\n  outline-offset: 2px;\n  border-radius: 3px;\n}\n"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "import DefaultTheme from \"vitepress/theme\";\nimport CustomHome from \"./CustomHome.vue\";\nimport \"./custom.css\";\n\nexport default {\n  extends: DefaultTheme,\n  enhanceApp({ app }) {\n    app.component(\"CustomHome\", CustomHome);\n  },\n};\n"
  },
  {
    "path": "docs/README.md",
    "content": "# VideoCaptioner 文档\n\n这是 VideoCaptioner 项目的文档源文件，使用 [VitePress](https://vitepress.dev/) 构建。\n\n## 📚 在线查看\n\n文档已自动部署到 GitHub Pages：\n\n**[https://weifeng2333.github.io/VideoCaptioner/](https://weifeng2333.github.io/VideoCaptioner/)**\n\n## 🚀 本地开发\n\n### 安装依赖\n\n```bash\nnpm install\n```\n\n### 启动开发服务器\n\n```bash\nnpm run docs:dev\n```\n\n访问 http://localhost:5173 查看文档\n\n### 构建文档\n\n```bash\nnpm run docs:build\n```\n\n构建产物位于 `docs/.vitepress/dist/`\n\n### 预览构建结果\n\n```bash\nnpm run docs:preview\n```\n\n## 📁 目录结构\n\n```\ndocs/\n├── .vitepress/\n│   ├── config.mts          # VitePress 配置文件（含 SEO 优化）\n│   └── theme/              # 自定义主题（可选）\n├── public/                 # 静态资源（图片、Logo、robots.txt）\n├── guide/                  # 中文使用指南\n│   ├── getting-started.md\n│   ├── configuration.md\n│   └── ...\n├── config/                 # 中文配置文档\n│   ├── llm.md\n│   ├── asr.md\n│   └── ...\n├── dev/                    # 中文开发者文档\n│   ├── architecture.md\n│   └── ...\n├── en/                     # 英文文档（镜像中文结构）\n│   ├── guide/\n│   ├── config/\n│   └── dev/\n└── index.md                # 中文首页\n```\n\n## ✍️ 贡献文档\n\n### 添加新页面\n\n1. 在对应目录下创建 Markdown 文件\n2. **添加 Frontmatter SEO 优化**（重要！）：\n\n```markdown\n---\ntitle: 页面标题 - VideoCaptioner\ndescription: 页面描述，包含关键词\nhead:\n  - - meta\n    - name: keywords\n      content: 关键词1,关键词2,关键词3\n---\n\n# 页面标题\n\n内容...\n```\n\n3. 在 `.vitepress/config.mts` 的 `sidebar` 中添加链接\n4. 提交 PR\n\n### 编辑现有页面\n\n直接编辑 Markdown 文件即可，支持：\n\n- **Markdown 扩展语法**：表格、代码块、提示框等\n- **Vue 组件**：可在 Markdown 中使用 Vue 组件\n- **自定义容器**：`::: tip`, `::: warning`, `::: danger`\n\n示例：\n\n```md\n::: tip 提示\n这是一个提示框\n:::\n\n::: warning 注意\n这是一个警告框\n:::\n\n::: danger 危险\n这是一个危险警告框\n:::\n```\n\n### 文档规范\n\n- **文件名**：使用小写字母和连字符（如 `getting-started.md`）\n- **标题**：使用清晰的层级结构（# → ## → ###）\n- **代码块**：标注语言类型以启用语法高亮\n- **图片**：放在 `public/` 目录，使用 `/image.png` 引用\n- **链接**：内部链接使用相对路径（如 `/guide/getting-started`）\n- **SEO**：每个页面都应添加 title、description 和 keywords\n\n## 🔍 SEO 优化\n\n本文档系统已经过全面 SEO 优化，详情请查看 [SEO_OPTIMIZATION.md](../SEO_OPTIMIZATION.md)。\n\n### 已实施的 SEO 功能\n\n✅ **基础 SEO**\n\n- Title 标签优化\n- Meta Description 和 Keywords\n- Open Graph（社交媒体卡片）\n- Twitter Card\n- JSON-LD 结构化数据\n- Sitemap 自动生成\n- robots.txt\n- Canonical URL\n\n✅ **技术 SEO**\n\n- 响应式设计\n- Clean URLs\n- 快速加载（Vite 优化）\n- HTTPS（GitHub Pages）\n\n### 提交到搜索引擎\n\n部署后需要手动提交到搜索引擎：\n\n1. **Google Search Console**\n   - 访问 https://search.google.com/search-console\n   - 添加网站并验证\n   - 提交 sitemap: `https://weifeng2333.github.io/VideoCaptioner/sitemap.xml`\n\n2. **Bing Webmaster Tools**\n   - 访问 https://www.bing.com/webmasters\n   - 添加网站并验证\n   - 提交 sitemap\n\n3. **百度站长平台**\n   - 访问 https://ziyuan.baidu.com/\n   - 添加网站并验证\n   - 提交 sitemap\n\n### SEO 检查工具\n\n- [Google PageSpeed Insights](https://pagespeed.web.dev/)\n- [Google Rich Results Test](https://search.google.com/test/rich-results)\n- [Open Graph Debugger](https://developers.facebook.com/tools/debug/)\n- [Twitter Card Validator](https://cards-dev.twitter.com/validator)\n\n## 🌐 多语言支持\n\n文档支持中英双语：\n\n- **中文**：`docs/` 根目录\n- **英文**：`docs/en/` 目录\n\n添加新语言：\n\n1. 在 `docs/` 下创建语言目录（如 `ja/`）\n2. 在 `.vitepress/config.mts` 中添加 locale 配置\n3. 复制文档结构并翻译内容\n\n## 🔧 技术栈\n\n- **VitePress**: 基于 Vite 的静态站点生成器\n- **Vue 3**: 组件化开发\n- **TypeScript**: 类型安全的配置\n\n## 📝 更新文档\n\n文档更新会自动触发 GitHub Actions 部署：\n\n1. 提交文档修改到 `docs/` 目录\n2. 推送到 `master` 或 `main` 分支\n3. GitHub Actions 自动构建并部署\n4. 约 2-3 分钟后更新生效\n\n## ❓ 常见问题\n\n### 本地开发时看不到样式？\n\n确保已安装依赖：\n\n```bash\nnpm install\n```\n\n### 如何添加自定义样式？\n\n在 `docs/.vitepress/theme/` 目录下创建自定义主题：\n\n```ts\n// docs/.vitepress/theme/index.ts\nimport DefaultTheme from \"vitepress/theme\";\nimport \"./custom.css\";\n\nexport default DefaultTheme;\n```\n\n### 如何配置搜索功能？\n\nVitePress 默认提供本地搜索，已在 `config.mts` 中配置。\n\n### 如何优化图片？\n\n1. 使用图片压缩工具（如 TinyPNG）\n2. 考虑使用 WebP 格式\n3. 添加 `loading=\"lazy\"` 属性\n\n### 如何添加 Google Analytics？\n\n在 `config.mts` 的 `head` 中添加：\n\n```typescript\n([\n  \"script\",\n  {\n    async: true,\n    src: \"https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX\",\n  },\n],\n  [\n    \"script\",\n    {},\n    `\n  window.dataLayer = window.dataLayer || [];\n  function gtag(){dataLayer.push(arguments);}\n  gtag('js', new Date());\n  gtag('config', 'G-XXXXXXXXXX');\n`,\n  ]);\n```\n\n---\n\n更多 VitePress 使用方法请参考 [官方文档](https://vitepress.dev/)。\n\n更多 SEO 优化细节请查看 [SEO_OPTIMIZATION.md](../SEO_OPTIMIZATION.md)。\n"
  },
  {
    "path": "docs/config/asr.md",
    "content": "# ASR 配置指南\n\n语音识别（ASR）配置详解。\n\n## 支持的 ASR 引擎\n\n| 引擎 | 特点 | 推荐场景 |\n|------|------|---------|\n| **FasterWhisper** | 准确度高，支持GPU | 推荐使用 |\n| **WhisperCpp** | 轻量级 | CPU环境 |\n| **Whisper API** | 云端服务 | 无需本地模型 |\n| **B接口/J接口** | 免费在线 | 快速测试 |\n\n## 模型下载\n\n待补充...\n\n## 配置参数\n\n待补充...\n\n---\n\n相关文档：\n- [快速开始](/guide/getting-started)\n- [LLM 配置](/config/llm)\n"
  },
  {
    "path": "docs/config/cookies.md",
    "content": "# Cookie 配置指南\n\n配置 Cookie 以下载高清视频。\n\n## 何时需要配置 Cookie？\n\n在以下情况下需要配置 Cookie：\n\n1. 下载视频网站需要登录信息\n2. 只能下载较低分辨率的视频\n3. 网络条件较差时需要验证\n\n## 获取 Cookie\n\n待补充...\n\n## 配置方法\n\n1. 获取 `cookies.txt` 文件\n2. 放置到 `AppData/` 目录\n3. 重启软件\n\n---\n\n相关文档：\n- [快速开始](/guide/getting-started)\n"
  },
  {
    "path": "docs/config/llm.md",
    "content": "---\ntitle: LLM 配置指南 - VideoCaptioner\ndescription: 详细的 LLM API 配置教程，支持 OpenAI、DeepSeek、SiliconCloud、Gemini、Ollama 等多种服务商。包含费用估算和优化建议。\nhead:\n  - - meta\n    - name: keywords\n      content: LLM配置,OpenAI API,DeepSeek,Gemini API,Ollama,字幕优化,AI翻译,大语言模型配置\n---\n\n# LLM 配置指南\n\nLLM（大语言模型）是 VideoCaptioner 的核心功能之一，用于字幕断句、优化和翻译。本指南将帮助你配置 LLM API。\n\n## 为什么需要配置 LLM？\n\n- **字幕断句**：使用 LLM 进行语义分析，生成自然流畅的字幕分段\n- **字幕优化**：自动修正错别字、统一专业术语、优化格式\n- **字幕翻译**：结合上下文的高质量翻译\n\n::: tip 提示\n软件内置了基础 LLM 模型可供测试使用，但配置自己的 API 可以获得：\n\n- ✅ 更稳定的服务\n- ✅ 更高的并发能力\n- ✅ 更好的处理质量\n  :::\n\n## 支持的 LLM 服务商\n\nVideoCaptioner 支持多种 LLM 服务商，你可以根据自己的需求选择：\n\n| 服务商           | 特点                    | 推荐场景     |\n| ---------------- | ----------------------- | ------------ |\n| **OpenAI**       | 质量最好，API 稳定      | 追求极致质量 |\n| **DeepSeek**     | 性价比高，中文优秀      | 中文内容处理 |\n| **SiliconCloud** | 国内可用，模型丰富      | 国内用户     |\n| **Gemini**       | Google 出品，免费额度大 | 预算有限     |\n| **Ollama**       | 完全本地运行，免费      | 隐私敏感场景 |\n| **LM Studio**    | 本地运行，图形化界面    | 本地部署     |\n| **ChatGLM**      | 国产模型                | 国内用户     |\n\n## 配置方法\n\n### 方式一：使用 SiliconCloud（推荐国内用户）\n\n[SiliconCloud](https://cloud.siliconflow.cn) 集成了国内多家大模型厂商，使用方便。\n\n**步骤：**\n\n1. **注册账号**\n\n   访问 [SiliconCloud](https://cloud.siliconflow.cn/i/onCHcaDx) 注册账号（通过链接注册可获得额外额度）\n\n2. **获取 API Key**\n\n   登录后，在 [设置页面](https://cloud.siliconflow.cn/account/ak) 获取 API Key\n\n   ![获取API Key](https://h1.appinn.me/file/get_api.png)\n\n3. **在 VideoCaptioner 中配置**\n\n   打开 VideoCaptioner，进入 **设置 → LLM 配置**：\n   - **LLM 服务**: 选择 `SiliconCloud`\n   - **API Base URL**: `https://api.siliconflow.cn/v1`\n   - **API Key**: 粘贴你的 API Key\n   - 点击 **\"检查连接\"** 测试配置\n   - **模型选择**: 推荐 `Qwen/Qwen2.5-72B-Instruct` 或 `deepseek-ai/DeepSeek-V3`\n\n   ![配置示例](/api-setting.png)\n\n4. **配置线程数**\n\n   SiliconCloud 并发能力有限，建议设置：\n   - **线程数**: 5 或更少\n\n::: warning 注意\n自 2025 年 2 月 6 日起，未实名用户每日最多请求 DeepSeek-V3 模型 100 次。如不想实名，可考虑使用其他中转站或模型。\n:::\n\n### 方式二：使用 OpenAI\n\n如果你有 OpenAI 账号和 API Key：\n\n1. 访问 [OpenAI Platform](https://platform.openai.com) 获取 API Key\n\n2. 在 VideoCaptioner 中配置：\n   - **LLM 服务**: 选择 `OpenAI`\n   - **API Base URL**: `https://api.openai.com/v1`\n   - **API Key**: 你的 OpenAI API Key\n   - **模型选择**:\n     - 经济实惠：`gpt-4o-mini`\n     - 高质量：`gpt-4o` 或 `gpt-4-turbo`\n\n3. **线程数配置**：\n   - OpenAI API 支持较高并发，可设置 10-20 个线程\n\n### 方式三：使用 DeepSeek\n\n[DeepSeek](https://platform.deepseek.com) 是一个性价比极高的国产 LLM。\n\n1. 访问 [DeepSeek 平台](https://platform.deepseek.com) 注册并获取 API Key\n\n2. 在 VideoCaptioner 中配置：\n   - **LLM 服务**: 选择 `DeepSeek`\n   - **API Base URL**: `https://api.deepseek.com/v1`\n   - **API Key**: 你的 DeepSeek API Key\n   - **模型选择**: `deepseek-chat` 或 `deepseek-coder`\n\n3. **线程数配置**：\n   - 建议 5-10 个线程\n\n### 方式四：使用本项目中转站（推荐）⭐\n\n本项目提供了高性价比的 LLM API 中转站，支持多种优质模型和高并发。\n\n**特点：**\n\n- ✅ 支持 OpenAI、Claude、Gemini 等优质模型\n- ✅ 超高并发能力，处理速度极快\n- ✅ 稳定可靠，专为本项目优化\n- ✅ 国内可直接访问\n\n**配置步骤：**\n\n1. **注册账号**\n\n   访问 [https://api.videocaptioner.cn/register](https://api.videocaptioner.cn/register?aff=UrLB) 注册（通过链接注册赠送 $0.4 测试余额）\n\n2. **获取 API Key**\n\n   登录后访问 [Token 页面](https://api.videocaptioner.cn/token) 获取 API Key\n\n3. **在 VideoCaptioner 中配置**\n   - **LLM 服务**: 选择 `OpenAI`（兼容模式）\n   - **API Base URL**: `https://api.videocaptioner.cn/v1`\n   - **API Key**: 你获取的 API Key\n   - 点击 **\"检查连接\"** 测试\n\n   ![中转站配置](/api-setting-2.png)\n\n4. **模型选择建议**\n\n   根据预算和质量需求选择：\n\n   | 质量层级     | 推荐模型                                               | 耗费比例 | 适用场景             |\n   | ------------ | ------------------------------------------------------ | -------- | -------------------- |\n   | **高质量**   | `gemini-2.0-flash-exp`<br>`claude-sonnet-4.5-20250929` | 3        | 重要内容、专业翻译   |\n   | **较高质量** | `gpt-4o-2024-08-07`<br>`claude-haiku-4-5-20251001`     | 1.2      | 日常使用、高质量需求 |\n   | **中质量**   | `gpt-4o-mini`<br>`gemini-2.0-flash-exp`                | 0.3      | 快速处理、预算有限   |\n\n5. **线程数配置**\n\n   中转站支持超高并发，可以直接拉满：\n   - **线程数**: 20-50（根据你的网络和机器性能）\n\n::: tip 推荐配置\n\n- **日常使用**: `gpt-4o-mini` + 30 线程\n- **追求质量**: `claude-sonnet-4.5` + 20 线程\n- **预算有限**: `gemini-2.0-flash-exp` + 50 线程\n  :::\n\n### 方式五：本地部署 Ollama\n\n如果你希望完全本地运行，保护隐私：\n\n1. **安装 Ollama**\n\n   访问 [Ollama 官网](https://ollama.com) 下载并安装\n\n2. **下载模型**\n\n   ```bash\n   # 下载推荐模型\n   ollama pull llama3.1:8b\n\n   # 或下载更大的模型\n   ollama pull qwen2.5:14b\n   ```\n\n3. **启动 Ollama 服务**\n\n   ```bash\n   ollama serve\n   ```\n\n4. **在 VideoCaptioner 中配置**\n   - **LLM 服务**: 选择 `Ollama`\n   - **API Base URL**: `http://localhost:11434/v1`\n   - **API Key**: 留空或填写任意值\n   - **模型选择**: 你下载的模型名称（如 `llama3.1:8b`）\n\n5. **线程数配置**\n\n   根据你的硬件配置：\n   - **CPU**: 2-4 个线程\n   - **GPU**: 4-8 个线程\n\n::: warning 注意\n本地模型的质量通常不如云端 API，建议使用 14B 以上参数的模型。\n:::\n\n## 高级配置\n\n### 自定义提示词\n\n在字幕优化和翻译时，你可以添加自定义提示词来改善输出质量：\n\n**示例：**\n\n```\n请注意以下专业术语：\n- 机器学习 -> Machine Learning\n- 深度学习 -> Deep Learning\n- 神经网络 -> Neural Network\n\n请保持技术术语的准确性，不要过度意译。\n```\n\n在 **字幕优化与翻译** 页面的 **\"文稿提示\"** 输入框中填写。\n\n### 并发线程数调优\n\n线程数影响处理速度和成本：\n\n| API 类型     | 推荐线程数 | 说明           |\n| ------------ | ---------- | -------------- |\n| OpenAI       | 10-20      | 支持高并发     |\n| 中转站       | 20-50      | 专为高并发优化 |\n| DeepSeek     | 5-10       | 有一定并发限制 |\n| SiliconCloud | 3-5        | 并发能力较弱   |\n| Ollama 本地  | 2-8        | 取决于硬件性能 |\n\n::: tip 提示\n如果遇到 **请求超时** 或 **429 错误**，说明并发过高，需要降低线程数。\n:::\n\n### 温度参数（Temperature）\n\n温度参数控制模型输出的随机性：\n\n- **0.1-0.3**：输出更稳定、保守（推荐用于字幕优化）\n- **0.5-0.7**：输出更自然、灵活（推荐用于翻译）\n- **0.8-1.0**：输出更有创意（不推荐）\n\n默认值 `0.3` 适用于大多数场景。\n\n## 费用估算\n\n使用 LLM API 会产生一定费用，以下是参考估算：\n\n**示例：处理 14 分钟视频**\n\n- **转录字数**：约 2000 字\n- **使用模型**：`gpt-4o-mini`\n- **处理流程**：断句 + 优化 + 翻译\n- **总费用**：< ¥0.01\n\n::: info 说明\n\n- LLM 仅处理文本内容，不包含时间轴信息，Token 消耗很少\n- 翻译采用 \"翻译-反思-翻译\" 方法，费用会相应增加\n- 使用批量处理时，费用基本按视频数量线性增长\n  :::\n\n## 常见问题\n\n### 连接测试失败\n\n::: details 解决方案\n\n1. **检查 API Key 格式**\n   - OpenAI: 以 `sk-` 开头\n   - 其他服务商可能有不同格式\n\n2. **检查 Base URL**\n   - 必须包含 `/v1` 后缀\n   - 不要有多余的斜杠\n\n3. **检查网络连接**\n   - 某些服务商需要科学上网\n   - 检查防火墙设置\n\n4. **查看详细错误**\n   - 在 **设置 → 日志** 中查看详细错误信息\n     :::\n\n### 请求频繁失败\n\n::: details 解决方案\n\n1. **降低线程数**\n   - 从 20 降低到 10 或 5\n\n2. **检查 API 额度**\n   - 登录服务商平台查看余额\n\n3. **更换服务商**\n   - 尝试使用本项目中转站\n\n4. **检查模型可用性**\n   - 某些模型可能有地区限制\n     :::\n\n### 输出质量不佳\n\n::: details 解决方案\n\n1. **更换更好的模型**\n   - `gpt-4o-mini` → `gpt-4o`\n   - `gemini-1.5-flash` → `gemini-2.0-flash-exp`\n\n2. **调整温度参数**\n   - 降低温度（如 0.3 → 0.1）获得更稳定输出\n\n3. **添加文稿提示**\n   - 在文稿提示中添加术语表和修正要求\n\n4. **使用反思翻译**\n   - 在翻译设置中启用 \"反思翻译\"\n     :::\n\n## 推荐配置方案\n\n### 新手推荐\n\n```\n服务商: 本项目中转站\n模型: gpt-4o-mini\n线程数: 20\n温度: 0.3\n```\n\n### 追求质量\n\n```\n服务商: 本项目中转站\n模型: claude-sonnet-4.5\n线程数: 15\n温度: 0.3\n反思翻译: 开启\n```\n\n### 预算有限\n\n```\n服务商: SiliconCloud\n模型: Qwen/Qwen2.5-72B-Instruct\n线程数: 5\n温度: 0.3\n```\n\n### 隐私优先\n\n```\n服务商: Ollama（本地）\n模型: qwen2.5:14b\n线程数: 4\n温度: 0.5\n```\n\n---\n\n如果还有其他问题，欢迎在 [GitHub Issues](https://github.com/WEIFENG2333/VideoCaptioner/issues) 提问。\n"
  },
  {
    "path": "docs/config/translator.md",
    "content": "# 翻译配置指南\n\n字幕翻译配置详解。\n\n## 支持的翻译服务\n\n| 服务 | 特点 | 推荐场景 |\n|------|------|---------|\n| **LLM 翻译** | 质量最好 | 追求质量 |\n| **Bing 翻译** | 速度快，免费 | 快速翻译 |\n| **Google 翻译** | 质量好 | 英语翻译 |\n| **DeepLX** | 专业翻译 | 自建服务 |\n\n## 配置方法\n\n待补充...\n\n## 支持的目标语言\n\n待补充...\n\n---\n\n相关文档：\n- [LLM 配置](/config/llm)\n- [快速开始](/guide/getting-started)\n"
  },
  {
    "path": "docs/dev/api.md",
    "content": "# API 文档\n\n核心 API 接口文档。\n\n## ASR API\n\n### `transcribe()`\n\n```python\nfrom app.core.asr import transcribe\n\nresult = transcribe(\n    audio_path=\"video.mp4\",\n    config=TranscribeConfig(...)\n)\n```\n\n## 字幕处理 API\n\n待补充...\n\n## 翻译 API\n\n待补充...\n\n---\n\n详细 API 说明请参考源代码和 `CLAUDE.md` 文档。\n\n相关文档：\n- [架构设计](/dev/architecture)\n- [贡献指南](/dev/contributing)\n"
  },
  {
    "path": "docs/dev/architecture.md",
    "content": "# 架构设计\n\nVideoCaptioner 的系统架构设计。\n\n## 技术栈\n\n- **UI 框架**: PyQt5 + QFluentWidgets\n- **ASR 引擎**: Whisper (FasterWhisper/WhisperCpp)\n- **LLM 集成**: OpenAI/DeepSeek/Gemini/Ollama 等\n- **视频处理**: FFmpeg\n\n## 核心模块\n\n### 1. ASR 模块 (`app/core/asr/`)\n\n语音识别模块，支持多种 ASR 引擎。\n\n### 2. 字幕处理模块 (`app/core/split/`, `app/core/optimize/`)\n\n字幕分割和优化模块，使用 LLM 进行智能处理。\n\n### 3. 翻译模块 (`app/core/translate/`)\n\n字幕翻译模块，支持多种翻译服务。\n\n### 4. UI 模块 (`app/view/`)\n\nPyQt5 用户界面模块。\n\n## 数据流\n\n```\n视频/音频 → ASR → ASRData → 分割 → 优化 → 翻译 → 字幕文件 → 视频合成\n```\n\n详细架构说明请参考 `CLAUDE.md` 文件。\n\n---\n\n相关文档：\n- [API 文档](/dev/api)\n- [贡献指南](/dev/contributing)\n"
  },
  {
    "path": "docs/dev/asr-chunk-merger.md",
    "content": "# ChunkMerger 使用指南\n\n## 概述\n\n`ChunkMerger` 用于合并多个音频分块的 ASR（语音识别）结果。当处理长音频时，通常需要将音频分割成多个片段分别识别，然后合并结果。本模块使用精确文本匹配算法（基于 Groq API Cookbook）来智能处理重叠区域。\n\n## 核心特性\n\n- ✅ **精确文本匹配**：使用滑动窗口找最长公共序列，不使用模糊相似度\n- ✅ **自动时间戳调整**：正确处理每个 chunk 的时间偏移\n- ✅ **重叠区域智能处理**：自动检测和去除重复的识别内容\n- ✅ **多语言支持**：支持中文、英文、混合文本等\n- ✅ **词级/句子级时间戳**：两种时间戳类型均可正确处理\n\n## 基本用法\n\n### 示例 1：合并两个有重叠的音频片段\n\n```python\nfrom app.core.asr.chunk_merger import ChunkMerger\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\n\n# 创建合并器\nmerger = ChunkMerger(min_match_count=2)\n\n# Chunk 1: 0-30s 的识别结果\nchunk1_segments = [\n    ASRDataSeg(\"Hello\", 0, 1000),\n    ASRDataSeg(\"world\", 1000, 2000),\n    ASRDataSeg(\"this\", 2000, 3000),\n    # ... 更多片段\n]\nchunk1 = ASRData(chunk1_segments)\n\n# Chunk 2: 20-50s 的识别结果（重叠 10s）\nchunk2_segments = [\n    ASRDataSeg(\"this\", 0, 1000),      # 实际时间 20-21s\n    ASRDataSeg(\"is\", 1000, 2000),     # 实际时间 21-22s\n    ASRDataSeg(\"test\", 2000, 3000),   # 实际时间 22-23s\n    # ... 更多片段\n]\nchunk2 = ASRData(chunk2_segments)\n\n# 合并\nmerged = merger.merge_chunks(\n    chunks=[chunk1, chunk2],\n    chunk_offsets=[0, 20000],      # chunk2 实际从 20s 开始\n    overlap_duration=10000         # 10s 重叠\n)\n\nprint(f\"合并后片段数: {len(merged.segments)}\")\n```\n\n### 示例 2：合并多个音频片段\n\n```python\n# 模拟长音频：3 个 30s 的片段，每个重叠 10s\nchunk1 = ASRData([...])  # 0-30s\nchunk2 = ASRData([...])  # 20-50s\nchunk3 = ASRData([...])  # 40-70s\n\n# 一次性合并所有片段\nmerged = merger.merge_chunks(\n    chunks=[chunk1, chunk2, chunk3],\n    chunk_offsets=[0, 20000, 40000],\n    overlap_duration=10000\n)\n```\n\n### 示例 3：自动推断时间偏移\n\n```python\n# 如果不提供 chunk_offsets，会自动推断\nmerged = merger.merge_chunks(\n    chunks=[chunk1, chunk2, chunk3],\n    overlap_duration=10000  # 只需指定重叠时长\n)\n```\n\n## 参数说明\n\n### ChunkMerger 构造函数\n\n```python\nChunkMerger(min_match_count: int = 2)\n```\n\n- `min_match_count`: 最小匹配词数阈值，低于此值视为无效匹配（默认 2）\n\n### merge_chunks 方法\n\n```python\nmerge_chunks(\n    chunks: List[ASRData],\n    chunk_offsets: Optional[List[int]] = None,\n    overlap_duration: int = 10000\n) -> ASRData\n```\n\n**参数**：\n\n- `chunks`: ASRData 对象列表（必需）\n- `chunk_offsets`: 每个 chunk 的起始时间（毫秒），如为 None 则自动推断\n- `overlap_duration`: 重叠时长（毫秒），默认 10 秒\n\n**返回**：\n\n- 合并后的 `ASRData` 对象\n\n## 算法原理\n\n### 1. 精确文本匹配\n\n使用滑动窗口遍历所有可能的对齐方式，计算每个位置的精确匹配词数（要求连续匹配）：\n\n```\nChunk1 末尾: [\"and\", \"we\", \"need\", \"to\", \"find\", \"the\", \"best\"]\nChunk2 开头: [\"need\", \"to\", \"find\", \"the\", \"best\", \"solution\"]\n\n最佳匹配: [\"need\", \"to\", \"find\", \"the\", \"best\"] (5个词)\n```\n\n### 2. 时间戳调整\n\n```python\n# Chunk2 的时间戳加上偏移量\nadjusted_time = original_time + chunk_offset\n```\n\n### 3. 合并策略\n\n- **有匹配**：保留 chunk1 的重叠部分，丢弃 chunk2 的重叠部分\n- **无匹配**：使用时间边界切分\n\n## 实际应用场景\n\n### 场景 1：长视频字幕生成\n\n```python\n# 60 分钟视频，每 30 秒一个片段，重叠 10 秒\nchunks = []\noffsets = []\n\nfor i in range(0, 3600, 20):  # 每 20s 一个起点（30s 片段 - 10s 重叠）\n    audio_chunk = extract_audio(video_path, start=i, duration=30)\n    asr_result = transcribe(audio_chunk)\n    chunks.append(asr_result)\n    offsets.append(i * 1000)  # 转换为毫秒\n\n# 合并所有片段\nfinal_result = merger.merge_chunks(\n    chunks=chunks,\n    chunk_offsets=offsets,\n    overlap_duration=10000\n)\n\n# 保存字幕\nfinal_result.save(\"output.srt\")\n```\n\n### 场景 2：在线流式识别\n\n```python\nclass StreamingASR:\n    def __init__(self):\n        self.merger = ChunkMerger()\n        self.chunks = []\n        self.offsets = []\n\n    def on_chunk_received(self, chunk_audio, timestamp):\n        # 识别当前片段\n        asr_result = transcribe(chunk_audio)\n        self.chunks.append(asr_result)\n        self.offsets.append(timestamp)\n\n        # 实时合并\n        if len(self.chunks) >= 2:\n            merged = self.merger.merge_chunks(\n                chunks=self.chunks,\n                chunk_offsets=self.offsets,\n                overlap_duration=5000  # 5s 重叠\n            )\n            return merged\n```\n\n## 注意事项\n\n### 1. 重叠时长建议\n\n- **推荐**：10 秒重叠（足以捕获句子边界）\n- **最小**：3-5 秒（太短可能匹配失败）\n- **最大**：不超过 chunk 长度的 1/3\n\n### 2. 匹配阈值\n\n```python\n# 对于短句子，可以降低阈值\nmerger = ChunkMerger(min_match_count=1)\n\n# 对于长句子，可以提高阈值以提高准确性\nmerger = ChunkMerger(min_match_count=3)\n```\n\n### 3. 时间戳连续性\n\n合并后，请验证时间戳的连续性：\n\n```python\n# 验证时间戳\nfor i in range(len(merged.segments) - 1):\n    seg1 = merged.segments[i]\n    seg2 = merged.segments[i + 1]\n    gap = seg2.start_time - seg1.end_time\n    if gap > 2000:  # 间隔超过 2s\n        print(f\"警告: 片段 {i} 和 {i+1} 之间有 {gap}ms 间隔\")\n```\n\n## 测试\n\n运行测试套件：\n\n```bash\n# 运行所有测试\nuv run pytest tests/test_asr/test_chunk_merger.py -v\n\n# 运行特定测试\nuv run pytest tests/test_asr/test_chunk_merger.py::TestChunkMergerBasic -v\n```\n\n## 常见问题\n\n### Q1: 合并后丢失了部分内容？\n\n**A**: 检查重叠区域是否足够长，确保 `overlap_duration` 至少为 5 秒。\n\n### Q2: 匹配失败，使用了时间边界切分？\n\n**A**: 可能是重叠区域的文本差异太大（识别错误）。可以：\n\n1. 降低 `min_match_count` 阈值\n2. 增加重叠时长\n3. 检查 ASR 质量\n\n### Q3: 时间戳不连续？\n\n**A**: 检查 `chunk_offsets` 是否正确，应该准确反映每个 chunk 的实际起始时间。\n\n## 相关文档\n\n- [ASRData 数据结构](../asr_data.py)\n- [Groq Audio Chunking Tutorial](https://github.com/groq/groq-api-cookbook/blob/main/tutorials/audio-chunking/audio_chunking_tutorial.ipynb)\n"
  },
  {
    "path": "docs/dev/asr-chunked-usage.md",
    "content": "# ChunkedASR 使用指南\n\n## 概述\n\n`ChunkedASR` 是一个装饰器类，为任何 `BaseASR` 实现添加音频分块转录能力。适用于长音频（>20分钟）的分块转录，避免 API 超时或内存溢出。\n\n## 核心特性\n\n- ✅ **装饰器模式** - 关注点分离，不污染 BaseASR\n- ✅ **并发转录** - 使用 ThreadPoolExecutor 并发处理多个块\n- ✅ **智能合并** - 使用 ChunkMerger 消除重叠区域的重复内容\n- ✅ **进度回调** - 支持细粒度的进度追踪\n- ✅ **自动判断** - 短音频自动跳过分块，直接转录\n\n## 快速开始\n\n### 基本用法\n\n```python\nfrom app.core.asr import BcutASR, ChunkedASR\n\n# 1. 创建基础 ASR 实例\nbase_asr = BcutASR(audio_path, need_word_time_stamp=True)\n\n# 2. 用 ChunkedASR 包装\nchunked_asr = ChunkedASR(\n    base_asr,\n    chunk_length=1200,    # 20 分钟/块\n    chunk_overlap=10,     # 10 秒重叠\n    chunk_concurrency=3   # 3 个并发\n)\n\n# 3. 运行转录\nresult = chunked_asr.run(callback=my_callback)\n```\n\n### 在 transcribe() 中自动使用\n\n`transcribe()` 函数已经自动为 `BIJIAN` 和 `JIANYING` 启用了分块：\n\n```python\nfrom app.core.asr import transcribe\nfrom app.core.entities import TranscribeConfig, TranscribeModelEnum\n\nconfig = TranscribeConfig(\n    transcribe_model=TranscribeModelEnum.BIJIAN,\n    need_word_time_stamp=True\n)\n\n# 自动使用 ChunkedASR 包装（20 分钟/块）\nresult = transcribe(audio_path, config, callback)\n```\n\n## 参数说明\n\n### `ChunkedASR.__init__`\n\n| 参数                | 类型    | 默认值   | 说明                 |\n| ------------------- | ------- | -------- | -------------------- |\n| `base_asr`          | BaseASR | **必需** | 底层 ASR 实例        |\n| `chunk_length`      | int     | 1200     | 每块长度（秒）       |\n| `chunk_overlap`     | int     | 10       | 块之间重叠时长（秒） |\n| `chunk_concurrency` | int     | 3        | 并发转录数量         |\n\n### 参数选择建议\n\n**chunk_length（分块长度）**\n\n- **公益 API（BIJIAN/JIANYING）**: 1200 秒（20 分钟）- 避免超时\n- **付费 API（Whisper API）**: 可更长，如 3600 秒（1 小时）\n- **本地转录（FasterWhisper）**: 通常不需要分块\n\n**chunk_overlap（重叠时长）**\n\n- **推荐值**: 10 秒\n- **作用**: 提供足够的上下文用于合并，避免丢失边界内容\n- **注意**: 过长会增加计算量，过短可能导致合并不准确\n\n**chunk_concurrency（并发数）**\n\n- **公益 API**: 2-3（避免触发限流）\n- **付费 API**: 5-10（根据账户配额调整）\n- **本地转录**: 根据 CPU/GPU 资源调整\n\n## 工作流程\n\n```\n┌──────────────┐\n│  长音频文件   │\n└──────┬───────┘\n       │\n       ▼\n┌──────────────────────────────┐\n│  1. _split_audio()           │\n│  - 使用 pydub 切割音频        │\n│  - 每块 20 分钟，重叠 10 秒   │\n└──────┬───────────────────────┘\n       │\n       ▼\n┌──────────────────────────────┐\n│  2. _transcribe_chunks()     │\n│  - ThreadPoolExecutor 并发   │\n│  - 每块独立调用 base_asr.run()│\n└──────┬───────────────────────┘\n       │\n       ▼\n┌──────────────────────────────┐\n│  3. _merge_results()         │\n│  - ChunkMerger 合并结果      │\n│  - 消除重叠区域的重复内容     │\n└──────┬───────────────────────┘\n       │\n       ▼\n┌──────────────┐\n│  ASRData 结果 │\n└──────────────┘\n```\n\n## 高级用法\n\n### 自定义进度回调\n\n```python\ndef progress_callback(progress: int, message: str):\n    print(f\"[{progress}%] {message}\")\n    # 可以更新 UI 进度条、发送通知等\n\nchunked_asr = ChunkedASR(base_asr)\nresult = chunked_asr.run(callback=progress_callback)\n```\n\n输出示例：\n\n```\n[5%] Chunk 1/5: uploading\n[25%] Chunk 1/5: transcribing\n[30%] Chunk 2/5: uploading\n[50%] Chunk 2/5: transcribing\n...\n```\n\n### 为其他 ASR 添加分块能力\n\n```python\n# 为 FasterWhisper 添加分块（处理超长音频）\nfrom app.core.asr import FasterWhisperASR, ChunkedASR\n\nbase_asr = FasterWhisperASR(\n    audio_path,\n    whisper_model=\"large-v3\",\n    language=\"zh\"\n)\n\n# 用于处理 2 小时的音频\nchunked_asr = ChunkedASR(\n    base_asr,\n    chunk_length=3600,   # 1 小时/块\n    chunk_overlap=30,    # 30 秒重叠\n    chunk_concurrency=2  # 2 个并发（避免显存不足）\n)\n\nresult = chunked_asr.run()\n```\n\n## 注意事项\n\n### 1. 音频格式要求\n\n- ChunkedASR 依赖 `pydub` 进行音频切割\n- 确保安装了 `ffmpeg`（pydub 的依赖）\n- 支持所有 pydub 支持的格式（mp3, wav, m4a, flac 等）\n\n### 2. 内存管理\n\n- 每个并发块会临时占用内存\n- `chunk_concurrency=3` 时，同时会有 3 个音频块在内存中\n- 对于超大文件，适当降低并发数\n\n### 3. 缓存行为\n\n- ChunkedASR 本身不处理缓存\n- 缓存由底层 `base_asr` 的 `run()` 方法处理\n- 每个块会独立缓存（如果 `use_cache=True`）\n\n### 4. 错误处理\n\n- 如果某个块转录失败，整个任务会抛出异常\n- 建议在外层捕获异常并进行重试\n\n## 性能优化建议\n\n### 1. 合理设置并发数\n\n```python\n# ❌ 不推荐：并发过高导致限流\nchunked_asr = ChunkedASR(base_asr, chunk_concurrency=10)\n\n# ✅ 推荐：根据 API 限制调整\nchunked_asr = ChunkedASR(base_asr, chunk_concurrency=3)\n```\n\n### 2. 根据音频长度调整分块大小\n\n```python\n# 短音频（< 20 分钟）- 不使用分块\nif audio_duration < 1200:\n    result = base_asr.run()\nelse:\n    # 长音频 - 使用分块\n    result = ChunkedASR(base_asr).run()\n```\n\n### 3. 启用缓存避免重复转录\n\n```python\n# 为底层 ASR 启用缓存\nbase_asr = BcutASR(audio_path, use_cache=True)\nchunked_asr = ChunkedASR(base_asr)\n\n# 第一次转录会缓存每个块\nresult1 = chunked_asr.run()  # 调用 API\n\n# 第二次转录直接读取缓存\nresult2 = chunked_asr.run()  # 从缓存读取\n```\n\n## 测试\n\n运行测试验证 ChunkedASR 功能：\n\n```bash\n# 测试 BcutASR 和 JianYingASR（已自动使用 ChunkedASR）\nuv run pytest tests/test_asr/test_bcut_asr.py -v\nuv run pytest tests/test_asr/test_jianying_asr.py -v\n\n# 测试分块相关功能\nuv run pytest tests/test_asr/test_chunking.py -v\nuv run pytest tests/test_asr/test_chunk_merger.py -v\n```\n\n## 常见问题\n\n**Q: 短音频会被分块吗？**\nA: 不会。ChunkedASR 会自动判断，如果音频短于 `chunk_length`，会直接调用 `base_asr.run()` 而不分块。\n\n**Q: 分块会丢失内容吗？**\nA: 不会。通过 `chunk_overlap` 保证块之间有重叠，ChunkMerger 会智能合并重叠区域，不会丢失内容。\n\n**Q: 如何调试分块问题？**\nA: 查看日志输出：\n\n```python\nimport logging\nlogging.getLogger(\"chunked_asr\").setLevel(logging.DEBUG)\n```\n\n**Q: 可以为本地 ASR 使用分块吗？**\nA: 可以，但通常不推荐。本地 ASR（如 FasterWhisper）通常足够快，不需要分块。仅在处理超长音频（>2 小时）或显存不足时使用。\n\n## 相关文档\n\n- [ChunkMerger 使用指南](./CHUNK_MERGER_USAGE.md)\n- [ASR 模块开发指南](./README.md)\n- [测试指南](../../tests/test_asr/TEST_GUIDE.md)\n"
  },
  {
    "path": "docs/dev/contributing.md",
    "content": "# 贡献指南\n\n感谢你对 VideoCaptioner 的贡献！\n\n## 开发环境设置\n\n1. Fork 本仓库\n2. 克隆你的 Fork\n3. 安装开发依赖\n\n```bash\ngit clone https://github.com/YOUR_USERNAME/VideoCaptioner.git\ncd VideoCaptioner\npip install -r requirements.txt\n```\n\n## 代码规范\n\n- 使用 `pyright` 进行类型检查\n- 使用 `ruff` 进行代码格式化\n\n```bash\n# 类型检查\nuv run pyright\n\n# 代码格式化\nuv run ruff check --select I --fix .\n```\n\n## 提交 Pull Request\n\n1. 创建新分支\n2. 提交你的修改\n3. 推送到你的 Fork\n4. 创建 Pull Request\n\n## 注释要求\n\n保持简洁清晰，只需要必要的注释即可。\n\n---\n\n相关文档：\n- [架构设计](/dev/architecture)\n- [API 文档](/dev/api)\n\n更多信息请参考 [GitHub Issues](https://github.com/WEIFENG2333/VideoCaptioner/issues)。\n"
  },
  {
    "path": "docs/dev/translate-module.md",
    "content": "# 翻译模块 (Translate Module)\n\n多语言字幕翻译模块，支持多种翻译服务。\n\n## 模块结构\n\n```\napp/core/translate/\n├── __init__.py              # 模块导出\n├── types.py                 # 翻译器类型枚举\n├── base.py                  # 翻译器基类\n├── llm_translator.py        # LLM 翻译器（使用 litellm）\n├── google_translator.py     # Google 翻译器\n├── bing_translator.py       # Bing 翻译器\n├── deeplx_translator.py     # DeepLX 翻译器\n└── factory.py               # 翻译器工厂\n```\n\n## 支持的翻译服务\n\n### 1. LLM 翻译器 (OpenAI 兼容)\n\n- 使用 `litellm` 直接调用 OpenAI 兼容 API\n- 支持批量翻译和单条翻译\n- 内置缓存机制\n- 支持 Reflect 模式（反思优化翻译）\n- 支持自定义 Prompt\n\n### 2. Google 翻译器\n\n- 免费翻译服务\n- 支持多种语言\n- 适合日常使用\n\n### 3. Bing 翻译器\n\n- Microsoft 翻译服务\n- 批量翻译支持\n- 自动 Token 管理\n\n### 4. DeepLX 翻译器\n\n- DeepL 的免费接口\n- 高质量翻译\n- 可自定义端点\n\n## 使用示例\n\n### 基础使用\n\n```python\nfrom app.core.translate import TranslatorFactory, TranslatorType\n\n# 创建 LLM 翻译器\ntranslator = TranslatorFactory.create_translator(\n    translator_type=TranslatorType.OPENAI,\n    model=\"gpt-4o-mini\",\n    target_language=\"Chinese\",\n    temperature=0.7,\n)\n\n# 翻译字幕\nresult = translator.translate_subtitle(\"subtitle.srt\")\n```\n\n### 使用 Google 翻译\n\n```python\ntranslator = TranslatorFactory.create_translator(\n    translator_type=TranslatorType.GOOGLE,\n    target_language=\"简体中文\",\n)\n\nresult = translator.translate_subtitle(\"subtitle.srt\")\n```\n\n### 使用 Bing 翻译\n\n```python\ntranslator = TranslatorFactory.create_translator(\n    translator_type=TranslatorType.BING,\n    target_language=\"Chinese\",\n)\n\nresult = translator.translate_subtitle(\"subtitle.srt\")\n```\n\n### 使用 DeepLX 翻译\n\n```python\nimport os\n\n# 设置 DeepLX 端点（可选）\nos.environ[\"DEEPLX_ENDPOINT\"] = \"https://your-deeplx-endpoint.com/translate\"\n\ntranslator = TranslatorFactory.create_translator(\n    translator_type=TranslatorType.DEEPLX,\n    target_language=\"Chinese\",\n)\n\nresult = translator.translate_subtitle(\"subtitle.srt\")\n```\n\n## 环境变量配置\n\n### LLM 翻译器\n\n```bash\nexport OPENAI_API_KEY=\"your-api-key\"\nexport OPENAI_BASE_URL=\"https://api.openai.com/v1\"\n```\n\n### DeepLX 翻译器\n\n```bash\nexport DEEPLX_ENDPOINT=\"https://api.deeplx.org/translate\"\n```\n\n## 高级功能\n\n### 并发翻译\n\n```python\ntranslator = TranslatorFactory.create_translator(\n    translator_type=TranslatorType.OPENAI,\n    thread_num=10,      # 并发线程数\n    batch_num=20,       # 每批处理数量\n)\n```\n\n### 自定义 Prompt\n\n```python\ntranslator = TranslatorFactory.create_translator(\n    translator_type=TranslatorType.OPENAI,\n    custom_prompt=\"请保持原文的语气和风格\",\n)\n```\n\n### 进度回调\n\n```python\ndef on_progress(result):\n    print(f\"翻译进度: {result}\")\n\ntranslator = TranslatorFactory.create_translator(\n    translator_type=TranslatorType.OPENAI,\n    update_callback=on_progress,\n)\n```\n\n### Reflect 模式（反思优化）\n\n```python\ntranslator = TranslatorFactory.create_translator(\n    translator_type=TranslatorType.OPENAI,\n    is_reflect=True,  # 启用反思模式\n)\n```\n\n## 缓存机制\n\n所有翻译器都内置了缓存支持：\n\n- **LLM 翻译器**: 使用 `CacheManager` 缓存翻译结果\n- **Google/Bing/DeepLX**: 使用 `CacheManager` 缓存翻译结果\n\n缓存基于：\n\n- 原文内容\n- 目标语言\n- 模型参数（LLM）\n- Prompt 哈希（LLM）\n\n## 扩展新的翻译器\n\n1. 继承 `BaseTranslator`\n2. 实现 `_translate_chunk` 方法\n3. 在 `factory.py` 中注册\n\n```python\nfrom app.core.translate.base import BaseTranslator\n\nclass MyTranslator(BaseTranslator):\n    def _translate_chunk(self, subtitle_chunk: Dict[str, str]) -> Dict[str, str]:\n        # 实现翻译逻辑\n        result = {}\n        for idx, text in subtitle_chunk.items():\n            result[idx] = my_translate_function(text)\n        return result\n```\n\n## 注意事项\n\n1. **LLM 翻译器**需要设置 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL`\n2. **批量大小**会影响翻译效率和 API 成本\n3. **并发数量**应根据网络和 API 限制调整\n4. 所有翻译器都支持 **停止**操作：`translator.stop()`\n5. 翻译结果会自动保存到 `ASRData` 的 `translated_text` 字段\n\n## 性能优化建议\n\n- 使用缓存避免重复翻译\n- 合理设置 `batch_num` 减少 API 调用\n- 调整 `thread_num` 提高并发效率\n- 对于大量字幕，使用 Google/Bing 等免费服务\n- 对于高质量要求，使用 LLM 或 DeepLX\n"
  },
  {
    "path": "docs/dev/view-structure.md",
    "content": "view/  目录结构：用户界面 (UI) 模块 \n\n下面是本软件的一个主要页面结构，方便开发者查看和修改。\n\n\n```\n├── main_window.py  ------------------  主窗口 (应用程序框架)\n│   │\n│   └── \n│       ├── home_interface.py -------- 主页窗口 (程序主界面，包含核心功能)\n│       │   │\n│       │   └── 包含以下子功能模块:\n│       │       ├── task_creation_interface.py - 任务创建窗口\n│       │       ├── transcription_interface.py - 语音转录窗口\n│       │       ├── subtitle_interface.py -------- 字幕优化窗口\n│       │       └── video_synthesis_interface.py - 视频合成窗口\n│       │\n│       ├── batch_process_interface.py ------- 批量处理窗口\n│       ├── subtitle_style_interface.py ------ 字幕样式窗口\n│       └── setting_interface.py -------------- 设置窗口\n│\n├── log_window.py -------------------- 日志窗口 (独立窗口，集成在 home_interface)\n\n```"
  },
  {
    "path": "docs/en/config/asr.md",
    "content": ""
  },
  {
    "path": "docs/en/config/cookies.md",
    "content": ""
  },
  {
    "path": "docs/en/config/llm.md",
    "content": ""
  },
  {
    "path": "docs/en/config/translator.md",
    "content": ""
  },
  {
    "path": "docs/en/dev/api.md",
    "content": ""
  },
  {
    "path": "docs/en/dev/architecture.md",
    "content": ""
  },
  {
    "path": "docs/en/dev/contributing.md",
    "content": ""
  },
  {
    "path": "docs/en/guide/batch-processing.md",
    "content": ""
  },
  {
    "path": "docs/en/guide/configuration.md",
    "content": "# Configuration\n\nEnglish documentation coming soon...\n"
  },
  {
    "path": "docs/en/guide/faq.md",
    "content": "# FAQ\n\nEnglish documentation coming soon...\n"
  },
  {
    "path": "docs/en/guide/getting-started.md",
    "content": "# Getting Started\n\nEnglish documentation coming soon...\n\nPlease refer to the Chinese version for now.\n"
  },
  {
    "path": "docs/en/guide/manuscript.md",
    "content": ""
  },
  {
    "path": "docs/en/guide/subtitle-style.md",
    "content": ""
  },
  {
    "path": "docs/en/guide/workflow.md",
    "content": "# Workflow\n\nEnglish documentation coming soon...\n"
  },
  {
    "path": "docs/en/index.md",
    "content": "---\nlayout: home\ntitle: VideoCaptioner - AI Video Subtitle Tool | Free & Open Source\ntitleTemplate: false\ndescription: Free and open-source AI-powered video subtitle tool. Supports Whisper speech recognition, LLM intelligent segmentation, subtitle optimization, and 99-language translation. Perfect for YouTube, Bilibili, and more.\n\nhead:\n  - - meta\n    - name: keywords\n      content: VideoCaptioner,video subtitle generator,AI automatic subtitles,Whisper subtitles,LLM subtitle translation,free subtitle tool,open source caption software,video transcription,speech to text,subtitle maker,YouTube subtitle tool,multilingual subtitles,automatic caption generator,subtitle editing software,video captioning,AI subtitle creator,subtitle optimization,video to text converter\n  - - meta\n    - property: og:title\n      content: VideoCaptioner - AI Video Subtitle Tool | Free & Open Source\n  - - meta\n    - property: og:description\n      content: Free & open-source AI subtitle tool powered by Whisper & LLM. Supports 99 languages with intelligent segmentation, professional translation, and one-click processing. Perfect for content creators on YouTube, Bilibili, and other platforms.\n  - - meta\n    - property: og:url\n      content: https://weifeng2333.github.io/VideoCaptioner/en/\n  - - meta\n    - property: og:locale\n      content: en_US\n  - - meta\n    - property: og:type\n      content: website\n  - - meta\n    - property: article:published_time\n      content: 2024-01-01T00:00:00Z\n  - - meta\n    - property: article:modified_time\n      content: 2025-01-25T00:00:00Z\n  - - meta\n    - name: twitter:title\n      content: VideoCaptioner - AI Video Subtitle Tool | Free & Open Source\n  - - meta\n    - name: twitter:description\n      content: Free AI-powered subtitle tool with Whisper & LLM. Supports 99 languages, intelligent segmentation, and professional translation. Perfect for content creators.\n\nhero:\n  name: VideoCaptioner\n  text: Professional Video Subtitle Processing\n  tagline: Open Source · LLM-Powered · Process 14-minute video in 4 minutes, cost less than $0.002\n  image:\n    src: /logo.png\n    alt: VideoCaptioner\n  actions:\n    - theme: brand\n      text: Get Started\n      link: /en/guide/getting-started\n    - theme: alt\n      text: GitHub Repository\n      link: https://github.com/WEIFENG2333/VideoCaptioner\n\nfeatures:\n  - icon: ⚡\n    title: Lightning Fast, Ultra Low Cost\n    details: Process 14-minute video in just 4 minutes, cost less than $0.002. Powered by Whisper + LLM stack for quality and speed.\n\n  - icon: 🧠\n    title: LLM-Powered Intelligence\n    details: Beyond speech recognition. LLM semantic segmentation, auto error correction, terminology unification, expression optimization for professional results.\n\n  - icon: 🌐\n    title: Multilingual Support\n    details: Recognize 99 languages, translate to 37 languages. Reflection translation mechanism ensures quality with precise timeline alignment.\n\n  - icon: 📖\n    title: Fully Open Source & Free\n    details: MIT license, no hidden fees. Run locally for complete data privacy control. Community-driven continuous improvement.\n\n  - icon: 💻\n    title: No High-End Hardware\n    details: Run Whisper on CPU, optional GPU acceleration. Choose between cloud API or local offline processing.\n\n  - icon: 📦\n    title: Batch Processing\n    details: Drag and drop videos for automatic processing with batch queue support. From recognition to translation to synthesis, zero manual intervention.\n\n  - icon: 🎨\n    title: Professional Subtitle Styles\n    details: Built-in style templates. Support hard/soft subtitles with SRT/ASS/VTT multi-format output.\n\n  - icon: 🔧\n    title: Advanced Features\n    details: VAD voice detection, vocal separation, word-level timestamps, manuscript matching, custom prompts, and more.\n\n  - icon: 🖥️\n    title: Cross-Platform Desktop App\n    details: Windows/macOS/Linux installers available. Modern PyQt5 interface with real-time preview and quick editing.\n---\n\n## Interface Preview\n\n<div align=\"center\">\n  <img src=\"https://h1.appinn.me/file/1731487405884_main.png\" alt=\"Software Interface Preview\" style=\"border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);\">\n</div>\n\n## Quick Experience\n\n::: code-group\n\n```bash [Windows]\n# Download and run the installer directly\n# Or run from source\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\nrun.bat\n```\n\n```bash [macOS/Linux]\n# Use automatic installation script\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\nchmod +x run.sh\n./run.sh\n```\n\n:::\n\n## Why Choose VideoCaptioner?\n\n- **🎯 Efficient Processing**: Full processing of a 14-minute video takes only about 4 minutes, costing less than ¥0.01\n- **🌟 Quality Assurance**: Uses advanced Whisper models and large language models to ensure subtitle quality\n- **💡 Intelligent Optimization**: Automatically corrects typos, unifies terminology, optimizes expressions\n- **🚀 Easy to Use**: Drag and drop videos for fully automatic processing, no complex configuration needed\n\n## Core Features\n\n### Speech Recognition & Transcription\n\n- Supports Whisper API, FasterWhisper, WhisperCpp, and other engines\n- Supports 99 language recognition\n- Supports VAD (Voice Activity Detection)\n- Supports vocal separation\n\n### Intelligent Subtitle Processing\n\n- LLM semantic segmentation for smoother reading\n- Automatically optimizes terminology, code snippets, mathematical formulas\n- Supports manuscript matching to improve accuracy\n- Precise subtitle timeline alignment\n\n### High-Quality Translation\n\n- Supports LLM translation, Google Translate, Bing Translate, DeepLX\n- Reflection translation mechanism improves translation quality\n- Maintains complete timeline consistency\n- Supports 37 target languages\n\n### Video Synthesis\n\n- Supports hard subtitles and soft subtitles\n- Rich subtitle style templates\n- Supports multiple subtitle formats (SRT, ASS, VTT, TXT)\n- Supports batch video processing\n\n## Get Started\n\nReady to begin? Check out the [Getting Started Guide](/en/guide/getting-started) to learn how to use VideoCaptioner.\n\n<style>\n:root {\n  --vp-home-hero-name-color: transparent;\n  --vp-home-hero-name-background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n}\n</style>\n"
  },
  {
    "path": "docs/guide/configuration.md",
    "content": "# 配置指南\n\n详细的配置选项说明。\n\n## 全局配置\n\n待补充...\n\n## 高级配置\n\n待补充...\n\n---\n\n更多配置细节，请参考：\n- [LLM 配置](/config/llm)\n- [ASR 配置](/config/asr)\n- [翻译配置](/config/translator)\n"
  },
  {
    "path": "docs/guide/cookies-config.md",
    "content": "# Cookie 配置指南\n\n本指南将帮助你配置浏览器 Cookie，以便下载需要登录才能访问的视频。\n\n## 为什么需要配置 Cookie？\n\n在使用 VideoCaptioner 下载视频时，你可能会遇到以下错误：\n\n![Cookie 错误提示](https://h1.appinn.me/file/1731487405884_cookies_error.png)\n\n这通常是因为：\n\n1. **某些视频平台**（如 B 站、YouTube）需要用户登录才能获取高质量视频\n2. **网络条件较差**时，部分网站需要验证用户身份才能下载\n3. **地区限制**的内容需要特定账号权限\n\n:::tip 何时需要配置\n只有当你看到上述错误提示时才需要配置 Cookie。大多数情况下，VideoCaptioner 可以直接下载视频。\n:::\n\n---\n\n## 配置步骤\n\n### 1. 安装浏览器扩展\n\n根据你使用的浏览器选择对应的扩展：\n\n| 浏览器      | 扩展名称              | 下载链接                                                                                                                |\n| ----------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| **Chrome**  | Get CookieTxt Locally | [Chrome 应用店](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)       |\n| **Edge**    | Export Cookies File   | [Edge 插件商店](https://microsoftedge.microsoft.com/addons/detail/export-cookies-file/hbglikhfdcfhdfikmocdflffaecbnedo) |\n| **Firefox** | cookies.txt           | [Firefox 附加组件](https://addons.mozilla.org/zh-CN/firefox/addon/cookies-txt/)                                         |\n\n:::info 其他浏览器\n如果你使用其他浏览器（如 Safari、Opera），可以搜索类似的 \"Export Cookies\" 扩展。\n:::\n\n### 2. 导出 Cookie 文件\n\n安装扩展后，按以下步骤操作：\n\n#### 步骤一：登录目标网站\n\n打开需要下载视频的网站（如 B 站、YouTube），**确保你已登录账号**。\n\n#### 步骤二：导出 Cookie\n\n1. 在该网站页面点击浏览器扩展图标\n2. 选择 **\"Export Cookies\"** 或类似选项\n3. 扩展会自动下载一个 `cookies.txt` 文件\n\n![导出 Cookie 示例](https://h1.appinn.me/file/1731487405884_cookies_export.png)\n\n:::warning 注意事项\n\n- 确保在**目标网站的页面**上导出 Cookie（不是在其他网站）\n- 某些扩展可能默认导出为 `cookies.json`，请重命名为 `cookies.txt`\n  :::\n\n### 3. 放置 Cookie 文件\n\n将导出的 `cookies.txt` 文件移动到 VideoCaptioner 的 **AppData** 目录下。\n\n#### AppData 目录位置\n\nVideoCaptioner 的 AppData 目录通常位于：\n\n```\nVideoCaptioner/\n├─ app/\n├─ resource/\n├─ AppData/          # Cookie 文件放这里\n│  ├─ cache/\n│  ├─ logs/\n│  ├─ models/\n│  ├─ cookies.txt    # ← 将文件放在这里\n│  └─ settings.json\n└─ work-dir/\n```\n\n:::tip 快速定位\n在 VideoCaptioner 中点击 **设置 → 打开日志文件夹**，然后返回上一级目录即可看到 `AppData` 文件夹。\n:::\n\n### 4. 验证配置\n\n配置完成后：\n\n1. 重启 VideoCaptioner\n2. 再次尝试下载视频\n3. 如果仍然失败，请检查 Cookie 文件是否正确放置\n\n---\n\n## 常见问题\n\n### Cookie 文件格式不正确\n\n**问题**：提示 \"Cookie 文件格式错误\"\n\n**解决方法**：\n\n- 确保文件名为 `cookies.txt`（不是 `cookies.json` 或其他）\n- 使用文本编辑器打开文件，检查是否为 Netscape Cookie 格式\n- 重新导出 Cookie，确保选择正确的格式\n\n### 下载仍然失败\n\n**问题**：配置 Cookie 后仍然无法下载\n\n**可能原因**：\n\n1. **Cookie 已过期** - 重新登录网站并导出新的 Cookie\n2. **账号权限不足** - 确认你的账号能否在浏览器中正常观看该视频\n3. **地区限制** - 视频可能仅限特定地区访问\n\n### 需要为每个网站单独配置吗？\n\n**答案**：不需要。\n\n- 一个 `cookies.txt` 文件可以包含多个网站的 Cookie\n- 浏览器扩展通常会导出**所有已登录网站**的 Cookie\n- 建议在常用的视频网站（B 站、YouTube 等）都登录后再导出\n\n### Cookie 安全吗？\n\n**安全建议**：\n\n- Cookie 文件包含你的登录信息，**不要分享给他人**\n- 定期更新 Cookie（每月导出一次）\n- 如果担心安全，可以使用**小号**登录并导出 Cookie\n\n### 支持哪些视频网站？\n\nVideoCaptioner 使用 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 作为下载引擎，支持 1000+ 个视频网站，包括：\n\n- 🎬 YouTube、Bilibili、抖音、快手\n- 📺 爱奇艺、腾讯视频、优酷\n- 🎓 Coursera、Udemy、Khan Academy\n- 🐦 Twitter、Facebook、Instagram\n- ...以及更多\n\n完整列表请查看 [yt-dlp 支持列表](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)\n\n---\n\n## 下一步\n\n配置完成后，你可以：\n\n- 查看 [快速开始指南](./getting-started.md) 下载并处理视频\n- 了解 [批量处理功能](./batch-processing.md) 处理多个视频\n- 探索 [视频下载技巧](./video-download.md)\n"
  },
  {
    "path": "docs/guide/faq.md",
    "content": "# 常见问题\n\n常见问题解答。\n\n## 安装问题\n\n### Q: 如何安装依赖？\n\nA: 参考[快速开始](/guide/getting-started)中的安装步骤。\n\n## 使用问题\n\n### Q: 转录时出现幻觉或重复怎么办？\n\nA: \n- 启用 VAD 过滤\n- 更换更大的模型\n- 尝试 Large-v2 而不是 Large-v3\n- 在嘈杂环境中启用音频分离\n\n### Q: LLM 请求失败怎么办？\n\nA:\n- 检查 API Key 是否正确\n- 检查 Base URL 是否正确\n- 降低线程数\n- 检查网络连接\n- 查看日志文件获取详细错误信息\n\n更多问题，请访问 [GitHub Issues](https://github.com/WEIFENG2333/VideoCaptioner/issues)。\n"
  },
  {
    "path": "docs/guide/getting-started.md",
    "content": "---\ntitle: 快速开始 - VideoCaptioner\ndescription: 快速安装和配置 VideoCaptioner，5分钟开始处理你的第一个视频字幕。支持 Windows、macOS、Linux 多平台。\nhead:\n  - - meta\n    - name: keywords\n      content: VideoCaptioner安装,快速开始,视频字幕教程,Whisper安装,LLM配置,字幕处理入门\n---\n\n# 快速开始\n\n本指南将帮助你快速上手 VideoCaptioner，开始处理你的第一个视频字幕。\n\n## 系统要求\n\n- **Windows**: Windows 10/11 (64位)\n- **macOS**: macOS 10.15 或更高版本\n- **Linux**: Ubuntu 20.04+ / Debian 11+ / Fedora 35+\n- **Python**: Python 3.10 或更高版本（源码运行时需要）\n- **内存**: 建议 4GB 以上（使用本地 Whisper 需要 8GB+）\n\n## 安装方式\n\n### Windows 用户（推荐使用打包版本）\n\n软件较为轻量，打包大小不足 60M，已集成所有必要环境，下载后可直接运行。\n\n1. 从 [Release](https://github.com/WEIFENG2333/VideoCaptioner/releases) 页面下载最新版本的可执行程序\n\n   或者：[蓝奏盘下载](https://wwwm.lanzoue.com/ii14G2pdsbej)\n\n2. 双击打开安装包进行安装\n\n3. 首次运行会自动检测环境，无需额外配置\n\n### macOS / Linux 用户\n\n#### 使用自动安装脚本（推荐）\n\n```bash\n# 1. 克隆项目\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\n\n# 2. 运行安装脚本\nchmod +x run.sh\n./run.sh\n```\n\n脚本会自动：\n\n- 检测 Python 环境\n- 创建虚拟环境并安装依赖\n- 检测系统工具（ffmpeg、aria2）\n- 启动应用程序\n\n::: tip 提示\nmacOS 用户需要先安装 [Homebrew](https://brew.sh/)\n:::\n\n#### 手动安装\n\n<details>\n<summary>点击展开手动安装步骤</summary>\n\n**1. 安装系统依赖**\n\n::: code-group\n\n```bash [macOS]\nbrew install ffmpeg aria2 python@3.11\n```\n\n```bash [Ubuntu/Debian]\nsudo apt update\nsudo apt install ffmpeg aria2 python3.11 python3.11-venv python3-pip\n```\n\n```bash [Fedora]\nsudo dnf install ffmpeg aria2 python3.11\n```\n\n:::\n\n**2. 克隆项目并安装 Python 依赖**\n\n```bash\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\n\n# 创建虚拟环境\npython3.11 -m venv venv\n\n# 激活虚拟环境\nsource venv/bin/activate  # macOS/Linux\n# 或\n.\\venv\\Scripts\\activate   # Windows\n\n# 安装依赖\npip install -r requirements.txt\n```\n\n**3. 运行程序**\n\n```bash\npython main.py\n```\n\n</details>\n\n### Docker 部署（实验性）\n\n::: warning 注意\nDocker 版本目前还比较基础，欢迎提交 PR 改进。\n:::\n\n```bash\n# 1. 构建镜像\ndocker build -t video-captioner .\n\n# 2. 运行容器\ndocker run -d \\\n  -p 8501:8501 \\\n  -v $(pwd)/temp:/app/temp \\\n  -e OPENAI_BASE_URL=\"Your API address\" \\\n  -e OPENAI_API_KEY=\"Your API key\" \\\n  --name video-captioner \\\n  video-captioner\n\n# 3. 访问应用\n# 打开浏览器访问 http://localhost:8501\n```\n\n## 基础配置\n\n在开始处理视频之前，建议先完成以下基础配置：\n\n### 1. LLM API 配置（可选但推荐）\n\nLLM 用于字幕断句、优化和翻译。软件内置了基础模型，但配置自己的 API 可以获得更好的效果。\n\n打开 **设置 → LLM 配置**，选择以下任一服务：\n\n| 服务商           | 特点               | 推荐模型                                |\n| ---------------- | ------------------ | --------------------------------------- |\n| **OpenAI**       | 质量最好           | `gpt-4o-mini` (经济), `gpt-4o` (高质量) |\n| **DeepSeek**     | 性价比高           | `deepseek-chat`                         |\n| **SiliconCloud** | 国内可用，并发较低 | `Qwen/Qwen2.5-72B-Instruct`             |\n| **Ollama**       | 本地运行，完全免费 | `llama3.1:8b`                           |\n\n::: tip 推荐\n如果需要高并发和优质模型，可使用本项目的 [LLM API 中转站](https://api.videocaptioner.cn)\n\n配置方式：\n\n- Base URL: `https://api.videocaptioner.cn/v1`\n- API Key: 注册后在个人中心获取\n\n推荐模型：\n\n- 高质量：`gemini-2.0-flash-exp`、`claude-sonnet-4.5`\n- 经济实惠：`gpt-4o-mini`、`gemini-2.0-flash-exp`\n  :::\n\n详细配置方法请查看 [LLM 配置指南](/config/llm)。\n\n### 2. 语音识别配置\n\n打开 **设置 → 转录配置**，选择语音识别引擎：\n\n| 引擎                 | 支持语言 | 运行方式 | 推荐场景                      |\n| -------------------- | -------- | -------- | ----------------------------- |\n| **FasterWhisper** ⭐ | 99种语言 | 本地     | 最推荐，准确度高，支持GPU加速 |\n| **B接口**            | 中英文   | 在线     | 快速测试，无需下载模型        |\n| **J接口**            | 中英文   | 在线     | 备用选项                      |\n| **WhisperCpp**       | 99种语言 | 本地     | 轻量级本地方案                |\n| **Whisper API**      | 99种语言 | 在线     | 使用 OpenAI API               |\n\n::: tip 推荐配置\n\n- **中文视频**: FasterWhisper + Medium 模型或以上\n- **英文视频**: FasterWhisper + Small 模型即可\n- **其他语言**: FasterWhisper + Large-v2 模型\n\n首次使用需要在软件内下载模型，国内网络可直接下载。\n:::\n\n详细配置方法请查看 [ASR 配置指南](/config/asr)。\n\n### 3. 翻译配置（可选）\n\n如果需要翻译字幕，打开 **设置 → 翻译配置**：\n\n| 翻译服务        | 特点                 | 推荐场景     |\n| --------------- | -------------------- | ------------ |\n| **LLM 翻译** ⭐ | 质量最好，理解上下文 | 追求翻译质量 |\n| **Bing 翻译**   | 速度快，免费         | 快速翻译     |\n| **Google 翻译** | 速度快，需要科学上网 | 英语翻译     |\n| **DeepLX**      | 质量好，需要自建服务 | 专业翻译     |\n\n详细配置方法请查看 [翻译配置指南](/config/translator)。\n\n## 开始处理视频\n\n### 全流程处理（最简单）\n\n这是最简单的方式，一键完成所有步骤：\n\n1. 在主界面点击 **\"任务创建\"** 标签\n2. 拖拽视频文件到窗口，或点击选择文件\n   - 也可以输入 YouTube、B站等视频链接\n3. 点击 **\"开始全流程处理\"** 按钮\n4. 等待处理完成，输出文件保存在 `work-dir/` 目录\n\n::: info 处理流程\n全流程会依次执行：\n\n1. 语音识别转录\n2. 字幕智能断句（可选）\n3. 字幕优化（可选）\n4. 字幕翻译（可选）\n5. 视频合成\n   :::\n\n### 分步处理\n\n如果你需要更精细的控制，可以分步处理：\n\n#### 步骤 1：语音识别转录\n\n1. 切换到 **\"语音转录\"** 标签\n2. 选择视频或音频文件\n3. 配置转录参数：\n   - 转录语言（自动检测或手动指定）\n   - VAD 方法（建议保持默认）\n   - 是否启用音频分离（嘈杂环境推荐）\n4. 点击 **\"开始转录\"**\n5. 转录完成后会生成字幕文件\n\n#### 步骤 2：字幕优化与翻译\n\n1. 切换到 **\"字幕优化与翻译\"** 标签\n2. 加载字幕文件（自动加载或手动选择）\n3. 配置处理选项：\n   - **智能断句**：重新分段，阅读更流畅\n   - **字幕校正**：修正错别字、优化格式\n   - **字幕翻译**：翻译为目标语言\n4. （可选）填写文稿提示，提升准确度\n5. 点击 **\"开始处理\"**\n6. 处理完成后可以实时预览和编辑\n\n#### 步骤 3：字幕视频合成\n\n1. 切换到 **\"字幕视频合成\"** 标签\n2. 选择字幕样式（科普风、新闻风等）\n3. 选择合成方式：\n   - **硬字幕**：烧录到视频中\n   - **软字幕**：内嵌字幕轨道（需要播放器支持）\n4. 点击 **\"开始合成\"**\n5. 输出视频保存在 `work-dir/` 目录\n\n## 实用技巧\n\n### 1. 提升字幕质量\n\n- ✅ 使用 FasterWhisper Large-v2 模型\n- ✅ 启用 VAD 过滤，减少幻觉\n- ✅ 在嘈杂环境中启用音频分离\n- ✅ 使用智能断句（语义分段）\n- ✅ 填写文稿提示（术语表、原文稿等）\n\n### 2. 加快处理速度\n\n- ✅ 使用在线 ASR（B接口/J接口）跳过模型下载\n- ✅ 提高 LLM 并发线程数（如果 API 支持）\n- ✅ 使用软字幕合成（速度极快）\n- ✅ 关闭不需要的功能（如翻译、优化）\n\n### 3. 批量处理\n\n如果需要处理多个视频：\n\n1. 切换到 **\"批量处理\"** 标签\n2. 选择处理类型（批量转录/字幕处理/视频合成）\n3. 添加视频文件到队列\n4. 点击 **\"开始批量处理\"**\n\n详细说明请查看 [批量处理指南](/guide/batch-processing)。\n\n## 常见问题\n\n### 转录时出现幻觉或重复\n\n::: details 解决方案\n\n- 启用 VAD 过滤\n- 更换更大的模型（如 Medium → Large）\n- 尝试 Large-v2 而不是 Large-v3\n- 在嘈杂环境中启用音频分离\n  :::\n\n### LLM 请求失败\n\n::: details 解决方案\n\n- 检查 API Key 是否正确\n- 检查 Base URL 是否正确\n- 降低线程数（某些服务商限制并发）\n- 检查网络连接\n- 查看日志文件获取详细错误信息\n  :::\n\n### 字幕时间轴不准确\n\n::: details 解决方案\n\n- 使用 FasterWhisper（时间轴最准确）\n- 启用智能断句时使用语义分段模式\n- 手动在字幕编辑界面调整\n  :::\n\n更多问题请查看 [常见问题解答](/guide/faq)。\n\n## 下一步\n\n- 📖 了解 [工作流程](/guide/workflow)\n- ⚙️ 查看 [详细配置指南](/guide/configuration)\n- 🎨 自定义 [字幕样式](/guide/subtitle-style)\n- 📝 使用 [文稿匹配](/guide/manuscript) 提升准确度\n\n---\n\n如果在使用过程中遇到问题，欢迎提交 [Issue](https://github.com/WEIFENG2333/VideoCaptioner/issues) 或加入社区讨论。\n"
  },
  {
    "path": "docs/guide/llm-config.md",
    "content": "# LLM API 配置指南\n\n本指南将帮助你配置大语言模型（LLM）API，用于字幕的智能断句、优化和翻译。\n\n## 为什么需要配置 LLM？\n\nVideoCaptioner 使用 LLM 提供以下核心功能：\n\n- **智能断句** - 根据语义自动分割字幕，而不是简单按时长切割\n- **字幕优化** - 纠正语音识别的错误，统一专业术语\n- **高质量翻译** - 提供符合语境的翻译，而不是机器直译\n\n:::tip 费用说明\n处理一个 14 分钟的视频，使用 `gpt-4o-mini` 模型，总费用约为 **¥0.01**（不到一分钱）\n:::\n\n## 配置方式\n\n目前有两种主流配置方式：\n\n1. [国内 API 服务商](#国内-api-服务商)（推荐新手）\n2. [OpenAI 官方或中转站](#openai-官方或中转站)\n\n---\n\n## 国内 API 服务商\n\n### 使用 SiliconCloud\n\n[SiliconCloud](https://cloud.siliconflow.cn/i/onCHcaDx) 集成了国内多家大模型厂商，注册即送测试额度。\n\n#### 1. 注册并获取 API Key\n\n访问 [SiliconCloud 设置页面](https://cloud.siliconflow.cn/account/ak) 获取 API Key\n\n![获取 API Key](https://h1.appinn.me/file/1731487405884_get_api.png)\n\n#### 2. 在软件中配置\n\n打开 VideoCaptioner，进入 **设置 → LLM 服务配置**\n\n填写以下信息：\n\n| 配置项           | 值                               |\n| ---------------- | -------------------------------- |\n| **API 接口地址** | `https://api.siliconflow.cn/v1`  |\n| **API Key**      | 粘贴你从 SiliconCloud 获取的密钥 |\n| **模型**         | 推荐 `deepseek-ai/DeepSeek-V3`   |\n\n![SiliconCloud 配置示例](https://h1.appinn.me/file/1731487405884_api-setting.png)\n\n#### 3. 验证连接\n\n点击 **检查连接** 按钮，如果配置正确：\n\n- 软件会自动填充所有支持的模型名称\n- 你可以从下拉菜单中选择需要的模型\n\n:::warning 并发限制\nSiliconCloud 对并发请求有限制，建议将 **线程数** 设置为 **5 或以下**\n:::\n\n:::info 实名要求\n自 2025 年 2 月 6 日起，DeepSeek-V3 模型要求实名认证才能获得更多调用次数。未实名用户每日最多请求 100 次。\n:::\n\n---\n\n## OpenAI 官方或中转站\n\n### 使用项目推荐的中转站\n\n如果你需要使用 OpenAI、Claude 或 Gemini 模型，可以使用中转服务。\n\n#### 1. 注册账号\n\n访问 [本项目的中转站](https://api.videocaptioner.cn/register?aff=UrLB)，通过此链接注册默认赠送 **$0.4** 测试余额。\n\n#### 2. 获取 API Key\n\n登录后访问 [https://api.videocaptioner.cn/token](https://api.videocaptioner.cn/token) 获取你的 API Key\n\n#### 3. 在软件中配置\n\n打开 VideoCaptioner，进入 **设置 → LLM 服务配置**\n\n填写以下信息：\n\n| 配置项           | 值                                 |\n| ---------------- | ---------------------------------- |\n| **API 接口地址** | `https://api.videocaptioner.cn/v1` |\n| **API Key**      | 粘贴你获取的密钥                   |\n| **模型**         | 见下方推荐                         |\n\n![中转站配置示例](https://h1.appinn.me/file/1731487405884_api-setting-2.png)\n\n#### 4. 模型选择建议\n\n根据质量和成本需求选择：\n\n| 质量层级        | 推荐模型                              | 成本比例 | 适用场景               |\n| --------------- | ------------------------------------- | -------- | ---------------------- |\n| 🏆 **高质量**   | `claude-3-5-sonnet-20241022`          | 3        | 专业内容、重要视频     |\n| ⭐ **较高质量** | `gemini-2.0-flash`<br>`deepseek-chat` | 1        | 日常使用、质量要求较高 |\n| 💰 **性价比**   | `gpt-4o-mini`<br>`gemini-1.5-flash`   | 0.15     | 大量视频、成本敏感     |\n\n:::tip 性能优势\n本中转站支持超高并发，软件中 **线程数可以拉满**，处理速度非常快！\n:::\n\n:::info 成本建议\n如果条件有限，直接使用 `gpt-4o-mini` 即可。这个模型便宜且速度快，处理一个视频只需几分钱，**建议不要折腾本地部署了**。\n:::\n\n---\n\n## 使用 OpenAI 官方 API\n\n如果你有 OpenAI 官方账号，可以直接使用官方 API。\n\n#### 1. 获取 API Key\n\n访问 [OpenAI API Keys](https://platform.openai.com/api-keys) 创建 API Key\n\n#### 2. 在软件中配置\n\n| 配置项           | 值                          |\n| ---------------- | --------------------------- |\n| **API 接口地址** | `https://api.openai.com/v1` |\n| **API Key**      | 粘贴你的 OpenAI API Key     |\n| **模型**         | `gpt-4o-mini` 或 `gpt-4o`   |\n\n---\n\n## 常见问题\n\n### 如何选择线程数？\n\n**线程数**决定了并发处理字幕的速度：\n\n- **SiliconCloud**: 建议 5 或以下（有并发限制）\n- **中转站**: 可以拉满（支持高并发）\n- **OpenAI 官方**: 建议 10-20（取决于账号等级）\n\n### 如何降低成本？\n\n1. 选择更便宜的模型（如 `gpt-4o-mini`）\n2. 禁用字幕优化功能（只保留翻译）\n3. 使用本地 Whisper 模型进行转录，只用 LLM 做翻译\n\n### API Key 安全吗？\n\n- 所有 API Key 都保存在本地 `AppData/settings.json` 文件中\n- 不会上传到任何服务器\n- 建议定期轮换 API Key\n\n### 连接失败怎么办？\n\n检查以下几点：\n\n1. API 接口地址是否正确（注意末尾的 `/v1`）\n2. API Key 是否正确复制（没有多余空格）\n3. 网络是否能访问 API 服务器\n4. 账号余额是否充足\n\n---\n\n## 下一步\n\n配置完成后，你可以：\n\n- 查看 [快速开始指南](./getting-started.md) 处理你的第一个视频\n- 了解 [字幕优化功能](./subtitle-optimization.md)\n- 探索 [批量处理功能](./batch-processing.md)\n"
  },
  {
    "path": "docs/guide/quick-example.md",
    "content": "# 快速示例教程\n\n通过一个 TED 演讲视频的完整处理流程，快速了解 VideoCaptioner 的强大功能。\n\n:::tip 示例视频信息\n\n- 视频时长：14 分钟\n- 原始语言：英语\n- 目标语言：简体中文\n- 总处理时间：约 4 分钟\n- LLM 费用：¥0.01\n  :::\n\n---\n\n## 处理流程总览\n\n```mermaid\ngraph LR\n    A[导入视频] --> B[Whisper 转录]\n    B --> C[LLM 智能断句]\n    C --> D[LLM 优化翻译]\n    D --> E[视频合成]\n    E --> F[完成]\n```\n\n---\n\n## 步骤 1：语音转录\n\n### 转录设置\n\n![开始转录](https://h1.appinn.me/file/1731487405884_test_zl.png)\n\n| 配置项       | 选择                    |\n| ------------ | ----------------------- |\n| **转录模型** | Faster Whisper Large-v2 |\n| **语言**     | English（自动检测）     |\n| **VAD 方法** | Silero V4               |\n\n### 转录结果\n\n转录完成后生成的原始字幕：\n\n```srt{1,3,6,9}\n1\n00:00:02,080 --> 00:00:08,600\nSo in college, I was a government major,\n\n2\n00:00:08,600 --> 00:00:11,080\nwhich means I had to write a lot of papers.\n\n3\n00:00:11,080 --> 00:00:12,600\nNow, when a normal student writes a paper,\n\n4\n00:00:12,600 --> 00:00:15,460\nthey might spread the work out a little like this.\n\n5\n00:00:15,460 --> 00:00:16,300\nSo you know.\n\n6\n00:00:16,300 --> 00:00:20,040\nYou get started maybe a little slowly,\n\n7\n00:00:20,040 --> 00:00:21,600\nbut you get enough done in the first week\n\n8\n00:00:21,600 --> 00:00:24,000\nthat with some heavier days later on,\n\n9\n00:00:24,000 --> 00:00:26,200\neverything gets done and things stay civil.\n```\n\n:::info 初步观察\n\n- ✅ 语音识别准确度高\n- ⚠️ 断句较为机械，按固定时长切割\n- ⚠️ 标点符号简单，只有逗号和句号\n  :::\n\n---\n\n## 步骤 2：智能断句与优化\n\n### 开启优化选项\n\n- ✅ **智能断句** - 语义分段模式\n- ✅ **字幕优化** - LLM 纠错和标点优化\n- ✅ **字幕翻译** - 简体中文\n- ✅ **反思翻译** - 提升译文质量\n\n### 优化后的双语字幕\n\n```srt{1,3-4,7-8,11-12}\n1\n00:00:02,080 --> 00:00:08,597\n所以在大学时，我是政府专业的学生\nSo in college, I was a government major.\n\n2\n00:00:08,600 --> 00:00:11,078\n这意味着我得写很多论文\nWhich means I had to write a lot of papers.\n\n3\n00:00:11,080 --> 00:00:12,596\n现在，普通学生写论文时\nNow when a normal student writes a paper,\n\n4\n00:00:12,600 --> 00:00:15,460\n他们可能会这样分散工作\nThey might spread the work out a little like this.\n\n5\n00:00:15,460 --> 00:00:20,040\n所以你知道，你可能会稍微慢一些开始\nSo you know, you get started maybe a little slowly,\n\n6\n00:00:20,040 --> 00:00:21,593\n但你在第一周能够完成足够的工作\nBut you get enough done in the first week.\n\n7\n00:00:21,600 --> 00:00:23,996\n这样之后的一些繁忙日子\nThat with some heavier days later on.\n\n8\n00:00:24,000 --> 00:00:26,200\n一切都能完成，事情保持得当\nEverything gets done and things stay civil.\n```\n\n:::tip 优化效果\n\n- ✨ 断句更自然，根据语义重新分段\n- ✨ 中文翻译流畅，符合中文表达习惯\n- ✨ 保留原文，方便对照学习\n  :::\n\n---\n\n## 步骤 3：查看翻译细节\n\nVideoCaptioner 使用**反思翻译**技术，每句字幕都经过两次优化：\n\n### 翻译对比示例\n\n#### 示例 1：优化冗余词汇\n\n```log{2-3}\n原字幕：So in college, I was a government major.\n翻译后字幕：所以在大学时，我是一个政府专业的学生。\n反思后字幕：所以在大学时，我是政府专业的学生。\n```\n\n**改进点**：删除不必要的\"一个\"，使译文更简洁\n\n#### 示例 2：自然化表达\n\n```log{2-3}\n原字幕：Which means I had to write a lot of papers.\n翻译后字幕：这意味着我必须写很多论文。\n反思后字幕：这意味着我得写很多论文。\n```\n\n**改进点**：\"必须\" → \"得\"，更符合口语表达\n\n#### 示例 3：精简句式\n\n```log{2-3}\n原字幕：Now when a normal student writes a paper,\n翻译后字幕：现在，当一个普通学生写论文时，\n反思后字幕：现在，普通学生写论文时，\n```\n\n**改进点**：删除\"当\"和\"一个\"，句式更紧凑\n\n#### 示例 4：优化动词选择\n\n```log{2-3}\n原字幕：They might spread the work out a little like this.\n翻译后字幕：他们可能会像这样分散工作。\n反思后字幕：他们可能会这样分散工作。\n```\n\n**改进点**：\"像这样\" → \"这样\"，更自然\n\n#### 示例 5：调整语序\n\n```log{2-3}\n原字幕：So you know, you get started maybe a little slowly,\n翻译后字幕：所以你知道，你可能会开始得有点慢，\n反思后字幕：所以你知道，你可能会稍微慢一些开始，\n```\n\n**改进点**：调整语序和用词，更符合中文习惯\n\n---\n\n## 步骤 4：视频合成\n\n### 合成设置\n\n| 配置项       | 选择                 |\n| ------------ | -------------------- |\n| **字幕样式** | 科普风格             |\n| **字幕布局** | 双语字幕（中文在上） |\n| **合成方式** | 硬字幕（烧录到视频） |\n\n### 最终效果\n\n#### 效果图 1：Hero Section\n\n![合成效果 1](https://h1.appinn.me/file/1731487405884_test_ted1.png)\n\n#### 效果图 2：中段内容\n\n![合成效果 2](https://h1.appinn.me/file/1731487405884_test_ted2.png)\n\n#### 效果图 3：结尾部分\n\n![合成效果 3](https://h1.appinn.me/file/1731487405884_test_ted3.png)\n\n:::tip 字幕特点\n\n- 双语对照，学习更方便\n- 字体清晰，阅读体验好\n- 位置合理，不遮挡画面重点\n  :::\n\n---\n\n## 步骤 5：查看成本统计\n\n处理完成后，可以在 LLM 服务商后台查看调用情况：\n\n![成本统计](https://h1.appinn.me/file/1731487405884_test_spend.png)\n\n### 费用明细\n\n| 项目           | 数值                           |\n| -------------- | ------------------------------ |\n| **视频时长**   | 14 分钟                        |\n| **字幕段数**   | ~50 段                         |\n| **使用模型**   | gpt-4o-mini                    |\n| **处理类型**   | 断句 + 优化 + 翻译（反思模式） |\n| **Token 消耗** | ~5,000 tokens                  |\n| **总费用**     | **¥0.01**                      |\n\n:::info 成本分析\n\n- 使用 `gpt-4o-mini` 模型，性价比极高\n- 即使开启反思翻译，费用依然不到一分钱\n- 处理 100 个类似视频，总费用约 ¥1\n  :::\n\n---\n\n## 性能总结\n\n### 时间统计\n\n| 步骤         | 耗时          |\n| ------------ | ------------- |\n| **语音转录** | ~2 分钟       |\n| **智能断句** | ~30 秒        |\n| **优化翻译** | ~1 分钟       |\n| **视频合成** | ~30 秒        |\n| **总计**     | **约 4 分钟** |\n\n:::tip 速度优势\n处理 14 分钟视频只需 4 分钟，效率远超人工处理！\n:::\n\n### 质量对比\n\n| 对比项       | 原始转录        | 优化后              |\n| ------------ | --------------- | ------------------- |\n| **断句质量** | ⭐⭐⭐ 机械切割 | ⭐⭐⭐⭐⭐ 语义分段 |\n| **标点符号** | ⭐⭐ 仅基础标点 | ⭐⭐⭐⭐⭐ 完整标点 |\n| **翻译质量** | -               | ⭐⭐⭐⭐⭐ 反思优化 |\n| **阅读体验** | ⭐⭐⭐ 可用     | ⭐⭐⭐⭐⭐ 接近专业 |\n\n---\n\n## 适用场景\n\n通过这个示例，VideoCaptioner 特别适合：\n\n### 1. 教育学习\n\n- 📚 为英文课程添加中文字幕\n- 🎓 制作双语学习材料\n- 📝 提取视频文字稿用于笔记\n\n### 2. 内容创作\n\n- 🎬 YouTube 视频搬运到 B 站\n- 🌍 为自己的视频制作多语言版本\n- 📺 字幕组快速打轴和翻译\n\n### 3. 商业用途\n\n- 💼 会议录音转文字稿\n- 🎤 演讲视频添加字幕\n- 🌐 企业宣传片多语言化\n\n---\n\n## 下一步\n\n掌握了基本流程后，你可以：\n\n- 🎨 [自定义字幕样式](./subtitle-style.md) - 打造独特风格\n- ⚙️ [调整高级参数](./advanced-settings.md) - 进一步提升质量\n- 🚀 [批量处理视频](./batch-processing.md) - 提高工作效率\n- 📖 [查看完整文档](./getting-started.md) - 了解所有功能\n\n---\n\n## 常见问题\n\n### 为什么我的翻译质量不如示例？\n\n可能原因：\n\n- 使用的模型质量较低（如 Qwen 小模型）\n- 没有启用反思翻译\n- 线程数过高导致 API 限流\n\n**建议**：使用 `gpt-4o-mini` 或 `gemini-2.0-flash`，启用反思翻译\n\n### 处理速度慢怎么办？\n\n**加速技巧**：\n\n- 使用在线 ASR（B 接口/J 接口）跳过模型下载\n- 提高 LLM 线程数（如果服务商支持高并发）\n- 使用软字幕合成（速度极快）\n\n### 如何降低成本？\n\n**省钱技巧**：\n\n- 选择更便宜的模型（`gpt-4o-mini` 已经很便宜）\n- 关闭字幕优化，只保留翻译\n- 使用本地 Whisper，不用 API\n\n---\n\n需要帮助？欢迎在 [GitHub Issues](https://github.com/WEIFENG2333/VideoCaptioner/issues) 提问！\n"
  },
  {
    "path": "docs/guide/workflow.md",
    "content": "# 工作流程\n\n了解 VideoCaptioner 的完整工作流程。\n\n## 处理流程图\n\n```\n视频输入 → 语音识别 → 字幕分割 → 字幕优化 → 字幕翻译 → 视频合成\n```\n\n## 详细说明\n\n待补充...\n\n---\n\n相关文档：\n- [快速开始](/guide/getting-started)\n- [配置指南](/guide/configuration)\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nlayout: page\ntitle: VideoCaptioner - 基于LLM的智能视频字幕处理工具\ntitleTemplate: false\ndescription: 免费开源的AI视频字幕处理助手，支持Whisper语音识别、LLM智能断句、字幕优化和99种语言翻译。一键生成高质量字幕，适用于YouTube、B站等平台。\n\nhead:\n  - - meta\n    - name: keywords\n      content: VideoCaptioner,卡卡字幕助手,视频字幕生成器,AI自动字幕,Whisper中文字幕,LLM字幕翻译,免费字幕工具,开源字幕软件,视频转文字,语音识别字幕,B站字幕生成,YouTube字幕工具,多语言字幕,字幕断句优化,视频字幕处理,自动生成字幕,字幕制作软件,视频配字幕\n  - - meta\n    - property: og:title\n      content: VideoCaptioner - 基于LLM的智能视频字幕处理工具 | 免费开源\n  - - meta\n    - property: og:description\n      content: 免费开源的AI视频字幕处理助手。支持Whisper语音识别、LLM智能断句与翻译、多语言字幕生成。适用于YouTube、B站等平台，支持99种语言。一键处理，专业质量。\n  - - meta\n    - property: og:url\n      content: https://weifeng2333.github.io/VideoCaptioner/\n  - - meta\n    - property: og:type\n      content: website\n  - - meta\n    - property: article:published_time\n      content: 2024-01-01T00:00:00+08:00\n  - - meta\n    - property: article:modified_time\n      content: 2025-01-25T00:00:00+08:00\n  - - meta\n    - name: twitter:title\n      content: VideoCaptioner - AI Video Subtitle Tool | Free & Open Source\n  - - meta\n    - name: twitter:description\n      content: Free AI-powered subtitle tool with Whisper & LLM. Supports 99 languages, intelligent segmentation, and professional translation.\n---\n\n<CustomHome />\n"
  },
  {
    "path": "docs/package-lock.json",
    "content": "{\n  \"name\": \"videocaptioner-docs\",\n  \"version\": \"1.4.0\",\n  \"lockfileVersion\": 3,\n  \"requires\": true,\n  \"packages\": {\n    \"\": {\n      \"name\": \"videocaptioner-docs\",\n      \"version\": \"1.4.0\",\n      \"devDependencies\": {\n        \"vitepress\": \"^1.6.4\",\n        \"vue\": \"^3.5.13\"\n      }\n    },\n    \"node_modules/@algolia/abtesting\": {\n      \"version\": \"1.7.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.7.0.tgz\",\n      \"integrity\": \"sha512-hOEItTFOvNLI6QX6TSGu7VE4XcUcdoKZT8NwDY+5mWwu87rGhkjlY7uesKTInlg6Sh8cyRkDBYRumxbkoBbBhA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/autocomplete-core\": {\n      \"version\": \"1.17.7\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz\",\n      \"integrity\": \"sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/autocomplete-plugin-algolia-insights\": \"1.17.7\",\n        \"@algolia/autocomplete-shared\": \"1.17.7\"\n      }\n    },\n    \"node_modules/@algolia/autocomplete-plugin-algolia-insights\": {\n      \"version\": \"1.17.7\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz\",\n      \"integrity\": \"sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/autocomplete-shared\": \"1.17.7\"\n      },\n      \"peerDependencies\": {\n        \"search-insights\": \">= 1 < 3\"\n      }\n    },\n    \"node_modules/@algolia/autocomplete-preset-algolia\": {\n      \"version\": \"1.17.7\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz\",\n      \"integrity\": \"sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/autocomplete-shared\": \"1.17.7\"\n      },\n      \"peerDependencies\": {\n        \"@algolia/client-search\": \">= 4.9.1 < 6\",\n        \"algoliasearch\": \">= 4.9.1 < 6\"\n      }\n    },\n    \"node_modules/@algolia/autocomplete-shared\": {\n      \"version\": \"1.17.7\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz\",\n      \"integrity\": \"sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peerDependencies\": {\n        \"@algolia/client-search\": \">= 4.9.1 < 6\",\n        \"algoliasearch\": \">= 4.9.1 < 6\"\n      }\n    },\n    \"node_modules/@algolia/client-abtesting\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.41.0.tgz\",\n      \"integrity\": \"sha512-iRuvbEyuHCAhIMkyzG3tfINLxTS7mSKo7q8mQF+FbQpWenlAlrXnfZTN19LRwnVjx0UtAdZq96ThMWGS6cQ61A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/client-analytics\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.41.0.tgz\",\n      \"integrity\": \"sha512-OIPVbGfx/AO8l1V70xYTPSeTt/GCXPEl6vQICLAXLCk9WOUbcLGcy6t8qv0rO7Z7/M/h9afY6Af8JcnI+FBFdQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/client-common\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/client-common/-/client-common-5.41.0.tgz\",\n      \"integrity\": \"sha512-8Mc9niJvfuO8dudWN5vSUlYkz7U3M3X3m1crDLc9N7FZrIVoNGOUETPk3TTHviJIh9y6eKZKbq1hPGoGY9fqPA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/client-insights\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.41.0.tgz\",\n      \"integrity\": \"sha512-vXzvCGZS6Ixxn+WyzGUVDeR3HO/QO5POeeWy1kjNJbEf6f+tZSI+OiIU9Ha+T3ntV8oXFyBEuweygw4OLmgfiQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/client-personalization\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.41.0.tgz\",\n      \"integrity\": \"sha512-tkymXhmlcc7w/HEvLRiHcpHxLFcUB+0PnE9FcG6hfFZ1ZXiWabH+sX+uukCVnluyhfysU9HRU2kUmUWfucx1Dg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/client-query-suggestions\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.41.0.tgz\",\n      \"integrity\": \"sha512-vyXDoz3kEZnosNeVQQwf0PbBt5IZJoHkozKRIsYfEVm+ylwSDFCW08qy2YIVSHdKy69/rWN6Ue/6W29GgVlmKQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/client-search\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/client-search/-/client-search-5.41.0.tgz\",\n      \"integrity\": \"sha512-G9I2atg1ShtFp0t7zwleP6aPS4DcZvsV4uoQOripp16aR6VJzbEnKFPLW4OFXzX7avgZSpYeBAS+Zx4FOgmpPw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/ingestion\": {\n      \"version\": \"1.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.41.0.tgz\",\n      \"integrity\": \"sha512-sxU/ggHbZtmrYzTkueTXXNyifn+ozsLP+Wi9S2hOBVhNWPZ8uRiDTDcFyL7cpCs1q72HxPuhzTP5vn4sUl74cQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/monitoring\": {\n      \"version\": \"1.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.41.0.tgz\",\n      \"integrity\": \"sha512-UQ86R6ixraHUpd0hn4vjgTHbViNO8+wA979gJmSIsRI3yli2v89QSFF/9pPcADR6PbtSio/99PmSNxhZy+CR3Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/recommend\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/recommend/-/recommend-5.41.0.tgz\",\n      \"integrity\": \"sha512-DxP9P8jJ8whJOnvmyA5mf1wv14jPuI0L25itGfOHSU6d4ZAjduVfPjTS3ROuUN5CJoTdlidYZE+DtfWHxJwyzQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/requester-browser-xhr\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.41.0.tgz\",\n      \"integrity\": \"sha512-C21J+LYkE48fDwtLX7YXZd2Fn7Fe0/DOEtvohSfr/ODP8dGDhy9faaYeWB0n1AvmZltugjkjAXT7xk0CYNIXsQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/requester-fetch\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.41.0.tgz\",\n      \"integrity\": \"sha512-FhJy/+QJhMx1Hajf2LL8og4J7SqOAHiAuUXq27cct4QnPhSIuIGROzeRpfDNH5BUbq22UlMuGd44SeD4HRAqvA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@algolia/requester-node-http\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.41.0.tgz\",\n      \"integrity\": \"sha512-tYv3rGbhBS0eZ5D8oCgV88iuWILROiemk+tQ3YsAKZv2J4kKUNvKkrX/If/SreRy4MGP2uJzMlyKcfSfO2mrsQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/client-common\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/@babel/helper-string-parser\": {\n      \"version\": \"7.27.1\",\n      \"resolved\": \"https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz\",\n      \"integrity\": \"sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6.9.0\"\n      }\n    },\n    \"node_modules/@babel/helper-validator-identifier\": {\n      \"version\": \"7.28.5\",\n      \"resolved\": \"https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz\",\n      \"integrity\": \"sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6.9.0\"\n      }\n    },\n    \"node_modules/@babel/parser\": {\n      \"version\": \"7.28.5\",\n      \"resolved\": \"https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz\",\n      \"integrity\": \"sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@babel/types\": \"^7.28.5\"\n      },\n      \"bin\": {\n        \"parser\": \"bin/babel-parser.js\"\n      },\n      \"engines\": {\n        \"node\": \">=6.0.0\"\n      }\n    },\n    \"node_modules/@babel/types\": {\n      \"version\": \"7.28.5\",\n      \"resolved\": \"https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz\",\n      \"integrity\": \"sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@babel/helper-string-parser\": \"^7.27.1\",\n        \"@babel/helper-validator-identifier\": \"^7.28.5\"\n      },\n      \"engines\": {\n        \"node\": \">=6.9.0\"\n      }\n    },\n    \"node_modules/@docsearch/css\": {\n      \"version\": \"3.8.2\",\n      \"resolved\": \"https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz\",\n      \"integrity\": \"sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@docsearch/js\": {\n      \"version\": \"3.8.2\",\n      \"resolved\": \"https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz\",\n      \"integrity\": \"sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@docsearch/react\": \"3.8.2\",\n        \"preact\": \"^10.0.0\"\n      }\n    },\n    \"node_modules/@docsearch/react\": {\n      \"version\": \"3.8.2\",\n      \"resolved\": \"https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz\",\n      \"integrity\": \"sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/autocomplete-core\": \"1.17.7\",\n        \"@algolia/autocomplete-preset-algolia\": \"1.17.7\",\n        \"@docsearch/css\": \"3.8.2\",\n        \"algoliasearch\": \"^5.14.2\"\n      },\n      \"peerDependencies\": {\n        \"@types/react\": \">= 16.8.0 < 19.0.0\",\n        \"react\": \">= 16.8.0 < 19.0.0\",\n        \"react-dom\": \">= 16.8.0 < 19.0.0\",\n        \"search-insights\": \">= 1 < 3\"\n      },\n      \"peerDependenciesMeta\": {\n        \"@types/react\": {\n          \"optional\": true\n        },\n        \"react\": {\n          \"optional\": true\n        },\n        \"react-dom\": {\n          \"optional\": true\n        },\n        \"search-insights\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/@esbuild/aix-ppc64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz\",\n      \"integrity\": \"sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"aix\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-arm\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz\",\n      \"integrity\": \"sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-arm64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz\",\n      \"integrity\": \"sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-x64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz\",\n      \"integrity\": \"sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/darwin-arm64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz\",\n      \"integrity\": \"sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/darwin-x64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz\",\n      \"integrity\": \"sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/freebsd-arm64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz\",\n      \"integrity\": \"sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/freebsd-x64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz\",\n      \"integrity\": \"sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-arm\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz\",\n      \"integrity\": \"sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-arm64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz\",\n      \"integrity\": \"sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-ia32\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz\",\n      \"integrity\": \"sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-loong64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz\",\n      \"integrity\": \"sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==\",\n      \"cpu\": [\n        \"loong64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-mips64el\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz\",\n      \"integrity\": \"sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==\",\n      \"cpu\": [\n        \"mips64el\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-ppc64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz\",\n      \"integrity\": \"sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-riscv64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz\",\n      \"integrity\": \"sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==\",\n      \"cpu\": [\n        \"riscv64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-s390x\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz\",\n      \"integrity\": \"sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==\",\n      \"cpu\": [\n        \"s390x\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-x64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz\",\n      \"integrity\": \"sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/netbsd-arm64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz\",\n      \"integrity\": \"sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"netbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/netbsd-x64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz\",\n      \"integrity\": \"sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"netbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openbsd-arm64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz\",\n      \"integrity\": \"sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openbsd-x64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz\",\n      \"integrity\": \"sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openharmony-arm64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz\",\n      \"integrity\": \"sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openharmony\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/sunos-x64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz\",\n      \"integrity\": \"sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"sunos\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-arm64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz\",\n      \"integrity\": \"sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-ia32\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz\",\n      \"integrity\": \"sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-x64\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz\",\n      \"integrity\": \"sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@iconify-json/simple-icons\": {\n      \"version\": \"1.2.55\",\n      \"resolved\": \"https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.55.tgz\",\n      \"integrity\": \"sha512-9vc04pmup/zcef8hDypWU8nMwMaFVkWuUzWkxyL++DVp5AA8baoJHK6RyKN1v+cvfR2agxkUb053XVggzFFkTA==\",\n      \"dev\": true,\n      \"license\": \"CC0-1.0\",\n      \"dependencies\": {\n        \"@iconify/types\": \"*\"\n      }\n    },\n    \"node_modules/@iconify/types\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz\",\n      \"integrity\": \"sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@jridgewell/sourcemap-codec\": {\n      \"version\": \"1.5.5\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz\",\n      \"integrity\": \"sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@rollup/rollup-android-arm-eabi\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz\",\n      \"integrity\": \"sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-android-arm64\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz\",\n      \"integrity\": \"sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-darwin-arm64\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz\",\n      \"integrity\": \"sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-darwin-x64\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz\",\n      \"integrity\": \"sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-freebsd-arm64\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz\",\n      \"integrity\": \"sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-freebsd-x64\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz\",\n      \"integrity\": \"sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-arm-gnueabihf\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz\",\n      \"integrity\": \"sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-arm-musleabihf\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz\",\n      \"integrity\": \"sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-arm64-gnu\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz\",\n      \"integrity\": \"sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-arm64-musl\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz\",\n      \"integrity\": \"sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-loong64-gnu\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz\",\n      \"integrity\": \"sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==\",\n      \"cpu\": [\n        \"loong64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-ppc64-gnu\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz\",\n      \"integrity\": \"sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-riscv64-gnu\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz\",\n      \"integrity\": \"sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==\",\n      \"cpu\": [\n        \"riscv64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-riscv64-musl\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz\",\n      \"integrity\": \"sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==\",\n      \"cpu\": [\n        \"riscv64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-s390x-gnu\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz\",\n      \"integrity\": \"sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==\",\n      \"cpu\": [\n        \"s390x\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-x64-gnu\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz\",\n      \"integrity\": \"sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-linux-x64-musl\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz\",\n      \"integrity\": \"sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-openharmony-arm64\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz\",\n      \"integrity\": \"sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openharmony\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-win32-arm64-msvc\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz\",\n      \"integrity\": \"sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-win32-ia32-msvc\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz\",\n      \"integrity\": \"sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-win32-x64-gnu\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz\",\n      \"integrity\": \"sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ]\n    },\n    \"node_modules/@rollup/rollup-win32-x64-msvc\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz\",\n      \"integrity\": \"sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ]\n    },\n    \"node_modules/@shikijs/core\": {\n      \"version\": \"2.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz\",\n      \"integrity\": \"sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@shikijs/engine-javascript\": \"2.5.0\",\n        \"@shikijs/engine-oniguruma\": \"2.5.0\",\n        \"@shikijs/types\": \"2.5.0\",\n        \"@shikijs/vscode-textmate\": \"^10.0.2\",\n        \"@types/hast\": \"^3.0.4\",\n        \"hast-util-to-html\": \"^9.0.4\"\n      }\n    },\n    \"node_modules/@shikijs/engine-javascript\": {\n      \"version\": \"2.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz\",\n      \"integrity\": \"sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@shikijs/types\": \"2.5.0\",\n        \"@shikijs/vscode-textmate\": \"^10.0.2\",\n        \"oniguruma-to-es\": \"^3.1.0\"\n      }\n    },\n    \"node_modules/@shikijs/engine-oniguruma\": {\n      \"version\": \"2.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz\",\n      \"integrity\": \"sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@shikijs/types\": \"2.5.0\",\n        \"@shikijs/vscode-textmate\": \"^10.0.2\"\n      }\n    },\n    \"node_modules/@shikijs/langs\": {\n      \"version\": \"2.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz\",\n      \"integrity\": \"sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@shikijs/types\": \"2.5.0\"\n      }\n    },\n    \"node_modules/@shikijs/themes\": {\n      \"version\": \"2.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz\",\n      \"integrity\": \"sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@shikijs/types\": \"2.5.0\"\n      }\n    },\n    \"node_modules/@shikijs/transformers\": {\n      \"version\": \"2.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz\",\n      \"integrity\": \"sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@shikijs/core\": \"2.5.0\",\n        \"@shikijs/types\": \"2.5.0\"\n      }\n    },\n    \"node_modules/@shikijs/types\": {\n      \"version\": \"2.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz\",\n      \"integrity\": \"sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@shikijs/vscode-textmate\": \"^10.0.2\",\n        \"@types/hast\": \"^3.0.4\"\n      }\n    },\n    \"node_modules/@shikijs/vscode-textmate\": {\n      \"version\": \"10.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz\",\n      \"integrity\": \"sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/estree\": {\n      \"version\": \"1.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz\",\n      \"integrity\": \"sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/hast\": {\n      \"version\": \"3.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz\",\n      \"integrity\": \"sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"*\"\n      }\n    },\n    \"node_modules/@types/linkify-it\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz\",\n      \"integrity\": \"sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/markdown-it\": {\n      \"version\": \"14.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz\",\n      \"integrity\": \"sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/linkify-it\": \"^5\",\n        \"@types/mdurl\": \"^2\"\n      }\n    },\n    \"node_modules/@types/mdast\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz\",\n      \"integrity\": \"sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"*\"\n      }\n    },\n    \"node_modules/@types/mdurl\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz\",\n      \"integrity\": \"sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/unist\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz\",\n      \"integrity\": \"sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/web-bluetooth\": {\n      \"version\": \"0.0.21\",\n      \"resolved\": \"https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz\",\n      \"integrity\": \"sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@ungap/structured-clone\": {\n      \"version\": \"1.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz\",\n      \"integrity\": \"sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/@vitejs/plugin-vue\": {\n      \"version\": \"5.2.4\",\n      \"resolved\": \"https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz\",\n      \"integrity\": \"sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"^18.0.0 || >=20.0.0\"\n      },\n      \"peerDependencies\": {\n        \"vite\": \"^5.0.0 || ^6.0.0\",\n        \"vue\": \"^3.2.25\"\n      }\n    },\n    \"node_modules/@vue/compiler-core\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz\",\n      \"integrity\": \"sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@babel/parser\": \"^7.28.4\",\n        \"@vue/shared\": \"3.5.22\",\n        \"entities\": \"^4.5.0\",\n        \"estree-walker\": \"^2.0.2\",\n        \"source-map-js\": \"^1.2.1\"\n      }\n    },\n    \"node_modules/@vue/compiler-dom\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz\",\n      \"integrity\": \"sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/compiler-core\": \"3.5.22\",\n        \"@vue/shared\": \"3.5.22\"\n      }\n    },\n    \"node_modules/@vue/compiler-sfc\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz\",\n      \"integrity\": \"sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@babel/parser\": \"^7.28.4\",\n        \"@vue/compiler-core\": \"3.5.22\",\n        \"@vue/compiler-dom\": \"3.5.22\",\n        \"@vue/compiler-ssr\": \"3.5.22\",\n        \"@vue/shared\": \"3.5.22\",\n        \"estree-walker\": \"^2.0.2\",\n        \"magic-string\": \"^0.30.19\",\n        \"postcss\": \"^8.5.6\",\n        \"source-map-js\": \"^1.2.1\"\n      }\n    },\n    \"node_modules/@vue/compiler-ssr\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz\",\n      \"integrity\": \"sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/compiler-dom\": \"3.5.22\",\n        \"@vue/shared\": \"3.5.22\"\n      }\n    },\n    \"node_modules/@vue/devtools-api\": {\n      \"version\": \"7.7.7\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz\",\n      \"integrity\": \"sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/devtools-kit\": \"^7.7.7\"\n      }\n    },\n    \"node_modules/@vue/devtools-kit\": {\n      \"version\": \"7.7.7\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz\",\n      \"integrity\": \"sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/devtools-shared\": \"^7.7.7\",\n        \"birpc\": \"^2.3.0\",\n        \"hookable\": \"^5.5.3\",\n        \"mitt\": \"^3.0.1\",\n        \"perfect-debounce\": \"^1.0.0\",\n        \"speakingurl\": \"^14.0.1\",\n        \"superjson\": \"^2.2.2\"\n      }\n    },\n    \"node_modules/@vue/devtools-shared\": {\n      \"version\": \"7.7.7\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz\",\n      \"integrity\": \"sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"rfdc\": \"^1.4.1\"\n      }\n    },\n    \"node_modules/@vue/reactivity\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz\",\n      \"integrity\": \"sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/shared\": \"3.5.22\"\n      }\n    },\n    \"node_modules/@vue/runtime-core\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz\",\n      \"integrity\": \"sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/reactivity\": \"3.5.22\",\n        \"@vue/shared\": \"3.5.22\"\n      }\n    },\n    \"node_modules/@vue/runtime-dom\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz\",\n      \"integrity\": \"sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/reactivity\": \"3.5.22\",\n        \"@vue/runtime-core\": \"3.5.22\",\n        \"@vue/shared\": \"3.5.22\",\n        \"csstype\": \"^3.1.3\"\n      }\n    },\n    \"node_modules/@vue/server-renderer\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz\",\n      \"integrity\": \"sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/compiler-ssr\": \"3.5.22\",\n        \"@vue/shared\": \"3.5.22\"\n      },\n      \"peerDependencies\": {\n        \"vue\": \"3.5.22\"\n      }\n    },\n    \"node_modules/@vue/shared\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz\",\n      \"integrity\": \"sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@vueuse/core\": {\n      \"version\": \"12.8.2\",\n      \"resolved\": \"https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz\",\n      \"integrity\": \"sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/web-bluetooth\": \"^0.0.21\",\n        \"@vueuse/metadata\": \"12.8.2\",\n        \"@vueuse/shared\": \"12.8.2\",\n        \"vue\": \"^3.5.13\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/antfu\"\n      }\n    },\n    \"node_modules/@vueuse/integrations\": {\n      \"version\": \"12.8.2\",\n      \"resolved\": \"https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz\",\n      \"integrity\": \"sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vueuse/core\": \"12.8.2\",\n        \"@vueuse/shared\": \"12.8.2\",\n        \"vue\": \"^3.5.13\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/antfu\"\n      },\n      \"peerDependencies\": {\n        \"async-validator\": \"^4\",\n        \"axios\": \"^1\",\n        \"change-case\": \"^5\",\n        \"drauu\": \"^0.4\",\n        \"focus-trap\": \"^7\",\n        \"fuse.js\": \"^7\",\n        \"idb-keyval\": \"^6\",\n        \"jwt-decode\": \"^4\",\n        \"nprogress\": \"^0.2\",\n        \"qrcode\": \"^1.5\",\n        \"sortablejs\": \"^1\",\n        \"universal-cookie\": \"^7\"\n      },\n      \"peerDependenciesMeta\": {\n        \"async-validator\": {\n          \"optional\": true\n        },\n        \"axios\": {\n          \"optional\": true\n        },\n        \"change-case\": {\n          \"optional\": true\n        },\n        \"drauu\": {\n          \"optional\": true\n        },\n        \"focus-trap\": {\n          \"optional\": true\n        },\n        \"fuse.js\": {\n          \"optional\": true\n        },\n        \"idb-keyval\": {\n          \"optional\": true\n        },\n        \"jwt-decode\": {\n          \"optional\": true\n        },\n        \"nprogress\": {\n          \"optional\": true\n        },\n        \"qrcode\": {\n          \"optional\": true\n        },\n        \"sortablejs\": {\n          \"optional\": true\n        },\n        \"universal-cookie\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/@vueuse/metadata\": {\n      \"version\": \"12.8.2\",\n      \"resolved\": \"https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz\",\n      \"integrity\": \"sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/antfu\"\n      }\n    },\n    \"node_modules/@vueuse/shared\": {\n      \"version\": \"12.8.2\",\n      \"resolved\": \"https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz\",\n      \"integrity\": \"sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"vue\": \"^3.5.13\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/antfu\"\n      }\n    },\n    \"node_modules/algoliasearch\": {\n      \"version\": \"5.41.0\",\n      \"resolved\": \"https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.41.0.tgz\",\n      \"integrity\": \"sha512-9E4b3rJmYbBkn7e3aAPt1as+VVnRhsR4qwRRgOzpeyz4PAOuwKh0HI4AN6mTrqK0S0M9fCCSTOUnuJ8gPY/tvA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@algolia/abtesting\": \"1.7.0\",\n        \"@algolia/client-abtesting\": \"5.41.0\",\n        \"@algolia/client-analytics\": \"5.41.0\",\n        \"@algolia/client-common\": \"5.41.0\",\n        \"@algolia/client-insights\": \"5.41.0\",\n        \"@algolia/client-personalization\": \"5.41.0\",\n        \"@algolia/client-query-suggestions\": \"5.41.0\",\n        \"@algolia/client-search\": \"5.41.0\",\n        \"@algolia/ingestion\": \"1.41.0\",\n        \"@algolia/monitoring\": \"1.41.0\",\n        \"@algolia/recommend\": \"5.41.0\",\n        \"@algolia/requester-browser-xhr\": \"5.41.0\",\n        \"@algolia/requester-fetch\": \"5.41.0\",\n        \"@algolia/requester-node-http\": \"5.41.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 14.0.0\"\n      }\n    },\n    \"node_modules/birpc\": {\n      \"version\": \"2.6.1\",\n      \"resolved\": \"https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz\",\n      \"integrity\": \"sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/antfu\"\n      }\n    },\n    \"node_modules/ccount\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz\",\n      \"integrity\": \"sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/character-entities-html4\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz\",\n      \"integrity\": \"sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/character-entities-legacy\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz\",\n      \"integrity\": \"sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/comma-separated-tokens\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz\",\n      \"integrity\": \"sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/copy-anything\": {\n      \"version\": \"4.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz\",\n      \"integrity\": \"sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"is-what\": \"^5.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/mesqueeb\"\n      }\n    },\n    \"node_modules/csstype\": {\n      \"version\": \"3.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz\",\n      \"integrity\": \"sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/dequal\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz\",\n      \"integrity\": \"sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/devlop\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz\",\n      \"integrity\": \"sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"dequal\": \"^2.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/emoji-regex-xs\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz\",\n      \"integrity\": \"sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/entities\": {\n      \"version\": \"4.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/entities/-/entities-4.5.0.tgz\",\n      \"integrity\": \"sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"engines\": {\n        \"node\": \">=0.12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/fb55/entities?sponsor=1\"\n      }\n    },\n    \"node_modules/esbuild\": {\n      \"version\": \"0.25.12\",\n      \"resolved\": \"https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz\",\n      \"integrity\": \"sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==\",\n      \"dev\": true,\n      \"hasInstallScript\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"esbuild\": \"bin/esbuild\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"optionalDependencies\": {\n        \"@esbuild/aix-ppc64\": \"0.25.12\",\n        \"@esbuild/android-arm\": \"0.25.12\",\n        \"@esbuild/android-arm64\": \"0.25.12\",\n        \"@esbuild/android-x64\": \"0.25.12\",\n        \"@esbuild/darwin-arm64\": \"0.25.12\",\n        \"@esbuild/darwin-x64\": \"0.25.12\",\n        \"@esbuild/freebsd-arm64\": \"0.25.12\",\n        \"@esbuild/freebsd-x64\": \"0.25.12\",\n        \"@esbuild/linux-arm\": \"0.25.12\",\n        \"@esbuild/linux-arm64\": \"0.25.12\",\n        \"@esbuild/linux-ia32\": \"0.25.12\",\n        \"@esbuild/linux-loong64\": \"0.25.12\",\n        \"@esbuild/linux-mips64el\": \"0.25.12\",\n        \"@esbuild/linux-ppc64\": \"0.25.12\",\n        \"@esbuild/linux-riscv64\": \"0.25.12\",\n        \"@esbuild/linux-s390x\": \"0.25.12\",\n        \"@esbuild/linux-x64\": \"0.25.12\",\n        \"@esbuild/netbsd-arm64\": \"0.25.12\",\n        \"@esbuild/netbsd-x64\": \"0.25.12\",\n        \"@esbuild/openbsd-arm64\": \"0.25.12\",\n        \"@esbuild/openbsd-x64\": \"0.25.12\",\n        \"@esbuild/openharmony-arm64\": \"0.25.12\",\n        \"@esbuild/sunos-x64\": \"0.25.12\",\n        \"@esbuild/win32-arm64\": \"0.25.12\",\n        \"@esbuild/win32-ia32\": \"0.25.12\",\n        \"@esbuild/win32-x64\": \"0.25.12\"\n      }\n    },\n    \"node_modules/estree-walker\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz\",\n      \"integrity\": \"sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/focus-trap\": {\n      \"version\": \"7.6.5\",\n      \"resolved\": \"https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.5.tgz\",\n      \"integrity\": \"sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"tabbable\": \"^6.2.0\"\n      }\n    },\n    \"node_modules/fsevents\": {\n      \"version\": \"2.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz\",\n      \"integrity\": \"sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==\",\n      \"dev\": true,\n      \"hasInstallScript\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \"^8.16.0 || ^10.6.0 || >=11.0.0\"\n      }\n    },\n    \"node_modules/hast-util-to-html\": {\n      \"version\": \"9.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz\",\n      \"integrity\": \"sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/hast\": \"^3.0.0\",\n        \"@types/unist\": \"^3.0.0\",\n        \"ccount\": \"^2.0.0\",\n        \"comma-separated-tokens\": \"^2.0.0\",\n        \"hast-util-whitespace\": \"^3.0.0\",\n        \"html-void-elements\": \"^3.0.0\",\n        \"mdast-util-to-hast\": \"^13.0.0\",\n        \"property-information\": \"^7.0.0\",\n        \"space-separated-tokens\": \"^2.0.0\",\n        \"stringify-entities\": \"^4.0.0\",\n        \"zwitch\": \"^2.0.4\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/hast-util-whitespace\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz\",\n      \"integrity\": \"sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/hast\": \"^3.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/hookable\": {\n      \"version\": \"5.5.3\",\n      \"resolved\": \"https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz\",\n      \"integrity\": \"sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/html-void-elements\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz\",\n      \"integrity\": \"sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/is-what\": {\n      \"version\": \"5.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz\",\n      \"integrity\": \"sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/mesqueeb\"\n      }\n    },\n    \"node_modules/magic-string\": {\n      \"version\": \"0.30.21\",\n      \"resolved\": \"https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz\",\n      \"integrity\": \"sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@jridgewell/sourcemap-codec\": \"^1.5.5\"\n      }\n    },\n    \"node_modules/mark.js\": {\n      \"version\": \"8.11.1\",\n      \"resolved\": \"https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz\",\n      \"integrity\": \"sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/mdast-util-to-hast\": {\n      \"version\": \"13.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz\",\n      \"integrity\": \"sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/hast\": \"^3.0.0\",\n        \"@types/mdast\": \"^4.0.0\",\n        \"@ungap/structured-clone\": \"^1.0.0\",\n        \"devlop\": \"^1.0.0\",\n        \"micromark-util-sanitize-uri\": \"^2.0.0\",\n        \"trim-lines\": \"^3.0.0\",\n        \"unist-util-position\": \"^5.0.0\",\n        \"unist-util-visit\": \"^5.0.0\",\n        \"vfile\": \"^6.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/micromark-util-character\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz\",\n      \"integrity\": \"sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"GitHub Sponsors\",\n          \"url\": \"https://github.com/sponsors/unifiedjs\"\n        },\n        {\n          \"type\": \"OpenCollective\",\n          \"url\": \"https://opencollective.com/unified\"\n        }\n      ],\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"micromark-util-symbol\": \"^2.0.0\",\n        \"micromark-util-types\": \"^2.0.0\"\n      }\n    },\n    \"node_modules/micromark-util-encode\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz\",\n      \"integrity\": \"sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"GitHub Sponsors\",\n          \"url\": \"https://github.com/sponsors/unifiedjs\"\n        },\n        {\n          \"type\": \"OpenCollective\",\n          \"url\": \"https://opencollective.com/unified\"\n        }\n      ],\n      \"license\": \"MIT\"\n    },\n    \"node_modules/micromark-util-sanitize-uri\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz\",\n      \"integrity\": \"sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"GitHub Sponsors\",\n          \"url\": \"https://github.com/sponsors/unifiedjs\"\n        },\n        {\n          \"type\": \"OpenCollective\",\n          \"url\": \"https://opencollective.com/unified\"\n        }\n      ],\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"micromark-util-character\": \"^2.0.0\",\n        \"micromark-util-encode\": \"^2.0.0\",\n        \"micromark-util-symbol\": \"^2.0.0\"\n      }\n    },\n    \"node_modules/micromark-util-symbol\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz\",\n      \"integrity\": \"sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"GitHub Sponsors\",\n          \"url\": \"https://github.com/sponsors/unifiedjs\"\n        },\n        {\n          \"type\": \"OpenCollective\",\n          \"url\": \"https://opencollective.com/unified\"\n        }\n      ],\n      \"license\": \"MIT\"\n    },\n    \"node_modules/micromark-util-types\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz\",\n      \"integrity\": \"sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"GitHub Sponsors\",\n          \"url\": \"https://github.com/sponsors/unifiedjs\"\n        },\n        {\n          \"type\": \"OpenCollective\",\n          \"url\": \"https://opencollective.com/unified\"\n        }\n      ],\n      \"license\": \"MIT\"\n    },\n    \"node_modules/minisearch\": {\n      \"version\": \"7.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz\",\n      \"integrity\": \"sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/mitt\": {\n      \"version\": \"3.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz\",\n      \"integrity\": \"sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/nanoid\": {\n      \"version\": \"3.3.11\",\n      \"resolved\": \"https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz\",\n      \"integrity\": \"sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/ai\"\n        }\n      ],\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"nanoid\": \"bin/nanoid.cjs\"\n      },\n      \"engines\": {\n        \"node\": \"^10 || ^12 || ^13.7 || ^14 || >=15.0.1\"\n      }\n    },\n    \"node_modules/oniguruma-to-es\": {\n      \"version\": \"3.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz\",\n      \"integrity\": \"sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"emoji-regex-xs\": \"^1.0.0\",\n        \"regex\": \"^6.0.1\",\n        \"regex-recursion\": \"^6.0.2\"\n      }\n    },\n    \"node_modules/perfect-debounce\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz\",\n      \"integrity\": \"sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/picocolors\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz\",\n      \"integrity\": \"sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/postcss\": {\n      \"version\": \"8.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz\",\n      \"integrity\": \"sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"opencollective\",\n          \"url\": \"https://opencollective.com/postcss/\"\n        },\n        {\n          \"type\": \"tidelift\",\n          \"url\": \"https://tidelift.com/funding/github/npm/postcss\"\n        },\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/ai\"\n        }\n      ],\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"nanoid\": \"^3.3.11\",\n        \"picocolors\": \"^1.1.1\",\n        \"source-map-js\": \"^1.2.1\"\n      },\n      \"engines\": {\n        \"node\": \"^10 || ^12 || >=14\"\n      }\n    },\n    \"node_modules/preact\": {\n      \"version\": \"10.27.2\",\n      \"resolved\": \"https://registry.npmjs.org/preact/-/preact-10.27.2.tgz\",\n      \"integrity\": \"sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/preact\"\n      }\n    },\n    \"node_modules/property-information\": {\n      \"version\": \"7.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz\",\n      \"integrity\": \"sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/regex\": {\n      \"version\": \"6.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/regex/-/regex-6.0.1.tgz\",\n      \"integrity\": \"sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"regex-utilities\": \"^2.3.0\"\n      }\n    },\n    \"node_modules/regex-recursion\": {\n      \"version\": \"6.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz\",\n      \"integrity\": \"sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"regex-utilities\": \"^2.3.0\"\n      }\n    },\n    \"node_modules/regex-utilities\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz\",\n      \"integrity\": \"sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/rfdc\": {\n      \"version\": \"1.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz\",\n      \"integrity\": \"sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/rollup\": {\n      \"version\": \"4.52.5\",\n      \"resolved\": \"https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz\",\n      \"integrity\": \"sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/estree\": \"1.0.8\"\n      },\n      \"bin\": {\n        \"rollup\": \"dist/bin/rollup\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\",\n        \"npm\": \">=8.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"@rollup/rollup-android-arm-eabi\": \"4.52.5\",\n        \"@rollup/rollup-android-arm64\": \"4.52.5\",\n        \"@rollup/rollup-darwin-arm64\": \"4.52.5\",\n        \"@rollup/rollup-darwin-x64\": \"4.52.5\",\n        \"@rollup/rollup-freebsd-arm64\": \"4.52.5\",\n        \"@rollup/rollup-freebsd-x64\": \"4.52.5\",\n        \"@rollup/rollup-linux-arm-gnueabihf\": \"4.52.5\",\n        \"@rollup/rollup-linux-arm-musleabihf\": \"4.52.5\",\n        \"@rollup/rollup-linux-arm64-gnu\": \"4.52.5\",\n        \"@rollup/rollup-linux-arm64-musl\": \"4.52.5\",\n        \"@rollup/rollup-linux-loong64-gnu\": \"4.52.5\",\n        \"@rollup/rollup-linux-ppc64-gnu\": \"4.52.5\",\n        \"@rollup/rollup-linux-riscv64-gnu\": \"4.52.5\",\n        \"@rollup/rollup-linux-riscv64-musl\": \"4.52.5\",\n        \"@rollup/rollup-linux-s390x-gnu\": \"4.52.5\",\n        \"@rollup/rollup-linux-x64-gnu\": \"4.52.5\",\n        \"@rollup/rollup-linux-x64-musl\": \"4.52.5\",\n        \"@rollup/rollup-openharmony-arm64\": \"4.52.5\",\n        \"@rollup/rollup-win32-arm64-msvc\": \"4.52.5\",\n        \"@rollup/rollup-win32-ia32-msvc\": \"4.52.5\",\n        \"@rollup/rollup-win32-x64-gnu\": \"4.52.5\",\n        \"@rollup/rollup-win32-x64-msvc\": \"4.52.5\",\n        \"fsevents\": \"~2.3.2\"\n      }\n    },\n    \"node_modules/search-insights\": {\n      \"version\": \"2.17.3\",\n      \"resolved\": \"https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz\",\n      \"integrity\": \"sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peer\": true\n    },\n    \"node_modules/shiki\": {\n      \"version\": \"2.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz\",\n      \"integrity\": \"sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@shikijs/core\": \"2.5.0\",\n        \"@shikijs/engine-javascript\": \"2.5.0\",\n        \"@shikijs/engine-oniguruma\": \"2.5.0\",\n        \"@shikijs/langs\": \"2.5.0\",\n        \"@shikijs/themes\": \"2.5.0\",\n        \"@shikijs/types\": \"2.5.0\",\n        \"@shikijs/vscode-textmate\": \"^10.0.2\",\n        \"@types/hast\": \"^3.0.4\"\n      }\n    },\n    \"node_modules/source-map-js\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz\",\n      \"integrity\": \"sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==\",\n      \"dev\": true,\n      \"license\": \"BSD-3-Clause\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/space-separated-tokens\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz\",\n      \"integrity\": \"sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/speakingurl\": {\n      \"version\": \"14.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz\",\n      \"integrity\": \"sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==\",\n      \"dev\": true,\n      \"license\": \"BSD-3-Clause\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/stringify-entities\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz\",\n      \"integrity\": \"sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"character-entities-html4\": \"^2.0.0\",\n        \"character-entities-legacy\": \"^3.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/superjson\": {\n      \"version\": \"2.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/superjson/-/superjson-2.2.3.tgz\",\n      \"integrity\": \"sha512-ay3d+LW/S6yppKoTz3Bq4mG0xrS5bFwfWEBmQfbC7lt5wmtk+Obq0TxVuA9eYRirBTQb1K3eEpBRHMQEo0WyVw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"copy-anything\": \"^4\"\n      },\n      \"engines\": {\n        \"node\": \">=16\"\n      }\n    },\n    \"node_modules/tabbable\": {\n      \"version\": \"6.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz\",\n      \"integrity\": \"sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/trim-lines\": {\n      \"version\": \"3.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz\",\n      \"integrity\": \"sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    },\n    \"node_modules/unist-util-is\": {\n      \"version\": \"6.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz\",\n      \"integrity\": \"sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"^3.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/unist-util-position\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz\",\n      \"integrity\": \"sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"^3.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/unist-util-stringify-position\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz\",\n      \"integrity\": \"sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"^3.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/unist-util-visit\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz\",\n      \"integrity\": \"sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"^3.0.0\",\n        \"unist-util-is\": \"^6.0.0\",\n        \"unist-util-visit-parents\": \"^6.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/unist-util-visit-parents\": {\n      \"version\": \"6.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz\",\n      \"integrity\": \"sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"^3.0.0\",\n        \"unist-util-is\": \"^6.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/vfile\": {\n      \"version\": \"6.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz\",\n      \"integrity\": \"sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"^3.0.0\",\n        \"vfile-message\": \"^4.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/vfile-message\": {\n      \"version\": \"4.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz\",\n      \"integrity\": \"sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/unist\": \"^3.0.0\",\n        \"unist-util-stringify-position\": \"^4.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/unified\"\n      }\n    },\n    \"node_modules/vite\": {\n      \"version\": \"5.4.21\",\n      \"resolved\": \"https://registry.npmjs.org/vite/-/vite-5.4.21.tgz\",\n      \"integrity\": \"sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"esbuild\": \"^0.21.3\",\n        \"postcss\": \"^8.4.43\",\n        \"rollup\": \"^4.20.0\"\n      },\n      \"bin\": {\n        \"vite\": \"bin/vite.js\"\n      },\n      \"engines\": {\n        \"node\": \"^18.0.0 || >=20.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/vitejs/vite?sponsor=1\"\n      },\n      \"optionalDependencies\": {\n        \"fsevents\": \"~2.3.3\"\n      },\n      \"peerDependencies\": {\n        \"@types/node\": \"^18.0.0 || >=20.0.0\",\n        \"less\": \"*\",\n        \"lightningcss\": \"^1.21.0\",\n        \"sass\": \"*\",\n        \"sass-embedded\": \"*\",\n        \"stylus\": \"*\",\n        \"sugarss\": \"*\",\n        \"terser\": \"^5.4.0\"\n      },\n      \"peerDependenciesMeta\": {\n        \"@types/node\": {\n          \"optional\": true\n        },\n        \"less\": {\n          \"optional\": true\n        },\n        \"lightningcss\": {\n          \"optional\": true\n        },\n        \"sass\": {\n          \"optional\": true\n        },\n        \"sass-embedded\": {\n          \"optional\": true\n        },\n        \"stylus\": {\n          \"optional\": true\n        },\n        \"sugarss\": {\n          \"optional\": true\n        },\n        \"terser\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/vitepress\": {\n      \"version\": \"1.6.4\",\n      \"resolved\": \"https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz\",\n      \"integrity\": \"sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@docsearch/css\": \"3.8.2\",\n        \"@docsearch/js\": \"3.8.2\",\n        \"@iconify-json/simple-icons\": \"^1.2.21\",\n        \"@shikijs/core\": \"^2.1.0\",\n        \"@shikijs/transformers\": \"^2.1.0\",\n        \"@shikijs/types\": \"^2.1.0\",\n        \"@types/markdown-it\": \"^14.1.2\",\n        \"@vitejs/plugin-vue\": \"^5.2.1\",\n        \"@vue/devtools-api\": \"^7.7.0\",\n        \"@vue/shared\": \"^3.5.13\",\n        \"@vueuse/core\": \"^12.4.0\",\n        \"@vueuse/integrations\": \"^12.4.0\",\n        \"focus-trap\": \"^7.6.4\",\n        \"mark.js\": \"8.11.1\",\n        \"minisearch\": \"^7.1.1\",\n        \"shiki\": \"^2.1.0\",\n        \"vite\": \"^5.4.14\",\n        \"vue\": \"^3.5.13\"\n      },\n      \"bin\": {\n        \"vitepress\": \"bin/vitepress.js\"\n      },\n      \"peerDependencies\": {\n        \"markdown-it-mathjax3\": \"^4\",\n        \"postcss\": \"^8\"\n      },\n      \"peerDependenciesMeta\": {\n        \"markdown-it-mathjax3\": {\n          \"optional\": true\n        },\n        \"postcss\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/vue\": {\n      \"version\": \"3.5.22\",\n      \"resolved\": \"https://registry.npmjs.org/vue/-/vue-3.5.22.tgz\",\n      \"integrity\": \"sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@vue/compiler-dom\": \"3.5.22\",\n        \"@vue/compiler-sfc\": \"3.5.22\",\n        \"@vue/runtime-dom\": \"3.5.22\",\n        \"@vue/server-renderer\": \"3.5.22\",\n        \"@vue/shared\": \"3.5.22\"\n      },\n      \"peerDependencies\": {\n        \"typescript\": \"*\"\n      },\n      \"peerDependenciesMeta\": {\n        \"typescript\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/zwitch\": {\n      \"version\": \"2.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz\",\n      \"integrity\": \"sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/wooorm\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"videocaptioner-docs\",\n  \"version\": \"1.4.0\",\n  \"description\": \"Documentation site for VideoCaptioner\",\n  \"private\": true,\n  \"scripts\": {\n    \"docs:dev\": \"vitepress dev\",\n    \"docs:build\": \"vitepress build\",\n    \"docs:preview\": \"vitepress preview\"\n  },\n  \"devDependencies\": {\n    \"vitepress\": \"^1.6.4\",\n    \"vue\": \"^3.5.13\"\n  },\n  \"overrides\": {\n    \"esbuild\": \"^0.25.0\"\n  }\n}\n"
  },
  {
    "path": "docs/public/BingSiteAuth.xml",
    "content": "<?xml version=\"1.0\"?>\n<users>\n  <user><!-- 需要时在 Bing Webmaster Tools 中获取验证码 --></user>\n</users>\n"
  },
  {
    "path": "docs/public/robots.txt",
    "content": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nAllow: /\n\n# Sitemaps\nSitemap: https://weifeng2333.github.io/VideoCaptioner/sitemap.xml\n"
  },
  {
    "path": "legacy-docs/README_EN.md",
    "content": "<div align=\"center\">\n  <img src=\"./images/logo.png\"alt=\"VideoCaptioner Logo\" width=\"100\">\n  <p>Kaka Subtitle Assistant</p>\n  <h1>VideoCaptioner</h1>\n  <p>An LLM-powered video subtitle processing assistant, supporting speech recognition, subtitle segmentation, optimization, and translation.</p>\n\n  [简体中文](../README.md) / [正體中文](./README_TW.md) / English / [日本語](./README_JA.md)\n\n</div>\n\n## 📖 Introduction\n\nKaka Subtitle Assistant (VideoCaptioner) is easy to operate and doesn't require high-end hardware. It supports both online API calls and local offline processing (with GPU support) for speech recognition. It leverages Large Language Models (LLMs) for intelligent subtitle segmentation, correction, and translation. It offers a one-click solution for the entire video subtitle workflow! Add stunning subtitles to your videos.\n\n- Support for word-level timestamps and VAD voice activity detection with high recognition accuracy\n- LLM-based semantic understanding to automatically reorganize word-by-word subtitles into natural, fluent sentence paragraphs\n- Context-aware AI translation with reflection optimization mechanism for idiomatic and professional translations\n- Batch video subtitle synthesis support to improve processing efficiency\n- Intuitive subtitle editing and viewing interface with real-time preview and quick editing\n\n## 📸 Interface Preview\n\n<div align=\"center\">\n  <img src=\"https://h1.appinn.me/file/1731487405884_main.png\" alt=\"Software Interface Preview\" width=\"90%\" style=\"border-radius: 5px;\">\n</div>\n\n![Page Preview](https://h1.appinn.me/file/1731487410170_preview1.png)\n![Page Preview](https://h1.appinn.me/file/1731487410832_preview2.png)\n\n\n## 🧪 Testing\n\nProcessing a 14-minute 1080P [English TED video from Bilibili](https://www.bilibili.com/video/BV1jT411X7Dz) end-to-end, using the local Whisper model for speech recognition and the `gpt-5-mini` model for optimization and translation into Chinese, took approximately **4 minutes**.\n\nBased on backend calculations, the cost for model optimization and translation was less than ¥0.01 (calculated using OpenAI's official pricing).\n\nFor detailed results of subtitle and video synthesis, please refer to the [TED Video Test](./test.md).\n\n\n## 🚀 Quick Start\n\n### For Windows Users\n\nThe software is lightweight, with a package size of less than 60MB, and includes all necessary environments. Download and run directly.\n\n1. Download the latest version of the executable from the [Release](https://github.com/WEIFENG2333/VideoCaptioner/releases) page. Or: [Lanzou Cloud Download](https://wwwm.lanzoue.com/ii14G2pdsbej)\n\n2. Open the installer to install.\n\n3. LLM API Configuration (for subtitle segmentation and correction), you can use [this project's API relay](https://api.videocaptioner.cn)\n\n4. Translation configuration, choose whether to enable translation (default uses Microsoft Translator, average quality, recommend configuring your own API KEY for LLM translation)\n\n5. Speech recognition configuration (default uses B interface for online speech recognition, use local transcription for languages other than Chinese and English)\n\n### For macOS Users\n\n#### One-Click Install & Run (Recommended)\n\n```bash\n# Method 1: Direct run (auto-installs uv, clones project, installs dependencies)\ncurl -fsSL https://raw.githubusercontent.com/WEIFENG2333/VideoCaptioner/main/scripts/run.sh | bash\n\n# Method 2: Clone first, then run\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\n./scripts/run.sh\n```\n\nThe script will automatically:\n\n1. Install the [uv](https://docs.astral.sh/uv/) package manager (if not installed)\n2. Clone the project to `~/VideoCaptioner` (if not running from project directory)\n3. Install all Python dependencies\n4. Launch the application\n\n<details>\n<summary>Manual Installation Steps</summary>\n\n#### 1. Install uv package manager\n\n```bash\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n```\n\n#### 2. Install system dependencies (macOS)\n\n```bash\nbrew install ffmpeg\n```\n\n#### 3. Clone and run\n\n```bash\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\nuv sync          # Install dependencies\nuv run python main.py  # Run\n```\n\n</details>\n\n### Developer Guide\n\n```bash\n# Install dependencies (including dev dependencies)\nuv sync\n\n# Run application\nuv run python main.py\n\n# Type checking\nuv run pyright\n\n# Code linting\nuv run ruff check .\n```\n\n## ✨ Main Features\n\nThe software fully utilizes the advantages of Large Language Models (LLMs) in understanding context to further process subtitles generated by speech recognition. It effectively corrects typos, unifies terminology, and makes the subtitle content more accurate and coherent, providing users with an excellent viewing experience!\n\n#### 1. Multi-platform Video Download and Processing\n- Supports mainstream video platforms (Bilibili, YouTube, TikTok, X, etc.)\n- Automatically extracts and processes the original subtitles of the video.\n\n#### 2. Professional Speech Recognition Engine\n- Provides multiple online recognition interfaces with effects comparable to Jianying (free, high-speed).\n- Supports local Whisper model (privacy protection, offline).\n\n#### 3. Intelligent Subtitle Correction\n- Automatically optimizes the format of terminology, code snippets, and mathematical formulas.\n- Contextual sentence segmentation optimization to improve reading experience.\n- Supports manuscript prompts, using original manuscripts or related prompts to optimize subtitle segmentation.\n\n#### 4. High-Quality Subtitle Translation\n- Context-aware intelligent translation ensures that the translation takes the entire text into account.\n- Guides the large model to reflect on the translation through prompts, improving translation quality.\n- Uses a sequence fuzzy matching algorithm to ensure complete consistency of the timeline.\n\n#### 5. Subtitle Style Adjustment\n- Rich subtitle style templates (popular science style, news style, anime style, etc.).\n- Multiple subtitle video formats (SRT, ASS, VTT, TXT).\n\n\n## ⚙️ Basic Configuration\n\n### 1. LLM API Configuration Instructions\n\nLLM is used for subtitle segmentation, optimization, and translation (if LLM translation is selected).\n\n| Configuration Item | Description |\n|--------|------|\n| SiliconCloud | [SiliconCloud Official](https://cloud.siliconflow.cn/i/onCHcaDx), for configuration see [online docs](https://weifeng2333.github.io/VideoCaptioner/config/llm)<br>Low concurrency, recommend setting threads below 5. |\n| DeepSeek | [DeepSeek Official](https://platform.deepseek.com), recommend using `deepseek-v3` model. |\n| OpenAI Compatible | If you have API from other providers, fill in directly. base_url and api_key [VideoCaptioner API](https://api.videocaptioner.cn) |\n\nNote: If your API provider doesn't support high concurrency, lower the \"thread count\" in settings to avoid request errors.\n\n---\n\nFor high concurrency, or to use quality models like OpenAI or Claude for subtitle correction and translation:\n\nUse this project's ✨LLM API Relay✨: [https://api.videocaptioner.cn](https://api.videocaptioner.cn)\n\nSupports high concurrency, excellent value, with many domestic and international models available.\n\nAfter registering and getting your key, configure settings as follows:\n\nBaseURL: `https://api.videocaptioner.cn/v1`\n\nAPI-key: `Get from Personal Center - API Token page.`\n\n💡 Model Selection Recommendations (high-value models selected at each quality tier):\n\n- High quality: `gemini-3-pro`, `claude-sonnet-4-5-20250929` (cost ratio: 3)\n\n- Higher quality: `gpt-5-2025-08-07`, `claude-haiku-4-5-20251001` (cost ratio: 1.2)\n\n- Medium quality: `gpt-5-mini`, `gemini-3-flash` (cost ratio: 0.3)\n\nThis site supports ultra-high concurrency, max out the thread count in the software~ Processing speed is very fast~\n\nFor more detailed API configuration tutorial: [API Configuration](https://weifeng2333.github.io/VideoCaptioner/config/llm)\n\n---\n\n### 2. Translation Configuration\n\n| Configuration Item | Description |\n| -------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| LLM Translation | 🌟 Best translation quality. Uses AI large models for translation, better context understanding, more natural translations. Requires LLM API configuration (e.g., OpenAI, DeepSeek, etc.) |\n| Microsoft Translator | Uses Microsoft's translation service, very fast |\n| Google Translate | Google's translation service, fast, but requires access to Google's network |\n\nRecommended: `LLM Translation` for the best translation quality.\n\n### 3. Speech Recognition Interface Description\n\n| Interface Name | Supported Languages | Running Mode | Description |\n|---------|---------|---------|------|\n| Interface B | Chinese, English only | Online | Free, fast |\n| Interface J | Chinese, English only | Online | Free, fast |\n| WhisperCpp | Chinese, Japanese, Korean, English, and 99 other languages. Good performance for foreign languages. | Local | (Actual use is unstable) Requires downloading transcription models.<br>Chinese: Medium or larger model recommended.<br>English, etc.: Smaller models can achieve good results. |\n| fasterWhisper 👍 | Chinese, English, and 99 other languages. Excellent performance for foreign languages, more accurate timeline. | Local | (🌟Recommended🌟) Requires downloading the program and transcription models.<br>Supports CUDA, faster, accurate transcription.<br>Super accurate timestamp subtitles.<br>Windows only |\n\n\n### 4. Local Whisper Speech Recognition Configuration (Requires download within the software)\n\nThere are two Whisper versions: WhisperCpp and fasterWhisper (recommended). The latter has better performance and both require downloading models within the software.\n\n| Model | Disk Space | RAM Usage | Description |\n|------|----------|----------|------|\n| Tiny | 75 MiB | ~273 MB | Transcription is mediocre, for testing only. |\n| Small | 466 MiB | ~852 MB | English recognition is already good. |\n| Medium | 1.5 GiB | ~2.1 GB | This version is recommended as the minimum for Chinese recognition. |\n| Large-v2 👍 | 2.9 GiB | ~3.9 GB | Good performance, recommended if your configuration allows. |\n| Large-v3 | 2.9 GiB | ~3.9 GB | Community feedback suggests potential hallucination/subtitle repetition issues. |\n\nRecommended model: `Large-v2` is stable and of good quality.\n\n\n### 5. Manuscript Matching\n\n- On the \"Subtitle Optimization and Translation\" page, there is a \"Manuscript Matching\" option, which supports the following **one or more** types of content to assist in subtitle correction and translation:\n\n| Type | Description | Example |\n|------|------|------|\n| Glossary | Correction table for terminology, names, and specific words. | Machine Learning->机器学习<br>Elon Musk->马斯克<br>Turing patterns<br>Bus paradox |\n| Original Subtitle Text | The original manuscript or related content of the video. | Complete speech scripts, lecture notes, etc. |\n| Correction Requirements | Specific correction requirements related to the content. | Unify personal pronouns, standardize terminology, etc.<br>Fill in requirements **related to the content**, [example reference](https://github.com/WEIFENG2333/VideoCaptioner/issues/59#issuecomment-2495849752) |\n\n- If you need manuscript assistance for subtitle optimization, fill in the manuscript information first, then start the task processing.\n- Note: When using small LLM models with limited context, it is recommended to keep the manuscript content within 1000 words. If using a model with a larger context window, you can appropriately increase the manuscript content.\n\n\n### 6. Cookie Configuration Instructions\n\nIf you encounter the following situations when using the URL download function:\n1. The video website requires login information to download.\n2. Only lower resolution videos can be downloaded.\n3. Verification is required when network conditions are poor.\n\n- Please refer to the [Cookie Configuration Instructions](https://weifeng2333.github.io/VideoCaptioner/guide/cookies-config) to obtain cookie information and place the `cookies.txt` file in the `AppData` directory of the software installation directory to download high-quality videos normally.\n\n## 💡 Software Process Introduction\n\nThe simple processing flow of the program is as follows:\n```\nSpeech Recognition -> Subtitle Segmentation (optional) -> Subtitle Optimization & Translation (optional) -> Subtitle & Video Synthesis\n```\n\nThe main directory structure of the project is as follows:\n```\nVideoCaptioner/\n├── app/                        # Application source code directory\n│   ├── common/                 # Common modules (config, signal bus)\n│   ├── components/             # UI components\n│   ├── core/                   # Core business logic (ASR, translation, optimization, etc.)\n│   ├── thread/                 # Async threads\n│   └── view/                   # Interface views\n├── resource/                   # Resource file directory\n│   ├── assets/                 # Icons, Logo, etc.\n│   ├── bin/                    # Binary programs (FFmpeg, Whisper, etc.)\n│   ├── fonts/                  # Font files\n│   ├── subtitle_style/         # Subtitle style templates\n│   └── translations/           # Multi-language translation files\n├── work-dir/                   # Working directory (processed videos and subtitles)\n├── AppData/                    # Application data directory\n│   ├── cache/                  # Cache directory (transcription, LLM requests)\n│   ├── models/                 # Whisper model files\n│   ├── logs/                   # Log files\n│   └── settings.json           # User settings\n├── scripts/                    # Installation and run scripts\n├── main.py                     # Program entry\n└── pyproject.toml              # Project configuration and dependencies\n```\n\n## 📝 Notes\n\n1. The quality of subtitle segmentation is crucial for the viewing experience. The software can intelligently reorganize word-by-word subtitles into paragraphs that conform to natural language habits and perfectly synchronize with the video frames.\n\n2. During processing, only the text content is sent to the large language model, without timeline information, which greatly reduces processing overhead.\n\n3. In the translation stage, we adopt the \"translate-reflect-translate\" methodology proposed by Andrew Ng. This iterative optimization method ensures the accuracy of the translation.\n\n4. When processing YouTube links, video subtitles are automatically downloaded, saving the transcription step and significantly reducing operation time.\n\n## 🤝 Contribution Guidelines\n\nThe project is constantly being improved. If you encounter any bugs during use, please feel free to submit [Issues](https://github.com/WEIFENG2333/VideoCaptioner/issues) and Pull Requests to help improve the project.\n\n## 📝 Changelog\n\nView the complete update history at [CHANGELOG.md](../CHANGELOG.md)\n\n## ⭐ Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=WEIFENG2333/VideoCaptioner&type=Date)](https://star-history.com/#WEIFENG2333/VideoCaptioner&Date)\n\n## 💖 Support the Author\n\nIf you find this project helpful, please give it a Star!\n\n<details>\n<summary>Donation Support</summary>\n<div align=\"center\">\n  <img src=\"./images/alipay.jpg\" alt=\"Alipay QR Code\" width=\"30%\">\n  <img src=\"./images/wechat.jpg\" alt=\"WeChat QR Code\" width=\"30%\">\n</div>\n</details>\n"
  },
  {
    "path": "legacy-docs/README_JA.md",
    "content": "<div align=\"center\">\n  <img src=\"./images/logo.png\" alt=\"VideoCaptioner ロゴ\" width=\"100\">\n  <p>Kaka カカ字幕アシスタント</p>\n  <h1>VideoCaptioner</h1>\n  <p>音声認識、字幕のセグメンテーション、最適化、翻訳をサポートするLLM駆動のビデオ字幕処理アシスタント。</p>\n\n  [简体中文](../README.md) / [正體中文](./README_TW.md) / [English](./README_EN.md) / 日本語\n\n</div>\n\n## 📖 はじめに\n\nKaka 字幕アシスタント（VideoCaptioner）は操作が簡単で、高性能なハードウェアを必要としません。音声認識のためのオンラインAPI呼び出しとローカルオフライン処理（GPUサポートあり）の両方をサポートしています。大規模言語モデル（LLM）を活用して、インテリジェントな字幕のセグメンテーション、修正、翻訳を行います。ビデオ字幕のワークフロー全体をワンクリックで解決します！あなたのビデオに素晴らしい字幕を追加しましょう。\n\n- 単語レベルのタイムスタンプとVAD音声活動検出をサポートし、高い認識精度を実現\n- LLMベースの意味理解により、単語ごとの字幕を自然で流暢な文章段落に自動再構成\n- 文脈を考慮したAI翻訳、反映最適化メカニズムにより、慣用的でプロフェッショナルな翻訳を実現\n- バッチビデオ字幕合成をサポートし、処理効率を向上\n- 直感的な字幕編集と表示インターフェース、リアルタイムプレビューとクイック編集をサポート\n\n## 📸 インターフェースプレビュー\n\n<div align=\"center\">\n  <img src=\"https://h1.appinn.me/file/1731487405884_main.png\" alt=\"ソフトウェアインターフェースプレビュー\" width=\"90%\" style=\"border-radius: 5px;\">\n</div>\n\n![ページプレビュー](https://h1.appinn.me/file/1731487410170_preview1.png)\n![ページプレビュー](https://h1.appinn.me/file/1731487410832_preview2.png)\n\n## 🧪 テスト\n\n14分の1080P [Bilibiliの英語TEDビデオ](https://www.bilibili.com/video/BV1jT411X7Dz)をエンドツーエンドで処理し、ローカルWhisperモデルを使用して音声認識を行い、`gpt-5-mini`モデルを使用して中国語に最適化および翻訳するのに約**4分**かかりました。\n\nバックエンドの計算に基づくと、モデルの最適化と翻訳のコストは¥0.01未満でした（OpenAIの公式価格を使用して計算）。\n\n字幕とビデオ合成の詳細な結果については、[TEDビデオテスト](./test.md)を参照してください。\n\n\n## 🚀 クイックスタート\n\n### Windowsユーザー向け\n\nこのソフトウェアは軽量で、パッケージサイズは60MB未満であり、必要な環境がすべて含まれています。ダウンロードして直接実行できます。\n\n1. [リリースページ](https://github.com/WEIFENG2333/VideoCaptioner/releases)から最新バージョンの実行ファイルをダウンロードします。または：[Lanzou Cloud Download](https://wwwm.lanzoue.com/ii14G2pdsbej)\n\n2. インストーラーを開いてインストールします。\n\n3. LLM API設定（字幕のセグメンテーションと修正用）、[このプロジェクトのAPIリレー](https://api.videocaptioner.cn)を使用できます\n\n4. 翻訳設定、翻訳を有効にするかどうかを選択（デフォルトはMicrosoft翻訳、品質は普通、LLM翻訳用に自分のAPI KEYを設定することを推奨）\n\n5. 音声認識設定（デフォルトはBインターフェースでオンライン音声認識、中国語と英語以外の言語にはローカル文字起こしを使用）\n\n### macOSユーザー向け\n\n#### ワンクリックインストール＆実行（推奨）\n\n```bash\n# 方法1：直接実行（自動的にuv、プロジェクトのクローン、依存関係のインストール）\ncurl -fsSL https://raw.githubusercontent.com/WEIFENG2333/VideoCaptioner/main/scripts/run.sh | bash\n\n# 方法2：先にクローンしてから実行\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\n./scripts/run.sh\n```\n\nスクリプトは自動的に：\n\n1. [uv](https://docs.astral.sh/uv/)パッケージマネージャーをインストール（未インストールの場合）\n2. プロジェクトを`~/VideoCaptioner`にクローン（プロジェクトディレクトリから実行していない場合）\n3. すべてのPython依存関係をインストール\n4. アプリケーションを起動\n\n<details>\n<summary>手動インストール手順</summary>\n\n#### 1. uvパッケージマネージャーをインストール\n\n```bash\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n```\n\n#### 2. システム依存関係をインストール（macOS）\n\n```bash\nbrew install ffmpeg\n```\n\n#### 3. クローンして実行\n\n```bash\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\nuv sync          # 依存関係をインストール\nuv run python main.py  # 実行\n```\n\n</details>\n\n### 開発者ガイド\n\n```bash\n# 依存関係をインストール（開発依存関係を含む）\nuv sync\n\n# アプリケーションを実行\nuv run python main.py\n\n# 型チェック\nuv run pyright\n\n# コードリンティング\nuv run ruff check .\n```\n\n## ✨ 主要機能\n\nこのソフトウェアは、大規模言語モデル（LLM）の文脈理解の利点を最大限に活用し、音声認識で生成された字幕をさらに処理します。誤字を効果的に修正し、用語を統一し、字幕の内容をより正確で一貫性のあるものにし、ユーザーに優れた視聴体験を提供します！\n\n#### 1. マルチプラットフォーム動画ダウンロードと処理\n- 国内外の主流のビデオプラットフォーム（Bilibili、YouTube、TikTok、Xなど）をサポート\n- ビデオの元の字幕を自動的に抽出して処理します。\n\n#### 2. プロフェッショナルな音声認識エンジン\n- Jianyingに匹敵する効果を持つ複数のオンライン認識インターフェースを提供（無料、高速）。\n- ローカルWhisperモデルをサポート（プライバシー保護、オフライン）。\n\n#### 3. 字幕のスマート修正\n- 用語、コードスニペット、数式のフォーマットを自動的に最適化。\n- 読みやすさを向上させるための文脈的な文の分割最適化。\n- 原稿プロンプトをサポートし、元の原稿や関連するプロンプトを使用して字幕のセグメンテーションを最適化。\n\n#### 4. 高品質な字幕翻訳\n- 文脈を考慮したインテリジェントな翻訳により、翻訳が全体のテキストを考慮することを保証。\n- プロンプトを通じて大規模モデルに翻訳を反映させ、翻訳の質を向上。\n- シーケンスのあいまい一致アルゴリズムを使用して、タイムラインの完全な一貫性を保証。\n\n#### 5. 字幕スタイル調整\n- 豊富な字幕スタイルテンプレート（科学スタイル、ニューススタイル、アニメスタイルなど）。\n- 複数の字幕ビデオ形式（SRT、ASS、VTT、TXT）。\n\n## ⚙️ 基本設定\n\n### 1. LLM API設定手順\n\nLLMは字幕のセグメンテーション、最適化、翻訳（LLM翻訳を選択した場合）に使用されます。\n\n| 設定項目 | 説明 |\n|--------|------|\n| SiliconCloud | [SiliconCloud公式](https://cloud.siliconflow.cn/i/onCHcaDx)、設定については[オンラインドキュメント](https://weifeng2333.github.io/VideoCaptioner/config/llm)を参照<br>並行性が低いため、スレッド数を5以下に設定することを推奨。 |\n| DeepSeek | [DeepSeek公式](https://platform.deepseek.com)、`deepseek-v3`モデルの使用を推奨。 |\n| OpenAI互換 | 他のプロバイダーからのAPIがある場合は、直接入力してください。base_urlとapi_key [VideoCaptioner API](https://api.videocaptioner.cn) |\n\n注意：APIプロバイダーが高並行性をサポートしていない場合は、設定で「スレッド数」を下げてリクエストエラーを回避してください。\n\n---\n\n高並行性、またはOpenAIやClaudeなどの高品質モデルを字幕修正と翻訳に使用する場合：\n\nこのプロジェクトの✨LLM APIリレー✨を使用：[https://api.videocaptioner.cn](https://api.videocaptioner.cn)\n\n高並行性をサポートし、優れた価値を提供し、国内外の多くのモデルが利用可能です。\n\n登録してキーを取得後、以下のように設定を構成します：\n\nBaseURL: `https://api.videocaptioner.cn/v1`\n\nAPI-key: `個人センター - APIトークンページから取得。`\n\n💡 モデル選択の推奨（各品質層で選ばれた高価値モデル）：\n\n- 高品質：`gemini-3-pro`、`claude-sonnet-4-5-20250929`（コスト比：3）\n\n- より高品質：`gpt-5-2025-08-07`、`claude-haiku-4-5-20251001`（コスト比：1.2）\n\n- 中品質：`gpt-5-mini`、`gemini-3-flash`（コスト比：0.3）\n\nこのサイトは超高並行性をサポートしています。ソフトウェアのスレッド数を最大にしてください～処理速度は非常に速いです～\n\n詳細なAPI設定チュートリアル：[API設定](https://weifeng2333.github.io/VideoCaptioner/config/llm)\n\n---\n\n### 2. 翻訳設定\n\n| 設定項目 | 説明 |\n| -------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| LLM翻訳 | 🌟 最高の翻訳品質。AI大規模モデルを使用した翻訳、より良い文脈理解、より自然な翻訳。LLM API設定が必要（例：OpenAI、DeepSeekなど） |\n| Microsoft翻訳 | Microsoftの翻訳サービスを使用、非常に高速 |\n| Google翻訳 | Googleの翻訳サービス、高速、ただしGoogleのネットワークへのアクセスが必要 |\n\n推奨：最高の翻訳品質には`LLM翻訳`。\n\n### 3. 音声認識インターフェースの説明\n\n| インターフェース名 | 対応言語 | 実行方式 | 説明 |\n|---------|---------|---------|------|\n| インターフェースB | 中国語、英語のみ | オンライン | 無料、高速 |\n| インターフェースJ | 中国語、英語のみ | オンライン | 無料、高速 |\n| WhisperCpp | 中国語、日本語、韓国語、英語、その他99の言語。外国語に対して良好なパフォーマンス。 | ローカル | （実際の使用は不安定）トランスクリプションモデルのダウンロードが必要。<br>中国語：中型以上のモデルを推奨。<br>英語など：小型モデルでも良好な結果が得られます。 |\n| fasterWhisper 👍 | 中国語、英語、その他99の言語。外国語に対して優れたパフォーマンス、より正確なタイムライン。 | ローカル | （🌟推奨🌟）プログラムとトランスクリプションモデルのダウンロードが必要。<br>CUDAをサポートし、より高速で正確なトランスクリプション。<br>非常に正確なタイムスタンプ字幕。<br>Windowsのみ |\n\n### 4. ローカルWhisper音声認識設定（ソフトウェア内でダウンロードが必要）\n\nWhisperには2つのバージョンがあります：WhisperCppとfasterWhisper（推奨）。後者はより良いパフォーマンスを持ち、どちらもソフトウェア内でモデルをダウンロードする必要があります。\n\n| モデル | ディスク容量 | メモリ使用量 | 説明 |\n|------|----------|----------|------|\n| Tiny | 75 MiB | ~273 MB | トランスクリプションは平均的で、テストのみを目的としています。 |\n| Small | 466 MiB | ~852 MB | 英語の認識はすでに良好です。 |\n| Medium | 1.5 GiB | ~2.1 GB | 中国語の認識にはこのバージョンが最低限推奨されます。 |\n| Large-v2 👍 | 2.9 GiB | ~3.9 GB | 良好なパフォーマンスを持ち、設定が許すなら推奨されます。 |\n| Large-v3 | 2.9 GiB | ~3.9 GB | コミュニティのフィードバックによると、幻覚/字幕の繰り返しの問題がある可能性があります。 |\n\n推奨モデル：`Large-v2`は安定しており、品質が良好です。\n\n\n### 5. 原稿マッチング\n\n- 「字幕の最適化と翻訳」ページには「原稿マッチング」オプションがあり、以下の**1つ以上**のタイプのコンテンツをサポートして字幕の修正と翻訳を支援します：\n\n| タイプ | 説明 | 例 |\n|------|------|------|\n| 用語集 | 用語、名前、特定の単語の修正表。 | Machine Learning->机器学习<br>Elon Musk->马斯克<br>Turing patterns<br>Bus paradox |\n| 元の字幕テキスト | ビデオの元の原稿または関連するコンテンツ。 | 完全なスピーチスクリプト、講義ノートなど。 |\n| 修正要件 | コンテンツに関連する特定の修正要件。 | 人称代名詞の統一、用語の標準化など。<br>**コンテンツに関連する**要件を記入してください。[例の参照](https://github.com/WEIFENG2333/VideoCaptioner/issues/59#issuecomment-2495849752) |\n\n- 字幕の最適化に原稿の支援が必要な場合は、まず原稿情報を記入し、タスク処理を開始してください。\n- 注意：コンテキストが限られている小さなLLMモデルを使用する場合、原稿の内容は1000語以内にすることをお勧めします。より大きなコンテキストウィンドウを持つモデルを使用する場合は、原稿の内容を適切に増やすことができます。\n\n### 6. Cookie設定手順\n\nURLダウンロード機能を使用する際に以下の状況に遭遇した場合：\n1. ビデオサイトがダウンロードにログイン情報を要求する。\n2. 低解像度のビデオしかダウンロードできない。\n3. ネットワーク状況が悪いときに認証が必要。\n\n- [Cookie設定手順](https://weifeng2333.github.io/VideoCaptioner/guide/cookies-config)を参照して、cookie情報を取得し、`cookies.txt`ファイルをソフトウェアのインストールディレクトリの`AppData`ディレクトリに配置して、高品質のビデオを通常通りダウンロードしてください。\n\n## 💡 ソフトウェアプロセスの紹介\n\nプログラムの簡単な処理フローは以下の通りです：\n```\n音声認識 -> 字幕セグメンテーション（オプション） -> 字幕の最適化と翻訳（オプション） -> 字幕とビデオの合成\n```\n\nプロジェクトの主なディレクトリ構造は以下の通りです：\n```\nVideoCaptioner/\n├── app/                        # アプリケーションソースコードディレクトリ\n│   ├── common/                 # 共通モジュール（設定、シグナルバス）\n│   ├── components/             # UIコンポーネント\n│   ├── core/                   # コアビジネスロジック（ASR、翻訳、最適化など）\n│   ├── thread/                 # 非同期スレッド\n│   └── view/                   # インターフェースビュー\n├── resource/                   # リソースファイルディレクトリ\n│   ├── assets/                 # アイコン、ロゴなど\n│   ├── bin/                    # バイナリプログラム（FFmpeg、Whisperなど）\n│   ├── fonts/                  # フォントファイル\n│   ├── subtitle_style/         # 字幕スタイルテンプレート\n│   └── translations/           # 多言語翻訳ファイル\n├── work-dir/                   # 作業ディレクトリ（処理されたビデオと字幕）\n├── AppData/                    # アプリケーションデータディレクトリ\n│   ├── cache/                  # キャッシュディレクトリ（トランスクリプション、LLMリクエスト）\n│   ├── models/                 # Whisperモデルファイル\n│   ├── logs/                   # ログファイル\n│   └── settings.json           # ユーザー設定\n├── scripts/                    # インストールと実行スクリプト\n├── main.py                     # プログラムエントリー\n└── pyproject.toml              # プロジェクト設定と依存関係\n```\n\n## 📝 注意事項\n\n1. 字幕セグメンテーションの品質は視聴体験にとって非常に重要です。ソフトウェアは単語ごとの字幕を自然言語の習慣に従って段落に再編成し、ビデオフレームと完全に同期させることができます。\n\n2. 処理中、タイムライン情報なしでテキストコンテンツのみが大規模言語モデルに送信され、処理のオーバーヘッドが大幅に削減されます。\n\n3. 翻訳段階では、Andrew Ngが提案した「翻訳-反映-翻訳」手法を採用しています。この反復的な最適化方法は、翻訳の正確性を保証します。\n\n4. YouTubeリンクを処理する際、ビデオ字幕が自動的にダウンロードされ、トランスクリプションステップが省略され、操作時間が大幅に短縮されます。\n\n## 🤝 貢献ガイドライン\n\nプロジェクトは継続的に改善中で、使用中にバグに遭遇した場合は、[Issue](https://github.com/WEIFENG2333/VideoCaptioner/issues)の提出とPull Requestによるプロジェクト改善へのご協力をお願いします。\n\n## 📝 更新履歴\n\n完全な更新履歴は[CHANGELOG.md](../CHANGELOG.md)をご覧ください。\n\n## ⭐ Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=WEIFENG2333/VideoCaptioner&type=Date)](https://star-history.com/#WEIFENG2333/VideoCaptioner&Date)\n\n## 💖 作者を支援する\n\nこのプロジェクトがお役に立てましたら、Starを付けていただけると幸いです！\n\n<details>\n<summary>寄付サポート</summary>\n<div align=\"center\">\n  <img src=\"./images/alipay.jpg\" alt=\"Alipayコード\" width=\"30%\">\n  <img src=\"./images/wechat.jpg\" alt=\"WeChatコード\" width=\"30%\">\n</div>\n</details>\n"
  },
  {
    "path": "legacy-docs/README_TW.md",
    "content": "<div align=\"center\">\n  <img src=\"./images/logo.png\" alt=\"VideoCaptioner Logo\" width=\"100\">\n  <p>卡卡字幕助手</p>\n  <h1>VideoCaptioner</h1>\n  <p>一款基於大語言模型(LLM)的視頻字幕處理助手，支持語音識別、字幕斷句、優化、翻譯全流程處理</p>\n\n  [简体中文](../README.md) / 正體中文 / [English](./README_EN.md) / [日本語](./README_JA.md)\n\n📚 **[線上文檔](https://weifeng2333.github.io/VideoCaptioner/)** | 🚀 **[快速開始](https://weifeng2333.github.io/VideoCaptioner/guide/getting-started)** | ⚙️ **[配置指南](https://weifeng2333.github.io/VideoCaptioner/config/llm)**\n\n</div>\n\n## 📖 項目介紹\n\n卡卡字幕助手（VideoCaptioner）操作簡單且無需高配置，支持 API 和本地離線兩種方式進行語音識別，利用大語言模型進行字幕智能斷句、校正、翻譯，字幕視頻全流程一鍵處理。為視頻配上效果驚艷的字幕。\n\n- 支持詞級時間戳與 VAD 語音活動檢測，識別準確率高\n- 基於 LLM 的語義理解，自動將逐字字幕重組為自然流暢的句子段落\n- 結合上下文的 AI 翻譯，支持反思優化機制，譯文地道專業\n- 支持批量視頻字幕合成，提升處理效率\n- 直觀的字幕編輯查看介面，支持即時預覽和快捷編輯\n\n## 📸 介面預覽\n\n<div align=\"center\">\n  <img src=\"https://h1.appinn.me/file/1731487405884_main.png\" alt=\"軟體介面預覽\" width=\"90%\" style=\"border-radius: 5px;\">\n</div>\n\n![頁面預覽](https://h1.appinn.me/file/1731487410170_preview1.png)\n![頁面預覽](https://h1.appinn.me/file/1731487410832_preview2.png)\n\n## 🧪 測試\n\n全流程處理一個 14 分鐘 1080P 的 [B站英文 TED 視頻](https://www.bilibili.com/video/BV1jT411X7Dz)，調用本地 Whisper 模型進行語音識別，使用 `gpt-5-mini` 模型優化和翻譯為中文，總共消耗時間約 **4 分鐘**。\n\n根據後台計算，模型優化和翻譯消耗費用不足 ￥0.01（以 OpenAI 官方價格計算）\n\n具體字幕和視頻合成效果的測試結果圖片，請參考 [TED 視頻測試](./test.md)\n\n## 🚀 快速開始\n\n### Windows 用戶\n\n#### 方式一：使用打包程式（推薦）\n\n軟體較為輕量，打包大小不足 60M，已集成所有必要環境，下載後可直接運行。\n\n1. 從 [Release](https://github.com/WEIFENG2333/VideoCaptioner/releases) 頁面下載最新版本的可執行程式。或者：[藍奏盤下載](https://wwwm.lanzoue.com/ii14G2pdsbej)\n\n2. 打開安裝包進行安裝\n\n3. LLM API 配置（用於字幕斷句、校正），可使用[本項目的中轉站](https://api.videocaptioner.cn)\n\n4. 翻譯配置，選擇是否啟用翻譯，翻譯服務（預設使用微軟翻譯，質量一般，推薦配置自己的 API KEY 使用大模型翻譯）\n\n5. 語音識別配置（預設使用B介面網路調用語音識別服務，中英以外的語言請使用本地轉錄）\n\n### macOS 用戶\n\n#### 一鍵安裝運行（推薦）\n\n```bash\n# 方式一：直接運行（自動安裝 uv、克隆項目、安裝相關依賴）\ncurl -fsSL https://raw.githubusercontent.com/WEIFENG2333/VideoCaptioner/main/scripts/run.sh | bash\n\n# 方式二：先克隆再運行\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\n./scripts/run.sh\n```\n\n腳本會自動：\n\n1. 安裝 [uv](https://docs.astral.sh/uv/) 套件管理器（如果未安裝）\n2. 克隆項目到 `~/VideoCaptioner`（如果不在項目目錄中運行）\n3. 安裝所有 Python 依賴\n4. 啟動應用\n\n<details>\n<summary>手動安裝步驟</summary>\n\n#### 1. 安裝 uv 套件管理器\n\n```bash\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n```\n\n#### 2. 安裝系統依賴（macOS）\n\n```bash\nbrew install ffmpeg\n```\n\n#### 3. 克隆並運行\n\n```bash\ngit clone https://github.com/WEIFENG2333/VideoCaptioner.git\ncd VideoCaptioner\nuv sync          # 安裝依賴\nuv run python main.py  # 運行\n```\n\n</details>\n\n### 開發者指南\n\n```bash\n# 安裝依賴（包括開發依賴）\nuv sync\n\n# 運行應用\nuv run python main.py\n\n# 類型檢查\nuv run pyright\n\n# 代碼檢查\nuv run ruff check .\n```\n\n## 基本配置\n\n### 1. LLM API 配置說明\n\nLLM 大模型是用來字幕斷句、字幕優化、以及字幕翻譯（如果選擇了LLM 大模型翻譯）。\n\n| 配置項         | 說明                                                                                                                                              |\n| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| SiliconCloud   | [SiliconCloud 官網](https://cloud.siliconflow.cn/i/onCHcaDx)配置方法請參考[配置文檔](https://weifeng2333.github.io/VideoCaptioner/config/llm)<br>該並發較低，建議把線程設置為5以下。 |\n| DeepSeek       | [DeepSeek 官網](https://platform.deepseek.com)，建議使用 `deepseek-v3` 模型。                                 |\n| OpenAI兼容介面 | 如果有其他服務商的API，可直接在軟體中填寫。base_url 和api_key [VideoCaptioner API](https://api.videocaptioner.cn)                                 |\n\n注：如果用的 API 服務商不支持高並發，請在軟體設置中將「線程數」調低，避免請求錯誤。\n\n---\n\n如果希望高並發，或者希望在軟體內使用 OpenAI 或者 Claude 等優質大模型進行字幕校正和翻譯。\n\n可使用本項目的✨LLM API中轉站✨： [https://api.videocaptioner.cn](https://api.videocaptioner.cn)\n\n其支持高並發，性價比極高，且有國內外大量模型可挑選。\n\n註冊獲取key之後，設置中按照下面配置：\n\nBaseURL: `https://api.videocaptioner.cn/v1`\n\nAPI-key: `個人中心-API 令牌頁面自行獲取。`\n\n💡 模型選擇建議 (本人在各質量層級中精選出的高性價比模型)：\n\n- 高質量之選： `gemini-3-pro`、`claude-sonnet-4-5-20250929` (耗費比例：3)\n\n- 較高質量之選： `gpt-5-2025-08-07`、 `claude-haiku-4-5-20251001` (耗費比例：1.2)\n\n- 中質量之選： `gpt-5-mini`、`gemini-3-flash` (耗費比例：0.3)\n\n本站支持超高並發，軟體中線程數直接拉滿即可~ 處理速度非常快~\n\n更詳細的API配置教程：[中轉站配置](https://weifeng2333.github.io/VideoCaptioner/config/llm)\n\n---\n\n## 2. 翻譯配置\n\n| 配置項         | 說明                                                                                                                          |\n| -------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| LLM 大模型翻譯 | 🌟 翻譯質量最好的選擇。使用 AI 大模型進行翻譯，能更好理解上下文，翻譯更自然。需要在設置中配置 LLM API(比如 OpenAI、DeepSeek 等) |\n| 微軟翻譯       | 使用微軟的翻譯服務，速度非常快                                                                                                |\n| 谷歌翻譯       | 谷歌的翻譯服務，速度快，但需要能訪問谷歌的網路環境                                                                              |\n\n推薦使用 `LLM 大模型翻譯` ，翻譯質量最好。\n\n### 3. 語音識別介面說明\n\n| 介面名稱         | 支持語言                                           | 運行方式 | 說明                                                                                                              |\n| ---------------- | -------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |\n| B介面            | 僅支持中文、英文                                   | 線上     | 免費、速度較快                                                                                                    |\n| J介面            | 僅支持中文、英文                                   | 線上     | 免費、速度較快                                                                                                    |\n| WhisperCpp       | 中文、日語、韓語、英文等 99 種語言，外語效果較好   | 本地     | （實際使用不穩定）需要下載轉錄模型<br>中文建議medium以上模型<br>英文等使用較小模型即可達到不錯效果。              |\n| fasterWhisper 👍 | 中文、英文等多99種語言，外語效果優秀，時間軸更準確 | 本地     | （🌟推薦🌟）需要下載程式和轉錄模型<br>支持CUDA，速度更快，轉錄準確。<br>超級準確的時間戳字幕。<br>僅支持 Windows |\n\n### 4. 本地 Whisper 語音識別模型\n\nWhisper 版本有 WhisperCpp 和 fasterWhisper（推薦） 兩種，後者效果更好，都需要自行在軟體內下載模型。\n\n| 模型        | 磁碟空間 | 記憶體佔用 | 說明                                |\n| ----------- | -------- | -------- | ----------------------------------- |\n| Tiny        | 75 MiB   | ~273 MB  | 轉錄很一般，僅用於測試              |\n| Small       | 466 MiB  | ~852 MB  | 英文識別效果已經不錯                |\n| Medium      | 1.5 GiB  | ~2.1 GB  | 中文識別建議至少使用此版本          |\n| Large-v2 👍 | 2.9 GiB  | ~3.9 GB  | 效果好，配置允許情況推薦使用        |\n| Large-v3    | 2.9 GiB  | ~3.9 GB  | 社區反饋可能會出現幻覺/字幕重複問題 |\n\n推薦模型: `Large-v2` 穩定且質量較好。\n\n\n### 5. 文稿匹配\n\n- 在「字幕優化與翻譯」頁面，包含「文稿匹配」選項，支持以下**一種或者多種**內容，輔助校正字幕和翻譯:\n\n| 類型       | 說明                                 | 填寫示例                                                                                                                                                |\n| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 術語表     | 專業術語、人名、特定詞語的修正對照表 | 機器學習->Machine Learning<br>馬斯克->Elon Musk<br>打call -> 應援<br>圖靈斑圖<br>公車悖論                                                             |\n| 原字幕文稿 | 視頻的原有文稿或相關內容             | 完整的演講稿、課程講義等                                                                                                                                |\n| 修正要求   | 內容相關的具體修正要求               | 統一人稱代詞、規範專業術語等<br>填寫**內容相關**的要求即可，[示例參考](https://github.com/WEIFENG2333/VideoCaptioner/issues/59#issuecomment-2495849752) |\n\n- 如果需要文稿進行字幕優化輔助，全流程處理時，先填寫文稿資訊，再進行開始任務處理\n- 注意: 使用上下文參數量不高的小型LLM模型時，建議控制文稿內容在1千字內，如果使用上下文較大的模型，則可以適當增加文稿內容。\n\n無特殊需求，可不填寫。\n\n### 6. Cookie 配置說明\n\n如果使用URL下載功能時，如果遇到以下情況:\n\n1. 下載視頻網站需要登入資訊才可以下載；\n2. 只能下載較低解析度的視頻；\n3. 網路條件較差時需要驗證；\n\n- 請參考 [Cookie 配置說明](https://weifeng2333.github.io/VideoCaptioner/guide/cookies-config) 獲取Cookie資訊，並將cookies.txt檔案放置到軟體安裝目錄的 `AppData` 目錄下，即可正常下載高質量視頻。\n\n## 軟體流程介紹\n\n程式簡單的處理流程如下:\n\n```\n語音識別轉錄 -> 字幕斷句(可選) -> 字幕優化翻譯(可選) -> 字幕視頻合成\n```\n\n## 軟體主要功能\n\n軟體利用大語言模型(LLM)在理解上下文方面的優勢，對語音識別生成的字幕進一步處理。有效修正錯別字、統一專業術語，讓字幕內容更加準確連貫，為用戶帶來出色的觀看體驗！\n\n#### 1. 多平台視頻下載與處理\n\n- 支持國內外主流視頻平台（B站、Youtube、小紅書、TikTok、X、西瓜視頻、抖音等）\n- 自動提取視頻原有字幕處理\n\n#### 2. 專業的語音識別引擎\n\n- 提供多種介面線上識別，效果媲美剪映（免費、高速）\n- 支持本地Whisper模型（保護隱私、可離線）\n\n#### 3. 字幕智能糾錯\n\n- 自動優化專業術語、代碼片段和數學公式格式\n- 上下文進行斷句優化，提升閱讀體驗\n- 支持文稿提示，使用原有文稿或者相關提示優化字幕斷句\n\n#### 4. 高質量字幕翻譯\n\n- 結合上下文的智能翻譯，確保譯文兼顧全文\n- 透過Prompt指導大模型反思翻譯，提升翻譯質量\n- 使用序列模糊匹配算法、保證時間軸完全一致\n\n#### 5. 字幕樣式調整\n\n- 豐富的字幕樣式模板（科普風、新聞風、番劇風等等）\n- 多種格式字幕視頻（SRT、ASS、VTT、TXT）\n\n項目主要目錄結構說明如下：\n\n```\nVideoCaptioner/\n├── app/                        # 應用源代碼目錄\n│   ├── common/                 # 公共模組（配置、信號匯流排）\n│   ├── components/             # UI 元件\n│   ├── core/                   # 核心業務邏輯（ASR、翻譯、優化等）\n│   ├── thread/                 # 異步線程\n│   └── view/                   # 介面視圖\n├── resource/                   # 資源檔案目錄\n│   ├── assets/                 # 圖示、Logo 等\n│   ├── bin/                    # 二進制程式（FFmpeg、Whisper 等）\n│   ├── fonts/                  # 字體檔案\n│   ├── subtitle_style/         # 字幕樣式模板\n│   └── translations/           # 多語言翻譯檔案\n├── work-dir/                   # 工作目錄（處理完成的視頻和字幕）\n├── AppData/                    # 應用資料目錄\n│   ├── cache/                  # 快取目錄（轉錄、LLM 請求）\n│   ├── models/                 # Whisper 模型檔案\n│   ├── logs/                   # 日誌檔案\n│   └── settings.json           # 用戶設置\n├── scripts/                    # 安裝和運行腳本\n├── main.py                     # 程式入口\n└── pyproject.toml              # 項目配置和依賴\n```\n\n## 📝 說明\n\n1. 字幕斷句的質量對觀看體驗至關重要。軟體能將逐字字幕智能重組為符合自然語言習慣的段落，並與視頻畫面完美同步。\n\n2. 在處理過程中，僅向大語言模型發送文本內容，不包含時間軸資訊，這大大降低了處理開銷。\n\n3. 在翻譯環節，我們採用吳恩達提出的「翻譯-反思-翻譯」方法論。這種迭代優化的方式確保了翻譯的準確性。\n\n4. 填入 YouTube 連結時進行處理時，會自動下載視頻的字幕，從而省去轉錄步驟，極大地節省操作時間。\n\n## 🤝 貢獻指南\n\n項目在不斷完善中，如果在使用過程遇到的Bug，歡迎提交 [Issue](https://github.com/WEIFENG2333/VideoCaptioner/issues) 和 Pull Request 幫助改進項目。\n\n## 📝 更新日誌\n\n查看完整的更新歷史，請訪問 [CHANGELOG.md](../CHANGELOG.md)\n\n## ⭐ Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=WEIFENG2333/VideoCaptioner&type=Date)](https://star-history.com/#WEIFENG2333/VideoCaptioner&Date)\n\n## 💖 支持作者\n\n如果覺得項目對你有幫助，可以給項目點個Star！\n\n<details>\n<summary>捐助支持</summary>\n<div align=\"center\">\n  <img src=\"./images/alipay.jpg\" alt=\"支付寶二維碼\" width=\"30%\">\n  <img src=\"./images/wechat.jpg\" alt=\"微信二維碼\" width=\"30%\">\n</div>\n</details>\n"
  },
  {
    "path": "legacy-docs/about_chunk_merge.md",
    "content": "\nhttps://github.com/groq/groq-api-cookbook/blob/main/tutorials/audio-chunking/audio_chunking_tutorial.ipynb"
  },
  {
    "path": "legacy-docs/get_cookies.md",
    "content": "# Cookie 配置说明\n\n## 问题说明\n在使用软件下载视频时，可能会遇到以下错误提示：\n\n![alt text](images/cookies_error.png)\n\n这是因为:\n1. 某些视频平台(如B站)需要用户登录信息才能获取高质量视频\n2. 部分网站(如YouTube)在网络条件较差时需要验证用户身份\n\n## 解决方法\n\n### 1. 安装浏览器扩展\n根据你使用的浏览器选择安装:\n\n- Chrome浏览器: [Get CookieTxt Locally](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)\n- Edge浏览器: [Export Cookies File](https://microsoftedge.microsoft.com/addons/detail/export-cookies-file/hbglikhfdcfhdfikmocdflffaecbnedo)\n\n### 2. 导出Cookie文件\n1. 登录需要下载视频的网站(如B站、YouTube等)\n2. 点击浏览器扩展图标\n3. 选择\"Export Cookies\"选项\n4. 将导出的cookies.txt文件保存到软件的AppData目录下\n\n![alt text](images/cookies_export.png)\n\n### 3. 确认文件位置\n完成后的目录结构应如下:\n\n```\n├─AppData\n│  ├─cache\n│  ├─logs\n│  ├─models\n│  ├─cookies.txt  # Cookie文件\n│  └─settings.json\n \n```\n"
  },
  {
    "path": "legacy-docs/llm_config.md",
    "content": "\n目前国内多家大模型厂商都提供了API接口，可以自行申请。也可以使用中转站，使用 OpenAI 或 Claude的API。\n\n本教程以两种配置方式为例进行说明：\n\n[SiliconFlow-API 配置](./llm_config.md#SiliconFlow-API-配置)\n\n[中转站配置](./llm_config.md#中转站配置)\n\n\n# SiliconFlow-API 配置\n\n1. 申请大模型API\n\n这里以国内的 [SiliconCloud](https://cloud.siliconflow.cn/i/onCHcaDx) 的 API 为例子，其已经集合国内多家大模型厂商。（注意以上是我的推广链接，通过此可以获得14元额度，介意就百度自行搜索注册，非广告）\n\n![api](images/get_api.png)\n\n注册后，在[设置](https://cloud.siliconflow.cn/account/ak)中获取API Key。\n\n![config](images/api-setting.png)\n\nAPI 接口地址： https://api.siliconflow.cn/v1 （需要添加 /v1）\n\nAPI Key： 将 SiliconCloud 平台的密钥粘贴到此处。\n\n点击检查连接，“模型”设置栏会自动填充所有支持的模型名称。\n\n选择需要的模型名称，推荐：deepseek-ai/DeepSeek-V3\n\n> 2025 年 2 月 6 日起，未实名用户每日最多请求此模型 100 次\n\n根据官方要求该模型需要实名才能获取更多的调用次数。不想实名可以考虑使用其他中转站。\n\n`线程数 (Thread Count)`: SiliconCloud 并发有限，推荐只设置 5 个线程或以下。\n\n\n# 中转站配置\n\n1. 先在 [本项目的中转站](https://api.videocaptioner.cn/register?aff=UrLB) 注册账号\n,通过此链接注册默认赠送 $0.4 测试余额。\n\n2. 然后获取 API Key： [https://api.videocaptioner.cn/token](https://api.videocaptioner.cn/token)\n\n3. 在软件设置中配置 API Key 和 API 接口地址, 如下图：\n\n![api_setting](images/api-setting-2.png)\n\nBaseURL: `https://api.videocaptioner.cn/v1`\n\nAPI-key: `上面获取的API Key`\n\n💡 模型选择建议 (本人在各质量层级中选出的高性价比模型)： \n\n - 高质量之选： `claude-3-5-sonnet-20241022` (耗费比例：3) \n\n - 较高质量之选： `gemini-2.0-flash`、`deepseek-chat` (耗费比例：1) \n\n - 中质量之选： `gpt-4o-mini`、`gemini-1.5-flash` (耗费比例：0.15) \n\n`线程数 (Thread Count)`: 本站支持超高并发，软件中线程数直接拉满即可~ 处理速度非常快~\n\n> PS: 条件差一点的可直接使用 `gpt-4o-mini`, 便宜且速度快。这个模型也花不了几个钱的，建议不要折腾本地部署了。\n\n\n\n\n"
  },
  {
    "path": "legacy-docs/test.md",
    "content": "### 使用 Whisper 转录\n![alt text](images/test_zl.png)\n\n### 转录成功以后的字幕\n```\n1\n00:00:02,080 --> 00:00:08,600\nSo in college, I was a government major,\n\n2\n00:00:08,600 --> 00:00:11,080\nwhich means I had to write a lot of papers.\n\n3\n00:00:11,080 --> 00:00:12,600\nNow, when a normal student writes a paper,\n\n4\n00:00:12,600 --> 00:00:15,460\nthey might spread the work out a little like this.\n\n5\n00:00:15,460 --> 00:00:16,300\nSo you know.\n\n6\n00:00:16,300 --> 00:00:20,040\nYou get started maybe a little slowly,\n\n7\n00:00:20,040 --> 00:00:21,600\nbut you get enough done in the first week\n\n8\n00:00:21,600 --> 00:00:24,000\nthat with some heavier days later on,\n\n9\n00:00:24,000 --> 00:00:26,200\neverything gets done and things stay civil.\n\n10\n00:00:26,200 --> 00:00:29,840\nAnd I would wanna do that like that.\n\n11\n00:00:29,840 --> 00:00:30,840\nThat would be the plan.\n\n12\n00:00:30,840 --> 00:00:33,580\nI would have it all ready to go,\n\n13\n00:00:33,580 --> 00:00:36,120\nbut then actually the paper would come along\n\n14\n00:00:36,120 --> 00:00:37,720\nand then I would kinda do this.\n\n15\n00:00:40,480 --> 00:00:43,280\nAnd that would happen to every single paper.\n\n16\n00:00:43,280 --> 00:00:47,240\nBut then came my 90 page senior thesis,\n\n17\n00:00:47,240 --> 00:00:49,580\na paper you're supposed to spend a year on.\n\n18\n00:00:49,580 --> 00:00:52,320\nI knew for a paper like that, my normal workflow\n\n19\n00:00:52,320 --> 00:00:54,580\nwas not an option, it was way too big a project.\n\n20\n00:00:54,580 --> 00:00:56,580\nSo I planned things out and I decided\n\n21\n00:00:56,580 --> 00:00:59,520\nI kinda had to go something like this.\n\n```\n\n### 进行断句与字幕的优化翻译\n```\n1\n00:00:02,080 --> 00:00:08,597\n所以在大学时，我是政府专业的学生\nSo in college, I was a government major.\n\n2\n00:00:08,600 --> 00:00:11,078\n这意味着我得写很多论文\nWhich means I had to write a lot of papers.\n\n3\n00:00:11,080 --> 00:00:12,596\n现在，普通学生写论文时\nNow when a normal student writes a paper,\n\n4\n00:00:12,600 --> 00:00:15,460\n他们可能会这样分散工作\nThey might spread the work out a little like this.\n\n5\n00:00:15,460 --> 00:00:20,040\n所以你知道，你可能会稍微慢一些开始\nSo you know, you get started maybe a little slowly,\n\n6\n00:00:20,040 --> 00:00:21,593\n但你在第一周能够完成足够的工作\nBut you get enough done in the first week.\n\n7\n00:00:21,600 --> 00:00:23,996\n这样之后的一些繁忙日子\nThat with some heavier days later on.\n\n8\n00:00:24,000 --> 00:00:26,200\n一切都能完成，事情保持得当\nEverything gets done and things stay civil.\n\n9\n00:00:26,200 --> 00:00:29,840\n我也希望那样去做\nAnd I would wanna do that like that.\n\n10\n00:00:29,840 --> 00:00:31,936\n那将是我的计划\nThat would be the plan I would have.\n\n11\n00:00:31,936 --> 00:00:35,059\n一切都准备好了，但实际上论文却并没有完成\nIt was all ready to go, but then actually the paper\n\n```\n\n### 最终合成视频\n![alt text](images/test_ted1.png)\n\n![alt text](images/test_ted2.png)\n\n![alt text](images/test_ted3.png)\n\n### 查看日志\n```\n原字幕：So in college, I was a government major.\n翻译后字幕：所以在大学时，我是一个政府专业的学生。\n反思后字幕：所以在大学时，我是政府专业的学生。\n===========\n原字幕：Which means I had to write a lot of papers.\n翻译后字幕：这意味着我必须写很多论文。\n反思后字幕：这意味着我得写很多论文。\n===========\n原字幕：Now when a normal student writes a paper,\n翻译后字幕：现在，当一个普通学生写论文时，\n反思后字幕：现在，普通学生写论文时，\n===========\n原字幕：They might spread the work out a little like this.\n翻译后字幕：他们可能会像这样分散工作。\n反思后字幕：他们可能会这样分散工作。\n===========\n原字幕：So you know, you get started maybe a little slowly,\n翻译后字幕：所以你知道，你可能会开始得有点慢，\n反思后字幕：所以你知道，你可能会稍微慢一些开始，\n===========\n原字幕：But you get enough done in the first week,\n翻译后字幕：但你在第一周能完成足够的工作，\n反思后字幕：但你在第一周能够完成足够的工作，\n===========\n原字幕：That with some heavier days later on,\n翻译后字幕：这样之后几天会比较忙，\n反思后字幕：这样之后的一些繁忙日子，\n===========\n原字幕：Everything gets done and things stay civil.\n翻译后字幕：所有事情都能完成，事情保持得体。\n反思后字幕：一切都能完成，事情保持得当。\n===========\n原字幕：And I would wanna do that like that.\n翻译后字幕：而我想要那样做。\n反思后字幕：我也希望那样去做。\n===========\n原字幕：That would be the plan I would have.\n翻译后字幕：那是我会有的计划。\n反思后字幕：那将是我的计划。\n\n```\n\n### 查看大模型调用情况\n\n本次字幕的优化翻译调用了大模型，进入服务商后台查看\n\n调用花费的Tokens很少，消耗金额仅仅 ￥0.01 (OpenAI 官方价格计费，使用一些中转站的逆向模型花费更少)\n\n![alt text](images/test_spend.png)\n"
  },
  {
    "path": "main.py",
    "content": "\"\"\"\nCopyright (c) 2024 [VideoCaptioner]\nAll rights reserved.\n\nAuthor: Weifeng\n\"\"\"\n\nimport os\nimport platform\nimport sys\nimport traceback\n\nfrom app.config import TRANSLATIONS_PATH\n\n# Add project root directory to Python path\nproject_root = os.path.dirname(os.path.abspath(__file__))\nsys.path.append(project_root)\n\n# Use appropriate library folder name based on OS\nlib_folder = \"Lib\" if platform.system() == \"Windows\" else \"lib\"\nplugin_path = os.path.join(\n    sys.prefix, lib_folder, \"site-packages\", \"PyQt5\", \"Qt5\", \"plugins\"\n)\nos.environ[\"QT_QPA_PLATFORM_PLUGIN_PATH\"] = plugin_path\n\n# Delete pyd files app*.pyd\nfor file in os.listdir():\n    if file.startswith(\"app\") and file.endswith(\".pyd\"):\n        os.remove(file)\n\n# Now import the modules that depend on the setup above\nfrom PyQt5.QtCore import Qt, QTranslator  # noqa: E402\nfrom PyQt5.QtWidgets import QApplication  # noqa: E402\nfrom qfluentwidgets import FluentTranslator  # noqa: E402\n\nfrom app.common.config import cfg  # noqa: E402\nfrom app.config import RESOURCE_PATH  # noqa: E402\nfrom app.core.utils.cache import disable_cache, enable_cache  # noqa: E402\nfrom app.core.utils.logger import setup_logger  # noqa: E402\nfrom app.view.main_window import MainWindow  # noqa: E402\n\nlogger_instance = setup_logger(\"VideoCaptioner\")\n\n\ndef exception_hook(exctype, value, tb):\n    logger_instance.error(\"\".join(traceback.format_exception(exctype, value, tb)))\n    sys.__excepthook__(exctype, value, tb)  # 调用默认的异常处理\n\n\nsys.excepthook = exception_hook\n\n# 应用缓存配置\nif cfg.get(cfg.cache_enabled):\n    enable_cache()\nelse:\n    disable_cache()\n\n\n# Enable DPI Scale\nif cfg.get(cfg.dpiScale) == \"Auto\":\n    QApplication.setHighDpiScaleFactorRoundingPolicy(\n        Qt.HighDpiScaleFactorRoundingPolicy.PassThrough  # type: ignore\n    )\n    QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)  # type: ignore\nelse:\n    os.environ[\"QT_ENABLE_HIGHDPI_SCALING\"] = \"0\"\n    os.environ[\"QT_SCALE_FACTOR\"] = str(cfg.get(cfg.dpiScale))\nQApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)  # type: ignore\n\napp = QApplication(sys.argv)\napp.setAttribute(Qt.AA_DontCreateNativeWidgetSiblings, True)  # type: ignore\n\n# Internationalization\nlocale = cfg.get(cfg.language).value\ntranslator = FluentTranslator(locale)\nmyTranslator = QTranslator()\ntranslations_path = TRANSLATIONS_PATH / f\"VideoCaptioner_{locale.name()}.qm\"\nmyTranslator.load(str(translations_path))\napp.installTranslator(translator)\napp.installTranslator(myTranslator)\n\n\ndef main():\n    w = MainWindow()\n    w.show()\n    sys.exit(app.exec_())\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"videocaptioner\"\nversion = \"1.3.3\"\ndescription = \"AI-powered video captioning tool based on LLM\"\nreadme = \"README.md\"\nlicense = { text = \"GPL-3.0\" }\nauthors = [{ name = \"Weifeng\" }]\nrequires-python = \">=3.10,<3.13\"\nkeywords = [\"video\", \"caption\", \"subtitle\", \"asr\", \"llm\", \"translation\"]\nclassifiers = [\n    \"Development Status :: 4 - Beta\",\n    \"Environment :: X11 Applications :: Qt\",\n    \"Intended Audience :: End Users/Desktop\",\n    \"License :: OSI Approved :: GNU General Public License v3 (GPLv3)\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Topic :: Multimedia :: Video\",\n]\ndependencies = [\n    \"requests>=2.32.4\",\n    \"openai>=1.97.1\",\n    \"diskcache>=5.6.3\",\n    \"PyQt5==5.15.11\",\n    \"PyQt-Fluent-Widgets==1.8.4\",\n    \"yt-dlp>=2025.7.21\",\n    \"modelscope>=1.32.0\",\n    \"psutil>=7.0.0\",\n    \"json-repair>=0.49.0\",\n    \"langdetect>=1.0.9\",\n    \"pydub>=0.25.1\",\n    \"tenacity>=8.2.0\",\n    \"GPUtil>=1.4.0\",\n    \"pillow>=12.0.0\",\n    \"fonttools>=4.61.1\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/WEIFENG2333/VideoCaptioner\"\nRepository = \"https://github.com/WEIFENG2333/VideoCaptioner\"\nIssues = \"https://github.com/WEIFENG2333/VideoCaptioner/issues\"\n\n[project.scripts]\nvideocaptioner = \"main:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"app\"]\n\n[tool.uv]\n# 为不同平台分别解析依赖（PyQt5-Qt5 版本因平台而异）\nenvironments = [\n    \"sys_platform == 'win32'\",\n    \"sys_platform == 'darwin'\",\n    \"sys_platform == 'linux'\",\n]\n# 覆盖 PyQt5-Qt5 版本：Windows 用 5.15.2，其他平台用最新版\noverride-dependencies = [\n    \"PyQt5-Qt5==5.15.2; sys_platform == 'win32'\",\n    \"PyQt5-Qt5>=5.15.11; sys_platform != 'win32'\",\n]\ndev-dependencies = [\n    \"pyright>=1.1.0\",\n    \"ruff>=0.4.0\",\n    \"pytest>=8.0.0\",\n]\n\n[tool.pyright]\nvenvPath = \".\"\nvenv = \".venv\"\npythonVersion = \"3.12\"\ntypeCheckingMode = \"basic\"\ninclude = [\"app\", \"main.py\"]\nexclude = [\n    \"**/__pycache__\",\n    \"**/.pytest_cache\",\n    \".venv\",\n    \"venv\",\n    \"build\",\n    \"dist\",\n    \"work-dir\",\n    \"AppData\",\n    \"resource\",\n    \"**/node_modules\"\n]\n# 导入相关\nreportMissingImports = \"warning\"\nreportMissingTypeStubs = false\n# 类型检查级别（降低严格度）\nreportGeneralTypeIssues = false\nreportOptionalOperand = \"warning\"\nreportOptionalMemberAccess = false\nreportArgumentType = \"warning\"\n# 禁用的检查\nreportCallIssue = false\nreportUnknownMemberType = false\nreportUnknownArgumentType = false\nreportUnknownVariableType = false\nreportUnknownParameterType = false\nreportUnusedImport = \"warning\"\nreportUnusedVariable = \"warning\"\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\"]\npython_classes = [\"Test*\"]\npython_functions = [\"test_*\"]\naddopts = \"-v --strict-markers --tb=short --disable-warnings\"\nmarkers = [\n    \"integration: Integration tests that require external services\",\n    \"slow: Slow running tests\",\n    \"llm: Tests that require LLM API access\",\n    \"translator: Tests for translation modules\",\n]\nlog_cli = true\nlog_cli_level = \"INFO\"\nlog_cli_format = \"%(asctime)s [%(levelname)8s] %(message)s\"\nlog_cli_date_format = \"%Y-%m-%d %H:%M:%S\"\n\n[tool.ruff]\nline-length = 100\ntarget-version = \"py310\"\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\", \"W\"]\nignore = [\"E501\"]\n"
  },
  {
    "path": "resource/assets/qss/dark/demo.qss",
    "content": "QWidget {\n    border: 1px solid rgb(29, 29, 29);\n    border-right: none;\n    border-bottom: none;\n    border-top-left-radius: 10px;\n    background-color: rgb(39, 39, 39);\n}\n\nWindow {\n    background-color: rgb(32, 32, 32);\n}"
  },
  {
    "path": "resource/assets/qss/light/demo.qss",
    "content": "Widget > QLabel {\n    font: 24px 'Segoe UI', 'Microsoft YaHei';\n}\n\nWidget {\n    border: 1px solid rgb(229, 229, 229);\n    border-right: none;\n    border-bottom: none;\n    border-top-left-radius: 10px;\n    background-color: rgb(249, 249, 249);\n}\n\nWindow {\n    background-color: rgb(243, 243, 243);\n}\n\n"
  },
  {
    "path": "resource/subtitle_style/default.json",
    "content": "{\n  \"font_name\": \"LXGW WenKai\",\n  \"font_size\": 32,\n  \"text_color\": \"#000000\",\n  \"bg_color\": \"#0de3ffe5\",\n  \"corner_radius\": 14,\n  \"padding_h\": 24,\n  \"padding_v\": 18,\n  \"margin_bottom\": 40,\n  \"line_spacing\": 12,\n  \"letter_spacing\": 1\n}"
  },
  {
    "path": "resource/subtitle_style/default.txt",
    "content": "[V4+ Styles]\nFormat: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\nStyle: Default,Arial,42,&H005aff65,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,3.2,0,1,2.0,0,2,10,10,30,1,\\q1\nStyle: Secondary,Arial,30,&H00ffffff,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0.8,0,1,2.0,0,2,10,10,30,1,\\q1"
  },
  {
    "path": "resource/subtitle_style/毕导科普风.txt",
    "content": "[V4+ Styles]\nFormat: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\nStyle: Default,微软雅黑,44,&H00e6e8f1,&H000000FF,&H00060606,&H00000000,-1,0,0,0,100,100,3.0,0,1,2.2,0,2,10,10,32,1,\\q1\nStyle: Secondary,微软雅黑,28,&H00ffffff,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0.2,0,1,2.0,0,2,10,10,32,1,\\q1"
  },
  {
    "path": "resource/subtitle_style/番剧可爱风.txt",
    "content": "[V4+ Styles]\nFormat: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\nStyle: Default,微软雅黑,46,&H00e6e8f1,&H000000FF,&H000987f5,&H00000000,-1,0,0,0,100,100,2.6,0,1,2.6,0,2,10,10,20,1,\\q1\nStyle: Secondary,微软雅黑,26,&H00ffffff,&H000000FF,&H000987f5,&H00000000,-1,0,0,0,100,100,0.0,0,1,2.0,0,2,10,10,20,1,\\q1"
  },
  {
    "path": "resource/subtitle_style/竖屏.txt",
    "content": "[V4+ Styles]\nFormat: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\nStyle: Default,微软雅黑,34,&H005aff65,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,4.0,0,1,2.0,0,2,10,10,182,1,\\q1\nStyle: Secondary,微软雅黑,18,&H00ffffff,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0.8,0,1,2.0,0,2,10,10,182,1,\\q1"
  },
  {
    "path": "resource/translations/VideoCaptioner_en_US.ts",
    "content": "<?xml version='1.0' encoding='utf-8'?>\n<TS version=\"2.1\">\n<context>\n    <name>BatchProcessInterface</name>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"50\" />\n        <source>批量处理</source>\n        <translation>Batch Process</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"73\" />\n        <source>添加文件</source>\n        <translation>Add Files</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"400\" />\n        <source>开始处理</source>\n        <translation>Start Processing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"75\" />\n        <source>清空列表</source>\n        <translation>Clear List</translation>\n    </message>\n</context>\n<context>\n    <name>ColorPickerButton</name>\n    <message>\n        <location filename=\"../../app/components/MySettingCard.py\" line=\"319\" />\n        <source>Choose </source>\n        <translation>Choose </translation>\n    </message>\n</context>\n<context>\n    <name>DonateDialog</name>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"19\" />\n        <source>支持作者</source>\n        <translation>Support Author</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"23\" />\n        <source>感谢支持</source>\n        <translation>Thank You for Your Support</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"26\" />\n        <source>目前本人精力有限，您的支持让我有动力继续折腾这个项目！\n感谢您对开源事业的热爱与支持！</source>\n        <translation>I have limited energy, and your support motivates me to continue working on this project!\nThank you for your passion and support for open source!</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"48\" />\n        <source>支付宝</source>\n        <translation>Alipay</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"64\" />\n        <source>微信</source>\n        <translation>WeChat</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"87\" />\n        <source>关闭</source>\n        <translation>Close</translation>\n    </message>\n</context>\n<context>\n    <name>DownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"127\" />\n        <source>下载模型</source>\n        <translation>Download Model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"147\" />\n        <source>下载</source>\n        <translation>Download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"161\" />\n        <source>关闭</source>\n        <translation>Close</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"170\" />\n        <source>提示</source>\n        <translation>Notice</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"170\" />\n        <source>模型文件已存在,无需重复下载</source>\n        <translation>Model file already exists, no need to download again</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"193\" />\n        <source>完成</source>\n        <translation>Complete</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"193\" />\n        <source>模型下载完成!</source>\n        <translation>Model download completed!</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"200\" />\n        <source>下载完成</source>\n        <translation>Download completed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"203\" />\n        <source>下载错误</source>\n        <translation>Download Error</translation>\n    </message>\n</context>\n<context>\n    <name>FasterWhisperDownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"203\" />\n        <source>关闭</source>\n        <translation>Close</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"212\" />\n        <source>Faster Whisper 下载</source>\n        <translation>Faster Whisper Download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"216\" />\n        <source>打开程序文件夹</source>\n        <translation>Open Program Folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"231\" />\n        <source>已安装版本: {versions_text}</source>\n        <translation>Installed Version: {versions_text}</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"237\" />\n        <source>您可以继续下载其他版本:</source>\n        <translation>You can continue downloading other versions:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"240\" />\n        <source>未下载Faster Whisper 程序</source>\n        <translation>Faster Whisper program not downloaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"258\" />\n        <source>下载程序</source>\n        <translation>Download Program</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"271\" />\n        <source>模型下载</source>\n        <translation>Model Download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"275\" />\n        <source>打开模型文件夹</source>\n        <translation>Open Model Folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\" />\n        <source>模型名称</source>\n        <translation>Model Name</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\" />\n        <source>大小</source>\n        <translation>Size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\" />\n        <source>状态</source>\n        <translation>Status</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\" />\n        <source>操作</source>\n        <translation>Action</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"540\" />\n        <source>已下载</source>\n        <translation>Downloaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"360\" />\n        <source>未下载</source>\n        <translation>Not Downloaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"547\" />\n        <source>重新下载</source>\n        <translation>Re-download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"373\" />\n        <source>下载</source>\n        <translation>Download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"505\" />\n        <source>下载进行中</source>\n        <translation>Downloading</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"505\" />\n        <source>请等待当前下载任务完成</source>\n        <translation>Please wait for the current download task to complete</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"417\" />\n        <source>下载错误</source>\n        <translation>Download Error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"417\" />\n        <source>未找到对应的程序配置</source>\n        <translation>Program configuration not found</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"465\" />\n        <source>正在解压文件...</source>\n        <translation>Extracting files...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"646\" />\n        <source>安装失败</source>\n        <translation>Installation failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"597\" />\n        <source>下载失败</source>\n        <translation>Download failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"519\" />\n        <source>正在下载 {model['label']} 模型...</source>\n        <translation>Downloading {model['label']} model...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"582\" />\n        <source>下载成功</source>\n        <translation>Download successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"582\" />\n        <source>{model['label']} 模型已下载完成</source>\n        <translation>{model['label']} model has been downloaded successfully</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"635\" />\n        <source>安装完成</source>\n        <translation>Installation completed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"635\" />\n        <source>Faster Whisper 程序已安装成功</source>\n        <translation>Faster Whisper program installed successfully</translation>\n    </message>\n</context>\n<context>\n    <name>FasterWhisperSettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"888\" />\n        <source>Faster Whisper程序不存在，请先下载程序</source>\n        <translation>Faster Whisper program does not exist, please download it first</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"685\" />\n        <source>Faster Whisper 设置（✨推荐✨））</source>\n        <translation>Faster Whisper Settings (✨ Recommended ✨)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"690\" />\n        <source>模型</source>\n        <translation>Model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"690\" />\n        <source>选择 Faster Whisper 模型</source>\n        <translation>Select Faster Whisper model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\" />\n        <source>管理模型</source>\n        <translation>Manage Models</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\" />\n        <source>模型管理</source>\n        <translation>Model Management</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\" />\n        <source>下载或更新 Faster Whisper 模型</source>\n        <translation>Download or update Faster Whisper models</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"728\" />\n        <source>源语言</source>\n        <translation>Source Language</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"728\" />\n        <source>音频的源语言</source>\n        <translation>Source language of the audio</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"739\" />\n        <source>运行设备</source>\n        <translation>Device</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"739\" />\n        <source>模型运行设备</source>\n        <translation>Device to run the model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"752\" />\n        <source>VAD设置</source>\n        <translation>VAD Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"755\" />\n        <source>VAD过滤</source>\n        <translation>VAD Filter</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"755\" />\n        <source>过滤无人声语音片断，减少幻觉</source>\n        <translation>Filter non-speech segments to reduce hallucinations</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"764\" />\n        <source>VAD阈值</source>\n        <translation>VAD Threshold</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"764\" />\n        <source>语音概率阈值，高于此值视为语音</source>\n        <translation>Speech probability threshold, values above this are considered speech</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"776\" />\n        <source>VAD方法</source>\n        <translation>VAD Method</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"776\" />\n        <source>选择VAD检测方法</source>\n        <translation>Select VAD detection method</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"786\" />\n        <source>其他设置</source>\n        <translation>Other Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"789\" />\n        <source>人声分离</source>\n        <translation>Voice Separation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"789\" />\n        <source>处理前使用MDX-Net降噪，分离人声和背景音乐</source>\n        <translation>Use MDX-Net for noise reduction before processing, separating vocals and background music</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"798\" />\n        <source>单字时间戳</source>\n        <translation>Word-level Timestamps</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"798\" />\n        <source>开启生成单字级时间戳；关闭后使用原始分段断句</source>\n        <translation>Enable word-level timestamps; disable to use original segment breaks</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"807\" />\n        <source>提示词</source>\n        <translation>Prompt</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"807\" />\n        <source>可选的提示词,默认空</source>\n        <translation>Optional prompt, empty by default</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"871\" />\n        <source>错误</source>\n        <translation>Error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"902\" />\n        <source>模型配置不存在</source>\n        <translation>Model configuration does not exist</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"909\" />\n        <source>模型文件不存在: </source>\n        <translation>Model file does not exist: </translation>\n    </message>\n</context>\n<context>\n    <name>FileDownloadThread</name>\n    <message>\n        <location filename=\"../../app/thread/file_download_thread.py\" line=\"35\" />\n        <source>正在连接...</source>\n        <translation>Connecting...</translation>\n    </message>\n</context>\n<context>\n    <name>HomeInterface</name>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"36\" />\n        <source>任务创建</source>\n        <translation>Task Creation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"39\" />\n        <source>语音转录</source>\n        <translation>Transcription</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"42\" />\n        <source>字幕优化与翻译</source>\n        <translation>Subtitle Optimization &amp; Translation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"47\" />\n        <source>字幕视频合成</source>\n        <translation>Video Synthesis</translation>\n    </message>\n</context>\n<context>\n    <name>LanguageSettingDialog</name>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"26\" />\n        <source>确定</source>\n        <translation>OK</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"27\" />\n        <source>取消</source>\n        <translation>Cancel</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"32\" />\n        <source>语言设置</source>\n        <translation>Language Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"35\" />\n        <source>源语言</source>\n        <translation>Source Language</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"35\" />\n        <source>音频的源语言</source>\n        <translation>Source language of the audio</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"56\" />\n        <source>设置已保存</source>\n        <translation>Settings saved</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"56\" />\n        <source>语言设置已更新</source>\n        <translation>Language settings updated</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"64\" />\n        <source>请注意身体！！</source>\n        <translation>Please take care of yourself!!</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"64\" />\n        <source>小心肝儿,注意身体哦~</source>\n        <translation>Take care, stay healthy~</translation>\n    </message>\n</context>\n<context>\n    <name>MainWindow</name>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"62\" />\n        <source>主页</source>\n        <translation>Home</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"63\" />\n        <source>批量处理</source>\n        <translation>Batch Process</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"64\" />\n        <source>字幕样式</source>\n        <translation>Subtitle Style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"76\" />\n        <source>Settings</source>\n        <translation>Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"98\" />\n        <source>卡卡字幕助手 -- VideoCaptioner</source>\n        <translation>Kaka Subtitle Assistant -- VideoCaptioner</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"117\" />\n        <source>GitHub信息</source>\n        <translation>GitHub Info</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"117\" />\n        <source>VideoCaptioner 由本人在课余时间独立开发完成，目前托管在GitHub上，欢迎Star和Fork。项目诚然还有很多地方需要完善，遇到软件的问题或者BUG欢迎提交Issue。\n\n https://github.com/WEIFENG2333/VideoCaptioner</source>\n        <translation>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.\n\n https://github.com/WEIFENG2333/VideoCaptioner</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"124\" />\n        <source>打开 GitHub</source>\n        <translation>Open GitHub</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"125\" />\n        <source>支持作者</source>\n        <translation>Support Author</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"152\" />\n        <source>当前版本部分功能已被禁用。请尽快更新。</source>\n        <translation>Some features in the current version have been disabled. Please update as soon as possible.</translation>\n    </message>\n</context>\n<context>\n    <name>PromptDialog</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"885\" />\n        <source>文稿提示</source>\n        <translation>Script Prompt</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"889\" />\n        <source>请输入文稿提示（辅助校正字幕和翻译）\n\n支持以下内容:\n1. 术语表 - 专业术语、人名、特定词语的修正对照表\n示例:\n机器学习-&gt;Machine Learning\n马斯克-&gt;Elon Musk\n打call-&gt;应援\n\n2. 原字幕文稿 - 视频的原有文稿或相关内容\n示例: 完整的演讲稿、课程讲义等\n\n3. 修正要求 - 内容相关的具体修正要求\n示例: 统一人称代词、规范专业术语等\n\n注意: 使用小型LLM模型时建议控制文稿在1千字内。对于不同字幕文件,请使用与该字幕相关的文稿提示。</source>\n        <translation>Please enter script prompt (to assist subtitle correction and translation)\n\nSupports the following content:\n1. Glossary - Correction reference table for technical terms, names, and specific words\nExample:\nMachine Learning-&gt;Machine Learning\nElon Musk-&gt;Elon Musk\n打call-&gt;应援\n\n2. Original script - Original script or related content of the video\nExample: Complete speech script, course handouts, etc.\n\n3. Correction requirements - Specific correction requirements related to content\nExample: Unify personal pronouns, standardize technical terms, etc.\n\nNote: 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.</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"913\" />\n        <source>确定</source>\n        <translation>OK</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"914\" />\n        <source>取消</source>\n        <translation>Cancel</translation>\n    </message>\n</context>\n<context>\n    <name>SettingInterface</name>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"46\" />\n        <source>设置</source>\n        <translation>Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"62\" />\n        <source>转录配置</source>\n        <translation>Transcription Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"64\" />\n        <source>LLM配置</source>\n        <translation>LLM Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"491\" />\n        <source>翻译服务</source>\n        <translation>Translation Service</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"70\" />\n        <source>翻译与优化</source>\n        <translation>Translation &amp; Optimization</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"72\" />\n        <source>字幕合成配置</source>\n        <translation>Video Synthesis Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"76\" />\n        <source>保存配置</source>\n        <translation>Save Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"78\" />\n        <source>个性化</source>\n        <translation>Personalization</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\" />\n        <source>关于</source>\n        <translation>About</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"95\" />\n        <source>字幕校正</source>\n        <translation>Subtitle Correction</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"95\" />\n        <source>字幕处理过程是否对生成的字幕错别字、名词等进行校正</source>\n        <translation>Whether to correct typos and nouns in generated subtitles during processing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"102\" />\n        <source>字幕翻译</source>\n        <translation>Subtitle Translation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"102\" />\n        <source>字幕处理过程是否对生成的字幕进行翻译</source>\n        <translation>Whether to translate generated subtitles during processing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"109\" />\n        <source>目标语言</source>\n        <translation>Target Language</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"109\" />\n        <source>选择翻译字幕的目标语言</source>\n        <translation>Select target language for subtitle translation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\" />\n        <source>修改</source>\n        <translation>Edit</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"119\" />\n        <source>字幕样式</source>\n        <translation>Subtitle Style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"119\" />\n        <source>选择字幕的样式（颜色、大小、字体等）</source>\n        <translation>Select subtitle style (color, size, font, etc.)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\" />\n        <source>字幕布局</source>\n        <translation>Subtitle Layout</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\" />\n        <source>选择字幕的布局（单语、双语）</source>\n        <translation>Select subtitle layout (monolingual, bilingual)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"135\" />\n        <source>需要合成视频</source>\n        <translation>Require Video Synthesis</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"135\" />\n        <source>开启时触发合成视频，关闭时跳过</source>\n        <translation>Enable to trigger video synthesis, disable to skip</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"142\" />\n        <source>软字幕</source>\n        <translation>Soft Subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"142\" />\n        <source>开启时字幕可在播放器中关闭或调整，关闭时字幕烧录到视频画面上</source>\n        <translation>When enabled, subtitles can be turned off or adjusted in the player; when disabled, subtitles are burned into the video</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"149\" />\n        <source>视频合成质量</source>\n        <translation>Video Synthesis Quality</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"149\" />\n        <source>硬字幕视频合成时的质量等级（质量越高文件越大，编码时间越长）</source>\n        <translation>Quality level for hard subtitle video synthesis (higher quality means larger file size and longer encoding time)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"159\" />\n        <source>工作文件夹</source>\n        <translation>Work Folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"159\" />\n        <source>工作目录路径</source>\n        <translation>Work directory path</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"168\" />\n        <source>启用缓存</source>\n        <translation>Enable Cache</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"168\" />\n        <source>相同配置下会复用之前的 ASR 和 LLM 结果；关闭缓存后每次重新生成</source>\n        <translation>Reuse previous ASR and LLM results under the same configuration; regenerate each time after disabling cache</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\" />\n        <source>应用主题</source>\n        <translation>Application Theme</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\" />\n        <source>更改应用程序的外观</source>\n        <translation>Change the appearance of the application</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\" />\n        <source>浅色</source>\n        <translation>Light</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\" />\n        <source>深色</source>\n        <translation>Dark</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\" />\n        <source>使用系统设置</source>\n        <translation>Use System Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"183\" />\n        <source>主题颜色</source>\n        <translation>Theme Color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"183\" />\n        <source>更改应用程序的主题颜色</source>\n        <translation>Change the theme color of the application</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"190\" />\n        <source>界面缩放</source>\n        <translation>Interface Scale</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"190\" />\n        <source>更改小部件和字体的大小</source>\n        <translation>Change the size of widgets and fonts</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\" />\n        <source>语言</source>\n        <translation>Language</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\" />\n        <source>设置您偏好的界面语言</source>\n        <translation>Set your preferred interface language</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\" />\n        <source>打开帮助页面</source>\n        <translation>Open Help Page</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\" />\n        <source>帮助</source>\n        <translation>Help</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\" />\n        <source>发现新功能并了解有关VideoCaptioner的使用技巧</source>\n        <translation>Discover new features and learn tips for using VideoCaptioner</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"216\" />\n        <source>提供反馈</source>\n        <translation>Provide Feedback</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"216\" />\n        <source>提供反馈帮助我们改进VideoCaptioner</source>\n        <translation>Provide feedback to help us improve VideoCaptioner</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\" />\n        <source>检查更新</source>\n        <translation>Check for Updates</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\" />\n        <source>版权所有</source>\n        <translation>Copyright</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\" />\n        <source>版本</source>\n        <translation>Version</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"262\" />\n        <source>LLM服务</source>\n        <translation>LLM Service</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"262\" />\n        <source>选择大模型服务，用于字幕断句、字幕优化、字幕翻译</source>\n        <translation>Select LLM service for subtitle segmentation, optimization, and translation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\" />\n        <source>访问</source>\n        <translation>Visit</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\" />\n        <source>VideoCaptioner 官方API</source>\n        <translation>VideoCaptioner Official API</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\" />\n        <source>集成多种大语言模型，支持高并发字幕优化、翻译</source>\n        <translation>Integrates multiple LLMs, supports high-concurrency subtitle optimization and translation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"365\" />\n        <source>API Key</source>\n        <translation>API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"365\" />\n        <source>输入您的 {service.value} API Key</source>\n        <translation>Enter your {service.value} API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"376\" />\n        <source>Base URL</source>\n        <translation>Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"376\" />\n        <source>输入 {service.value} Base URL</source>\n        <translation>Enter {service.value} Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"395\" />\n        <source>模型</source>\n        <translation>Model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"395\" />\n        <source>选择 {service.value} 模型</source>\n        <translation>Select {service.value} model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"781\" />\n        <source>检查连接</source>\n        <translation>Check Connection</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"416\" />\n        <source>检查 LLM 连接</source>\n        <translation>Check LLM Connection</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"416\" />\n        <source>点击检查 API 连接是否正常，并获取模型列表</source>\n        <translation>Click to check if API connection is normal and get model list</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"430\" />\n        <source>转录模型</source>\n        <translation>Transcription Model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"430\" />\n        <source>语音转换文字要使用的语音识别服务</source>\n        <translation>Speech recognition service to use for speech-to-text conversion</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"441\" />\n        <source>Whisper API Base URL</source>\n        <translation>Whisper API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"441\" />\n        <source>输入 Whisper API Base URL</source>\n        <translation>Enter Whisper API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"451\" />\n        <source>Whisper API Key</source>\n        <translation>Whisper API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"451\" />\n        <source>输入 Whisper API Key</source>\n        <translation>Enter Whisper API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"461\" />\n        <source>Whisper 模型</source>\n        <translation>Whisper Model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"461\" />\n        <source>选择 Whisper 模型</source>\n        <translation>Select Whisper model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"975\" />\n        <source>测试 Whisper 连接</source>\n        <translation>Test Whisper Connection</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"474\" />\n        <source>测试 Whisper API 连接</source>\n        <translation>Test Whisper API Connection</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"474\" />\n        <source>点击测试 API 连接是否正常</source>\n        <translation>Click to test if API connection is normal</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"491\" />\n        <source>选择翻译服务</source>\n        <translation>Select Translation Service</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"505\" />\n        <source>需要反思翻译</source>\n        <translation>Require Reflection Translation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"505\" />\n        <source>启用反思翻译可以提高翻译质量，但耗费更多时间和token</source>\n        <translation>Enabling reflection translation can improve translation quality but consumes more time and tokens</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"514\" />\n        <source>DeepLx 后端</source>\n        <translation>DeepLx Backend</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"514\" />\n        <source>输入 DeepLx 的后端地址(开启deeplx翻译时必填)</source>\n        <translation>Enter DeepLx backend address (required when enabling DeepLx translation)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"524\" />\n        <source>批处理大小</source>\n        <translation>Batch Size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"524\" />\n        <source>每批处理字幕的数量，建议为 10 的倍数</source>\n        <translation>Number of subtitles processed per batch, recommended to be a multiple of 10</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"533\" />\n        <source>线程数</source>\n        <translation>Thread Count</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"533\" />\n        <source>请求并行处理的数量，模型服务商允许的情况下建议尽可能大，数值越大速度越快</source>\n        <translation>Number of parallel processing requests, recommended to be as large as possible if the model provider allows, larger values mean faster speed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"695\" />\n        <source>更新成功</source>\n        <translation>Update successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"695\" />\n        <source>配置将在重启后生效</source>\n        <translation>Configuration will take effect after restart</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"704\" />\n        <source>选择文件夹</source>\n        <translation>Select Folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"716\" />\n        <source>缓存已启用</source>\n        <translation>Cache enabled</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"716\" />\n        <source>ASR、翻译等操作将优先使用缓存</source>\n        <translation>ASR, translation and other operations will prioritize using cache</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"724\" />\n        <source>缓存已禁用</source>\n        <translation>Cache disabled</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"724\" />\n        <source>所有操作将重新生成，不使用缓存（建议开启缓存）</source>\n        <translation>All operations will regenerate without using cache (recommended to enable cache)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"759\" />\n        <source>正在检查...</source>\n        <translation>Checking...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"801\" />\n        <source>LLM 连接测试错误</source>\n        <translation>LLM Connection Test Error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\" />\n        <source>获取模型列表成功:</source>\n        <translation>Successfully retrieved model list:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\" />\n        <source>一共</source>\n        <translation>Total</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\" />\n        <source>个模型</source>\n        <translation>models</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"808\" />\n        <source>LLM 连接测试成功</source>\n        <translation>LLM connection test successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"928\" />\n        <source>配置不完整</source>\n        <translation>Configuration incomplete</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"910\" />\n        <source>请输入 Whisper API Base URL</source>\n        <translation>Please enter Whisper API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"919\" />\n        <source>请输入 Whisper API Key</source>\n        <translation>Please enter Whisper API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"928\" />\n        <source>请输入 Whisper 模型名称</source>\n        <translation>Please enter Whisper model name</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"938\" />\n        <source>正在测试...</source>\n        <translation>Testing...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"957\" />\n        <source>连接成功</source>\n        <translation>Connection successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"957\" />\n        <source>Whisper API 连接成功！\n转录结果:</source>\n        <translation>Whisper API connection successful!</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"964\" />\n        <source>连接失败</source>\n        <translation>Connection failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"964\" />\n        <source>Whisper API 连接失败！\n{result}</source>\n        <translation>Whisper API connection failed!</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"977\" />\n        <source>测试错误</source>\n        <translation>Test error</translation>\n    </message>\n</context>\n<context>\n    <name>StyleNameDialog</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"765\" />\n        <source>新建样式</source>\n        <translation>Create new style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"768\" />\n        <source>输入样式名称</source>\n        <translation>Enter style name</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"776\" />\n        <source>确定</source>\n        <translation>OK</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"777\" />\n        <source>取消</source>\n        <translation>Cancel</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleInterface</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"251\" />\n        <source>保存</source>\n        <translation>Save</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"257\" />\n        <source>字幕排布</source>\n        <translation>Subtitle layout</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"277\" />\n        <source>字幕校正</source>\n        <translation>Subtitle Correction</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"286\" />\n        <source>字幕翻译</source>\n        <translation>Subtitle Translation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"295\" />\n        <source>翻译语言</source>\n        <translation>Translate Language</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"317\" />\n        <source>文稿提示</source>\n        <translation>Script Prompt</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"343\" />\n        <source>开始</source>\n        <translation>Start</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"389\" />\n        <source>请拖入字幕文件</source>\n        <translation>Please drag in the subtitle file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"394\" />\n        <source>取消</source>\n        <translation>Cancel</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"628\" />\n        <source>已加载文件</source>\n        <translation>File loaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"603\" />\n        <source>警告</source>\n        <translation>Warning</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"603\" />\n        <source>请先加载字幕文件</source>\n        <translation>Please load the subtitle file first</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"482\" />\n        <source>开始优化</source>\n        <translation>Start Optimization</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"482\" />\n        <source>开始优化字幕</source>\n        <translation>Start optimizing subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"501\" />\n        <source>优化完成</source>\n        <translation>Optimization complete</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"501\" />\n        <source>优化完成字幕...</source>\n        <translation>Optimized subtitles complete...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"513\" />\n        <source>优化失败</source>\n        <translation>Optimization failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"547\" />\n        <source>选择字幕文件</source>\n        <translation>Select subtitle file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"567\" />\n        <source>保存字幕文件</source>\n        <translation>Save subtitle file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"586\" />\n        <source>保存成功</source>\n        <translation>Save successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"586\" />\n        <source>字幕已保存至:</source>\n        <translation>Subtitles saved to:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"593\" />\n        <source>保存失败</source>\n        <translation>Save failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"593\" />\n        <source>保存字幕文件失败: </source>\n        <translation>Failed to save subtitle file: </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"647\" />\n        <source>导入成功</source>\n        <translation>Import successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"647\" />\n        <source>成功导入</source>\n        <translation>Successfully imported</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"656\" />\n        <source>格式错误</source>\n        <translation>Format error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"656\" />\n        <source>支持的字幕格式:</source>\n        <translation>Supported subtitle formats:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"742\" />\n        <source>合并</source>\n        <translation>Merge</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"814\" />\n        <source>合并成功</source>\n        <translation>Merge successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"814\" />\n        <source>已成功合并选中的字幕行</source>\n        <translation>Successfully merged selected subtitle lines</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"841\" />\n        <source>已取消校正</source>\n        <translation>Correction canceled</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"842\" />\n        <source>已取消</source>\n        <translation>Canceled</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"842\" />\n        <source>字幕校正已取消</source>\n        <translation>Subtitle correction has been canceled</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitlePipelineThread</name>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"50\" />\n        <source>开始转录</source>\n        <translation>Start transcription</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"74\" />\n        <source>开始优化字幕</source>\n        <translation>Start optimizing subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"100\" />\n        <source>开始合成视频</source>\n        <translation>Start synthesizing video</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"125\" />\n        <source>处理完成</source>\n        <translation>Processing Complete</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleSettingDialog</name>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"68\" />\n        <source>字幕设置</source>\n        <translation>Subtitle Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"21\" />\n        <source>字幕分割</source>\n        <translation>Subtitle Segmentation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"21\" />\n        <source>字幕是否使用大语言模型进行智能断句</source>\n        <translation>Use LLM for intelligent segmentation of subtitles?</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"29\" />\n        <source>中文最大字数</source>\n        <translation>Maximum Chinese Characters</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"29\" />\n        <source>单条字幕的最大字数 (对于中日韩等字符)</source>\n        <translation>Maximum Characters per Subtitle (for CJK characters)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"39\" />\n        <source>英文最大单词数</source>\n        <translation>Maximum English Words</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"39\" />\n        <source>单条字幕的最大单词数 (英文)</source>\n        <translation>Maximum Words per Subtitle (English)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"49\" />\n        <source>去除末尾标点符号</source>\n        <translation>Remove Trailing Punctuation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"49\" />\n        <source>是否去除中文字幕中的末尾标点符号</source>\n        <translation>Remove trailing punctuation in Chinese subtitles?</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"72\" />\n        <source>关闭</source>\n        <translation>Close</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleStyleInterface</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"97\" />\n        <source>字幕样式配置</source>\n        <translation>Subtitle Style Configuration</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"186\" />\n        <source>字幕排布</source>\n        <translation>Subtitle Layout</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"129\" />\n        <source>主字幕样式</source>\n        <translation>Main Subtitle Style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"130\" />\n        <source>副字幕样式</source>\n        <translation>Secondary Subtitle Style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"131\" />\n        <source>预览设置</source>\n        <translation>Preview Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"144\" />\n        <source>预览效果</source>\n        <translation>Preview Effect</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"154\" />\n        <source>选择样式</source>\n        <translation>Select Style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"154\" />\n        <source>选择已保存的字幕样式</source>\n        <translation>Select Saved Subtitle Style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"161\" />\n        <source>新建样式</source>\n        <translation>Create New Style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"161\" />\n        <source>基于当前样式新建预设</source>\n        <translation>Create Preset Based on Current Style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"168\" />\n        <source>打开样式文件夹</source>\n        <translation>Open Style Folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"168\" />\n        <source>在文件管理器中打开样式文件夹</source>\n        <translation>Open Style Folder in File Manager</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"186\" />\n        <source>设置主字幕和副字幕的显示方式</source>\n        <translation>Set Display Method for Main and Secondary Subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"194\" />\n        <source>垂直间距</source>\n        <translation>Vertical Spacing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"194\" />\n        <source>设置字幕的垂直间距</source>\n        <translation>Set Subtitle Vertical Spacing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"203\" />\n        <source>主字幕字体</source>\n        <translation>Main Subtitle Font</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"203\" />\n        <source>设置主字幕的字体</source>\n        <translation>Set Main Subtitle Font</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"210\" />\n        <source>主字幕字号</source>\n        <translation>Main Subtitle Size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"210\" />\n        <source>设置主字幕的大小</source>\n        <translation>Set Main Subtitle Size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"218\" />\n        <source>主字幕间距</source>\n        <translation>Main Subtitle Spacing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"218\" />\n        <source>设置主字幕的字符间距</source>\n        <translation>Set main subtitle character spacing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"227\" />\n        <source>主字幕颜色</source>\n        <translation>Main subtitle color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"227\" />\n        <source>设置主字幕的颜色</source>\n        <translation>Set main subtitle color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"234\" />\n        <source>主字幕边框颜色</source>\n        <translation>Main subtitle border color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"234\" />\n        <source>设置主字幕的边框颜色</source>\n        <translation>Set main subtitle border color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"241\" />\n        <source>主字幕边框大小</source>\n        <translation>Main subtitle border size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"241\" />\n        <source>设置主字幕的边框粗细</source>\n        <translation>Set main subtitle border thickness</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"251\" />\n        <source>副字幕字体</source>\n        <translation>Secondary subtitle font</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"251\" />\n        <source>设置副字幕的字体</source>\n        <translation>Set secondary subtitle font</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"258\" />\n        <source>副字幕字号</source>\n        <translation>Secondary subtitle font size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"258\" />\n        <source>设置副字幕的大小</source>\n        <translation>Set secondary subtitle size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"266\" />\n        <source>副字幕间距</source>\n        <translation>Secondary subtitle spacing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"266\" />\n        <source>设置副字幕的字符间距</source>\n        <translation>Set secondary subtitle character spacing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"275\" />\n        <source>副字幕颜色</source>\n        <translation>Secondary subtitle color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"275\" />\n        <source>设置副字幕的颜色</source>\n        <translation>Set secondary subtitle color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"282\" />\n        <source>副字幕边框颜色</source>\n        <translation>Subtitle Border Color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"282\" />\n        <source>设置副字幕的边框颜色</source>\n        <translation>Set Subtitle Border Color</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"289\" />\n        <source>副字幕边框大小</source>\n        <translation>Subtitle Border Size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"289\" />\n        <source>设置副字幕的边框粗细</source>\n        <translation>Set Subtitle Border Thickness</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"299\" />\n        <source>预览文字</source>\n        <translation>Preview Text</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"299\" />\n        <source>设置预览显示的文字内容</source>\n        <translation>Set Preview Text Content</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"307\" />\n        <source>预览方向</source>\n        <translation>Preview Direction</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"307\" />\n        <source>设置预览图片的显示方向</source>\n        <translation>Set Preview Image Orientation</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\" />\n        <source>选择图片</source>\n        <translation>Select Image</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\" />\n        <source>预览背景</source>\n        <translation>Preview Background</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\" />\n        <source>选择预览使用的背景图片</source>\n        <translation>Select Background Image for Preview</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"486\" />\n        <source>选择背景图片</source>\n        <translation>Select Background Image</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"486\" />\n        <source>图片文件</source>\n        <translation>Image File</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"734\" />\n        <source>成功</source>\n        <translation>Success</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"695\" />\n        <source>已加载样式 </source>\n        <translation>Style Loaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\" />\n        <source>警告</source>\n        <translation>Warning</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\" />\n        <source>样式 </source>\n        <translation>Style </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\" />\n        <source> 已存在</source>\n        <translation> already exists</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"734\" />\n        <source>已创建新样式 </source>\n        <translation> New style created </translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleTableModel</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"143\" />\n        <source>开始时间</source>\n        <translation>Start Time</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"144\" />\n        <source>结束时间</source>\n        <translation>End Time</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"145\" />\n        <source>字幕内容</source>\n        <translation>Subtitle Text</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"146\" />\n        <source>翻译字幕</source>\n        <translation>Translate Subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"146\" />\n        <source>优化字幕</source>\n        <translation>Optimize Subtitles</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleThread</name>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"69\" />\n        <source>LLM API 未配置, 请检查LLM配置</source>\n        <translation>LLM API not configured, please check LLM settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"85\" />\n        <source>字幕文件路径为空</source>\n        <translation>Subtitle file path is empty</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"88\" />\n        <source>字幕配置为空</source>\n        <translation>Subtitle settings are empty</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"99\" />\n        <source>开始验证 LLM 配置...</source>\n        <translation>Validating LLM settings...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"104\" />\n        <source>字幕断句...</source>\n        <translation>Segmenting subtitles...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"121\" />\n        <source>优化字幕...</source>\n        <translation>Optimizing subtitles...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"148\" />\n        <source>LLM 模型未配置</source>\n        <translation>LLM model not configured</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"138\" />\n        <source>翻译字幕...</source>\n        <translation>Translating subtitles...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"144\" />\n        <source>目标语言未配置</source>\n        <translation>Target language not configured</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"185\" />\n        <source>不支持的翻译服务: {translator_service}</source>\n        <translation>Unsupported translation service: {translator_service}</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"238\" />\n        <source>优化完成</source>\n        <translation>Optimization complete</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"245\" />\n        <source>字幕处理失败</source>\n        <translation>Subtitle processing failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"268\" />\n        <source>{0}% 处理字幕</source>\n        <translation>{0}% processing subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"295\" />\n        <source>已终止</source>\n        <translation>Terminated</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"299\" />\n        <source>终止时发生错误</source>\n        <translation>An error occurred during termination</translation>\n    </message>\n</context>\n<context>\n    <name>TaskCreationInterface</name>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"97\" />\n        <source>请拖拽文件或输入视频URL</source>\n        <translation>Please drag files or enter video URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"148\" />\n        <source>准备就绪</source>\n        <translation>Ready</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"167\" />\n        <source>查看日志</source>\n        <translation>View logs</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"180\" />\n        <source>捐助</source>\n        <translation>Donate</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"193\" />\n        <source>©VideoCaptioner {VERSION} • By Weifeng</source>\n        <translation>©VideoCaptioner {VERSION} • By Weifeng</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"230\" />\n        <source>选择媒体文件</source>\n        <translation>Select media file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"265\" />\n        <source>导入成功</source>\n        <translation>Import successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"265\" />\n        <source>导入媒体文件成功</source>\n        <translation>Media file import successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"273\" />\n        <source>格式错误</source>\n        <translation>Format error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"273\" />\n        <source>不支持该文件格式</source>\n        <translation>Unsupported file format</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"387\" />\n        <source>错误</source>\n        <translation>Error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"287\" />\n        <source>请输入有效的文件路径或视频URL</source>\n        <translation>Please enter a valid file path or video URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"308\" />\n        <source>警告</source>\n        <translation>Warning</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"308\" />\n        <source>建议根据文档配置cookies.txt文件，以可以下载高清视频</source>\n        <translation>It is recommended to configure the cookies.txt file as per the documentation to download HD videos</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"322\" />\n        <source>开始下载</source>\n        <translation>Start download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"322\" />\n        <source>开始下载视频...</source>\n        <translation>Starting video download...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"333\" />\n        <source>下载成功</source>\n        <translation>Download successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"333\" />\n        <source>视频下载完成，开始自动处理...</source>\n        <translation>Video download complete, starting automatic processing...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"341\" />\n        <source>视频下载失败</source>\n        <translation>Video download failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"387\" />\n        <source>请输入音视频文件路径或URL</source>\n        <translation>Please enter the audio/video file path or URL</translation>\n    </message>\n</context>\n<context>\n    <name>TranscriptThread</name>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"40\" />\n        <source>转录失败</source>\n        <translation>Transcription failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"45\" />\n        <source>文件路径为空</source>\n        <translation>File path is empty</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"50\" />\n        <source>视频文件不存在</source>\n        <translation>Video file does not exist</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"53\" />\n        <source>转录配置为空</source>\n        <translation>Transcription configuration is empty</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"56\" />\n        <source>输出路径为空</source>\n        <translation>Output path is empty</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"74\" />\n        <source>字幕已下载</source>\n        <translation>Subtitles downloaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"86\" />\n        <source>转换音频中</source>\n        <translation>Converting audio</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"103\" />\n        <source>音频转换失败</source>\n        <translation>Audio conversion failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"105\" />\n        <source>语音转录中</source>\n        <translation>Transcribing speech</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"121\" />\n        <source>转录完成</source>\n        <translation>Transcription completed</translation>\n    </message>\n</context>\n<context>\n    <name>TranscriptionInterface</name>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"397\" />\n        <source>打开文件</source>\n        <translation>Open file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"404\" />\n        <source>转录模型</source>\n        <translation>Transcription Model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"473\" />\n        <source>转录完成</source>\n        <translation>Transcription completed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"473\" />\n        <source>开始字幕优化...</source>\n        <translation>Starting subtitle optimization...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"490\" />\n        <source>选择媒体文件</source>\n        <translation>Select media file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"506\" />\n        <source>错误</source>\n        <translation>Error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"531\" />\n        <source>警告</source>\n        <translation>Warning</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"531\" />\n        <source>正在处理中，请等待当前任务完成</source>\n        <translation>Processing, please wait for the current task to complete</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"554\" />\n        <source>导入成功</source>\n        <translation>Import successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"554\" />\n        <source>开始语音转文字</source>\n        <translation>Starting speech to text</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"562\" />\n        <source>格式错误</source>\n        <translation>Format Error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"562\" />\n        <source>请拖入音频或视频文件</source>\n        <translation>Please drag audio or video files here</translation>\n    </message>\n</context>\n<context>\n    <name>VideoInfoCard</name>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"103\" />\n        <source>请拖入音频或视频文件</source>\n        <translation>Please drag audio or video files here</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"111\" />\n        <source>画质</source>\n        <translation>Video Quality</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"112\" />\n        <source>文件大小</source>\n        <translation>File Size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"113\" />\n        <source>时长</source>\n        <translation>Duration</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"191\" />\n        <source>音轨</source>\n        <translation>Audio Track</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"141\" />\n        <source>打开文件夹</source>\n        <translation>Open Folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"345\" />\n        <source>开始转录</source>\n        <translation>Start Transcription</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"159\" />\n        <source>画质: </source>\n        <translation>Video Quality: </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"163\" />\n        <source>大小: </source>\n        <translation>Size: </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"165\" />\n        <source>时长: </source>\n        <translation>Duration: </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"292\" />\n        <source>警告</source>\n        <translation>Warning</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"292\" />\n        <source>没有可用的字幕文件夹</source>\n        <translation>No available subtitle folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"327\" />\n        <source>重新转录</source>\n        <translation>Re-transcribe</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"329\" />\n        <source>转录失败</source>\n        <translation>Transcription failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"339\" />\n        <source>转录完成</source>\n        <translation>Transcription completed</translation>\n    </message>\n</context>\n<context>\n    <name>VideoSynthesisInterface</name>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"81\" />\n        <source>开始合成</source>\n        <translation>Start synthesis</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"98\" />\n        <source>字幕文件</source>\n        <translation>Subtitle file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"100\" />\n        <source>选择或者拖拽字幕文件</source>\n        <translation>Select or drag subtitle file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"115\" />\n        <source>浏览</source>\n        <translation>Browse</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"111\" />\n        <source>视频文件</source>\n        <translation>Video file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"113\" />\n        <source>选择或者拖拽视频文件</source>\n        <translation>Select or drag video file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"128\" />\n        <source>就绪</source>\n        <translation>Ready</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"138\" />\n        <source>软字幕</source>\n        <translation>Soft Subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"144\" />\n        <source>使用软字幕嵌入视频</source>\n        <translation>Embed soft subtitles in video</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"151\" />\n        <source>视频质量</source>\n        <translation>Video quality</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"170\" />\n        <source>合成视频</source>\n        <translation>Synthesize video</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"176\" />\n        <source>是否生成新的视频文件</source>\n        <translation>Generate a new video file?</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"183\" />\n        <source>打开输出文件夹</source>\n        <translation>Open output folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"344\" />\n        <source>选择视频文件</source>\n        <translation>Select video file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"261\" />\n        <source>开启软字幕</source>\n        <translation>Enable soft subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"261\" />\n        <source>字幕作为独立轨道嵌入视频，播放器中可关闭或调整</source>\n        <translation>Subtitles are embedded as a separate track in the video, which can be turned off or adjusted in the player</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"269\" />\n        <source>开启硬烧录字幕</source>\n        <translation>Enable hardcoded subtitles</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"269\" />\n        <source>字幕直接烧录到视频画面中，带有设置的样式</source>\n        <translation>Subtitles are directly burned into the video frame with the specified style</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"287\" />\n        <source>开启视频合成</source>\n        <translation>Enable video composition</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"287\" />\n        <source>将进行视频与字幕的合成操作</source>\n        <translation>Video and subtitles will be composed together</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"295\" />\n        <source>关闭视频合成</source>\n        <translation>Disable video composition</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"295\" />\n        <source>仅生成字幕文件，不生成新的视频文件</source>\n        <translation>Only generate subtitle file, no new video file will be created</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"333\" />\n        <source>选择字幕文件</source>\n        <translation>Select subtitle file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"413\" />\n        <source>错误</source>\n        <translation>Error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"354\" />\n        <source>请选择字幕文件和视频文件</source>\n        <translation>Please select a subtitle file and a video file</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"398\" />\n        <source>成功</source>\n        <translation>Success</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"398\" />\n        <source>视频合成已完成</source>\n        <translation>Video composition completed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"436\" />\n        <source>警告</source>\n        <translation>Warning</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"436\" />\n        <source>没有可用的视频文件夹</source>\n        <translation>No available video folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"469\" />\n        <source>导入成功</source>\n        <translation>Import successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"460\" />\n        <source>字幕文件已放入输入框</source>\n        <translation>Subtitle file has been placed in the input box</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"469\" />\n        <source>视频文件已输入框</source>\n        <translation>Video file has been placed in the input box</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"477\" />\n        <source>格式错误</source>\n        <translation>Format error</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"477\" />\n        <source>请拖入视频或者字幕文件</source>\n        <translation>Please drag in a video or subtitle file</translation>\n    </message>\n</context>\n<context>\n    <name>VideoSynthesisThread</name>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"63\" />\n        <source>合成完成</source>\n        <translation>Synthesis completed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"39\" />\n        <source>正在合成</source>\n        <translation>Synthesizing</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"42\" />\n        <source>视频路径为空</source>\n        <translation>Video path is empty</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"44\" />\n        <source>字幕路径为空</source>\n        <translation>Subtitle path is empty</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"46\" />\n        <source>输出路径为空</source>\n        <translation>Output path is empty</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"70\" />\n        <source>视频合成失败</source>\n        <translation>Video synthesis failed</translation>\n    </message>\n</context>\n<context>\n    <name>WhisperAPISettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"42\" />\n        <source>Whisper API 设置</source>\n        <translation>Whisper API Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"45\" />\n        <source>API Base URL</source>\n        <translation>API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"45\" />\n        <source>输入 Whisper API Base URL</source>\n        <translation>Enter Whisper API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"55\" />\n        <source>API Key</source>\n        <translation>API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"55\" />\n        <source>输入 Whisper API Key</source>\n        <translation>Enter Whisper API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"65\" />\n        <source>Whisper 模型</source>\n        <translation>Whisper Model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"65\" />\n        <source>选择 Whisper 模型</source>\n        <translation>Select Whisper model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"75\" />\n        <source>原语言</source>\n        <translation>Source Language</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"75\" />\n        <source>音频的原语言</source>\n        <translation>Original language of the audio</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"85\" />\n        <source>提示词</source>\n        <translation>Prompt</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"85\" />\n        <source>可选的提示词,默认空</source>\n        <translation>Optional prompt, empty by default</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"187\" />\n        <source>测试连接</source>\n        <translation>Test connection</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"95\" />\n        <source>测试 Whisper API 连接</source>\n        <translation>Test Whisper API Connection</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"95\" />\n        <source>点击测试 API 连接是否正常</source>\n        <translation>Click to test if API connection is normal</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"141\" />\n        <source>配置不完整</source>\n        <translation>Configuration is incomplete</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"141\" />\n        <source>请输入 API Base URL、API Key 和 model</source>\n        <translation>Please enter API Base URL, API Key, and model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"152\" />\n        <source>正在测试...</source>\n        <translation>Testing...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"167\" />\n        <source>连接成功</source>\n        <translation>Connection successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"167\" />\n        <source>Whisper API 连接成功！</source>\n        <translation>Whisper API connection successful!</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"175\" />\n        <source>连接失败</source>\n        <translation>Connection failed</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"175\" />\n        <source>Whisper API 连接失败！\n{result}</source>\n        <translation>Whisper API connection failed!</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"188\" />\n        <source>测试错误</source>\n        <translation>Test error</translation>\n    </message>\n</context>\n<context>\n    <name>WhisperCppDownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"242\" />\n        <source>关闭</source>\n        <translation>Close</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"248\" />\n        <source>WhisperCpp程序</source>\n        <translation>WhisperCpp program</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"258\" />\n        <source>已安装版本: {versions_text}</source>\n        <translation>Installed version: {versions_text}</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"262\" />\n        <source>未下载 WhisperCpp 程序</source>\n        <translation>WhisperCpp program not downloaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"271\" />\n        <source>模型下载</source>\n        <translation>Model download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"275\" />\n        <source>打开模型文件夹</source>\n        <translation>Open model folder</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\" />\n        <source>模型名称</source>\n        <translation>Model name</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\" />\n        <source>大小</source>\n        <translation>Size</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\" />\n        <source>状态</source>\n        <translation>Status</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\" />\n        <source>操作</source>\n        <translation>Action</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"416\" />\n        <source>已下载</source>\n        <translation>Downloaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"357\" />\n        <source>未下载</source>\n        <translation>Not Downloaded</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"423\" />\n        <source>重新下载</source>\n        <translation>Re-download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"370\" />\n        <source>下载</source>\n        <translation>Download</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"386\" />\n        <source>下载进行中</source>\n        <translation>Downloading in progress</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"386\" />\n        <source>请等待当前下载任务完成</source>\n        <translation>Please wait for the current download task to complete.</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"400\" />\n        <source>正在下载 {model['label']} 模型...</source>\n        <translation>Downloading {model['label']} model...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"462\" />\n        <source>下载成功</source>\n        <translation>Download successful</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"462\" />\n        <source>{model['label']} 模型已下载完成</source>\n        <translation>{model['label']} model has been downloaded.</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"477\" />\n        <source>下载失败</source>\n        <translation>Download failed</translation>\n    </message>\n</context>\n<context>\n    <name>WhisperCppSettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"530\" />\n        <source>Whisper CPP 设置</source>\n        <translation>Whisper CPP Settings (unstable 🤔)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"535\" />\n        <source>模型</source>\n        <translation>Model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"535\" />\n        <source>选择Whisper模型</source>\n        <translation>Select Whisper model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"556\" />\n        <source>源语言</source>\n        <translation>Source Language</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"556\" />\n        <source>音频的源语言</source>\n        <translation>Source language of the audio</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\" />\n        <source>管理模型</source>\n        <translation>Manage Models</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\" />\n        <source>模型管理</source>\n        <translation>Model Management</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\" />\n        <source>下载或更新 Whisper CPP 模型</source>\n        <translation>Download or update Whisper CPP model.</translation>\n    </message>\n</context>\n</TS>"
  },
  {
    "path": "resource/translations/VideoCaptioner_zh_CN.qm",
    "content": "<d\u0018!\u001c`"
  },
  {
    "path": "resource/translations/VideoCaptioner_zh_CN.ts",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE TS>\n<TS version=\"2.1\">\n<context>\n    <name>BatchProcessInterface</name>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"50\"/>\n        <source>批量处理</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"73\"/>\n        <source>添加文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"400\"/>\n        <source>开始处理</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"75\"/>\n        <source>清空列表</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>ColorPickerButton</name>\n    <message>\n        <location filename=\"../../app/components/MySettingCard.py\" line=\"319\"/>\n        <source>Choose </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>DonateDialog</name>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"19\"/>\n        <source>支持作者</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"23\"/>\n        <source>感谢支持</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"26\"/>\n        <source>目前本人精力有限，您的支持让我有动力继续折腾这个项目！\n感谢您对开源事业的热爱与支持！</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"48\"/>\n        <source>支付宝</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"64\"/>\n        <source>微信</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"87\"/>\n        <source>关闭</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>DownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"127\"/>\n        <source>下载模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"147\"/>\n        <source>下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"161\"/>\n        <source>关闭</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"170\"/>\n        <source>提示</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"170\"/>\n        <source>模型文件已存在,无需重复下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"193\"/>\n        <source>完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"193\"/>\n        <source>模型下载完成!</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"200\"/>\n        <source>下载完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"203\"/>\n        <source>下载错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>FasterWhisperDownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"203\"/>\n        <source>关闭</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"212\"/>\n        <source>Faster Whisper 下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"216\"/>\n        <source>打开程序文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"231\"/>\n        <source>已安装版本: {versions_text}</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"237\"/>\n        <source>您可以继续下载其他版本:</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"240\"/>\n        <source>未下载Faster Whisper 程序</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"258\"/>\n        <source>下载程序</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"271\"/>\n        <source>模型下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"275\"/>\n        <source>打开模型文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\"/>\n        <source>模型名称</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\"/>\n        <source>大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\"/>\n        <source>状态</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\"/>\n        <source>操作</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"540\"/>\n        <source>已下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"360\"/>\n        <source>未下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"547\"/>\n        <source>重新下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"373\"/>\n        <source>下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"505\"/>\n        <source>下载进行中</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"505\"/>\n        <source>请等待当前下载任务完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"417\"/>\n        <source>下载错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"417\"/>\n        <source>未找到对应的程序配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"465\"/>\n        <source>正在解压文件...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"646\"/>\n        <source>安装失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"597\"/>\n        <source>下载失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"519\"/>\n        <source>正在下载 {model['label']} 模型...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"582\"/>\n        <source>下载成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"582\"/>\n        <source>{model['label']} 模型已下载完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"635\"/>\n        <source>安装完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"635\"/>\n        <source>Faster Whisper 程序已安装成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>FasterWhisperSettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"888\"/>\n        <source>Faster Whisper程序不存在，请先下载程序</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"685\"/>\n        <source>Faster Whisper 设置（✨推荐✨））</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"690\"/>\n        <source>模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"690\"/>\n        <source>选择 Faster Whisper 模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\"/>\n        <source>管理模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\"/>\n        <source>模型管理</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\"/>\n        <source>下载或更新 Faster Whisper 模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"728\"/>\n        <source>源语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"728\"/>\n        <source>音频的源语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"739\"/>\n        <source>运行设备</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"739\"/>\n        <source>模型运行设备</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"752\"/>\n        <source>VAD设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"755\"/>\n        <source>VAD过滤</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"755\"/>\n        <source>过滤无人声语音片断，减少幻觉</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"764\"/>\n        <source>VAD阈值</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"764\"/>\n        <source>语音概率阈值，高于此值视为语音</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"776\"/>\n        <source>VAD方法</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"776\"/>\n        <source>选择VAD检测方法</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"786\"/>\n        <source>其他设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"789\"/>\n        <source>人声分离</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"789\"/>\n        <source>处理前使用MDX-Net降噪，分离人声和背景音乐</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"798\"/>\n        <source>单字时间戳</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"798\"/>\n        <source>开启生成单字级时间戳；关闭后使用原始分段断句</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"807\"/>\n        <source>提示词</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"807\"/>\n        <source>可选的提示词,默认空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"871\"/>\n        <source>错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"902\"/>\n        <source>模型配置不存在</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"909\"/>\n        <source>模型文件不存在: </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>FileDownloadThread</name>\n    <message>\n        <location filename=\"../../app/thread/file_download_thread.py\" line=\"35\"/>\n        <source>正在连接...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>HomeInterface</name>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"36\"/>\n        <source>任务创建</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"39\"/>\n        <source>语音转录</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"42\"/>\n        <source>字幕优化与翻译</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"47\"/>\n        <source>字幕视频合成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>LanguageSettingDialog</name>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"26\"/>\n        <source>确定</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"27\"/>\n        <source>取消</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"32\"/>\n        <source>语言设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"35\"/>\n        <source>源语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"35\"/>\n        <source>音频的源语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"56\"/>\n        <source>设置已保存</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"56\"/>\n        <source>语言设置已更新</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"64\"/>\n        <source>请注意身体！！</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"64\"/>\n        <source>小心肝儿,注意身体哦~</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>MainWindow</name>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"62\"/>\n        <source>主页</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"63\"/>\n        <source>批量处理</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"64\"/>\n        <source>字幕样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"76\"/>\n        <source>Settings</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"98\"/>\n        <source>卡卡字幕助手 -- VideoCaptioner</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"117\"/>\n        <source>GitHub信息</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"117\"/>\n        <source>VideoCaptioner 由本人在课余时间独立开发完成，目前托管在GitHub上，欢迎Star和Fork。项目诚然还有很多地方需要完善，遇到软件的问题或者BUG欢迎提交Issue。\n\n https://github.com/WEIFENG2333/VideoCaptioner</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"124\"/>\n        <source>打开 GitHub</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"125\"/>\n        <source>支持作者</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"152\"/>\n        <source>当前版本部分功能已被禁用。请尽快更新。</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>PromptDialog</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"885\"/>\n        <source>文稿提示</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"889\"/>\n        <source>请输入文稿提示（辅助校正字幕和翻译）\n\n支持以下内容:\n1. 术语表 - 专业术语、人名、特定词语的修正对照表\n示例:\n机器学习-&gt;Machine Learning\n马斯克-&gt;Elon Musk\n打call-&gt;应援\n\n2. 原字幕文稿 - 视频的原有文稿或相关内容\n示例: 完整的演讲稿、课程讲义等\n\n3. 修正要求 - 内容相关的具体修正要求\n示例: 统一人称代词、规范专业术语等\n\n注意: 使用小型LLM模型时建议控制文稿在1千字内。对于不同字幕文件,请使用与该字幕相关的文稿提示。</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"913\"/>\n        <source>确定</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"914\"/>\n        <source>取消</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>SettingInterface</name>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"46\"/>\n        <source>设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"62\"/>\n        <source>转录配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"64\"/>\n        <source>LLM配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"491\"/>\n        <source>翻译服务</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"70\"/>\n        <source>翻译与优化</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"72\"/>\n        <source>字幕合成配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"76\"/>\n        <source>保存配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"78\"/>\n        <source>个性化</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\"/>\n        <source>关于</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"95\"/>\n        <source>字幕校正</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"95\"/>\n        <source>字幕处理过程是否对生成的字幕错别字、名词等进行校正</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"102\"/>\n        <source>字幕翻译</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"102\"/>\n        <source>字幕处理过程是否对生成的字幕进行翻译</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"109\"/>\n        <source>目标语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"109\"/>\n        <source>选择翻译字幕的目标语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\"/>\n        <source>修改</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"119\"/>\n        <source>字幕样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"119\"/>\n        <source>选择字幕的样式（颜色、大小、字体等）</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\"/>\n        <source>字幕布局</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\"/>\n        <source>选择字幕的布局（单语、双语）</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"135\"/>\n        <source>需要合成视频</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"135\"/>\n        <source>开启时触发合成视频，关闭时跳过</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"142\"/>\n        <source>软字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"142\"/>\n        <source>开启时字幕可在播放器中关闭或调整，关闭时字幕烧录到视频画面上</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"149\"/>\n        <source>视频合成质量</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"149\"/>\n        <source>硬字幕视频合成时的质量等级（质量越高文件越大，编码时间越长）</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"159\"/>\n        <source>工作文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"159\"/>\n        <source>工作目录路径</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"168\"/>\n        <source>启用缓存</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"168\"/>\n        <source>相同配置下会复用之前的 ASR 和 LLM 结果；关闭缓存后每次重新生成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\"/>\n        <source>应用主题</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\"/>\n        <source>更改应用程序的外观</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\"/>\n        <source>浅色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\"/>\n        <source>深色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\"/>\n        <source>使用系统设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"183\"/>\n        <source>主题颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"183\"/>\n        <source>更改应用程序的主题颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"190\"/>\n        <source>界面缩放</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"190\"/>\n        <source>更改小部件和字体的大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\"/>\n        <source>语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\"/>\n        <source>设置您偏好的界面语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\"/>\n        <source>打开帮助页面</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\"/>\n        <source>帮助</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\"/>\n        <source>发现新功能并了解有关VideoCaptioner的使用技巧</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"216\"/>\n        <source>提供反馈</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"216\"/>\n        <source>提供反馈帮助我们改进VideoCaptioner</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\"/>\n        <source>检查更新</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\"/>\n        <source>版权所有</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\"/>\n        <source>版本</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"262\"/>\n        <source>LLM服务</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"262\"/>\n        <source>选择大模型服务，用于字幕断句、字幕优化、字幕翻译</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\"/>\n        <source>访问</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\"/>\n        <source>VideoCaptioner 官方API</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\"/>\n        <source>集成多种大语言模型，支持高并发字幕优化、翻译</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"365\"/>\n        <source>API Key</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"365\"/>\n        <source>输入您的 {service.value} API Key</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"376\"/>\n        <source>Base URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"376\"/>\n        <source>输入 {service.value} Base URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"395\"/>\n        <source>模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"395\"/>\n        <source>选择 {service.value} 模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"781\"/>\n        <source>检查连接</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"416\"/>\n        <source>检查 LLM 连接</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"416\"/>\n        <source>点击检查 API 连接是否正常，并获取模型列表</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"430\"/>\n        <source>转录模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"430\"/>\n        <source>语音转换文字要使用的语音识别服务</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"441\"/>\n        <source>Whisper API Base URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"441\"/>\n        <source>输入 Whisper API Base URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"451\"/>\n        <source>Whisper API Key</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"451\"/>\n        <source>输入 Whisper API Key</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"461\"/>\n        <source>Whisper 模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"461\"/>\n        <source>选择 Whisper 模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"975\"/>\n        <source>测试 Whisper 连接</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"474\"/>\n        <source>测试 Whisper API 连接</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"474\"/>\n        <source>点击测试 API 连接是否正常</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"491\"/>\n        <source>选择翻译服务</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"505\"/>\n        <source>需要反思翻译</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"505\"/>\n        <source>启用反思翻译可以提高翻译质量，但耗费更多时间和token</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"514\"/>\n        <source>DeepLx 后端</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"514\"/>\n        <source>输入 DeepLx 的后端地址(开启deeplx翻译时必填)</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"524\"/>\n        <source>批处理大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"524\"/>\n        <source>每批处理字幕的数量，建议为 10 的倍数</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"533\"/>\n        <source>线程数</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"533\"/>\n        <source>请求并行处理的数量，模型服务商允许的情况下建议尽可能大，数值越大速度越快</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"695\"/>\n        <source>更新成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"695\"/>\n        <source>配置将在重启后生效</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"704\"/>\n        <source>选择文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"716\"/>\n        <source>缓存已启用</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"716\"/>\n        <source>ASR、翻译等操作将优先使用缓存</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"724\"/>\n        <source>缓存已禁用</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"724\"/>\n        <source>所有操作将重新生成，不使用缓存（建议开启缓存）</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"759\"/>\n        <source>正在检查...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"801\"/>\n        <source>LLM 连接测试错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\"/>\n        <source>获取模型列表成功:</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\"/>\n        <source>一共</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\"/>\n        <source>个模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"808\"/>\n        <source>LLM 连接测试成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"928\"/>\n        <source>配置不完整</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"910\"/>\n        <source>请输入 Whisper API Base URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"919\"/>\n        <source>请输入 Whisper API Key</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"928\"/>\n        <source>请输入 Whisper 模型名称</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"938\"/>\n        <source>正在测试...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"957\"/>\n        <source>连接成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"957\"/>\n        <source>Whisper API 连接成功！\n转录结果:</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"964\"/>\n        <source>连接失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"964\"/>\n        <source>Whisper API 连接失败！\n{result}</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"977\"/>\n        <source>测试错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>StyleNameDialog</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"765\"/>\n        <source>新建样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"768\"/>\n        <source>输入样式名称</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"776\"/>\n        <source>确定</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"777\"/>\n        <source>取消</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleInterface</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"251\"/>\n        <source>保存</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"257\"/>\n        <source>字幕排布</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"277\"/>\n        <source>字幕校正</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"286\"/>\n        <source>字幕翻译</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"295\"/>\n        <source>翻译语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"317\"/>\n        <source>文稿提示</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"343\"/>\n        <source>开始</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"389\"/>\n        <source>请拖入字幕文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"394\"/>\n        <source>取消</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"628\"/>\n        <source>已加载文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"603\"/>\n        <source>警告</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"603\"/>\n        <source>请先加载字幕文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"482\"/>\n        <source>开始优化</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"482\"/>\n        <source>开始优化字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"501\"/>\n        <source>优化完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"501\"/>\n        <source>优化完成字幕...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"513\"/>\n        <source>优化失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"547\"/>\n        <source>选择字幕文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"567\"/>\n        <source>保存字幕文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"586\"/>\n        <source>保存成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"586\"/>\n        <source>字幕已保存至:</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"593\"/>\n        <source>保存失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"593\"/>\n        <source>保存字幕文件失败: </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"647\"/>\n        <source>导入成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"647\"/>\n        <source>成功导入</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"656\"/>\n        <source>格式错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"656\"/>\n        <source>支持的字幕格式:</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"742\"/>\n        <source>合并</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"814\"/>\n        <source>合并成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"814\"/>\n        <source>已成功合并选中的字幕行</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"841\"/>\n        <source>已取消校正</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"842\"/>\n        <source>已取消</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"842\"/>\n        <source>字幕校正已取消</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>SubtitlePipelineThread</name>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"50\"/>\n        <source>开始转录</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"74\"/>\n        <source>开始优化字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"100\"/>\n        <source>开始合成视频</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"125\"/>\n        <source>处理完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleSettingDialog</name>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"68\"/>\n        <source>字幕设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"21\"/>\n        <source>字幕分割</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"21\"/>\n        <source>字幕是否使用大语言模型进行智能断句</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"29\"/>\n        <source>中文最大字数</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"29\"/>\n        <source>单条字幕的最大字数 (对于中日韩等字符)</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"39\"/>\n        <source>英文最大单词数</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"39\"/>\n        <source>单条字幕的最大单词数 (英文)</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"49\"/>\n        <source>去除末尾标点符号</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"49\"/>\n        <source>是否去除中文字幕中的末尾标点符号</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"72\"/>\n        <source>关闭</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleStyleInterface</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"97\"/>\n        <source>字幕样式配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"186\"/>\n        <source>字幕排布</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"129\"/>\n        <source>主字幕样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"130\"/>\n        <source>副字幕样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"131\"/>\n        <source>预览设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"144\"/>\n        <source>预览效果</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"154\"/>\n        <source>选择样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"154\"/>\n        <source>选择已保存的字幕样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"161\"/>\n        <source>新建样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"161\"/>\n        <source>基于当前样式新建预设</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"168\"/>\n        <source>打开样式文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"168\"/>\n        <source>在文件管理器中打开样式文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"186\"/>\n        <source>设置主字幕和副字幕的显示方式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"194\"/>\n        <source>垂直间距</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"194\"/>\n        <source>设置字幕的垂直间距</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"203\"/>\n        <source>主字幕字体</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"203\"/>\n        <source>设置主字幕的字体</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"210\"/>\n        <source>主字幕字号</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"210\"/>\n        <source>设置主字幕的大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"218\"/>\n        <source>主字幕间距</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"218\"/>\n        <source>设置主字幕的字符间距</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"227\"/>\n        <source>主字幕颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"227\"/>\n        <source>设置主字幕的颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"234\"/>\n        <source>主字幕边框颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"234\"/>\n        <source>设置主字幕的边框颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"241\"/>\n        <source>主字幕边框大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"241\"/>\n        <source>设置主字幕的边框粗细</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"251\"/>\n        <source>副字幕字体</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"251\"/>\n        <source>设置副字幕的字体</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"258\"/>\n        <source>副字幕字号</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"258\"/>\n        <source>设置副字幕的大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"266\"/>\n        <source>副字幕间距</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"266\"/>\n        <source>设置副字幕的字符间距</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"275\"/>\n        <source>副字幕颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"275\"/>\n        <source>设置副字幕的颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"282\"/>\n        <source>副字幕边框颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"282\"/>\n        <source>设置副字幕的边框颜色</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"289\"/>\n        <source>副字幕边框大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"289\"/>\n        <source>设置副字幕的边框粗细</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"299\"/>\n        <source>预览文字</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"299\"/>\n        <source>设置预览显示的文字内容</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"307\"/>\n        <source>预览方向</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"307\"/>\n        <source>设置预览图片的显示方向</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\"/>\n        <source>选择图片</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\"/>\n        <source>预览背景</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\"/>\n        <source>选择预览使用的背景图片</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"486\"/>\n        <source>选择背景图片</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"486\"/>\n        <source>图片文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"734\"/>\n        <source>成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"695\"/>\n        <source>已加载样式 </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\"/>\n        <source>警告</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\"/>\n        <source>样式 </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\"/>\n        <source> 已存在</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"734\"/>\n        <source>已创建新样式 </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleTableModel</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"143\"/>\n        <source>开始时间</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"144\"/>\n        <source>结束时间</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"145\"/>\n        <source>字幕内容</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"146\"/>\n        <source>翻译字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"146\"/>\n        <source>优化字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleThread</name>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"69\"/>\n        <source>LLM API 未配置, 请检查LLM配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"85\"/>\n        <source>字幕文件路径为空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"88\"/>\n        <source>字幕配置为空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"99\"/>\n        <source>开始验证 LLM 配置...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"104\"/>\n        <source>字幕断句...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"121\"/>\n        <source>优化字幕...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"148\"/>\n        <source>LLM 模型未配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"138\"/>\n        <source>翻译字幕...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"144\"/>\n        <source>目标语言未配置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"185\"/>\n        <source>不支持的翻译服务: {translator_service}</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"238\"/>\n        <source>优化完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"245\"/>\n        <source>字幕处理失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"268\"/>\n        <source>{0}% 处理字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"295\"/>\n        <source>已终止</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"299\"/>\n        <source>终止时发生错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>TaskCreationInterface</name>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"97\"/>\n        <source>请拖拽文件或输入视频URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"148\"/>\n        <source>准备就绪</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"167\"/>\n        <source>查看日志</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"180\"/>\n        <source>捐助</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"193\"/>\n        <source>©VideoCaptioner {VERSION} • By Weifeng</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"230\"/>\n        <source>选择媒体文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"265\"/>\n        <source>导入成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"265\"/>\n        <source>导入媒体文件成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"273\"/>\n        <source>格式错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"273\"/>\n        <source>不支持该文件格式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"387\"/>\n        <source>错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"287\"/>\n        <source>请输入有效的文件路径或视频URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"308\"/>\n        <source>警告</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"308\"/>\n        <source>建议根据文档配置cookies.txt文件，以可以下载高清视频</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"322\"/>\n        <source>开始下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"322\"/>\n        <source>开始下载视频...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"333\"/>\n        <source>下载成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"333\"/>\n        <source>视频下载完成，开始自动处理...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"341\"/>\n        <source>视频下载失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"387\"/>\n        <source>请输入音视频文件路径或URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>TranscriptThread</name>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"40\"/>\n        <source>转录失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"45\"/>\n        <source>文件路径为空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"50\"/>\n        <source>视频文件不存在</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"53\"/>\n        <source>转录配置为空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"56\"/>\n        <source>输出路径为空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"74\"/>\n        <source>字幕已下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"86\"/>\n        <source>转换音频中</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"103\"/>\n        <source>音频转换失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"105\"/>\n        <source>语音转录中</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"121\"/>\n        <source>转录完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>TranscriptionInterface</name>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"397\"/>\n        <source>打开文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"404\"/>\n        <source>转录模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"473\"/>\n        <source>转录完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"473\"/>\n        <source>开始字幕优化...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"490\"/>\n        <source>选择媒体文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"506\"/>\n        <source>错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"531\"/>\n        <source>警告</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"531\"/>\n        <source>正在处理中，请等待当前任务完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"554\"/>\n        <source>导入成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"554\"/>\n        <source>开始语音转文字</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"562\"/>\n        <source>格式错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"562\"/>\n        <source>请拖入音频或视频文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>VideoInfoCard</name>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"103\"/>\n        <source>请拖入音频或视频文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"111\"/>\n        <source>画质</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"112\"/>\n        <source>文件大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"113\"/>\n        <source>时长</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"191\"/>\n        <source>音轨</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"141\"/>\n        <source>打开文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"345\"/>\n        <source>开始转录</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"159\"/>\n        <source>画质: </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"163\"/>\n        <source>大小: </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"165\"/>\n        <source>时长: </source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"292\"/>\n        <source>警告</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"292\"/>\n        <source>没有可用的字幕文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"327\"/>\n        <source>重新转录</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"329\"/>\n        <source>转录失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"339\"/>\n        <source>转录完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>VideoSynthesisInterface</name>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"81\"/>\n        <source>开始合成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"98\"/>\n        <source>字幕文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"100\"/>\n        <source>选择或者拖拽字幕文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"115\"/>\n        <source>浏览</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"111\"/>\n        <source>视频文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"113\"/>\n        <source>选择或者拖拽视频文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"128\"/>\n        <source>就绪</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"138\"/>\n        <source>软字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"144\"/>\n        <source>使用软字幕嵌入视频</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"151\"/>\n        <source>视频质量</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"170\"/>\n        <source>合成视频</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"176\"/>\n        <source>是否生成新的视频文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"183\"/>\n        <source>打开输出文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"344\"/>\n        <source>选择视频文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"261\"/>\n        <source>开启软字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"261\"/>\n        <source>字幕作为独立轨道嵌入视频，播放器中可关闭或调整</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"269\"/>\n        <source>开启硬烧录字幕</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"269\"/>\n        <source>字幕直接烧录到视频画面中，带有设置的样式</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"287\"/>\n        <source>开启视频合成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"287\"/>\n        <source>将进行视频与字幕的合成操作</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"295\"/>\n        <source>关闭视频合成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"295\"/>\n        <source>仅生成字幕文件，不生成新的视频文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"333\"/>\n        <source>选择字幕文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"413\"/>\n        <source>错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"354\"/>\n        <source>请选择字幕文件和视频文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"398\"/>\n        <source>成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"398\"/>\n        <source>视频合成已完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"436\"/>\n        <source>警告</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"436\"/>\n        <source>没有可用的视频文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"469\"/>\n        <source>导入成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"460\"/>\n        <source>字幕文件已放入输入框</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"469\"/>\n        <source>视频文件已输入框</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"477\"/>\n        <source>格式错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"477\"/>\n        <source>请拖入视频或者字幕文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>VideoSynthesisThread</name>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"63\"/>\n        <source>合成完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"39\"/>\n        <source>正在合成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"42\"/>\n        <source>视频路径为空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"44\"/>\n        <source>字幕路径为空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"46\"/>\n        <source>输出路径为空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"70\"/>\n        <source>视频合成失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>WhisperAPISettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"42\"/>\n        <source>Whisper API 设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"45\"/>\n        <source>API Base URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"45\"/>\n        <source>输入 Whisper API Base URL</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"55\"/>\n        <source>API Key</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"55\"/>\n        <source>输入 Whisper API Key</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"65\"/>\n        <source>Whisper 模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"65\"/>\n        <source>选择 Whisper 模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"75\"/>\n        <source>原语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"75\"/>\n        <source>音频的原语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"85\"/>\n        <source>提示词</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"85\"/>\n        <source>可选的提示词,默认空</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"187\"/>\n        <source>测试连接</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"95\"/>\n        <source>测试 Whisper API 连接</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"95\"/>\n        <source>点击测试 API 连接是否正常</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"141\"/>\n        <source>配置不完整</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"141\"/>\n        <source>请输入 API Base URL、API Key 和 model</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"152\"/>\n        <source>正在测试...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"167\"/>\n        <source>连接成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"167\"/>\n        <source>Whisper API 连接成功！</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"175\"/>\n        <source>连接失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"175\"/>\n        <source>Whisper API 连接失败！\n{result}</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"188\"/>\n        <source>测试错误</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>WhisperCppDownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"242\"/>\n        <source>关闭</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"248\"/>\n        <source>WhisperCpp程序</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"258\"/>\n        <source>已安装版本: {versions_text}</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"262\"/>\n        <source>未下载 WhisperCpp 程序</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"271\"/>\n        <source>模型下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"275\"/>\n        <source>打开模型文件夹</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\"/>\n        <source>模型名称</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\"/>\n        <source>大小</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\"/>\n        <source>状态</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\"/>\n        <source>操作</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"416\"/>\n        <source>已下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"357\"/>\n        <source>未下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"423\"/>\n        <source>重新下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"370\"/>\n        <source>下载</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"386\"/>\n        <source>下载进行中</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"386\"/>\n        <source>请等待当前下载任务完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"400\"/>\n        <source>正在下载 {model['label']} 模型...</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"462\"/>\n        <source>下载成功</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"462\"/>\n        <source>{model['label']} 模型已下载完成</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"477\"/>\n        <source>下载失败</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>WhisperCppSettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"530\"/>\n        <source>Whisper CPP 设置</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"535\"/>\n        <source>模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"535\"/>\n        <source>选择Whisper模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"556\"/>\n        <source>源语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"556\"/>\n        <source>音频的源语言</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\"/>\n        <source>管理模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\"/>\n        <source>模型管理</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\"/>\n        <source>下载或更新 Whisper CPP 模型</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n</TS>\n"
  },
  {
    "path": "resource/translations/VideoCaptioner_zh_HK.ts",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE TS>\n<TS version=\"2.1\">\n<context>\n    <name>BatchProcessInterface</name>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"50\"/>\n        <source>批量处理</source>\n        <translation>批量處理</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"73\"/>\n        <source>添加文件</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"400\"/>\n        <source>开始处理</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/batch_process_interface.py\" line=\"75\"/>\n        <source>清空列表</source>\n        <translation type=\"unfinished\"></translation>\n    </message>\n</context>\n<context>\n    <name>ColorPickerButton</name>\n    <message>\n        <location filename=\"../../app/components/MySettingCard.py\" line=\"319\"/>\n        <source>Choose </source>\n        <translation>Choose </translation>\n    </message>\n</context>\n<context>\n    <name>DonateDialog</name>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"19\"/>\n        <source>支持作者</source>\n        <translation>支持作者</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"23\"/>\n        <source>感谢支持</source>\n        <translation>感謝支持</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"26\"/>\n        <source>目前本人精力有限，您的支持让我有动力继续折腾这个项目！\n感谢您对开源事业的热爱与支持！</source>\n        <translation>目前本人精力有限，您的支持讓我有動力繼續折騰這個項目！\n感謝您對開源事業的熱愛與支持！</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"48\"/>\n        <source>支付宝</source>\n        <translation>支付寶</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"64\"/>\n        <source>微信</source>\n        <translation>微信</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/DonateDialog.py\" line=\"87\"/>\n        <source>关闭</source>\n        <translation>關閉</translation>\n    </message>\n</context>\n<context>\n    <name>DownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"127\"/>\n        <source>下载模型</source>\n        <translation>下載模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"147\"/>\n        <source>下载</source>\n        <translation>下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"161\"/>\n        <source>关闭</source>\n        <translation>關閉</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"170\"/>\n        <source>提示</source>\n        <translation>提示</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"170\"/>\n        <source>模型文件已存在,无需重复下载</source>\n        <translation>模型文件已存在,無需重複下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"193\"/>\n        <source>完成</source>\n        <translation>完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"193\"/>\n        <source>模型下载完成!</source>\n        <translation>模型下載完成!</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"200\"/>\n        <source>下载完成</source>\n        <translation>下載完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"203\"/>\n        <source>下载错误</source>\n        <translation>下載錯誤</translation>\n    </message>\n</context>\n<context>\n    <name>FasterWhisperDownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"203\"/>\n        <source>关闭</source>\n        <translation>關閉</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"212\"/>\n        <source>Faster Whisper 下载</source>\n        <translation>Faster Whisper 下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"216\"/>\n        <source>打开程序文件夹</source>\n        <translation>打開程序文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"231\"/>\n        <source>已安装版本: {versions_text}</source>\n        <translation>已安裝版本: {versions_text}</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"237\"/>\n        <source>您可以继续下载其他版本:</source>\n        <translation>您可以繼續下載其他版本:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"240\"/>\n        <source>未下载Faster Whisper 程序</source>\n        <translation>未下載Faster Whisper 程序</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"258\"/>\n        <source>下载程序</source>\n        <translation>下載程序</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"271\"/>\n        <source>模型下载</source>\n        <translation>模型下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"275\"/>\n        <source>打开模型文件夹</source>\n        <translation>打開模型文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\"/>\n        <source>模型名称</source>\n        <translation>模型名稱</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\"/>\n        <source>大小</source>\n        <translation>大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\"/>\n        <source>状态</source>\n        <translation>狀態</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"295\"/>\n        <source>操作</source>\n        <translation>操作</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"540\"/>\n        <source>已下载</source>\n        <translation>已下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"360\"/>\n        <source>未下载</source>\n        <translation>未下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"547\"/>\n        <source>重新下载</source>\n        <translation>重新下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"373\"/>\n        <source>下载</source>\n        <translation>下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"505\"/>\n        <source>下载进行中</source>\n        <translation>下載進行中</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"505\"/>\n        <source>请等待当前下载任务完成</source>\n        <translation>請等待當前下載任務完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"417\"/>\n        <source>下载错误</source>\n        <translation>下載錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"417\"/>\n        <source>未找到对应的程序配置</source>\n        <translation>未找到對應的程序配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"465\"/>\n        <source>正在解压文件...</source>\n        <translation>正在解壓文件...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"646\"/>\n        <source>安装失败</source>\n        <translation>安裝失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"597\"/>\n        <source>下载失败</source>\n        <translation>下載失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"519\"/>\n        <source>正在下载 {model['label']} 模型...</source>\n        <translation>正在下載 {model['label']} 模型...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"582\"/>\n        <source>下载成功</source>\n        <translation>下載成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"582\"/>\n        <source>{model['label']} 模型已下载完成</source>\n        <translation>{model['label']} 模型已下載完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"635\"/>\n        <source>安装完成</source>\n        <translation>安裝完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"635\"/>\n        <source>Faster Whisper 程序已安装成功</source>\n        <translation>Faster Whisper 程序已安裝成功</translation>\n    </message>\n</context>\n<context>\n    <name>FasterWhisperSettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"888\"/>\n        <source>Faster Whisper程序不存在，请先下载程序</source>\n        <translation>Faster Whisper程序不存在，請先下載程序</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"685\"/>\n        <source>Faster Whisper 设置（✨推荐✨））</source>\n        <translation>Faster Whisper 設置（✨推薦✨））</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"690\"/>\n        <source>模型</source>\n        <translation>模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"690\"/>\n        <source>选择 Faster Whisper 模型</source>\n        <translation>選擇 Faster Whisper 模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\"/>\n        <source>管理模型</source>\n        <translation>管理模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\"/>\n        <source>模型管理</source>\n        <translation>模型管理</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"718\"/>\n        <source>下载或更新 Faster Whisper 模型</source>\n        <translation>下載或更新 Faster Whisper 模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"728\"/>\n        <source>源语言</source>\n        <translation>源語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"728\"/>\n        <source>音频的源语言</source>\n        <translation>音頻的源語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"739\"/>\n        <source>运行设备</source>\n        <translation>運行設備</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"739\"/>\n        <source>模型运行设备</source>\n        <translation>模型運行設備</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"752\"/>\n        <source>VAD设置</source>\n        <translation>VAD設置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"755\"/>\n        <source>VAD过滤</source>\n        <translation>VAD過濾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"755\"/>\n        <source>过滤无人声语音片断，减少幻觉</source>\n        <translation>過濾無人聲語音片斷，減少幻覺</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"764\"/>\n        <source>VAD阈值</source>\n        <translation>VAD閾值</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"764\"/>\n        <source>语音概率阈值，高于此值视为语音</source>\n        <translation>語音概率閾值，高於此值視為語音</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"776\"/>\n        <source>VAD方法</source>\n        <translation>VAD方法</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"776\"/>\n        <source>选择VAD检测方法</source>\n        <translation>選擇VAD檢測方法</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"786\"/>\n        <source>其他设置</source>\n        <translation>其他設置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"789\"/>\n        <source>人声分离</source>\n        <translation>人聲分離</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"789\"/>\n        <source>处理前使用MDX-Net降噪，分离人声和背景音乐</source>\n        <translation>處理前使用MDX-Net降噪，分離人聲和背景音樂</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"798\"/>\n        <source>单字时间戳</source>\n        <translation>單字時間戳</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"798\"/>\n        <source>开启生成单字级时间戳；关闭后使用原始分段断句</source>\n        <translation>開啓生成單字級時間戳；關閉後使用原始分段斷句</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"807\"/>\n        <source>提示词</source>\n        <translation>提示詞</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"807\"/>\n        <source>可选的提示词,默认空</source>\n        <translation>可選的提示詞,默認空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"871\"/>\n        <source>错误</source>\n        <translation>錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"902\"/>\n        <source>模型配置不存在</source>\n        <translation>模型配置不存在</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/FasterWhisperSettingWidget.py\" line=\"909\"/>\n        <source>模型文件不存在: </source>\n        <translation>模型文件不存在: </translation>\n    </message>\n</context>\n<context>\n    <name>FileDownloadThread</name>\n    <message>\n        <location filename=\"../../app/thread/file_download_thread.py\" line=\"35\"/>\n        <source>正在连接...</source>\n        <translation>正在連接...</translation>\n    </message>\n</context>\n<context>\n    <name>HomeInterface</name>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"36\"/>\n        <source>任务创建</source>\n        <translation>任務創建</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"39\"/>\n        <source>语音转录</source>\n        <translation>語音轉錄</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"42\"/>\n        <source>字幕优化与翻译</source>\n        <translation>字幕優化與翻譯</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/home_interface.py\" line=\"47\"/>\n        <source>字幕视频合成</source>\n        <translation>字幕視頻合成</translation>\n    </message>\n</context>\n<context>\n    <name>LanguageSettingDialog</name>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"26\"/>\n        <source>确定</source>\n        <translation>確定</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"27\"/>\n        <source>取消</source>\n        <translation>取消</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"32\"/>\n        <source>语言设置</source>\n        <translation>語言設置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"35\"/>\n        <source>源语言</source>\n        <translation>源語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"35\"/>\n        <source>音频的源语言</source>\n        <translation>音頻的源語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"56\"/>\n        <source>设置已保存</source>\n        <translation>設置已保存</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"56\"/>\n        <source>语言设置已更新</source>\n        <translation>語言設置已更新</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"64\"/>\n        <source>请注意身体！！</source>\n        <translation>請注意身體！！</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/LanguageSettingDialog.py\" line=\"64\"/>\n        <source>小心肝儿,注意身体哦~</source>\n        <translation>小心肝兒,注意身體哦~</translation>\n    </message>\n</context>\n<context>\n    <name>MainWindow</name>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"62\"/>\n        <source>主页</source>\n        <translation>主頁</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"63\"/>\n        <source>批量处理</source>\n        <translation>批量處理</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"64\"/>\n        <source>字幕样式</source>\n        <translation>字幕樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"76\"/>\n        <source>Settings</source>\n        <translation>Settings</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"98\"/>\n        <source>卡卡字幕助手 -- VideoCaptioner</source>\n        <translation>卡卡字幕助手 -- VideoCaptioner</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"117\"/>\n        <source>GitHub信息</source>\n        <translation>GitHub信息</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"117\"/>\n        <source>VideoCaptioner 由本人在课余时间独立开发完成，目前托管在GitHub上，欢迎Star和Fork。项目诚然还有很多地方需要完善，遇到软件的问题或者BUG欢迎提交Issue。\n\n https://github.com/WEIFENG2333/VideoCaptioner</source>\n        <translation>VideoCaptioner 由本人在課餘時間獨立開發完成，目前託管在GitHub上，歡迎Star和Fork。項目誠然還有很多地方需要完善，遇到軟件的問題或者BUG歡迎提交Issue。\n\n https://github.com/WEIFENG2333/VideoCaptioner</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"124\"/>\n        <source>打开 GitHub</source>\n        <translation>打開 GitHub</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"125\"/>\n        <source>支持作者</source>\n        <translation>支持作者</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/main_window.py\" line=\"152\"/>\n        <source>当前版本部分功能已被禁用。请尽快更新。</source>\n        <translation>當前版本部分功能已被禁用。請儘快更新。</translation>\n    </message>\n</context>\n<context>\n    <name>PromptDialog</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"885\"/>\n        <source>文稿提示</source>\n        <translation>文稿提示</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"889\"/>\n        <source>请输入文稿提示（辅助校正字幕和翻译）\n\n支持以下内容:\n1. 术语表 - 专业术语、人名、特定词语的修正对照表\n示例:\n机器学习-&gt;Machine Learning\n马斯克-&gt;Elon Musk\n打call-&gt;应援\n\n2. 原字幕文稿 - 视频的原有文稿或相关内容\n示例: 完整的演讲稿、课程讲义等\n\n3. 修正要求 - 内容相关的具体修正要求\n示例: 统一人称代词、规范专业术语等\n\n注意: 使用小型LLM模型时建议控制文稿在1千字内。对于不同字幕文件,请使用与该字幕相关的文稿提示。</source>\n        <translation>請輸入文稿提示（輔助校正字幕和翻譯）\n\n支持以下內容:\n1. 術語表 - 專業術語、人名、特定詞語的修正對照表\n示例:\n機器學習-&gt;Machine Learning\n馬斯克-&gt;Elon Musk\n打call-&gt;應援\n\n2. 原字幕文稿 - 視頻的原有文稿或相關內容\n示例: 完整的演講稿、課程講義等\n\n3. 修正要求 - 內容相關的具體修正要求\n示例: 統一人稱代詞、規範專業術語等\n\n注意: 使用小型LLM模型時建議控制文稿在1千字內。對於不同字幕文件,請使用與該字幕相關的文稿提示。</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"913\"/>\n        <source>确定</source>\n        <translation>確定</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"914\"/>\n        <source>取消</source>\n        <translation>取消</translation>\n    </message>\n</context>\n<context>\n    <name>SettingInterface</name>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"46\"/>\n        <source>设置</source>\n        <translation>設置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"62\"/>\n        <source>转录配置</source>\n        <translation>轉錄配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"64\"/>\n        <source>LLM配置</source>\n        <translation>LLM配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"491\"/>\n        <source>翻译服务</source>\n        <translation>翻譯服務</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"70\"/>\n        <source>翻译与优化</source>\n        <translation>翻譯與優化</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"72\"/>\n        <source>字幕合成配置</source>\n        <translation>字幕合成配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"76\"/>\n        <source>保存配置</source>\n        <translation>保存配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"78\"/>\n        <source>个性化</source>\n        <translation>個性化</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\"/>\n        <source>关于</source>\n        <translation>關於</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"95\"/>\n        <source>字幕校正</source>\n        <translation>字幕校正</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"95\"/>\n        <source>字幕处理过程是否对生成的字幕错别字、名词等进行校正</source>\n        <translation>字幕處理過程是否對生成的字幕錯別字、名詞等進行校正</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"102\"/>\n        <source>字幕翻译</source>\n        <translation>字幕翻譯</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"102\"/>\n        <source>字幕处理过程是否对生成的字幕进行翻译</source>\n        <translation>字幕處理過程是否對生成的字幕進行翻譯</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"109\"/>\n        <source>目标语言</source>\n        <translation>目標語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"109\"/>\n        <source>选择翻译字幕的目标语言</source>\n        <translation>選擇翻譯字幕的目標語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\"/>\n        <source>修改</source>\n        <translation>修改</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"119\"/>\n        <source>字幕样式</source>\n        <translation>字幕樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"119\"/>\n        <source>选择字幕的样式（颜色、大小、字体等）</source>\n        <translation>選擇字幕的樣式（顏色、大小、字體等）</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\"/>\n        <source>字幕布局</source>\n        <translation>字幕布局</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"127\"/>\n        <source>选择字幕的布局（单语、双语）</source>\n        <translation>選擇字幕的佈局（單語、雙語）</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"135\"/>\n        <source>需要合成视频</source>\n        <translation>需要合成視頻</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"135\"/>\n        <source>开启时触发合成视频，关闭时跳过</source>\n        <translation>開啓時觸發合成視頻，關閉時跳過</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"142\"/>\n        <source>软字幕</source>\n        <translation>軟字幕</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"142\"/>\n        <source>开启时字幕可在播放器中关闭或调整，关闭时字幕烧录到视频画面上</source>\n        <translation>開啓時字幕可在播放器中關閉或調整，關閉時字幕燒錄到視頻畫面上</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"149\"/>\n        <source>视频合成质量</source>\n        <translation>視頻合成質量</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"149\"/>\n        <source>硬字幕视频合成时的质量等级（质量越高文件越大，编码时间越长）</source>\n        <translation>硬字幕視頻合成時的質量等級（質量越高文件越大，編碼時間越長）</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"159\"/>\n        <source>工作文件夹</source>\n        <translation>工作文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"159\"/>\n        <source>工作目录路径</source>\n        <translation>工作目錄路徑</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"168\"/>\n        <source>启用缓存</source>\n        <translation>啓用緩存</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"168\"/>\n        <source>相同配置下会复用之前的 ASR 和 LLM 结果；关闭缓存后每次重新生成</source>\n        <translation>相同配置下會複用之前的 ASR 和 LLM 結果；關閉緩存後每次重新生成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\"/>\n        <source>应用主题</source>\n        <translation>應用主題</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\"/>\n        <source>更改应用程序的外观</source>\n        <translation>更改應用程序的外觀</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\"/>\n        <source>浅色</source>\n        <translation>淺色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"175\"/>\n        <source>深色</source>\n        <translation>深色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\"/>\n        <source>使用系统设置</source>\n        <translation>使用系統設置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"183\"/>\n        <source>主题颜色</source>\n        <translation>主題顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"183\"/>\n        <source>更改应用程序的主题颜色</source>\n        <translation>更改應用程序的主題顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"190\"/>\n        <source>界面缩放</source>\n        <translation>界面縮放</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"190\"/>\n        <source>更改小部件和字体的大小</source>\n        <translation>更改小部件和字體的大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\"/>\n        <source>语言</source>\n        <translation>語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"198\"/>\n        <source>设置您偏好的界面语言</source>\n        <translation>設置您偏好的界面語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\"/>\n        <source>打开帮助页面</source>\n        <translation>打開幫助頁面</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\"/>\n        <source>帮助</source>\n        <translation>幫助</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"208\"/>\n        <source>发现新功能并了解有关VideoCaptioner的使用技巧</source>\n        <translation>發現新功能並瞭解有關VideoCaptioner的使用技巧</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"216\"/>\n        <source>提供反馈</source>\n        <translation>提供反饋</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"216\"/>\n        <source>提供反馈帮助我们改进VideoCaptioner</source>\n        <translation>提供反饋幫助我們改進VideoCaptioner</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\"/>\n        <source>检查更新</source>\n        <translation>檢查更新</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\"/>\n        <source>版权所有</source>\n        <translation>版權所有</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"223\"/>\n        <source>版本</source>\n        <translation>版本</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"262\"/>\n        <source>LLM服务</source>\n        <translation>LLM服務</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"262\"/>\n        <source>选择大模型服务，用于字幕断句、字幕优化、字幕翻译</source>\n        <translation>選擇大模型服務，用於字幕斷句、字幕優化、字幕翻譯</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\"/>\n        <source>访问</source>\n        <translation>訪問</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\"/>\n        <source>VideoCaptioner 官方API</source>\n        <translation>VideoCaptioner 官方API</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"273\"/>\n        <source>集成多种大语言模型，支持高并发字幕优化、翻译</source>\n        <translation>集成多種大語言模型，支持高併發字幕優化、翻譯</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"365\"/>\n        <source>API Key</source>\n        <translation>API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"365\"/>\n        <source>输入您的 {service.value} API Key</source>\n        <translation>輸入您的 {service.value} API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"376\"/>\n        <source>Base URL</source>\n        <translation>Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"376\"/>\n        <source>输入 {service.value} Base URL</source>\n        <translation>輸入 {service.value} Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"395\"/>\n        <source>模型</source>\n        <translation>模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"395\"/>\n        <source>选择 {service.value} 模型</source>\n        <translation>選擇 {service.value} 模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"781\"/>\n        <source>检查连接</source>\n        <translation>檢查連接</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"416\"/>\n        <source>检查 LLM 连接</source>\n        <translation>檢查 LLM 連接</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"416\"/>\n        <source>点击检查 API 连接是否正常，并获取模型列表</source>\n        <translation>點擊檢查 API 連接是否正常，並獲取模型列表</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"430\"/>\n        <source>转录模型</source>\n        <translation>轉錄模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"430\"/>\n        <source>语音转换文字要使用的语音识别服务</source>\n        <translation>語音轉換文字要使用的語音識別服務</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"441\"/>\n        <source>Whisper API Base URL</source>\n        <translation>Whisper API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"441\"/>\n        <source>输入 Whisper API Base URL</source>\n        <translation>輸入 Whisper API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"451\"/>\n        <source>Whisper API Key</source>\n        <translation>Whisper API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"451\"/>\n        <source>输入 Whisper API Key</source>\n        <translation>輸入 Whisper API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"461\"/>\n        <source>Whisper 模型</source>\n        <translation>Whisper 模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"461\"/>\n        <source>选择 Whisper 模型</source>\n        <translation>選擇 Whisper 模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"975\"/>\n        <source>测试 Whisper 连接</source>\n        <translation>測試 Whisper 連接</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"474\"/>\n        <source>测试 Whisper API 连接</source>\n        <translation>測試 Whisper API 連接</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"474\"/>\n        <source>点击测试 API 连接是否正常</source>\n        <translation>點擊測試 API 連接是否正常</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"491\"/>\n        <source>选择翻译服务</source>\n        <translation>選擇翻譯服務</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"505\"/>\n        <source>需要反思翻译</source>\n        <translation>需要反思翻譯</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"505\"/>\n        <source>启用反思翻译可以提高翻译质量，但耗费更多时间和token</source>\n        <translation>啓用反思翻譯可以提高翻譯質量，但耗費更多時間和token</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"514\"/>\n        <source>DeepLx 后端</source>\n        <translation>DeepLx 後端</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"514\"/>\n        <source>输入 DeepLx 的后端地址(开启deeplx翻译时必填)</source>\n        <translation>輸入 DeepLx 的後端地址(開啓deeplx翻譯時必填)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"524\"/>\n        <source>批处理大小</source>\n        <translation>批處理大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"524\"/>\n        <source>每批处理字幕的数量，建议为 10 的倍数</source>\n        <translation>每批處理字幕的數量，建議為 10 的倍數</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"533\"/>\n        <source>线程数</source>\n        <translation>線程數</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"533\"/>\n        <source>请求并行处理的数量，模型服务商允许的情况下建议尽可能大，数值越大速度越快</source>\n        <translation>請求並行處理的數量，模型服務商允許的情況下建議儘可能大，數值越大速度越快</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"695\"/>\n        <source>更新成功</source>\n        <translation>更新成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"695\"/>\n        <source>配置将在重启后生效</source>\n        <translation>配置將在重啓後生效</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"704\"/>\n        <source>选择文件夹</source>\n        <translation>選擇文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"716\"/>\n        <source>缓存已启用</source>\n        <translation>緩存已啓用</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"716\"/>\n        <source>ASR、翻译等操作将优先使用缓存</source>\n        <translation>ASR、翻譯等操作將優先使用緩存</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"724\"/>\n        <source>缓存已禁用</source>\n        <translation>緩存已禁用</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"724\"/>\n        <source>所有操作将重新生成，不使用缓存（建议开启缓存）</source>\n        <translation>所有操作將重新生成，不使用緩存（建議開啓緩存）</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"759\"/>\n        <source>正在检查...</source>\n        <translation>正在檢查...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"801\"/>\n        <source>LLM 连接测试错误</source>\n        <translation>LLM 連接測試錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\"/>\n        <source>获取模型列表成功:</source>\n        <translation>獲取模型列表成功:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\"/>\n        <source>一共</source>\n        <translation>一共</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"794\"/>\n        <source>个模型</source>\n        <translation>個模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"808\"/>\n        <source>LLM 连接测试成功</source>\n        <translation>LLM 連接測試成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"928\"/>\n        <source>配置不完整</source>\n        <translation>配置不完整</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"910\"/>\n        <source>请输入 Whisper API Base URL</source>\n        <translation>請輸入 Whisper API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"919\"/>\n        <source>请输入 Whisper API Key</source>\n        <translation>請輸入 Whisper API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"928\"/>\n        <source>请输入 Whisper 模型名称</source>\n        <translation>請輸入 Whisper 模型名稱</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"938\"/>\n        <source>正在测试...</source>\n        <translation>正在測試...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"957\"/>\n        <source>连接成功</source>\n        <translation>連接成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"957\"/>\n        <source>Whisper API 连接成功！\n转录结果:</source>\n        <translation>Whisper API 連接成功！\n轉錄結果:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"964\"/>\n        <source>连接失败</source>\n        <translation>連接失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"964\"/>\n        <source>Whisper API 连接失败！\n{result}</source>\n        <translation>Whisper API 連接失敗！\n{result}</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/setting_interface.py\" line=\"977\"/>\n        <source>测试错误</source>\n        <translation>測試錯誤</translation>\n    </message>\n</context>\n<context>\n    <name>StyleNameDialog</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"765\"/>\n        <source>新建样式</source>\n        <translation>新建樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"768\"/>\n        <source>输入样式名称</source>\n        <translation>輸入樣式名稱</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"776\"/>\n        <source>确定</source>\n        <translation>確定</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"777\"/>\n        <source>取消</source>\n        <translation>取消</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleInterface</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"251\"/>\n        <source>保存</source>\n        <translation>保存</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"257\"/>\n        <source>字幕排布</source>\n        <translation>字幕排布</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"277\"/>\n        <source>字幕校正</source>\n        <translation>字幕校正</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"286\"/>\n        <source>字幕翻译</source>\n        <translation>字幕翻譯</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"295\"/>\n        <source>翻译语言</source>\n        <translation>翻譯語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"317\"/>\n        <source>文稿提示</source>\n        <translation>文稿提示</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"343\"/>\n        <source>开始</source>\n        <translation>開始</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"389\"/>\n        <source>请拖入字幕文件</source>\n        <translation>請拖入字幕文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"394\"/>\n        <source>取消</source>\n        <translation>取消</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"628\"/>\n        <source>已加载文件</source>\n        <translation>已加載文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"603\"/>\n        <source>警告</source>\n        <translation>警告</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"603\"/>\n        <source>请先加载字幕文件</source>\n        <translation>請先加載字幕文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"482\"/>\n        <source>开始优化</source>\n        <translation>開始優化</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"482\"/>\n        <source>开始优化字幕</source>\n        <translation>開始優化字幕</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"501\"/>\n        <source>优化完成</source>\n        <translation>優化完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"501\"/>\n        <source>优化完成字幕...</source>\n        <translation>優化完成字幕...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"513\"/>\n        <source>优化失败</source>\n        <translation>優化失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"547\"/>\n        <source>选择字幕文件</source>\n        <translation>選擇字幕文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"567\"/>\n        <source>保存字幕文件</source>\n        <translation>保存字幕文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"586\"/>\n        <source>保存成功</source>\n        <translation>保存成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"586\"/>\n        <source>字幕已保存至:</source>\n        <translation>字幕已保存至:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"593\"/>\n        <source>保存失败</source>\n        <translation>保存失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"593\"/>\n        <source>保存字幕文件失败: </source>\n        <translation>保存字幕文件失敗: </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"647\"/>\n        <source>导入成功</source>\n        <translation>導入成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"647\"/>\n        <source>成功导入</source>\n        <translation>成功導入</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"656\"/>\n        <source>格式错误</source>\n        <translation>格式錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"656\"/>\n        <source>支持的字幕格式:</source>\n        <translation>支持的字幕格式:</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"742\"/>\n        <source>合并</source>\n        <translation>合併</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"814\"/>\n        <source>合并成功</source>\n        <translation>合併成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"814\"/>\n        <source>已成功合并选中的字幕行</source>\n        <translation>已成功合併選中的字幕行</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"841\"/>\n        <source>已取消校正</source>\n        <translation>已取消校正</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"842\"/>\n        <source>已取消</source>\n        <translation>已取消</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"842\"/>\n        <source>字幕校正已取消</source>\n        <translation>字幕校正已取消</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitlePipelineThread</name>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"50\"/>\n        <source>开始转录</source>\n        <translation>開始轉錄</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"74\"/>\n        <source>开始优化字幕</source>\n        <translation>開始優化字幕</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"100\"/>\n        <source>开始合成视频</source>\n        <translation>開始合成視頻</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_pipeline_thread.py\" line=\"125\"/>\n        <source>处理完成</source>\n        <translation>處理完成</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleSettingDialog</name>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"68\"/>\n        <source>字幕设置</source>\n        <translation>字幕設置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"21\"/>\n        <source>字幕分割</source>\n        <translation>字幕分割</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"21\"/>\n        <source>字幕是否使用大语言模型进行智能断句</source>\n        <translation>字幕是否使用大語言模型進行智能斷句</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"29\"/>\n        <source>中文最大字数</source>\n        <translation>中文最大字數</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"29\"/>\n        <source>单条字幕的最大字数 (对于中日韩等字符)</source>\n        <translation>單條字幕的最大字數 (對於中日韓等字符)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"39\"/>\n        <source>英文最大单词数</source>\n        <translation>英文最大單詞數</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"39\"/>\n        <source>单条字幕的最大单词数 (英文)</source>\n        <translation>單條字幕的最大單詞數 (英文)</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"49\"/>\n        <source>去除末尾标点符号</source>\n        <translation>去除末尾標點符號</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"49\"/>\n        <source>是否去除中文字幕中的末尾标点符号</source>\n        <translation>是否去除中文字幕中的末尾標點符號</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/SubtitleSettingDialog.py\" line=\"72\"/>\n        <source>关闭</source>\n        <translation>關閉</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleStyleInterface</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"97\"/>\n        <source>字幕样式配置</source>\n        <translation>字幕樣式配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"186\"/>\n        <source>字幕排布</source>\n        <translation>字幕排布</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"129\"/>\n        <source>主字幕样式</source>\n        <translation>主字幕樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"130\"/>\n        <source>副字幕样式</source>\n        <translation>副字幕樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"131\"/>\n        <source>预览设置</source>\n        <translation>預覽設置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"144\"/>\n        <source>预览效果</source>\n        <translation>預覽效果</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"154\"/>\n        <source>选择样式</source>\n        <translation>選擇樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"154\"/>\n        <source>选择已保存的字幕样式</source>\n        <translation>選擇已保存的字幕樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"161\"/>\n        <source>新建样式</source>\n        <translation>新建樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"161\"/>\n        <source>基于当前样式新建预设</source>\n        <translation>基於當前樣式新建預設</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"168\"/>\n        <source>打开样式文件夹</source>\n        <translation>打開樣式文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"168\"/>\n        <source>在文件管理器中打开样式文件夹</source>\n        <translation>在文件管理器中打開樣式文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"186\"/>\n        <source>设置主字幕和副字幕的显示方式</source>\n        <translation>設置主字幕和副字幕的顯示方式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"194\"/>\n        <source>垂直间距</source>\n        <translation>垂直間距</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"194\"/>\n        <source>设置字幕的垂直间距</source>\n        <translation>設置字幕的垂直間距</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"203\"/>\n        <source>主字幕字体</source>\n        <translation>主字幕字體</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"203\"/>\n        <source>设置主字幕的字体</source>\n        <translation>設置主字幕的字體</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"210\"/>\n        <source>主字幕字号</source>\n        <translation>主字幕字號</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"210\"/>\n        <source>设置主字幕的大小</source>\n        <translation>設置主字幕的大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"218\"/>\n        <source>主字幕间距</source>\n        <translation>主字幕間距</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"218\"/>\n        <source>设置主字幕的字符间距</source>\n        <translation>設置主字幕的字符間距</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"227\"/>\n        <source>主字幕颜色</source>\n        <translation>主字幕顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"227\"/>\n        <source>设置主字幕的颜色</source>\n        <translation>設置主字幕的顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"234\"/>\n        <source>主字幕边框颜色</source>\n        <translation>主字幕邊框顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"234\"/>\n        <source>设置主字幕的边框颜色</source>\n        <translation>設置主字幕的邊框顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"241\"/>\n        <source>主字幕边框大小</source>\n        <translation>主字幕邊框大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"241\"/>\n        <source>设置主字幕的边框粗细</source>\n        <translation>設置主字幕的邊框粗細</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"251\"/>\n        <source>副字幕字体</source>\n        <translation>副字幕字體</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"251\"/>\n        <source>设置副字幕的字体</source>\n        <translation>設置副字幕的字體</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"258\"/>\n        <source>副字幕字号</source>\n        <translation>副字幕字號</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"258\"/>\n        <source>设置副字幕的大小</source>\n        <translation>設置副字幕的大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"266\"/>\n        <source>副字幕间距</source>\n        <translation>副字幕間距</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"266\"/>\n        <source>设置副字幕的字符间距</source>\n        <translation>設置副字幕的字符間距</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"275\"/>\n        <source>副字幕颜色</source>\n        <translation>副字幕顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"275\"/>\n        <source>设置副字幕的颜色</source>\n        <translation>設置副字幕的顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"282\"/>\n        <source>副字幕边框颜色</source>\n        <translation>副字幕邊框顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"282\"/>\n        <source>设置副字幕的边框颜色</source>\n        <translation>設置副字幕的邊框顏色</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"289\"/>\n        <source>副字幕边框大小</source>\n        <translation>副字幕邊框大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"289\"/>\n        <source>设置副字幕的边框粗细</source>\n        <translation>設置副字幕的邊框粗細</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"299\"/>\n        <source>预览文字</source>\n        <translation>預覽文字</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"299\"/>\n        <source>设置预览显示的文字内容</source>\n        <translation>設置預覽顯示的文字內容</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"307\"/>\n        <source>预览方向</source>\n        <translation>預覽方向</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"307\"/>\n        <source>设置预览图片的显示方向</source>\n        <translation>設置預覽圖片的顯示方向</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\"/>\n        <source>选择图片</source>\n        <translation>選擇圖片</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\"/>\n        <source>预览背景</source>\n        <translation>預覽背景</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"315\"/>\n        <source>选择预览使用的背景图片</source>\n        <translation>選擇預覽使用的背景圖片</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"486\"/>\n        <source>选择背景图片</source>\n        <translation>選擇背景圖片</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"486\"/>\n        <source>图片文件</source>\n        <translation>圖片文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"734\"/>\n        <source>成功</source>\n        <translation>成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"695\"/>\n        <source>已加载样式 </source>\n        <translation>已加載樣式 </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\"/>\n        <source>警告</source>\n        <translation>警告</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\"/>\n        <source>样式 </source>\n        <translation>樣式 </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"715\"/>\n        <source> 已存在</source>\n        <translation> 已存在</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_style_interface.py\" line=\"734\"/>\n        <source>已创建新样式 </source>\n        <translation>已創建新樣式 </translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleTableModel</name>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"143\"/>\n        <source>开始时间</source>\n        <translation>開始時間</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"144\"/>\n        <source>结束时间</source>\n        <translation>結束時間</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"145\"/>\n        <source>字幕内容</source>\n        <translation>字幕內容</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"146\"/>\n        <source>翻译字幕</source>\n        <translation>翻譯字幕</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/subtitle_interface.py\" line=\"146\"/>\n        <source>优化字幕</source>\n        <translation>優化字幕</translation>\n    </message>\n</context>\n<context>\n    <name>SubtitleThread</name>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"69\"/>\n        <source>LLM API 未配置, 请检查LLM配置</source>\n        <translation>LLM API 未配置, 請檢查LLM配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"85\"/>\n        <source>字幕文件路径为空</source>\n        <translation>字幕文件路徑為空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"88\"/>\n        <source>字幕配置为空</source>\n        <translation>字幕配置為空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"99\"/>\n        <source>开始验证 LLM 配置...</source>\n        <translation>開始驗證 LLM 配置...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"104\"/>\n        <source>字幕断句...</source>\n        <translation>字幕斷句...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"121\"/>\n        <source>优化字幕...</source>\n        <translation>優化字幕...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"148\"/>\n        <source>LLM 模型未配置</source>\n        <translation>LLM 模型未配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"138\"/>\n        <source>翻译字幕...</source>\n        <translation>翻譯字幕...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"144\"/>\n        <source>目标语言未配置</source>\n        <translation>目標語言未配置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"185\"/>\n        <source>不支持的翻译服务: {translator_service}</source>\n        <translation>不支持的翻譯服務: {translator_service}</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"238\"/>\n        <source>优化完成</source>\n        <translation>優化完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"245\"/>\n        <source>字幕处理失败</source>\n        <translation>字幕處理失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"268\"/>\n        <source>{0}% 处理字幕</source>\n        <translation>{0}% 處理字幕</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"295\"/>\n        <source>已终止</source>\n        <translation>已終止</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/subtitle_thread.py\" line=\"299\"/>\n        <source>终止时发生错误</source>\n        <translation>終止時發生錯誤</translation>\n    </message>\n</context>\n<context>\n    <name>TaskCreationInterface</name>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"97\"/>\n        <source>请拖拽文件或输入视频URL</source>\n        <translation>請拖拽文件或輸入視頻URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"148\"/>\n        <source>准备就绪</source>\n        <translation>準備就緒</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"167\"/>\n        <source>查看日志</source>\n        <translation>查看日誌</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"180\"/>\n        <source>捐助</source>\n        <translation>捐助</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"193\"/>\n        <source>©VideoCaptioner {VERSION} • By Weifeng</source>\n        <translation>©VideoCaptioner {VERSION} • By Weifeng</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"230\"/>\n        <source>选择媒体文件</source>\n        <translation>選擇媒體文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"265\"/>\n        <source>导入成功</source>\n        <translation>導入成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"265\"/>\n        <source>导入媒体文件成功</source>\n        <translation>導入媒體文件成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"273\"/>\n        <source>格式错误</source>\n        <translation>格式錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"273\"/>\n        <source>不支持该文件格式</source>\n        <translation>不支持該文件格式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"387\"/>\n        <source>错误</source>\n        <translation>錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"287\"/>\n        <source>请输入有效的文件路径或视频URL</source>\n        <translation>請輸入有效的文件路徑或視頻URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"308\"/>\n        <source>警告</source>\n        <translation>警告</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"308\"/>\n        <source>建议根据文档配置cookies.txt文件，以可以下载高清视频</source>\n        <translation>建議根據文檔配置cookies.txt文件，以可以下載高清視頻</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"322\"/>\n        <source>开始下载</source>\n        <translation>開始下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"322\"/>\n        <source>开始下载视频...</source>\n        <translation>開始下載視頻...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"333\"/>\n        <source>下载成功</source>\n        <translation>下載成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"333\"/>\n        <source>视频下载完成，开始自动处理...</source>\n        <translation>視頻下載完成，開始自動處理...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"341\"/>\n        <source>视频下载失败</source>\n        <translation>視頻下載失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/task_creation_interface.py\" line=\"387\"/>\n        <source>请输入音视频文件路径或URL</source>\n        <translation>請輸入音視頻文件路徑或URL</translation>\n    </message>\n</context>\n<context>\n    <name>TranscriptThread</name>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"40\"/>\n        <source>转录失败</source>\n        <translation>轉錄失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"45\"/>\n        <source>文件路径为空</source>\n        <translation>文件路徑為空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"50\"/>\n        <source>视频文件不存在</source>\n        <translation>視頻文件不存在</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"53\"/>\n        <source>转录配置为空</source>\n        <translation>轉錄配置為空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"56\"/>\n        <source>输出路径为空</source>\n        <translation>輸出路徑為空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"74\"/>\n        <source>字幕已下载</source>\n        <translation>字幕已下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"86\"/>\n        <source>转换音频中</source>\n        <translation>轉換音頻中</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"103\"/>\n        <source>音频转换失败</source>\n        <translation>音頻轉換失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"105\"/>\n        <source>语音转录中</source>\n        <translation>語音轉錄中</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/transcript_thread.py\" line=\"121\"/>\n        <source>转录完成</source>\n        <translation>轉錄完成</translation>\n    </message>\n</context>\n<context>\n    <name>TranscriptionInterface</name>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"397\"/>\n        <source>打开文件</source>\n        <translation>打開文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"404\"/>\n        <source>转录模型</source>\n        <translation>轉錄模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"473\"/>\n        <source>转录完成</source>\n        <translation>轉錄完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"473\"/>\n        <source>开始字幕优化...</source>\n        <translation>開始字幕優化...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"490\"/>\n        <source>选择媒体文件</source>\n        <translation>選擇媒體文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"506\"/>\n        <source>错误</source>\n        <translation>錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"531\"/>\n        <source>警告</source>\n        <translation>警告</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"531\"/>\n        <source>正在处理中，请等待当前任务完成</source>\n        <translation>正在處理中，請等待當前任務完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"554\"/>\n        <source>导入成功</source>\n        <translation>導入成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"554\"/>\n        <source>开始语音转文字</source>\n        <translation>開始語音轉文字</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"562\"/>\n        <source>格式错误</source>\n        <translation>格式錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"562\"/>\n        <source>请拖入音频或视频文件</source>\n        <translation>請拖入音頻或視頻文件</translation>\n    </message>\n</context>\n<context>\n    <name>VideoInfoCard</name>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"103\"/>\n        <source>请拖入音频或视频文件</source>\n        <translation>請拖入音頻或視頻文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"111\"/>\n        <source>画质</source>\n        <translation>畫質</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"112\"/>\n        <source>文件大小</source>\n        <translation>文件大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"113\"/>\n        <source>时长</source>\n        <translation>時長</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"191\"/>\n        <source>音轨</source>\n        <translation>音軌</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"141\"/>\n        <source>打开文件夹</source>\n        <translation>打開文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"345\"/>\n        <source>开始转录</source>\n        <translation>開始轉錄</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"159\"/>\n        <source>画质: </source>\n        <translation>畫質: </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"163\"/>\n        <source>大小: </source>\n        <translation>大小: </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"165\"/>\n        <source>时长: </source>\n        <translation>時長: </translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"292\"/>\n        <source>警告</source>\n        <translation>警告</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"292\"/>\n        <source>没有可用的字幕文件夹</source>\n        <translation>沒有可用的字幕文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"327\"/>\n        <source>重新转录</source>\n        <translation>重新轉錄</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"329\"/>\n        <source>转录失败</source>\n        <translation>轉錄失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/transcription_interface.py\" line=\"339\"/>\n        <source>转录完成</source>\n        <translation>轉錄完成</translation>\n    </message>\n</context>\n<context>\n    <name>VideoSynthesisInterface</name>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"81\"/>\n        <source>开始合成</source>\n        <translation>開始合成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"98\"/>\n        <source>字幕文件</source>\n        <translation>字幕文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"100\"/>\n        <source>选择或者拖拽字幕文件</source>\n        <translation>選擇或者拖拽字幕文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"115\"/>\n        <source>浏览</source>\n        <translation>瀏覽</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"111\"/>\n        <source>视频文件</source>\n        <translation>視頻文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"113\"/>\n        <source>选择或者拖拽视频文件</source>\n        <translation>選擇或者拖拽視頻文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"128\"/>\n        <source>就绪</source>\n        <translation>就緒</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"138\"/>\n        <source>软字幕</source>\n        <translation>軟字幕</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"144\"/>\n        <source>使用软字幕嵌入视频</source>\n        <translation>使用軟字幕嵌入視頻</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"151\"/>\n        <source>视频质量</source>\n        <translation>視頻質量</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"170\"/>\n        <source>合成视频</source>\n        <translation>合成視頻</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"176\"/>\n        <source>是否生成新的视频文件</source>\n        <translation>是否生成新的視頻文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"183\"/>\n        <source>打开输出文件夹</source>\n        <translation>打開輸出文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"344\"/>\n        <source>选择视频文件</source>\n        <translation>選擇視頻文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"261\"/>\n        <source>开启软字幕</source>\n        <translation>開啓軟字幕</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"261\"/>\n        <source>字幕作为独立轨道嵌入视频，播放器中可关闭或调整</source>\n        <translation>字幕作為獨立軌道嵌入視頻，播放器中可關閉或調整</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"269\"/>\n        <source>开启硬烧录字幕</source>\n        <translation>開啓硬燒錄字幕</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"269\"/>\n        <source>字幕直接烧录到视频画面中，带有设置的样式</source>\n        <translation>字幕直接燒錄到視頻畫面中，帶有設置的樣式</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"287\"/>\n        <source>开启视频合成</source>\n        <translation>開啓視頻合成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"287\"/>\n        <source>将进行视频与字幕的合成操作</source>\n        <translation>將進行視頻與字幕的合成操作</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"295\"/>\n        <source>关闭视频合成</source>\n        <translation>關閉視頻合成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"295\"/>\n        <source>仅生成字幕文件，不生成新的视频文件</source>\n        <translation>僅生成字幕文件，不生成新的視頻文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"333\"/>\n        <source>选择字幕文件</source>\n        <translation>選擇字幕文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"413\"/>\n        <source>错误</source>\n        <translation>錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"354\"/>\n        <source>请选择字幕文件和视频文件</source>\n        <translation>請選擇字幕文件和視頻文件</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"398\"/>\n        <source>成功</source>\n        <translation>成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"398\"/>\n        <source>视频合成已完成</source>\n        <translation>視頻合成已完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"436\"/>\n        <source>警告</source>\n        <translation>警告</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"436\"/>\n        <source>没有可用的视频文件夹</source>\n        <translation>沒有可用的視頻文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"469\"/>\n        <source>导入成功</source>\n        <translation>導入成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"460\"/>\n        <source>字幕文件已放入输入框</source>\n        <translation>字幕文件已放入輸入框</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"469\"/>\n        <source>视频文件已输入框</source>\n        <translation>視頻文件已輸入框</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"477\"/>\n        <source>格式错误</source>\n        <translation>格式錯誤</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/view/video_synthesis_interface.py\" line=\"477\"/>\n        <source>请拖入视频或者字幕文件</source>\n        <translation>請拖入視頻或者字幕文件</translation>\n    </message>\n</context>\n<context>\n    <name>VideoSynthesisThread</name>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"63\"/>\n        <source>合成完成</source>\n        <translation>合成完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"39\"/>\n        <source>正在合成</source>\n        <translation>正在合成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"42\"/>\n        <source>视频路径为空</source>\n        <translation>視頻路徑為空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"44\"/>\n        <source>字幕路径为空</source>\n        <translation>字幕路徑為空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"46\"/>\n        <source>输出路径为空</source>\n        <translation>輸出路徑為空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/thread/video_synthesis_thread.py\" line=\"70\"/>\n        <source>视频合成失败</source>\n        <translation>視頻合成失敗</translation>\n    </message>\n</context>\n<context>\n    <name>WhisperAPISettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"42\"/>\n        <source>Whisper API 设置</source>\n        <translation>Whisper API 設置</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"45\"/>\n        <source>API Base URL</source>\n        <translation>API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"45\"/>\n        <source>输入 Whisper API Base URL</source>\n        <translation>輸入 Whisper API Base URL</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"55\"/>\n        <source>API Key</source>\n        <translation>API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"55\"/>\n        <source>输入 Whisper API Key</source>\n        <translation>輸入 Whisper API Key</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"65\"/>\n        <source>Whisper 模型</source>\n        <translation>Whisper 模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"65\"/>\n        <source>选择 Whisper 模型</source>\n        <translation>選擇 Whisper 模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"75\"/>\n        <source>原语言</source>\n        <translation>原語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"75\"/>\n        <source>音频的原语言</source>\n        <translation>音頻的原語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"85\"/>\n        <source>提示词</source>\n        <translation>提示詞</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"85\"/>\n        <source>可选的提示词,默认空</source>\n        <translation>可選的提示詞,默認空</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"187\"/>\n        <source>测试连接</source>\n        <translation>測試連接</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"95\"/>\n        <source>测试 Whisper API 连接</source>\n        <translation>測試 Whisper API 連接</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"95\"/>\n        <source>点击测试 API 连接是否正常</source>\n        <translation>點擊測試 API 連接是否正常</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"141\"/>\n        <source>配置不完整</source>\n        <translation>配置不完整</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"141\"/>\n        <source>请输入 API Base URL、API Key 和 model</source>\n        <translation>請輸入 API Base URL、API Key 和 model</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"152\"/>\n        <source>正在测试...</source>\n        <translation>正在測試...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"167\"/>\n        <source>连接成功</source>\n        <translation>連接成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"167\"/>\n        <source>Whisper API 连接成功！</source>\n        <translation>Whisper API 連接成功！</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"175\"/>\n        <source>连接失败</source>\n        <translation>連接失敗</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"175\"/>\n        <source>Whisper API 连接失败！\n{result}</source>\n        <translation>Whisper API 連接失敗！\n{result}</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperAPISettingWidget.py\" line=\"188\"/>\n        <source>测试错误</source>\n        <translation>測試錯誤</translation>\n    </message>\n</context>\n<context>\n    <name>WhisperCppDownloadDialog</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"242\"/>\n        <source>关闭</source>\n        <translation>關閉</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"248\"/>\n        <source>WhisperCpp程序</source>\n        <translation>WhisperCpp程序</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"258\"/>\n        <source>已安装版本: {versions_text}</source>\n        <translation>已安裝版本: {versions_text}</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"262\"/>\n        <source>未下载 WhisperCpp 程序</source>\n        <translation>未下載 WhisperCpp 程序</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"271\"/>\n        <source>模型下载</source>\n        <translation>模型下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"275\"/>\n        <source>打开模型文件夹</source>\n        <translation>打開模型文件夾</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\"/>\n        <source>模型名称</source>\n        <translation>模型名稱</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\"/>\n        <source>大小</source>\n        <translation>大小</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\"/>\n        <source>状态</source>\n        <translation>狀態</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"295\"/>\n        <source>操作</source>\n        <translation>操作</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"416\"/>\n        <source>已下载</source>\n        <translation>已下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"357\"/>\n        <source>未下载</source>\n        <translation>未下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"423\"/>\n        <source>重新下载</source>\n        <translation>重新下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"370\"/>\n        <source>下载</source>\n        <translation>下載</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"386\"/>\n        <source>下载进行中</source>\n        <translation>下載進行中</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"386\"/>\n        <source>请等待当前下载任务完成</source>\n        <translation>請等待當前下載任務完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"400\"/>\n        <source>正在下载 {model['label']} 模型...</source>\n        <translation>正在下載 {model['label']} 模型...</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"462\"/>\n        <source>下载成功</source>\n        <translation>下載成功</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"462\"/>\n        <source>{model['label']} 模型已下载完成</source>\n        <translation>{model['label']} 模型已下載完成</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"477\"/>\n        <source>下载失败</source>\n        <translation>下載失敗</translation>\n    </message>\n</context>\n<context>\n    <name>WhisperCppSettingWidget</name>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"530\"/>\n        <source>Whisper CPP 设置</source>\n        <translation>Whisper CPP 設置（不穩定 🤔）</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"535\"/>\n        <source>模型</source>\n        <translation>模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"535\"/>\n        <source>选择Whisper模型</source>\n        <translation>選擇Whisper模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"556\"/>\n        <source>源语言</source>\n        <translation>源語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"556\"/>\n        <source>音频的源语言</source>\n        <translation>音頻的源語言</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\"/>\n        <source>管理模型</source>\n        <translation>管理模型</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\"/>\n        <source>模型管理</source>\n        <translation>模型管理</translation>\n    </message>\n    <message>\n        <location filename=\"../../app/components/WhisperCppSettingWidget.py\" line=\"566\"/>\n        <source>下载或更新 Whisper CPP 模型</source>\n        <translation>下載或更新 Whisper CPP 模型</translation>\n    </message>\n</context>\n</TS>\n"
  },
  {
    "path": "scripts/lint.sh",
    "content": "#!/bin/bash\n# Clean unused imports and sort import order in Python project\n\necho \"🧹 Cleaning unused imports...\"\n\n# Remove unused imports (F401)\necho \"📍 Step 1: Remove unused imports\"\nruff check . --select F401 --fix\n\n# Sort import order (I)\necho \"📍 Step 2: Sort import order\"\nruff check . --select I --fix\n\n# Show statistics\necho \"\"\necho \"✅ Done!\"\necho \"📊 Check other issues:\"\nruff check . --statistics\n\necho \"\"\necho \"💡 Tip: Run 'ruff check . --fix' to auto-fix most code style issues\""
  },
  {
    "path": "scripts/run.bat",
    "content": "@echo off\nchcp 65001 >nul\nsetlocal EnableDelayedExpansion\n\n:: VideoCaptioner Installer & Launcher for Windows\n:: Usage: Download and run this script, or run from project directory\n\n:: Configuration\nset \"REPO_URL=https://github.com/WEIFENG2333/VideoCaptioner.git\"\nif not defined VIDEOCAPTIONER_HOME set \"INSTALL_DIR=%USERPROFILE%\\VideoCaptioner\"\nif defined VIDEOCAPTIONER_HOME set \"INSTALL_DIR=%VIDEOCAPTIONER_HOME%\"\n\necho.\necho ==================================\necho   VideoCaptioner Installer\necho ==================================\necho.\n\n:: Check if running from project directory (current dir)\nif exist \"main.py\" if exist \"pyproject.toml\" if exist \"app\" (\n    set \"INSTALL_DIR=%CD%\"\n    echo [INFO] Running from project directory: %INSTALL_DIR%\n    goto :after_detect\n)\n\n:: Check if running from scripts/ subdirectory\nset \"SCRIPT_DIR=%~dp0\"\nset \"PARENT_DIR=%SCRIPT_DIR%..\"\nif exist \"%PARENT_DIR%\\main.py\" if exist \"%PARENT_DIR%\\pyproject.toml\" (\n    pushd \"%PARENT_DIR%\"\n    set \"INSTALL_DIR=%CD%\"\n    popd\n    echo [INFO] Running from project directory: %INSTALL_DIR%\n)\n\n:after_detect\n\n:: Check git\nwhere git >nul 2>&1\nif %errorlevel% neq 0 (\n    echo [ERROR] Git is not installed. Please install git first.\n    echo Download from: https://git-scm.com/download/win\n    pause\n    exit /b 1\n)\n\n:: Check and install uv\ncall :install_uv\nif %errorlevel% neq 0 exit /b 1\n\n:: Setup repository if needed\nif not exist \"%INSTALL_DIR%\\main.py\" (\n    call :setup_repository\n    if %errorlevel% neq 0 exit /b 1\n)\n\ncd /d \"%INSTALL_DIR%\"\n\n:: Install dependencies\ncall :install_dependencies\nif %errorlevel% neq 0 exit /b 1\n\n:: Check system dependencies\ncall :check_system_deps\n\n:: Run the application\ncall :run_app\nexit /b 0\n\n:: ============================================\n:: Functions\n:: ============================================\n\n:install_uv\nwhere uv >nul 2>&1\nif %errorlevel% equ 0 (\n    for /f \"tokens=*\" %%i in ('uv --version') do echo [OK] uv is already installed: %%i\n    exit /b 0\n)\n\necho [INFO] Installing uv package manager...\n\n:: Try PowerShell installation\npowershell -ExecutionPolicy ByPass -NoProfile -Command \"irm https://astral.sh/uv/install.ps1 | iex\"\n\n:: Add to PATH for current session\nset \"PATH=%USERPROFILE%\\.local\\bin;%PATH%\"\nset \"PATH=%LOCALAPPDATA%\\uv\\bin;%PATH%\"\n\nwhere uv >nul 2>&1\nif %errorlevel% equ 0 (\n    for /f \"tokens=*\" %%i in ('uv --version') do echo [OK] uv installed successfully: %%i\n    exit /b 0\n) else (\n    echo [ERROR] Failed to install uv. Please install manually: https://docs.astral.sh/uv/\n    pause\n    exit /b 1\n)\n\n:setup_repository\nif exist \"%INSTALL_DIR%\\.git\" (\n    echo [INFO] Project found at %INSTALL_DIR%\n    exit /b 0\n)\n\necho [INFO] Cloning VideoCaptioner to %INSTALL_DIR%...\ngit clone \"%REPO_URL%\" \"%INSTALL_DIR%\"\nif %errorlevel% neq 0 (\n    echo [ERROR] Failed to clone repository\n    pause\n    exit /b 1\n)\necho [OK] Repository cloned successfully\nexit /b 0\n\n:install_dependencies\necho [INFO] Installing dependencies with uv...\nuv sync\nif %errorlevel% neq 0 (\n    echo [ERROR] Failed to install dependencies\n    pause\n    exit /b 1\n)\necho [OK] Dependencies installed\nexit /b 0\n\n:check_system_deps\nwhere ffmpeg >nul 2>&1\nif %errorlevel% neq 0 (\n    echo [WARN] FFmpeg not found ^(required for video synthesis^)\n    echo   Install with: winget install ffmpeg\n    echo   Or download from: https://ffmpeg.org/download.html\n)\nexit /b 0\n\n:run_app\necho.\necho [INFO] Starting VideoCaptioner...\necho.\nuv run python main.py\nif %errorlevel% neq 0 (\n    echo.\n    echo Application exited with error.\n    pause\n)\nexit /b 0\n\n"
  },
  {
    "path": "scripts/run.sh",
    "content": "#!/bin/bash\n# VideoCaptioner Installer & Launcher for macOS/Linux\n# Usage: curl -fsSL https://raw.githubusercontent.com/WEIFENG2333/VideoCaptioner/main/scripts/run.sh | bash\n\nset -e\n\n# Configuration\nREPO_URL=\"https://github.com/WEIFENG2333/VideoCaptioner.git\"\nINSTALL_DIR=\"${VIDEOCAPTIONER_HOME:-$HOME/VideoCaptioner}\"\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\nprint_info() { echo -e \"${BLUE}[INFO]${NC} $1\"; }\nprint_success() { echo -e \"${GREEN}[OK]${NC} $1\"; }\nprint_warning() { echo -e \"${YELLOW}[WARN]${NC} $1\"; }\nprint_error() { echo -e \"${RED}[ERROR]${NC} $1\"; }\n\n# Check if running from within the project directory\ndetect_project_dir() {\n    # If main.py exists in current directory, use it\n    if [ -f \"main.py\" ] && [ -f \"pyproject.toml\" ] && [ -d \"app\" ]; then\n        INSTALL_DIR=\"$(pwd)\"\n        return 0\n    fi\n\n    # If script is run from scripts/ subdirectory, check parent\n    SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n    PARENT_DIR=\"$(dirname \"$SCRIPT_DIR\")\"\n    \n    if [ -f \"$PARENT_DIR/main.py\" ] && [ -f \"$PARENT_DIR/pyproject.toml\" ]; then\n        INSTALL_DIR=\"$PARENT_DIR\"\n        return 0\n    fi\n\n    # If script is in project root\n    if [ -f \"$SCRIPT_DIR/main.py\" ] && [ -f \"$SCRIPT_DIR/pyproject.toml\" ]; then\n        INSTALL_DIR=\"$SCRIPT_DIR\"\n        return 0\n    fi\n\n    return 1\n}\n\n# Install uv if not present\ninstall_uv() {\n    if command -v uv &> /dev/null; then\n        print_success \"uv is already installed: $(uv --version)\"\n        return 0\n    fi\n\n    print_info \"Installing uv package manager...\"\n\n    if command -v curl &> /dev/null; then\n        curl -LsSf https://astral.sh/uv/install.sh | sh\n    elif command -v wget &> /dev/null; then\n        wget -qO- https://astral.sh/uv/install.sh | sh\n    else\n        print_error \"Neither curl nor wget found. Please install one of them first.\"\n        exit 1\n    fi\n\n    # Add uv to PATH for current session\n    export PATH=\"$HOME/.local/bin:$HOME/.cargo/bin:$PATH\"\n\n    if command -v uv &> /dev/null; then\n        print_success \"uv installed successfully: $(uv --version)\"\n    else\n        print_error \"Failed to install uv. Please install manually: https://docs.astral.sh/uv/\"\n        exit 1\n    fi\n}\n\n# Clone or update repository\nsetup_repository() {\n    if [ -d \"$INSTALL_DIR/.git\" ]; then\n        print_info \"Project found at $INSTALL_DIR\"\n        cd \"$INSTALL_DIR\"\n\n        # Optional: pull latest changes\n        if [ \"${VIDEOCAPTIONER_AUTO_UPDATE:-false}\" = \"true\" ]; then\n            print_info \"Checking for updates...\"\n            git pull --ff-only 2>/dev/null || print_warning \"Could not update (local changes?)\"\n        fi\n    else\n        print_info \"Cloning VideoCaptioner to $INSTALL_DIR...\"\n        git clone \"$REPO_URL\" \"$INSTALL_DIR\"\n        cd \"$INSTALL_DIR\"\n        print_success \"Repository cloned successfully\"\n    fi\n}\n\n# Install dependencies with uv\ninstall_dependencies() {\n    print_info \"Installing dependencies with uv...\"\n\n    # Sync dependencies (creates .venv if needed)\n    uv sync\n\n    print_success \"Dependencies installed\"\n}\n\n# Check system dependencies\ncheck_system_deps() {\n    # Check ffmpeg (required)\n    if ! command -v ffmpeg &> /dev/null; then\n        print_warning \"FFmpeg not found (required for video synthesis)\"\n\n        if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n            echo \"  Install with: brew install ffmpeg\"\n        elif command -v apt &> /dev/null; then\n            echo \"  Install with: sudo apt install ffmpeg\"\n        elif command -v dnf &> /dev/null; then\n            echo \"  Install with: sudo dnf install ffmpeg\"\n        elif command -v pacman &> /dev/null; then\n            echo \"  Install with: sudo pacman -S ffmpeg\"\n        fi\n    fi\n}\n\n# Run the application\nrun_app() {\n    print_info \"Starting VideoCaptioner...\"\n    echo \"\"\n\n    cd \"$INSTALL_DIR\"\n    uv run python main.py\n}\n\n# Main\nmain() {\n    echo \"\"\n    echo \"==================================\"\n    echo \"  VideoCaptioner Installer\"\n    echo \"==================================\"\n    echo \"\"\n\n    # Try to detect if we're in project directory\n    if detect_project_dir; then\n        print_info \"Running from project directory: $INSTALL_DIR\"\n    fi\n\n    # Check git\n    if ! command -v git &> /dev/null; then\n        print_error \"Git is not installed. Please install git first.\"\n        exit 1\n    fi\n\n    # Install uv\n    install_uv\n\n    # Setup repository (clone if needed)\n    if [ ! -f \"$INSTALL_DIR/main.py\" ]; then\n        setup_repository\n    else\n        cd \"$INSTALL_DIR\"\n    fi\n\n    # Install/update dependencies\n    install_dependencies\n\n    # Check system dependencies\n    check_system_deps\n\n    # Run the app\n    run_app\n}\n\nmain \"$@\"\n\n"
  },
  {
    "path": "scripts/trans-compile.sh",
    "content": "#!/bin/bash\n# Compile .ts translation files to .qm binary files\n# Usage: ./scripts/trans-compile.sh [language_code]\n#   ./scripts/trans-compile.sh         # Compile all languages\n#   ./scripts/trans-compile.sh en_US   # Compile English only\n\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_ROOT=\"$(dirname \"$SCRIPT_DIR\")\"\nTRANS_DIR=\"$PROJECT_ROOT/resource/translations\"\n\n# Check for lrelease tool\ncheck_lrelease() {\n    if command -v lrelease &> /dev/null; then\n        echo \"lrelease\"\n    elif command -v lrelease-qt5 &> /dev/null; then\n        echo \"lrelease-qt5\"\n    else\n        echo \"\"\n    fi\n}\n\nLRELEASE=$(check_lrelease)\n\nif [ -z \"$LRELEASE\" ]; then\n    echo \"❌ lrelease tool not found\"\n    echo \"\"\n    echo \"Please install Qt toolchain:\"\n    echo \"  macOS:   brew install qt@5\"\n    echo \"  Linux:   sudo apt-get install qttools5-dev-tools\"\n    echo \"\"\n    echo \"Then add lrelease to PATH:\"\n    echo \"  export PATH=\\\"/opt/homebrew/opt/qt@5/bin:\\$PATH\\\"\"\n    exit 1\nfi\n\necho \"🔨 Compiling translation files...\"\necho \"\"\n\n# Compile specific language if provided\nif [ -n \"$1\" ]; then\n    LANG_CODE=\"$1\"\n    TS_FILE=\"$TRANS_DIR/VideoCaptioner_$LANG_CODE.ts\"\n\n    if [ ! -f \"$TS_FILE\" ]; then\n        echo \"❌ Translation file not found: $TS_FILE\"\n        exit 1\n    fi\n\n    echo \"📦 Compiling $LANG_CODE...\"\n    $LRELEASE \"$TS_FILE\" -qm \"$TRANS_DIR/VideoCaptioner_$LANG_CODE.qm\"\nelse\n    # Compile all translation files\n    for ts_file in \"$TRANS_DIR\"/*.ts; do\n        if [ -f \"$ts_file\" ]; then\n            filename=$(basename \"$ts_file\" .ts)\n            echo \"📦 Compiling $filename...\"\n            $LRELEASE \"$ts_file\" -qm \"$TRANS_DIR/$filename.qm\"\n        fi\n    done\nfi\n\necho \"\"\necho \"✅ Compilation completed!\"\necho \"📁 Output files: resource/translations/*.qm\"\n"
  },
  {
    "path": "scripts/trans-extract.sh",
    "content": "#!/bin/bash\n# Extract translation strings from Python code to .ts files\n# Auto-removes obsolete entries\n# Usage: ./scripts/trans-extract.sh\n\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_ROOT=\"$(dirname \"$SCRIPT_DIR\")\"\nTRANS_DIR=\"$PROJECT_ROOT/resource/translations\"\n\necho \"🔍 Extracting translation strings...\"\necho \"\"\n\ncd \"$PROJECT_ROOT\"\n\n# Check if pylupdate5 is available\nif ! command -v pylupdate5 &> /dev/null; then\n    echo \"❌ pylupdate5 not found\"\n    exit 1\nfi\n\n# Extract all tr() calls from Python files to .ts files\necho \"📝 Scanning tr() calls in Python code...\"\npylupdate5 -verbose \\\n    $(find app -name \"*.py\") \\\n    -ts \"$TRANS_DIR/VideoCaptioner_zh_CN.ts\" \\\n    -ts \"$TRANS_DIR/VideoCaptioner_zh_HK.ts\" \\\n    -ts \"$TRANS_DIR/VideoCaptioner_en_US.ts\"\n\n# Remove obsolete translations\necho \"\"\necho \"🧹 Cleaning obsolete translations...\"\n\nfor ts_file in \"$TRANS_DIR\"/*.ts; do\n    if [ -f \"$ts_file\" ]; then\n        filename=$(basename \"$ts_file\")\n\n        # Count obsolete entries before removal\n        obsolete_count=$(grep -c 'type=\"obsolete\"' \"$ts_file\" 2>/dev/null || echo \"0\")\n        obsolete_count=$(echo \"$obsolete_count\" | head -1)  # Ensure single value\n\n        if [ \"$obsolete_count\" -gt 0 ] 2>/dev/null; then\n            # Create temp file and remove obsolete messages\n            python3 << EOF\nimport re\nfrom pathlib import Path\n\nts_path = Path(\"$ts_file\")\ncontent = ts_path.read_text(encoding='utf-8')\n\n# Remove entire <message>...</message> blocks that contain type=\"obsolete\"\n# This regex matches from <message> to </message> if it contains type=\"obsolete\"\npattern = r'    <message>.*?type=\"obsolete\".*?</message>\\n'\ncleaned_content = re.sub(pattern, '', content, flags=re.DOTALL)\n\nts_path.write_text(cleaned_content, encoding='utf-8')\nEOF\n\n            echo \"   ✓ $filename: Removed $obsolete_count obsolete entries\"\n        else\n            echo \"   ✓ $filename: No obsolete entries\"\n        fi\n    fi\ndone\n\necho \"\"\necho \"✅ Translation strings extracted and cleaned successfully!\"\necho \"📁 Translation files: resource/translations/\"\necho \"\"\necho \"💡 Next steps:\"\necho \"   1. Edit translations with Qt Linguist: linguist resource/translations/VideoCaptioner_en_US.ts\"\necho \"   2. Or compile directly: ./scripts/trans-compile.sh\"\n"
  },
  {
    "path": "scripts/translate_llm.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTranslate .ts files using OpenAI Structured Outputs\n\nEnsures 1:1 mapping between source and translation with zero data loss.\nTarget language is automatically detected from filename.\n\nUsage:\n    python scripts/translate_llm.py <file>\n\nExamples:\n    python scripts/translate_llm.py resource/translations/VideoCaptioner_en_US.ts\n    python scripts/translate_llm.py resource/translations/VideoCaptioner_zh_HK.ts\n    python scripts/translate_llm.py resource/translations/VideoCaptioner_ja_JP.ts\n\"\"\"\nimport os\nimport re\nimport sys\nimport xml.etree.ElementTree as ET\nfrom pathlib import Path\nfrom typing import List\n\nfrom openai import OpenAI\nfrom pydantic import BaseModel\n\n# ============================================================================\n# Configuration\n# ============================================================================\n\nBATCH_SIZE = 10\nMODEL = \"gpt-5\"\nTEMPERATURE = 1\n\n# Technical terms that should not be translated\nPRESERVE_TERMS = [\n    \"ASR\",\n    \"LLM\",\n    \"TTS\",\n    \"FFmpeg\",\n    \"Whisper\",\n    \"FasterWhisper\",\n    \"WhisperCpp\",\n    \"OpenAI\",\n    \"GPU\",\n    \"CPU\",\n    \"CUDA\",\n    \"VAD\",\n    \"Silero\",\n    \"Pyannote\",\n    \"WebRTC\",\n    \"Auditok\",\n]\n\n# Language mapping from locale code to target language\nLANGUAGE_MAP = {\n    \"en_US\": \"English\",\n    \"zh_HK\": \"Traditional Chinese (Hong Kong)\",\n    \"zh_TW\": \"Traditional Chinese (Taiwan)\",\n    \"ja_JP\": \"Japanese\",\n    \"ko_KR\": \"Korean\",\n    \"fr_FR\": \"French\",\n    \"de_DE\": \"German\",\n    \"es_ES\": \"Spanish\",\n    \"it_IT\": \"Italian\",\n    \"pt_BR\": \"Portuguese (Brazil)\",\n    \"ru_RU\": \"Russian\",\n    \"ar_SA\": \"Arabic\",\n    \"th_TH\": \"Thai\",\n    \"vi_VN\": \"Vietnamese\",\n}\n\n# ============================================================================\n# Structured Output Models\n# ============================================================================\n\n\nclass Translation(BaseModel):\n    \"\"\"Single translation with index for guaranteed ordering\"\"\"\n\n    index: int\n    source: str\n    translation: str\n\n\nclass TranslationBatch(BaseModel):\n    \"\"\"Batch of translations with strict schema\"\"\"\n\n    translations: List[Translation]\n\n\n# ============================================================================\n# OpenAI Client\n# ============================================================================\n\n# Use direct OpenAI API (bypass any custom base_url in environment)\n\napi_key = os.environ.get(\"OPENAI_API_KEY\")\nif not api_key:\n    raise ValueError(\"OPENAI_API_KEY environment variable is not set\")\n\nclient = OpenAI(\n    api_key=api_key, base_url=\"https://api.openai.com/v1\"  # Force direct OpenAI API\n)\n\n\n# ============================================================================\n# Core Functions\n# ============================================================================\n\n\ndef detect_target_language(filename: str) -> str:\n    \"\"\"Detect target language from filename\"\"\"\n    # Extract locale code (e.g., \"en_US\" from \"VideoCaptioner_en_US.ts\")\n    match = re.search(r\"_([a-z]{2}_[A-Z]{2})\\.ts$\", filename)\n\n    if not match:\n        raise ValueError(\n            f\"Cannot detect language from filename: {filename}\\n\"\n            f\"Expected format: VideoCaptioner_<locale>.ts (e.g., VideoCaptioner_en_US.ts)\"\n        )\n\n    locale = match.group(1)\n\n    if locale not in LANGUAGE_MAP:\n        raise ValueError(\n            f\"Unsupported locale: {locale}\\n\"\n            f\"Supported: {', '.join(LANGUAGE_MAP.keys())}\"\n        )\n\n    return LANGUAGE_MAP[locale]\n\n\ndef translate_batch(\n    texts: List[str], target_lang: str, start_index: int\n) -> List[Translation]:\n    \"\"\"\n    Translate a batch of texts using structured outputs.\n\n    Returns translations with guaranteed index matching.\n    \"\"\"\n\n    # Build numbered input\n    items = [{\"index\": start_index + i, \"text\": text} for i, text in enumerate(texts)]\n\n    # Construct clear, professional prompt\n    prompt = f\"\"\"You are a professional UI translator. Translate these texts to {target_lang}.\n\n**CRITICAL REQUIREMENTS:**\n1. Maintain exact 1:1 mapping - every input MUST have corresponding output\n2. Keep translations concise and natural for UI context\n3. Use standard UI terminology (e.g., \"Settings\", \"Cancel\", \"OK\")\n4. NEVER translate technical terms: {', '.join(PRESERVE_TERMS)}\n5. Preserve formatting markers like {{variable}}, %s, \\\\n\n6. Match the tone: formal for settings, friendly for messages\n\n**Input texts (index: text):**\n{chr(10).join([f\"{item['index']}: {item['text']}\" for item in items])}\n\n**Your task:**\nReturn EXACTLY {len(texts)} translations with matching indices.\"\"\"\n\n    # Call OpenAI with structured output\n    completion = client.beta.chat.completions.parse(\n        model=MODEL,\n        messages=[\n            {\n                \"role\": \"system\",\n                \"content\": f\"You are an expert UI translator specializing in {target_lang}. \"\n                \"You always return complete, accurate translations.\",\n            },\n            {\"role\": \"user\", \"content\": prompt},\n        ],\n        response_format=TranslationBatch,\n        temperature=TEMPERATURE,\n    )\n\n    result = completion.choices[0].message.parsed\n\n    # Validate we got all translations\n    if len(result.translations) != len(texts):\n        raise ValueError(\n            f\"Translation mismatch: expected {len(texts)}, got {len(result.translations)}\"\n        )\n\n    return sorted(result.translations, key=lambda x: x.index)\n\n\ndef translate_file(ts_file: Path, target_lang: str) -> None:\n    \"\"\"Translate a .ts file with progress tracking\"\"\"\n\n    # Parse XML\n    tree = ET.parse(ts_file)\n    root = tree.getroot()\n\n    # Collect untranslated entries\n    entries = []\n    for message in root.findall(\".//message\"):\n        source = message.find(\"source\")\n        translation = message.find(\"translation\")\n\n        if source is not None and translation is not None:\n            text = source.text or \"\"\n            if not translation.text or translation.get(\"type\") == \"unfinished\":\n                entries.append((text, translation))\n\n    if not entries:\n        print(\"✨ All translations already complete!\")\n        return\n\n    total = len(entries)\n    print(f\"📊 Found {total} texts to translate\")\n    print(f\"🎯 Target language: {target_lang}\")\n    print(f\"🔧 Using model: {MODEL}\")\n    print(\"─\" * 60)\n\n    # Process in batches\n    success_count = 0\n\n    for i in range(0, total, BATCH_SIZE):\n        batch_texts = [entry[0] for entry in entries[i : i + BATCH_SIZE]]\n        batch_elements = [entry[1] for entry in entries[i : i + BATCH_SIZE]]\n\n        batch_num = i // BATCH_SIZE + 1\n        total_batches = (total - 1) // BATCH_SIZE + 1\n\n        print(\n            f\"🔄 Batch {batch_num}/{total_batches} ({len(batch_texts)} texts)...\",\n            end=\" \",\n            flush=True,\n        )\n\n        try:\n            # Get structured translations\n            translations = translate_batch(batch_texts, target_lang, i)\n\n            # Verify and apply translations\n            for j, trans in enumerate(translations):\n                # Double-check index matches\n                expected_index = i + j\n                if trans.index != expected_index:\n                    raise ValueError(f\"Index mismatch at position {j}\")\n\n                # Apply translation\n                elem = batch_elements[j]\n                elem.text = trans.translation\n\n                # Remove 'unfinished' attribute\n                if \"type\" in elem.attrib:\n                    del elem.attrib[\"type\"]\n\n            success_count += len(translations)\n            print(f\"✅ {len(translations)}\")\n\n        except Exception as e:\n            print(f\"❌ {type(e).__name__}: {str(e)[:50]}\")\n            continue\n\n    # Save with pretty formatting\n    print(\"\\n💾 Saving translations...\")\n    tree.write(ts_file, encoding=\"utf-8\", xml_declaration=True)\n\n    # Summary\n    print(\"─\" * 60)\n    print(f\"✨ Complete! {success_count}/{total} translations applied\")\n    print(f\"📁 File: {ts_file}\")\n    print(\"\\n💡 Next steps:\")\n    print(f\"   1. Review: linguist {ts_file}\")\n    print(f\"   2. Compile: ./scripts/trans-compile.sh\")\n    print(f\"   3. Test: Switch to {target_lang} in app\\n\")\n\n\n# ============================================================================\n# CLI Entry Point\n# ============================================================================\n\n\ndef main():\n    # Validate arguments\n    if len(sys.argv) < 2:\n        print(__doc__)\n        sys.exit(1)\n\n    ts_file = Path(sys.argv[1])\n\n    # Validate file exists\n    if not ts_file.exists():\n        print(f\"❌ File not found: {ts_file}\")\n        sys.exit(1)\n\n    # Auto-detect target language\n    try:\n        target_lang = detect_target_language(ts_file.name)\n    except ValueError as e:\n        print(f\"❌ {e}\")\n        sys.exit(1)\n\n    # Banner\n    print(\"\\n\" + \"=\" * 60)\n    print(\"🌐 OpenAI Structured Translation\")\n    print(\"=\" * 60)\n    print(f\"📄 File: {ts_file.name}\")\n    print(f\"🎯 Target: {target_lang} (auto-detected)\")\n    print(\"=\" * 60 + \"\\n\")\n\n    # Execute translation\n    try:\n        translate_file(ts_file, target_lang)\n    except KeyboardInterrupt:\n        print(\"\\n\\n⚠️  Translation interrupted by user\")\n        sys.exit(1)\n    except Exception as e:\n        print(f\"\\n❌ Fatal error: {e}\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "tests/README.md",
    "content": "# 测试套件\n\nVideoCaptioner 翻译模块的集成测试。\n\n## 📁 测试文件\n\n```\ntests/test_translate/\n├── test_google_translator.py   # Google 翻译器（免费 API）\n├── test_bing_translator.py     # Bing 翻译器（免费 API）\n├── test_llm_translator.py      # LLM 翻译器（需要 API 密钥）\n└── test_deeplx_translator.py   # DeepLX 翻译器（可选）\n```\n\n## 🚀 运行测试\n\n### 快速测试（免费 API）\n\n```bash\n# Google + Bing 翻译器（无需配置）\nuv run pytest tests/test_translate/test_google_translator.py tests/test_translate/test_bing_translator.py -v\n```\n\n### 完整测试（需要 API 密钥）\n\n```bash\n# 1. 配置环境变量\nexport OPENAI_BASE_URL=https://api.openai.com/v1\nexport OPENAI_API_KEY=sk-your-key\n\n# 2. 运行所有测试\nuv run pytest tests/test_translate/ -v\n```\n\n### 运行特定测试\n\n```bash\n# 只运行 Google 翻译器\nuv run pytest tests/test_translate/test_google_translator.py::TestGoogleTranslator::test_translate_simple_text -v\n\n# 跳过需要 API 的测试\nuv run pytest tests/test_translate/ -m \"not integration\" -v\n```\n\n## ⚙️ 环境变量\n\n### 本地开发\n\n创建 `.env` 文件（已在 .gitignore 中）：\n\n```bash\n# LLM 翻译器测试（必需）\nOPENAI_BASE_URL=https://api.openai.com/v1\nOPENAI_API_KEY=sk-your-api-key\n\n# DeepLX 翻译器测试（可选）\nDEEPLX_ENDPOINT=https://api.deeplx.org/translate\n```\n\n### CI/CD\n\nGitHub Actions 中通过 **Settings → Secrets** 配置：\n\n- `OPENAI_BASE_URL`\n- `OPENAI_API_KEY`\n- `DEEPLX_ENDPOINT`（可选）\n\n详见 [docs/CI_SETUP.md](../docs/CI_SETUP.md)\n\n## 📊 测试结果示例\n\n```\n=================== 6 passed, 6 skipped ===================\n\n✅ test_google_translator.py    3 passed\n✅ test_bing_translator.py      3 passed\n⏭️ test_llm_translator.py       4 skipped (no API key)\n⏭️ test_deeplx_translator.py    2 skipped (no endpoint)\n```\n\n## 🐛 常见问题\n\n### 测试被跳过\n\n**原因**: 缺少环境变量\n\n**解决**:\n\n```bash\nexport OPENAI_BASE_URL=...\nexport OPENAI_API_KEY=...\n```\n\n### ImportError\n\n**原因**: 缺少依赖\n\n**解决**:\n\n```bash\nuv sync --all-extras\n```\n\n### 翻译测试失败\n\n**原因**: 免费 API 可能不稳定或有频率限制\n\n**解决**:\n\n- Google/Bing 测试失败是正常的（免费服务）\n- 等待几分钟后重试\n- 只运行 LLM 测试（更稳定）\n\n## 📝 添加新测试\n\n```python\n# tests/test_translate/test_my_translator.py\nimport pytest\nfrom app.core.translate.my_translator import MyTranslator\n\n@pytest.mark.integration\nclass TestMyTranslator:\n    @pytest.fixture\n    def translator(self, target_language):\n        return MyTranslator(\n            thread_num=2,\n            batch_num=5,\n            target_language=target_language,\n            update_callback=None,\n        )\n\n    def test_translate(self, translator, sample_asr_data):\n        result = translator.translate_subtitle(sample_asr_data)\n        assert len(result.segments) == len(sample_asr_data.segments)\n        for seg in result.segments:\n            assert seg.translated_text  # 确保有翻译结果\n```\n\n## 🔗 相关文档\n\n- [CI/CD 配置](../docs/CI_SETUP.md)\n- [测试指南](../docs/TESTING.md)\n"
  },
  {
    "path": "tests/__init__.py",
    "content": "\"\"\"\n测试套件\n\n用于测试 VideoCaptioner 核心功能\n\"\"\"\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"Root-level test configuration and shared fixtures.\n\nThis conftest.py provides shared fixtures and utilities for all tests.\nModule-specific fixtures should be placed in their respective conftest.py files.\n\"\"\"\n\nimport os\nfrom pathlib import Path\nfrom typing import Dict, List\n\nimport pytest\nfrom dotenv import load_dotenv\nfrom openinference.instrumentation.openai import OpenAIInstrumentor\nfrom opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter\n\n# from phoenix.otel import register\nfrom opentelemetry.sdk import trace as trace_sdk\nfrom opentelemetry.sdk.trace.export import SimpleSpanProcessor\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.translate import SubtitleProcessData, TargetLanguage\nfrom app.core.utils import cache\n\n# Load environment variables from tests/.env\nload_dotenv(Path(__file__).parent / \".env\")\n\n# Register OpenAI OTel tracing\n# tracer_provider = register(\n#     project_name=\"default\",\n#     endpoint=\"http://localhost:6006/v1/traces\",\n#     auto_instrument=True,\n# )\ntracer_provider = trace_sdk.TracerProvider()\ntracer_provider.add_span_processor(\n    SimpleSpanProcessor(OTLPSpanExporter(endpoint=\"http://localhost:6006/v1/traces\"))\n)\nOpenAIInstrumentor().instrument(tracer_provider=tracer_provider)\n\n\n# Disable cache for testing\ncache.disable_cache()\n\n\n# ============================================================================\n# Shared Data Fixtures\n# ============================================================================\n\n\n@pytest.fixture\ndef sample_asr_data():\n    \"\"\"Create sample ASR data for translation testing.\n\n    Returns:\n        ASRData with 3 English segments\n    \"\"\"\n    segments = [\n        ASRDataSeg(\n            start_time=0,\n            end_time=1000,\n            text=\"I am a student\",\n        ),\n        ASRDataSeg(\n            start_time=1000,\n            end_time=2000,\n            text=\"You are a teacher\",\n        ),\n        ASRDataSeg(\n            start_time=2000,\n            end_time=3000,\n            text=\"VideoCaptioner is a tool for captioning videos\",\n        ),\n    ]\n    return ASRData(segments)\n\n\n@pytest.fixture\ndef sample_translate_data():\n    \"\"\"Create sample translation data for testing.\"\"\"\n    return [\n        SubtitleProcessData(\n            index=1, original_text=\"I am a student\", translated_text=\"\"\n        ),\n        SubtitleProcessData(\n            index=2, original_text=\"You are a teacher\", translated_text=\"\"\n        ),\n        SubtitleProcessData(\n            index=3,\n            original_text=\"VideoCaptioner is a tool for captioning videos\",\n            translated_text=\"\",\n        ),\n    ]\n\n\n@pytest.fixture\ndef target_language():\n    \"\"\"Default target language for translation tests.\n\n    Returns:\n        Simplified Chinese as default target language\n    \"\"\"\n    return TargetLanguage.SIMPLIFIED_CHINESE\n\n\n# ============================================================================\n# Shared Utility Fixtures\n# ============================================================================\n\n\n@pytest.fixture\ndef check_env_vars():\n    \"\"\"Check if required environment variables are set.\n\n    Returns:\n        Function that takes variable names and skips test if any are missing\n\n    Example:\n        def test_api(check_env_vars):\n            check_env_vars(\"OPENAI_API_KEY\", \"OPENAI_BASE_URL\")\n            # Test continues only if both variables are set\n    \"\"\"\n\n    def _check(*var_names):\n        missing = [var for var in var_names if not os.getenv(var)]\n        if missing:\n            pytest.skip(f\"Required environment variables not set: {', '.join(missing)}\")\n\n    return _check\n\n\n# ============================================================================\n# Translation Test Data\n# ============================================================================\n\n\n@pytest.fixture\ndef expected_translations() -> Dict[str, Dict[str, List[str]]]:\n    \"\"\"Expected translation keywords for quality validation.\n\n    Returns:\n        Dictionary mapping language -> original text -> expected keywords\n\n    Example:\n        {\n            \"简体中文\": {\n                \"I am a student\": [\"学生\"],\n                \"You are a teacher\": [\"老师\", \"教师\"]\n            }\n        }\n    \"\"\"\n    return {\n        \"简体中文\": {\n            \"I am a student\": [\"学生\"],\n            \"You are a teacher\": [\"老师\", \"教师\"],\n            \"VideoCaptioner is a tool for captioning videos\": [\"工具\"],\n            \"Hello world\": [\"你好\", \"世界\"],\n            \"This is a test\": [\"测试\"],\n            \"Machine learning\": [\"机器学习\"],\n        },\n        \"日本語\": {\n            \"I am a student\": [\"学生\"],\n            \"You are a teacher\": [\"先生\", \"教師\"],\n            \"VideoCaptioner is a tool for captioning videos\": [\n                \"VideoCaptioner\",\n                \"ツール\",\n                \"字幕\",\n            ],\n            \"Hello world\": [\"こんにちは\", \"世界\"],\n            \"This is a test\": [\"テスト\"],\n            \"Machine learning\": [\"機械学習\"],\n        },\n        \"English\": {\n            \"我是学生\": [\"student\"],\n            \"你是老师\": [\"teacher\"],\n            \"这是一个测试\": [\"test\"],\n        },\n    }\n\n\n# ============================================================================\n# LLM Mocking Utilities\n# ============================================================================\n\n\n@pytest.fixture\ndef mock_llm_client(monkeypatch):\n    \"\"\"Mock LLM client for testing without external API calls.\n\n    Provides reasonable default responses for common LLM operations.\n    Tests can use this fixture to avoid real API calls.\n\n    Example:\n        def test_split(mock_llm_client):\n            # LLM calls will be mocked automatically\n            result = split_by_llm(\"你好世界\")\n    \"\"\"\n    from unittest.mock import MagicMock\n\n    from openai.types.chat import ChatCompletion, ChatCompletionMessage\n    from openai.types.chat.chat_completion import Choice\n\n    def mock_create(**kwargs):\n        \"\"\"Mock OpenAI chat completion create method.\"\"\"\n        messages = kwargs.get(\"messages\", [])\n        model = kwargs.get(\"model\", \"gpt-4o-mini\")\n\n        # Extract system and user messages\n        system_content = \"\"\n        user_content = \"\"\n        for msg in messages:\n            if msg.get(\"role\") == \"system\":\n                system_content = msg.get(\"content\", \"\")\n            elif msg.get(\"role\") == \"user\":\n                user_content = msg.get(\"content\", \"\")\n\n        # Generate mock response based on request\n        if \"<br>\" in user_content or \"separate\" in user_content.lower():\n            # Split request - return text with <br> tags\n            text_to_split = user_content.split(\"sentence:\\n\")[-1].strip()\n\n            # Extract max length from system prompt\n            import re\n\n            max_cjk = 18  # default\n            max_eng = 12  # default\n            if \"max\" in system_content.lower():\n                cjk_match = re.search(r\"中文.*?(\\d+)\", system_content)\n                if cjk_match:\n                    max_cjk = int(cjk_match.group(1))\n                eng_match = re.search(r\"英文.*?(\\d+)\", system_content)\n                if eng_match:\n                    max_eng = int(eng_match.group(1))\n\n            # Split by punctuation first\n            sentences = re.split(r\"([。！？\\.!?])\", text_to_split)\n            initial_parts = []\n            for i in range(0, len(sentences) - 1, 2):\n                if i + 1 < len(sentences):\n                    initial_parts.append(sentences[i] + sentences[i + 1])\n            if len(sentences) % 2 == 1 and sentences[-1].strip():\n                initial_parts.append(sentences[-1])\n\n            # Further split long segments\n            from app.core.utils.text_utils import count_words, is_mainly_cjk\n\n            result_parts = []\n            for part in initial_parts:\n                part = part.strip()\n                if not part:\n                    continue\n\n                word_count = count_words(part)\n                max_limit = max_cjk if is_mainly_cjk(part) else max_eng\n\n                if word_count <= max_limit:\n                    result_parts.append(part)\n                else:\n                    # Split long part into smaller chunks\n                    words = list(part) if is_mainly_cjk(part) else part.split()\n                    chunk = []\n                    for word in words:\n                        chunk.append(word)\n                        if (\n                            count_words(\n                                \"\".join(chunk)\n                                if is_mainly_cjk(part)\n                                else \" \".join(chunk)\n                            )\n                            >= max_limit\n                        ):\n                            result_parts.append(\n                                \"\".join(chunk)\n                                if is_mainly_cjk(part)\n                                else \" \".join(chunk)\n                            )\n                            chunk = []\n                    if chunk:\n                        result_parts.append(\n                            \"\".join(chunk) if is_mainly_cjk(part) else \" \".join(chunk)\n                        )\n\n            response_text = \"<br>\".join(p for p in result_parts if p)\n        elif \"translate\" in system_content.lower() or \"翻译\" in system_content.lower():\n            # Translation request - parse JSON input and return translated JSON\n            import json\n\n            import json_repair\n\n            try:\n                # Try to parse JSON from user content\n                input_dict = json_repair.loads(user_content)\n\n                # Create mock translations\n                translated_dict = {}\n                for key, value in input_dict.items():\n                    # Simple mock translation: add \"[译]\" prefix\n                    if (\n                        \"简体中文\" in system_content\n                        or \"Simplified Chinese\" in system_content\n                    ):\n                        translated_dict[key] = f\"[中文]{value}\"\n                    elif \"日本語\" in system_content or \"Japanese\" in system_content:\n                        translated_dict[key] = f\"[日]{value}\"\n                    else:\n                        translated_dict[key] = f\"[译]{value}\"\n\n                response_text = json.dumps(translated_dict, ensure_ascii=False)\n            except Exception:\n                # Fallback to simple response\n                response_text = '{\"1\": \"Mocked translation\"}'\n        elif \"correct\" in system_content.lower() or \"优化\" in system_content.lower():\n            # Optimization request - parse JSON input and return optimized JSON\n            import json\n\n            import json_repair\n\n            try:\n                # Extract input from user content\n                if \"<input_subtitle>\" in user_content:\n                    # Extract dict from <input_subtitle> tags\n                    import re\n\n                    match = re.search(\n                        r\"<input_subtitle>({[^}]+})</input_subtitle>\", user_content\n                    )\n                    if match:\n                        input_dict = json_repair.loads(match.group(1))\n                    else:\n                        # Try to find dict in content\n                        match = re.search(r\"{[^}]+}\", user_content)\n                        if match:\n                            input_dict = json_repair.loads(match.group(0))\n                        else:\n                            input_dict = {}\n                else:\n                    # Try to parse entire user content as JSON\n                    input_dict = json_repair.loads(user_content)\n\n                # Return the same text (mock optimization = no change)\n                response_text = json.dumps(input_dict, ensure_ascii=False)\n            except Exception:\n                # Fallback to simple response\n                response_text = '{\"1\": \"Mocked optimization\"}'\n        else:\n            # Default response\n            response_text = \"Mocked LLM response\"\n\n        # Create mock response object\n        mock_response = MagicMock(spec=ChatCompletion)\n        mock_message = MagicMock(spec=ChatCompletionMessage)\n        mock_message.content = response_text\n        mock_message.role = \"assistant\"\n\n        mock_choice = MagicMock(spec=Choice)\n        mock_choice.message = mock_message\n        mock_choice.finish_reason = \"stop\"\n        mock_choice.index = 0\n\n        mock_response.choices = [mock_choice]\n        mock_response.model = model\n        mock_response.id = \"mock-id\"\n\n        return mock_response\n\n    # Patch the LLM client\n    mock_client = MagicMock()\n    mock_client.chat.completions.create = mock_create\n\n    def mock_get_client():\n        return mock_client\n\n    monkeypatch.setattr(\"app.core.llm.client.get_llm_client\", mock_get_client)\n\n    # Mock check_llm_connection to prevent real API calls\n    def mock_check_llm_connection(base_url, api_key, model):\n        \"\"\"Mock LLM connection check - always returns success.\"\"\"\n        return True, None\n\n    monkeypatch.setattr(\n        \"app.thread.subtitle_thread.check_llm_connection\", mock_check_llm_connection\n    )\n\n    return mock_client\n\n\n# ============================================================================\n# Shared Assertion Utilities\n# ============================================================================\n\n\ndef assert_translation_quality(\n    original: str, translated: str, expected_keywords: List[str]\n) -> None:\n    \"\"\"Validate translation contains expected keywords.\n\n    Args:\n        original: Original text\n        translated: Translated text\n        expected_keywords: List of keywords that should appear in translation\n\n    Raises:\n        AssertionError: If translation is empty or doesn't contain expected keywords\n    \"\"\"\n    assert translated, f\"Translation is empty for: {original}\"\n\n    found_keywords = [kw for kw in expected_keywords if kw in translated]\n\n    assert found_keywords, (\n        f\"Translation quality issue:\\n\"\n        f\"  Original: {original}\\n\"\n        f\"  Translated: {translated}\\n\"\n        f\"  Expected keywords: {expected_keywords}\\n\"\n        f\"  Found: {found_keywords}\"\n    )\n"
  },
  {
    "path": "tests/fixtures/README.md",
    "content": "# Test Fixtures\n\nThis directory contains shared test resources used across multiple test modules.\n\n## Structure\n\n```\ntests/fixtures/\n├── audio/\n│   └── zh.mp3       # Chinese speech audio for ASR testing\n└── subtitle/\n    └── sample_en.srt # English subtitle sample for subtitle processing tests\n```\n\n## Audio Files\n\n### zh.mp3\n\n- **Content**: Chinese speech saying \"今天深圳天气怎么样\" (What's the weather like in Shenzhen today?)\n- **Duration**: ~2 seconds\n- **Format**: MP3\n- **Usage**: Used by ASR integration tests in `tests/test_asr/`\n- **Access**: Via `test_audio_path` fixture in `tests/test_asr/conftest.py`\n\n## Subtitle Files\n\n### sample_en.srt\n\n- **Content**: English tutorial about Python programming (10 segments)\n- **Duration**: ~38 seconds\n- **Format**: SRT (SubRip)\n- **Usage**: Used by subtitle processing tests (split, optimize, translate)\n- **Access**: Via fixtures in test modules\n\n## Adding New Fixtures\n\nWhen adding new shared test resources:\n\n1. Create subdirectories by resource type (e.g., `audio/`, `video/`, `subtitle/`)\n2. Use descriptive filenames indicating the content or purpose\n3. Document the fixture in this README\n4. Create appropriate fixtures in the relevant test module's `conftest.py`\n5. Keep file sizes reasonable (commit only necessary test data)\n\n## Guidelines\n\n- **Keep it small**: Only commit minimal test data needed for tests\n- **Reusable**: Place resources here if used by multiple test modules\n- **Documented**: Update this README when adding new fixtures\n- **Format**: Use common formats that don't require special codecs\n"
  },
  {
    "path": "tests/test_asr/README.md",
    "content": "# ASR Integration Tests\n\nThis directory contains integration tests for various ASR (Automatic Speech Recognition) services.\n\n## Test Structure\n\n```\ntests/\n├── fixtures/\n│   └── audio/\n│       └── zh.mp3           # Shared test audio file (Chinese speech)\n└── test_asr/\n    ├── conftest.py              # Shared fixtures and utilities\n    ├── test_whisper_api_asr.py  # WhisperAPI tests (OpenAI-compatible)\n    ├── test_bcut_asr.py         # BcutASR tests (Bilibili public API)\n    └── test_jianying_asr.py     # JianYingASR tests (CapCut public API)\n```\n\n## Environment Variables\n\n### WhisperAPI Tests\n\nRequired environment variables:\n\n- `OPENAI_BASE_URL`: OpenAI API base URL (e.g., `https://api.openai.com/v1`)\n- `OPENAI_API_KEY`: OpenAI API key\n- `OPENAI_MODEL`: (Optional) Model name, defaults to `whisper-1`\n\nExample `.env`:\n\n```bash\nOPENAI_BASE_URL=https://api.openai.com/v1\nOPENAI_API_KEY=sk-...\nOPENAI_MODEL=whisper-1\n```\n\n### Public API Tests (Bcut, JianYing)\n\nThese tests use public APIs and do not require environment variables, but they:\n\n- Have rate limits\n- Are marked as `@pytest.mark.slow`\n- Should be used sparingly\n\n## Running Tests\n\n### Run all ASR tests\n\n```bash\npytest tests/test_asr/ -v\n```\n\n### Run specific test file\n\n```bash\npytest tests/test_asr/test_whisper_api_asr.py -v\n```\n\n### Run with output\n\n```bash\npytest tests/test_asr/ -s\n```\n\n### Skip slow tests (public APIs)\n\n```bash\npytest tests/test_asr/ -v -m \"not slow\"\n```\n\n### Run only integration tests\n\n```bash\npytest tests/test_asr/ -v -m integration\n```\n\n## Test Guidelines\n\n### Test Structure\n\nAll tests follow this structure:\n\n1. **Type Annotations**: All parameters and return types are annotated\n\n   ```python\n   def test_transcribe_audio(self, whisper_api: WhisperAPI) -> None:\n   ```\n\n2. **English Documentation**: All docstrings and comments in English\n\n   ```python\n   \"\"\"Test basic audio transcription functionality.\n\n   Args:\n       whisper_api: WhisperAPI instance\n   \"\"\"\n   ```\n\n3. **Print Output**: Tests print results for manual verification\n\n   ```python\n   print(\"\\n\" + \"=\" * 60)\n   print(f\"WhisperAPI Transcription Results:\")\n   print(f\"  Total segments: {len(result.segments)}\")\n   print(\"=\" * 60)\n   ```\n\n4. **Validation**: Use shared validation functions\n   ```python\n   assert_asr_result_valid(result, min_segments=0)\n   ```\n\n### Fixtures\n\n- `test_audio_path`: Path to tests/fixtures/audio/zh.mp3 (real Chinese speech audio file)\n  - Contains actual speech content for meaningful ASR testing\n  - Shared across all tests (session scope)\n  - Located in shared fixtures directory for potential reuse by other test modules\n- `whisper_api`, `bcut_asr`, `jianying_asr`: Configured ASR instances\n- `expected_asr_keywords`: Common keywords for result validation\n\n### Skipping Tests\n\nTests are skipped if required environment variables are not set:\n\n```python\n@pytest.fixture(autouse=True)\ndef skip_if_no_env(self) -> None:\n    if not check_env_vars(\"OPENAI_BASE_URL\", \"OPENAI_API_KEY\"):\n        pytest.skip(\"Environment variables not set\")\n```\n\n## Platform-Specific Notes\n\n### Windows-Only Tests\n\nFasterWhisper tests are not included as they only work on Windows.\nTests will be skipped automatically on macOS/Linux.\n\n### Public API Rate Limits\n\nBcut and JianYing tests use public APIs with rate limits:\n\n- Marked with `@pytest.mark.slow`\n- Use caching to minimize API calls\n- Should not be run frequently in CI\n\n## Adding New Tests\n\nWhen adding tests for new ASR services:\n\n1. Create a new test file: `test_<service_name>_asr.py`\n2. Follow the existing test structure\n3. Add type annotations for all parameters\n4. Use English documentation\n5. Add print statements for output verification\n6. Use `check_env_vars()` if environment variables required\n7. Mark as `@pytest.mark.slow` if using rate-limited API\n8. Update this README with environment variable requirements\n\n## Test Audio File\n\nThe `zh.mp3` file is located in `tests/fixtures/audio/` directory:\n\n- Contains real Chinese speech: \"今天深圳天气怎么样\"\n- Shared across all ASR tests via the `test_audio_path` fixture\n- Can be reused by other test modules if needed\n- Should remain in the repository for testing purposes\n"
  },
  {
    "path": "tests/test_asr/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_asr/conftest.py",
    "content": "\"\"\"ASR-specific fixtures and utilities for integration tests.\n\nThis conftest.py provides ASR-specific fixtures that are only needed for ASR tests.\nGeneral fixtures are available from the root-level tests/conftest.py.\n\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\n\n# ============================================================================\n# ASR-Specific Fixtures\n# ============================================================================\n\n\n@pytest.fixture(scope=\"session\")\ndef test_audio_path() -> Path:\n    \"\"\"Get path to Chinese test audio file for ASR tests (default).\n\n    Uses the Chinese speech audio file: tests/fixtures/audio/zh.mp3\n    Session-scoped to avoid repeated file system checks.\n\n    Returns:\n        Path to the Chinese test audio file\n\n    Raises:\n        FileNotFoundError: If zh.mp3 doesn't exist\n    \"\"\"\n    audio_path = Path(__file__).parent.parent / \"fixtures\" / \"audio\" / \"zh.mp3\"\n\n    if not audio_path.exists():\n        raise FileNotFoundError(\n            f\"Test audio file not found: {audio_path}\\n\"\n            \"Please ensure zh.mp3 exists in tests/fixtures/audio/ directory\"\n        )\n\n    return audio_path\n\n\n@pytest.fixture(scope=\"session\")\ndef test_audio_path_zh() -> Path:\n    \"\"\"Get path to Chinese test audio file for ASR tests.\n\n    Uses: tests/fixtures/audio/zh.mp3\n    Session-scoped to avoid repeated file system checks.\n\n    Returns:\n        Path to the Chinese test audio file\n\n    Raises:\n        FileNotFoundError: If zh.mp3 doesn't exist\n    \"\"\"\n    audio_path = Path(__file__).parent.parent / \"fixtures\" / \"audio\" / \"zh.mp3\"\n\n    if not audio_path.exists():\n        raise FileNotFoundError(\n            f\"Test audio file not found: {audio_path}\\n\"\n            \"Please ensure zh.mp3 exists in tests/fixtures/audio/ directory\"\n        )\n\n    return audio_path\n\n\n@pytest.fixture(scope=\"session\")\ndef test_audio_path_en() -> Path:\n    \"\"\"Get path to English test audio file for ASR tests.\n\n    Uses: tests/fixtures/audio/en.mp3\n    Session-scoped to avoid repeated file system checks.\n\n    Returns:\n        Path to the English test audio file\n\n    Raises:\n        FileNotFoundError: If en.mp3 doesn't exist\n    \"\"\"\n    audio_path = Path(__file__).parent.parent / \"fixtures\" / \"audio\" / \"en.mp3\"\n\n    if not audio_path.exists():\n        raise FileNotFoundError(\n            f\"Test audio file not found: {audio_path}\\n\"\n            \"Please ensure en.mp3 exists in tests/fixtures/audio/ directory\"\n        )\n\n    return audio_path\n\n\ndef assert_asr_result_valid(result, min_segments: int = 0) -> None:\n    \"\"\"Validate ASR result structure and content.\n\n    Checks that:\n    - Result is not None\n    - Has minimum number of segments\n    - All segments have non-empty text\n    - All segments have valid timestamps (start >= 0, end > start)\n\n    Args:\n        result: ASRData object returned from ASR service\n        min_segments: Minimum number of segments expected (default 0)\n\n    Raises:\n        AssertionError: If validation fails\n    \"\"\"\n    assert result is not None, \"ASR result should not be None\"\n    assert (\n        len(result.segments) >= min_segments\n    ), f\"Expected at least {min_segments} segments, got {len(result.segments)}\"\n\n    for i, seg in enumerate(result.segments):\n        assert seg.text, f\"Segment {i} should have non-empty text\"\n        assert seg.start_time >= 0, f\"Segment {i} start_time should be non-negative\"\n        assert (\n            seg.end_time > seg.start_time\n        ), f\"Segment {i} end_time should be greater than start_time\"\n"
  },
  {
    "path": "tests/test_asr/test_asr_data.py",
    "content": "\"\"\"ASRData 核心功能测试 - 严格边缘用例\"\"\"\n\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\n\n\nclass TestASRDataSegEdgeCases:\n    \"\"\"测试 ASRDataSeg 边缘情况\"\"\"\n\n    def test_zero_duration_segment(self):\n        \"\"\"测试零时长字幕段\"\"\"\n        seg = ASRDataSeg(\"Instant\", 1000, 1000)\n        assert seg.start_time == seg.end_time\n        timestamp = seg.to_srt_ts()\n        assert timestamp == \"00:00:01,000 --> 00:00:01,000\"\n\n    def test_negative_duration(self):\n        \"\"\"测试倒序时间戳(start > end)\"\"\"\n        seg = ASRDataSeg(\"Reversed\", 2000, 1000)\n        assert seg.start_time > seg.end_time  # 不应自动修正\n\n    def test_very_long_timestamp(self):\n        \"\"\"测试超长时间戳(超过24小时)\"\"\"\n        seg = ASRDataSeg(\"Long\", 90000000, 90001000)  # 25小时\n        timestamp = seg.to_srt_ts()\n        assert \"25:00:00,000\" in timestamp\n\n    def test_unicode_text_extreme(self):\n        \"\"\"测试极端Unicode文本\"\"\"\n        # Emoji + 中文 + 日文 + 韩文 + 阿拉伯文\n        text = \"😀你好こんにちは안녕مرحبا\"\n        seg = ASRDataSeg(text, 0, 1000)\n        assert seg.text == text\n\n    def test_empty_translation(self):\n        \"\"\"测试空翻译与无翻译的区别\"\"\"\n        seg1 = ASRDataSeg(\"Test\", 0, 1000)\n        seg2 = ASRDataSeg(\"Test\", 0, 1000, translated_text=\"\")\n        assert seg1.translated_text == seg2.translated_text == \"\"\n\n    def test_multiline_text(self):\n        \"\"\"测试多行文本\"\"\"\n        text = \"Line 1\\nLine 2\\nLine 3\"\n        seg = ASRDataSeg(text, 0, 1000)\n        assert \"\\n\" in seg.text\n        assert seg.text.count(\"\\n\") == 2\n\n\nclass TestASRDataEdgeCases:\n    \"\"\"测试 ASRData 边缘情况\"\"\"\n\n    def test_mixed_empty_and_whitespace(self):\n        \"\"\"测试混合空字符串和纯空格\"\"\"\n        segments = [\n            ASRDataSeg(\"Valid\", 0, 1000),\n            ASRDataSeg(\"\", 1000, 2000),\n            ASRDataSeg(\"   \", 2000, 3000),\n            ASRDataSeg(\"\\t\\n\", 3000, 4000),\n            ASRDataSeg(\"  Valid  \", 4000, 5000),  # 前后空格应保留\n        ]\n        asr_data = ASRData(segments)\n        assert len(asr_data) == 2\n        assert asr_data.segments[1].text == \"  Valid  \"\n\n    def test_overlapping_timestamps(self):\n        \"\"\"测试重叠的时间戳\"\"\"\n        segments = [\n            ASRDataSeg(\"First\", 0, 2000),\n            ASRDataSeg(\"Overlap\", 1000, 3000),  # 重叠\n            ASRDataSeg(\"Third\", 2500, 4000),\n        ]\n        asr_data = ASRData(segments)\n        # 应按start_time排序，但不修正重叠\n        assert asr_data.segments[0].text == \"First\"\n        assert asr_data.segments[1].text == \"Overlap\"\n\n    def test_unsorted_large_dataset(self):\n        \"\"\"测试大量乱序数据\"\"\"\n        segments = [\n            ASRDataSeg(f\"Text{i}\", i * 1000, (i + 1) * 1000) for i in range(1000, 0, -1)\n        ]\n        asr_data = ASRData(segments)\n        # 应该正确排序\n        for i in range(len(asr_data) - 1):\n            assert (\n                asr_data.segments[i].start_time <= asr_data.segments[i + 1].start_time\n            )\n\n    def test_duplicate_timestamps(self):\n        \"\"\"测试完全相同的时间戳\"\"\"\n        segments = [\n            ASRDataSeg(\"First\", 1000, 2000),\n            ASRDataSeg(\"Second\", 1000, 2000),\n            ASRDataSeg(\"Third\", 1000, 2000),\n        ]\n        asr_data = ASRData(segments)\n        assert len(asr_data) == 3  # 都应保留\n\n    def test_single_segment(self):\n        \"\"\"测试单个字幕段的边界情况\"\"\"\n        segments = [ASRDataSeg(\"Only\", 0, 1000)]\n        asr_data = ASRData(segments)\n        # 各种操作不应崩溃\n        asr_data.optimize_timing()\n        assert len(asr_data) == 1\n\n\nclass TestWordTimestampEdgeCases:\n    \"\"\"测试词级时间戳检测边缘情况\"\"\"\n\n    def test_exactly_80_percent_threshold(self):\n        \"\"\"测试恰好80%阈值\"\"\"\n        # 10个片段，8个词级，2个句子级\n        segments = [ASRDataSeg(f\"word{i}\", i * 100, (i + 1) * 100) for i in range(8)]\n        segments.extend(\n            [\n                ASRDataSeg(\"This is sentence\", 800, 900),\n                ASRDataSeg(\"Another sentence\", 900, 1000),\n            ]\n        )\n        asr_data = ASRData(segments)\n        assert asr_data.is_word_timestamp()  # 80% 应该通过\n\n    def test_79_percent_below_threshold(self):\n        \"\"\"测试略低于80%阈值\"\"\"\n        # 10个片段，7个词级，3个句子级\n        segments = [ASRDataSeg(f\"word{i}\", i * 100, (i + 1) * 100) for i in range(7)]\n        segments.extend(\n            [\n                ASRDataSeg(\"This is sentence\", 700, 800),\n                ASRDataSeg(\"Another sentence\", 800, 900),\n                ASRDataSeg(\"Third sentence\", 900, 1000),\n            ]\n        )\n        asr_data = ASRData(segments)\n        assert not asr_data.is_word_timestamp()  # 70% 不应通过\n\n    def test_mixed_cjk_latin_single_chars(self):\n        \"\"\"测试混合CJK和拉丁单字符\"\"\"\n        segments = [\n            ASRDataSeg(\"你\", 0, 100),  # CJK单字\n            ASRDataSeg(\"好\", 100, 200),\n            ASRDataSeg(\"a\", 200, 300),  # 拉丁单字符\n            ASRDataSeg(\"b\", 300, 400),\n        ]\n        asr_data = ASRData(segments)\n        assert asr_data.is_word_timestamp()\n\n    def test_three_char_cjk(self):\n        \"\"\"测试3字符CJK(边界情况)\"\"\"\n        segments = [ASRDataSeg(\"你好吗\", 0, 1000)]  # 3个字符，不是词级\n        asr_data = ASRData(segments)\n        assert not asr_data.is_word_timestamp()\n\n\nclass TestSplitToWordsEdgeCases:\n    \"\"\"测试分词边缘情况\"\"\"\n\n    def test_split_empty_text(self):\n        \"\"\"测试空文本分词\"\"\"\n        segments = [ASRDataSeg(\"\", 0, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.split_to_word_segments()\n        assert len(asr_data.segments) == 0\n\n    def test_split_only_punctuation(self):\n        \"\"\"测试纯标点分词\"\"\"\n        segments = [ASRDataSeg(\"..., !!!\", 0, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.split_to_word_segments()\n        assert len(asr_data.segments) == 0  # 标点不应匹配\n\n    def test_split_very_long_word(self):\n        \"\"\"测试超长单词\"\"\"\n        long_word = \"a\" * 1000\n        segments = [ASRDataSeg(long_word, 0, 10000)]\n        asr_data = ASRData(segments)\n        asr_data.split_to_word_segments()\n        assert len(asr_data.segments) == 1\n        assert asr_data.segments[0].text == long_word\n\n    def test_split_mixed_scripts(self):\n        \"\"\"测试混合多种文字系统\"\"\"\n        # 拉丁+中文+日文+韩文+阿拉伯文+俄文\n        text = \"Hello你好こんにちは안녕مرحباПривет\"\n        segments = [ASRDataSeg(text, 0, 7000)]\n        asr_data = ASRData(segments)\n        asr_data.split_to_word_segments()\n        # 应该正确分割各种文字\n        assert len(asr_data.segments) > 5\n        texts = [seg.text for seg in asr_data.segments]\n        assert \"Hello\" in texts\n        assert \"Привет\" in texts\n\n    def test_split_numbers_and_words(self):\n        \"\"\"测试数字和单词混合\"\"\"\n        segments = [ASRDataSeg(\"version 3.14 build 2024\", 0, 3000)]\n        asr_data = ASRData(segments)\n        asr_data.split_to_word_segments()\n        texts = [seg.text for seg in asr_data.segments]\n        assert \"version\" in texts\n        assert \"3\" in texts or \"14\" in texts  # 数字应被分开\n        assert \"build\" in texts\n        assert \"2024\" in texts\n\n    def test_split_thai_with_combining_chars(self):\n        \"\"\"测试泰文带组合字符\"\"\"\n        thai_text = \"สวัสดี\"  # 泰文 \"你好\"\n        segments = [ASRDataSeg(thai_text, 0, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.split_to_word_segments()\n        assert len(asr_data.segments) > 0  # 应该能匹配泰文\n\n    def test_split_zero_duration_distribution(self):\n        \"\"\"测试零时长的时间分配\"\"\"\n        segments = [ASRDataSeg(\"Hello world\", 1000, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.split_to_word_segments()\n        # 零时长应该不崩溃\n        assert all(seg.start_time == 1000 for seg in asr_data.segments)\n        assert all(seg.end_time == 1000 for seg in asr_data.segments)\n\n\nclass TestMergeEdgeCases:\n    \"\"\"测试合并边缘情况\"\"\"\n\n    def test_merge_single_segment(self):\n        \"\"\"测试合并单个片段(自己和自己)\"\"\"\n        segments = [ASRDataSeg(\"Only\", 0, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.merge_segments(0, 0)\n        assert len(asr_data.segments) == 1\n        assert asr_data.segments[0].text == \"Only\"\n\n    def test_merge_all_segments(self):\n        \"\"\"测试合并所有片段\"\"\"\n        segments = [ASRDataSeg(f\"T{i}\", i * 100, (i + 1) * 100) for i in range(10)]\n        asr_data = ASRData(segments)\n        asr_data.merge_segments(0, 9)\n        assert len(asr_data.segments) == 1\n        assert \"T0\" in asr_data.segments[0].text\n        assert \"T9\" in asr_data.segments[0].text\n\n    def test_merge_invalid_indices(self):\n        \"\"\"测试无效的合并索引\"\"\"\n        segments = [ASRDataSeg(\"A\", 0, 1000), ASRDataSeg(\"B\", 1000, 2000)]\n        asr_data = ASRData(segments)\n\n        with pytest.raises(IndexError):\n            asr_data.merge_segments(-1, 1)  # 负索引\n        with pytest.raises(IndexError):\n            asr_data.merge_segments(0, 5)  # 超出范围\n        with pytest.raises(IndexError):\n            asr_data.merge_segments(1, 0)  # start > end\n\n    def test_merge_with_next_at_boundary(self):\n        \"\"\"测试在边界位置合并\"\"\"\n        segments = [ASRDataSeg(\"Only\", 0, 1000)]\n        asr_data = ASRData(segments)\n\n        with pytest.raises(IndexError):\n            asr_data.merge_with_next_segment(0)  # 没有下一个\n\n    def test_merge_with_unicode(self):\n        \"\"\"测试合并Unicode文本\"\"\"\n        segments = [\n            ASRDataSeg(\"😀你好\", 0, 1000),\n            ASRDataSeg(\"🌍world\", 1000, 2000),\n        ]\n        asr_data = ASRData(segments)\n        asr_data.merge_with_next_segment(0)\n        assert \"😀\" in asr_data.segments[0].text\n        assert \"🌍\" in asr_data.segments[0].text\n\n\nclass TestOptimizeTimingEdgeCases:\n    \"\"\"测试时间优化边缘情况\"\"\"\n\n    def test_optimize_negative_gap(self):\n        \"\"\"测试负间隔(重叠)\"\"\"\n        segments = [\n            ASRDataSeg(\"First\", 0, 2000),\n            ASRDataSeg(\"Overlap\", 1500, 3000),  # 重叠500ms\n        ]\n        asr_data = ASRData(segments)\n        asr_data.optimize_timing()\n        # 负间隔不应优化(或根据实现调整)\n        assert asr_data.segments[0].end_time == 2000\n\n    def test_optimize_exact_threshold(self):\n        \"\"\"测试恰好在阈值边界\"\"\"\n        segments = [\n            ASRDataSeg(\"First sentence\", 0, 1000),\n            ASRDataSeg(\"Second sentence\", 2000, 3000),  # 恰好1000ms gap\n        ]\n        asr_data = ASRData(segments)\n        asr_data.optimize_timing(threshold_ms=1000)\n        # 恰好等于阈值不优化(需要 < threshold)\n        gap = asr_data.segments[1].start_time - asr_data.segments[0].end_time\n        assert gap == 1000  # 应该保持不变\n\n    def test_optimize_word_level_no_change(self):\n        \"\"\"测试词级时间戳不优化\"\"\"\n        segments = [\n            ASRDataSeg(\"Word1\", 0, 500),\n            ASRDataSeg(\"Word2\", 1000, 1500),\n        ]\n        asr_data = ASRData(segments)\n        original_end = asr_data.segments[0].end_time\n\n        asr_data.optimize_timing()\n        # 词级应该跳过优化\n        assert asr_data.segments[0].end_time == original_end\n\n\nclass TestRemovePunctuationEdgeCases:\n    \"\"\"测试移除标点边缘情况\"\"\"\n\n    def test_remove_multiple_punctuation(self):\n        \"\"\"测试连续多个标点\"\"\"\n        segments = [ASRDataSeg(\"你好，，，。。。\", 0, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.remove_punctuation()\n        assert asr_data.segments[0].text == \"你好\"\n\n    def test_remove_punctuation_only(self):\n        \"\"\"测试纯标点文本\"\"\"\n        segments = [ASRDataSeg(\"，。，。\", 0, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.remove_punctuation()\n        assert asr_data.segments[0].text == \"\"\n\n    def test_remove_punctuation_middle(self):\n        \"\"\"测试中间的标点不移除\"\"\"\n        segments = [ASRDataSeg(\"你好，世界。\", 0, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.remove_punctuation()\n        assert asr_data.segments[0].text == \"你好，世界\"  # 只删尾部\n\n    def test_remove_non_chinese_punctuation(self):\n        \"\"\"测试非中文标点不移除\"\"\"\n        segments = [ASRDataSeg(\"Hello, world!\", 0, 1000)]\n        asr_data = ASRData(segments)\n        asr_data.remove_punctuation()\n        assert asr_data.segments[0].text == \"Hello, world!\"  # 不变\n\n\nclass TestFormatConversionEdgeCases:\n    \"\"\"测试格式转换边缘情况\"\"\"\n\n    def test_srt_layout_modes_all(self):\n        \"\"\"测试所有SRT布局模式\"\"\"\n        from app.core.entities import SubtitleLayoutEnum\n\n        segments = [ASRDataSeg(\"Hello\", 0, 1000, translated_text=\"你好\")]\n        asr_data = ASRData(segments)\n\n        srt1 = asr_data.to_srt(layout=SubtitleLayoutEnum.ORIGINAL_ON_TOP)\n        assert \"Hello\\n你好\" in srt1\n\n        srt2 = asr_data.to_srt(layout=SubtitleLayoutEnum.TRANSLATE_ON_TOP)\n        assert \"你好\\nHello\" in srt2\n\n        srt3 = asr_data.to_srt(layout=SubtitleLayoutEnum.ONLY_ORIGINAL)\n        assert \"Hello\" in srt3\n        assert \"你好\" not in srt3\n\n        srt4 = asr_data.to_srt(layout=SubtitleLayoutEnum.ONLY_TRANSLATE)\n        assert \"你好\" in srt4\n\n    def test_srt_no_translation_all_layouts(self):\n        \"\"\"测试无翻译时的所有布局\"\"\"\n        segments = [ASRDataSeg(\"Hello\", 0, 1000)]\n        asr_data = ASRData(segments)\n\n        for layout in [\"原文在上\", \"译文在上\", \"仅原文\", \"仅译文\"]:\n            srt = asr_data.to_srt(layout=layout)\n            assert \"Hello\" in srt  # 所有模式都应显示原文\n\n    def test_json_large_dataset(self):\n        \"\"\"测试大数据集JSON转换\"\"\"\n        segments = [\n            ASRDataSeg(f\"Text{i}\", i * 1000, (i + 1) * 1000) for i in range(1000)\n        ]\n        asr_data = ASRData(segments)\n        json_data = asr_data.to_json()\n        assert len(json_data) == 1000\n        assert \"1\" in json_data\n        assert \"1000\" in json_data\n\n    def test_txt_multiline_segments(self):\n        \"\"\"测试多行文本转换\"\"\"\n        segments = [\n            ASRDataSeg(\"Line1\\nLine2\", 0, 1000),\n            ASRDataSeg(\"Line3\", 1000, 2000),\n        ]\n        asr_data = ASRData(segments)\n        txt = asr_data.to_txt()\n        assert \"Line1\\nLine2\" in txt\n\n\nclass TestFileIOEdgeCases:\n    \"\"\"测试文件读写边缘情况\"\"\"\n\n    def test_save_unsupported_format(self):\n        \"\"\"测试不支持的格式\"\"\"\n        segments = [ASRDataSeg(\"Test\", 0, 1000)]\n        asr_data = ASRData(segments)\n\n        with tempfile.NamedTemporaryFile(suffix=\".xyz\", delete=False) as f:\n            temp_path = f.name\n\n        try:\n            with pytest.raises(ValueError, match=\"Unsupported file extension\"):\n                asr_data.save(temp_path)\n        finally:\n            Path(temp_path).unlink(missing_ok=True)\n\n    def test_load_nonexistent_file(self):\n        \"\"\"测试加载不存在的文件\"\"\"\n        with pytest.raises(FileNotFoundError):\n            ASRData.from_subtitle_file(\"/nonexistent/path/file.srt\")\n\n    def test_save_load_unicode_path(self):\n        \"\"\"测试Unicode文件路径\"\"\"\n        segments = [ASRDataSeg(\"测试\", 0, 1000)]\n        asr_data = ASRData(segments)\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            unicode_path = Path(tmpdir) / \"测试文件名.srt\"\n            asr_data.save(str(unicode_path))\n            loaded = ASRData.from_subtitle_file(str(unicode_path))\n            assert loaded.segments[0].text == \"测试\"\n\n\nclass TestParseEdgeCases:\n    \"\"\"测试解析边缘情况\"\"\"\n\n    def test_parse_malformed_srt(self):\n        \"\"\"测试畸形SRT\"\"\"\n        malformed = \"\"\"1\n00:00:00,000 --> INVALID\nHello\n\n2\nINVALID TIMESTAMP\nWorld\n\"\"\"\n        asr_data = ASRData.from_srt(malformed)\n        assert len(asr_data.segments) == 0  # 应跳过无效块\n\n    def test_parse_srt_missing_text(self):\n        \"\"\"测试缺少文本的SRT块\"\"\"\n        srt = \"\"\"1\n00:00:00,000 --> 00:00:01,000\n\n2\n00:00:01,000 --> 00:00:02,000\nValid\n\"\"\"\n        asr_data = ASRData.from_srt(srt)\n        assert len(asr_data.segments) == 1\n        assert asr_data.segments[0].text == \"Valid\"\n\n    def test_parse_srt_97_percent_translation(self):\n        \"\"\"测试97%翻译(低于98%阈值)\"\"\"\n        # 100个块，97个有翻译\n        blocks = []\n        for i in range(97):\n            blocks.append(\n                f\"{i+1}\\n00:00:{i:02d},000 --> 00:00:{i+1:02d},000\\nText{i}\\nTrans{i}\\n\"\n            )\n        for i in range(97, 100):\n            blocks.append(\n                f\"{i+1}\\n00:00:{i:02d},000 --> 00:00:{i+1:02d},000\\nText{i}\\n\"\n            )\n\n        srt = \"\\n\".join(blocks)\n        asr_data = ASRData.from_srt(srt)\n        # 低于98%不应识别为翻译格式\n        assert not asr_data.segments[0].translated_text\n\n    def test_parse_json_non_numeric_keys(self):\n        \"\"\"测试JSON非数字键\"\"\"\n        json_data = {\n            \"a\": {\n                \"original_subtitle\": \"Test\",\n                \"translated_subtitle\": \"\",\n                \"start_time\": 0,\n                \"end_time\": 1000,\n            }\n        }\n        with pytest.raises(ValueError):\n            ASRData.from_json(json_data)\n\n    def test_parse_vtt_empty_blocks(self):\n        \"\"\"测试VTT空块\"\"\"\n        vtt = \"\"\"WEBVTT\n\nHEADER\n\n\n1\n00:00:01.000 --> 00:00:02.000\nText1\n\n\n\"\"\"\n        asr_data = ASRData.from_vtt(vtt)\n        assert len(asr_data.segments) == 1\n"
  },
  {
    "path": "tests/test_asr/test_bcut_asr.py",
    "content": "\"\"\"BcutASR integration tests.\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom app.core.asr import BcutASR\nfrom app.core.asr.asr_data import ASRData\nfrom tests.test_asr.conftest import assert_asr_result_valid\n\n\n@pytest.mark.integration\n@pytest.mark.slow\nclass TestBcutASR:\n    \"\"\"Test suite for BcutASR using public Bilibili API.\n\n    Note: This service has rate limits and should be used sparingly.\n    Tests are marked as 'slow' to avoid running in normal CI.\n    \"\"\"\n\n    @pytest.fixture\n    def bcut_asr_sentence(self, test_audio_path: Path) -> BcutASR:\n        \"\"\"Create BcutASR instance with sentence-level timestamps.\n\n        Args:\n            test_audio_path: Path to test audio file\n\n        Returns:\n            BcutASR instance configured for sentence-level timestamps\n        \"\"\"\n        return BcutASR(\n            audio_input=str(test_audio_path),\n            need_word_time_stamp=False,\n        )\n\n    @pytest.fixture\n    def bcut_asr_word(self, test_audio_path: Path) -> BcutASR:\n        \"\"\"Create BcutASR instance with word-level timestamps.\n\n        Args:\n            test_audio_path: Path to test audio file\n\n        Returns:\n            BcutASR instance configured for word-level timestamps\n        \"\"\"\n        return BcutASR(\n            audio_input=str(test_audio_path),\n            need_word_time_stamp=True,\n        )\n\n    # def test_transcribe_sentence_level(self, bcut_asr_sentence: BcutASR) -> None:\n    #     \"\"\"Test sentence-level transcription (need_word_time_stamp=False).\n\n    #     Args:\n    #         bcut_asr_sentence: BcutASR instance with sentence-level timestamps\n    #     \"\"\"\n    #     result: ASRData = bcut_asr_sentence.run()\n\n    #     print(\"\\n\" + \"=\" * 60)\n    #     print(\"BcutASR Sentence-Level Transcription Results:\")\n    #     print(f\"  Total segments: {len(result.segments)}\")\n    #     print(f\"  Is word timestamp: {result.is_word_timestamp()}\")\n    #     for i, seg in enumerate(result.segments[:3], 1):\n    #         print(f\"  [{i}] {seg.text} ({seg.start_time}-{seg.end_time}ms)\")\n    #     print(\"=\" * 60)\n\n    #     assert_asr_result_valid(result, min_segments=0)\n    #     assert (\n    #         not result.is_word_timestamp()\n    #     ), \"Result should be sentence-level, not word-level\"\n\n    # def test_transcribe_word_level(self, bcut_asr_word: BcutASR) -> None:\n    #     \"\"\"Test word-level transcription (need_word_time_stamp=True).\n\n    #     Args:\n    #         bcut_asr_word: BcutASR instance with word-level timestamps\n    #     \"\"\"\n    #     result: ASRData = bcut_asr_word.run()\n\n    #     print(\"\\n\" + \"=\" * 60)\n    #     print(\"BcutASR Word-Level Transcription Results:\")\n    #     print(f\"  Total segments: {len(result.segments)}\")\n    #     print(f\"  Is word timestamp: {result.is_word_timestamp()}\")\n    #     for i, seg in enumerate(result.segments[:5], 1):\n    #         print(f\"  [{i}] {seg.text} ({seg.start_time}-{seg.end_time}ms)\")\n    #     print(\"=\" * 60)\n\n    #     assert_asr_result_valid(result, min_segments=0)\n\n    #     if len(result.segments) > 0:\n    #         assert (\n    #             result.is_word_timestamp()\n    #         ), \"Result should be word-level when need_word_time_stamp=True\"\n\n    @pytest.mark.parametrize(\n        \"need_word_ts,audio_fixture\",\n        [\n            (False, \"test_audio_path_zh\"),\n            (True, \"test_audio_path_zh\"),\n            (False, \"test_audio_path_en\"),\n            (True, \"test_audio_path_en\"),\n        ],\n    )\n    def test_transcribe_parametrized(\n        self, need_word_ts: bool, audio_fixture: str, request\n    ) -> None:\n        \"\"\"Test transcription with different configurations and languages.\n\n        Args:\n            need_word_ts: Whether to use word-level timestamps\n            audio_fixture: Name of the audio fixture to use\n            request: Pytest request object for fixture access\n        \"\"\"\n        audio_path: Path = request.getfixturevalue(audio_fixture)\n        lang = \"Chinese\" if \"zh\" in audio_fixture else \"English\"\n        level = \"word\" if need_word_ts else \"sentence\"\n\n        asr = BcutASR(\n            audio_input=str(audio_path),\n            need_word_time_stamp=need_word_ts,\n        )\n\n        result: ASRData = asr.run()\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"BcutASR - {lang.upper()} - {level.title()}-Level Results:\")\n        print(f\"  Total Segments: {len(result.segments)}\")\n        print(f\"  Is Word Timestamp: {result.is_word_timestamp()}\")\n        for i, seg in enumerate(result.segments[:50], 1):\n            print(\n                f\"    [{i:2d}] {seg.text:<30} ({seg.start_time:6d} - {seg.end_time:6d} ms)\"\n            )\n        print(\"=\" * 60)\n\n        assert_asr_result_valid(result, min_segments=0)\n\n        if not need_word_ts and len(result.segments) > 0:\n            assert not result.is_word_timestamp()\n"
  },
  {
    "path": "tests/test_asr/test_chunk_merger.py",
    "content": "\"\"\"ChunkMerger 真实场景测试套件\n\n测试策略：\n1. 使用真实的 ASR 输出场景（句子级 + 字/词级）\n2. 覆盖中文、英文、中英混合场景\n3. 测试 ASR 识别错误的真实 bad cases\n4. 直接验证合并后的完整文本（快照验证）\n\"\"\"\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.asr.chunk_merger import ChunkMerger\n\n\ndef create_sentence_segments(sentences, start_time=0):\n    \"\"\"Create sentence-level segments from text list.\"\"\"\n    segments = []\n    current_time = start_time\n    for text in sentences:\n        duration = len(text) * 100  # 简单估算，每个字符100ms\n        segments.append(\n            ASRDataSeg(\n                text=text, start_time=current_time, end_time=current_time + duration\n            )\n        )\n        current_time += duration + 200  # 200ms间隔\n    return segments\n\n\ndef create_word_level_segments(words, start_time=0, is_chinese=True):\n    \"\"\"Create word-level segments from text.\n\n    Args:\n        words: 文本字符串（会自动分词）\n        start_time: 起始时间（毫秒）\n        is_chinese: 是否为中文（True则按字符分割，False则按空格分词）\n    \"\"\"\n    segments = []\n    current_time = start_time\n\n    # 根据语言类型分词\n    if is_chinese:\n        # 中文：每个字符作为一个词\n        word_list = list(words)\n    else:\n        # 英文：按空格分词\n        word_list = words.split()\n\n    for word in word_list:\n        duration = len(word) * 80  # 简单估算，每个字符80ms\n        segments.append(\n            ASRDataSeg(\n                text=word, start_time=current_time, end_time=current_time + duration\n            )\n        )\n        current_time += duration + 100  # 100ms间隔\n    return segments\n\n\n# ============================================================================\n# 基础合并 - 句子级（真实 ASR 输出）\n# ============================================================================\n\n\nclass TestSentenceLevelMerging:\n    \"\"\"句子级 ASR 输出合并（最常见场景）\"\"\"\n\n    @pytest.fixture\n    def merger(self):\n        return ChunkMerger(min_match_count=2)\n\n    def test_chinese_podcast_perfect_overlap(self, merger):\n        \"\"\"中文播客：模糊匹配场景（略有差异）\"\"\"\n        # Chunk 1: 0-30s 音频\n        chunk1_sentences = [\n            \"大家好，欢迎收听今天的节目\",\n            \"今天我们要聊一聊人工智能\",\n            \"人工智能渗透到我们生活的方方面面\",  # 缺少\"已经\"\n            \"比如语音识别、图像识别\",\n        ]\n        chunk1 = ASRData(create_sentence_segments(chunk1_sentences, start_time=0))\n\n        # Chunk 2: 20-50s 音频（10s 重叠区域，文本略有差异，相似度0.94）\n        chunk2_sentences = [\n            \"人工智能已经渗透到我们生活的方方面面\",  # 重叠（多了\"已经\"）\n            \"比如语音识别、图像识别\",  # 重叠（完全匹配）\n            \"还有自然语言处理等等\",\n            \"这些技术正在改变我们的生活\",\n        ]\n        chunk2 = ASRData(create_sentence_segments(chunk2_sentences, start_time=0))\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 20000],\n            overlap_duration=10000,\n        )\n\n        # 验证：中点切分，取 left[:3] + right[1:]\n        # 结果使用 chunk1 的\"人工智能渗透...\"版本（无\"已经\"）\n        actual = \"\".join([s.text for s in result.segments])\n        expected = (\n            \"大家好，欢迎收听今天的节目\"\n            \"今天我们要聊一聊人工智能\"\n            \"人工智能渗透到我们生活的方方面面\"  # 来自 chunk1（无\"已经\"）\n            \"比如语音识别、图像识别\"\n            \"还有自然语言处理等等\"\n            \"这些技术正在改变我们的生活\"\n        )\n        assert actual == expected\n\n    def test_english_lecture_perfect_overlap(self, merger):\n        \"\"\"英文讲座：完美重叠场景\"\"\"\n        # Chunk 1: 0-10s（缩短时间范围，确保重叠在 overlap_duration 内）\n        chunk1_sentences = [\n            \"Welcome to today's lecture on machine learning.\",\n            \"We will discuss neural networks and deep learning.\",\n            \"These topics are fundamental to modern AI.\",\n        ]\n        chunk1 = ASRData(create_sentence_segments(chunk1_sentences, start_time=0))\n\n        # Chunk 2: 8-18s（重叠最后一句）\n        chunk2_sentences = [\n            \"These topics are fundamental to modern AI.\",  # 重叠\n            \"Let's start with the basics of neural networks.\",\n            \"A neural network consists of layers of neurons.\",\n        ]\n        chunk2 = ASRData(create_sentence_segments(chunk2_sentences, start_time=0))\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 8000],\n            overlap_duration=5000,\n        )\n\n        actual = \" \".join([s.text for s in result.segments])\n        assert \"Welcome to today's lecture\" in actual\n        assert \"layers of neurons\" in actual\n        # 确保重叠句子只出现一次\n        assert actual.count(\"These topics are fundamental to modern AI.\") == 1\n\n    def test_no_overlap_sequential_chunks(self, merger):\n        \"\"\"无重叠：顺序拼接场景\"\"\"\n        chunk1_sentences = [\"这是第一段话\", \"内容很有趣\"]\n        chunk2_sentences = [\"这是第二段话\", \"继续讲下去\"]\n\n        chunk1 = ASRData(create_sentence_segments(chunk1_sentences, start_time=0))\n        chunk2 = ASRData(create_sentence_segments(chunk2_sentences, start_time=0))\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 50000],\n            overlap_duration=10000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        assert actual == \"这是第一段话内容很有趣这是第二段话继续讲下去\"\n\n    def test_three_chunks_continuous_merge(self, merger):\n        \"\"\"3个连续 chunk 合并\"\"\"\n        chunk1 = ASRData(\n            create_sentence_segments(\n                [\"第一段开始\", \"第一段内容\", \"第一段过渡\", \"第一段结尾\"], start_time=0\n            )\n        )\n        chunk2 = ASRData(\n            create_sentence_segments(\n                [\"第一段过渡\", \"第一段结尾\", \"第二段内容\", \"第二段结尾\"], start_time=0\n            )\n        )\n        chunk3 = ASRData(\n            create_sentence_segments(\n                [\"第二段内容\", \"第二段结尾\", \"第三段内容\", \"第三段结束\"], start_time=0\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2, chunk3],\n            chunk_offsets=[0, 20000, 40000],\n            overlap_duration=10000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        # 验证重叠部分只出现一次\n        assert actual.count(\"第一段过渡\") == 1\n        assert actual.count(\"第一段结尾\") == 1\n        assert actual.count(\"第二段内容\") == 1\n        assert actual.count(\"第二段结尾\") == 1\n        assert \"第一段开始\" in actual\n        assert \"第三段结束\" in actual\n\n\n# ============================================================================\n# Bad Cases - 真实 ASR 识别错误场景\n# ============================================================================\n\n\nclass TestASRErrorCases:\n    \"\"\"真实 ASR 识别错误场景\"\"\"\n\n    @pytest.fixture\n    def merger(self):\n        return ChunkMerger(min_match_count=2)\n\n    def test_homophone_error_chinese(self, merger):\n        \"\"\"中文同音字错误：ASR 把重叠部分识别成了同音字\"\"\"\n        # Chunk 1: \"今天天气很好\" -> 正确\n        chunk1 = ASRData(\n            create_sentence_segments(\n                [\"我们今天去爬山\", \"今天天气很好\", \"非常适合户外活动\"], start_time=0\n            )\n        )\n\n        # Chunk 2: \"今天天气很好\" -> 识别错误成 \"今天天气和好\"（同音）\n        chunk2 = ASRData(\n            create_sentence_segments(\n                [\"今天天气和好\", \"我们带了很多零食\", \"准备野餐\"], start_time=15000\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 15000],\n            overlap_duration=10000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        # 由于匹配失败，会使用时间边界切分，两个版本可能都保留\n        assert \"爬山\" in actual\n        assert \"野餐\" in actual\n\n    def test_punctuation_difference_english(self, merger):\n        \"\"\"英文标点差异：ASR 识别的标点不一致\"\"\"\n        chunk1 = ASRData(\n            create_sentence_segments(\n                [\n                    \"Hello, how are you doing today?\",\n                    \"I'm feeling great, thanks for asking.\",\n                ],\n                start_time=0,\n            )\n        )\n\n        # 第二次识别：标点不同\n        chunk2 = ASRData(\n            create_sentence_segments(\n                [\n                    \"Im feeling great thanks for asking\",  # 没有标点和缩写符号\n                    \"What about you?\",\n                    \"Are you ready for the meeting?\",\n                ],\n                start_time=10000,\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 10000],\n            overlap_duration=8000,\n        )\n\n        actual = \" \".join([s.text for s in result.segments])\n        assert \"Hello\" in actual\n        assert \"meeting\" in actual\n\n    def test_partial_match_only_one_sentence(self, merger):\n        \"\"\"部分匹配：重叠区域只有 1 句话匹配（不满足 min_match_count=2）\"\"\"\n        chunk1 = ASRData(\n            create_sentence_segments(\n                [\"这是第一句话\", \"这是第二句话\", \"这是第三句话\"], start_time=0\n            )\n        )\n\n        # 只有\"这是第三句话\"匹配，其他都识别错了\n        chunk2 = ASRData(\n            create_sentence_segments(\n                [\"这是第三句话\", \"完全不同的内容\", \"全新的句子\"], start_time=15000\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 15000],\n            overlap_duration=10000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        # 匹配数量不足，回退到时间边界\n        assert \"第一句话\" in actual\n        assert \"全新的句子\" in actual\n\n    def test_complete_mismatch_noise_in_overlap(self, merger):\n        \"\"\"完全不匹配：重叠区域有噪音导致识别完全错误\"\"\"\n        chunk1 = ASRData(\n            create_sentence_segments(\n                [\"正常的语音内容\", \"背景音乐开始播放\", \"声音变得模糊\"], start_time=0\n            )\n        )\n\n        # 重叠部分全是噪音识别结果\n        chunk2 = ASRData(\n            create_sentence_segments(\n                [\"嗯啊哦\", \"咳咳咳\", \"清晰的内容恢复了\", \"继续正常讲述\"],\n                start_time=12000,\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 12000],\n            overlap_duration=8000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        # 完全不匹配，使用时间边界\n        assert \"正常的语音内容\" in actual or \"清晰的内容恢复了\" in actual\n\n    def test_filler_words_different_recognition(self, merger):\n        \"\"\"口语填充词不一致：um, uh, well 等识别不稳定\"\"\"\n        chunk1 = ASRData(\n            create_sentence_segments(\n                [\n                    \"So, um, let me think about this.\",\n                    \"Well, I believe the answer is yes.\",\n                ],\n                start_time=0,\n            )\n        )\n\n        # 第二次识别：填充词被识别成不同形式或被过滤掉\n        chunk2 = ASRData(\n            create_sentence_segments(\n                [\n                    \"Let me think about this.\",  # \"um\" 被过滤\n                    \"I believe the answer is yes.\",  # \"Well,\" 被过滤\n                    \"That makes sense to me.\",\n                ],\n                start_time=10000,\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 10000],\n            overlap_duration=8000,\n        )\n\n        actual = \" \".join([s.text for s in result.segments])\n        assert \"think about this\" in actual\n        assert \"makes sense\" in actual\n\n\n# ============================================================================\n# Word-Level (字/词级时间戳场景)\n# ============================================================================\n\n\nclass TestWordLevelMerging:\n    \"\"\"字/词级时间戳合并（Whisper word_timestamps 场景）\"\"\"\n\n    @pytest.fixture\n    def merger(self):\n        return ChunkMerger(min_match_count=2)\n\n    def test_chinese_word_level_perfect_overlap(self, merger):\n        \"\"\"中文字级时间戳：完美重叠\"\"\"\n        # Chunk 1: \"今天天气不错我们去公园\"\n        chunk1_text = \"今天天气不错我们去公园\"\n        chunk1 = ASRData(\n            create_word_level_segments(chunk1_text, start_time=0, is_chinese=True)\n        )\n\n        # Chunk 2: \"我们去公园看看风景拍照\"（重叠 \"我们去公园\"）\n        chunk2_text = \"我们去公园看看风景拍照\"\n        chunk2 = ASRData(\n            create_word_level_segments(chunk2_text, start_time=1500, is_chinese=True)\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 1500],\n            overlap_duration=1500,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        expected = \"今天天气不错我们去公园看看风景拍照\"\n        assert actual == expected\n        # 确保\"我们去公园\"只出现一次\n        assert actual.count(\"我们去公园\") == 1\n\n    def test_english_word_level_perfect_overlap(self, merger):\n        \"\"\"英文词级时间戳：完美重叠\"\"\"\n        # Chunk 1: \"Hello world this is a test\"\n        chunk1_text = \"Hello world this is a test\"\n        chunk1 = ASRData(\n            create_word_level_segments(chunk1_text, start_time=0, is_chinese=False)\n        )\n\n        # Chunk 2: \"is a test of the system\"（重叠 \"is a test\"）\n        chunk2_text = \"is a test of the system\"\n        chunk2 = ASRData(\n            create_word_level_segments(chunk2_text, start_time=1200, is_chinese=False)\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 1200],\n            overlap_duration=1000,\n        )\n\n        actual = \" \".join([s.text for s in result.segments])\n        expected = \"Hello world this is a test of the system\"\n        assert actual == expected\n\n    def test_chinese_word_level_partial_match(self, merger):\n        \"\"\"中文字级：部分字识别错误\"\"\"\n        # Chunk 1: \"人工智能技术发展\"\n        chunk1 = ASRData(\n            create_word_level_segments(\n                \"人工智能技术发展\", start_time=0, is_chinese=True\n            )\n        )\n\n        # Chunk 2: \"技数发展迅速应用\" （\"术\" 误识别成 \"数\"）\n        chunk2 = ASRData(\n            create_word_level_segments(\n                \"技数发展迅速应用\", start_time=1500, is_chinese=True\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 1500],\n            overlap_duration=1200,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        # 由于部分不匹配，可能保留两种版本或使用时间切分\n        assert \"人工智能\" in actual\n        assert \"应用\" in actual\n\n    def test_english_word_level_capitalization_difference(self, merger):\n        \"\"\"英文词级：大小写不一致\"\"\"\n        chunk1 = ASRData(\n            create_word_level_segments(\n                \"The quick brown fox\", start_time=0, is_chinese=False\n            )\n        )\n\n        # 第二次识别：大小写不同\n        chunk2 = ASRData(\n            create_word_level_segments(\n                \"brown fox jumps over\", start_time=800, is_chinese=False\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 800],\n            overlap_duration=600,\n        )\n\n        actual = \" \".join([s.text for s in result.segments])\n        assert \"quick\" in actual\n        assert \"over\" in actual\n\n\n# ============================================================================\n# Mixed Chinese-English (中英混合场景)\n# ============================================================================\n\n\nclass TestMixedLanguage:\n    \"\"\"中英混合场景\"\"\"\n\n    @pytest.fixture\n    def merger(self):\n        return ChunkMerger(min_match_count=2)\n\n    def test_tech_talk_chinese_english_mixed(self, merger):\n        \"\"\"技术分享：中英混合（真实场景）\"\"\"\n        chunk1_sentences = [\n            \"今天我们讨论 Machine Learning 的基础知识\",\n            \"首先介绍一下 Neural Network 的概念\",\n            \"Neural Network 是由多个 layer 组成的\",\n        ]\n        chunk1 = ASRData(create_sentence_segments(chunk1_sentences, start_time=0))\n\n        # 重叠最后一句（调整时间确保在 overlap_duration 内）\n        chunk2_sentences = [\n            \"Neural Network 是由多个 layer 组成的\",\n            \"每个 layer 包含很多 neuron\",\n            \"这些 neuron 会进行 forward propagation\",\n        ]\n        chunk2 = ASRData(create_sentence_segments(chunk2_sentences, start_time=0))\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 8000],\n            overlap_duration=6000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        assert \"Machine Learning\" in actual\n        assert \"forward propagation\" in actual\n        assert actual.count(\"Neural Network 是由多个 layer 组成的\") == 1\n\n    def test_product_name_mixed_word_level(self, merger):\n        \"\"\"产品名混合：字/词级\"\"\"\n        # \"我使用 iPhone 拍摄视频\"\n        chunk1 = ASRData(\n            create_word_level_segments(\n                \"我使用 iPhone 拍摄视频\", start_time=0, is_chinese=True\n            )\n        )\n\n        # \"iPhone 拍摄视频效果很好\"\n        chunk2 = ASRData(\n            create_word_level_segments(\n                \"iPhone 拍摄视频效果很好\", start_time=1500, is_chinese=True\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 1500],\n            overlap_duration=1200,\n        )\n\n        actual = \"\".join([s.text.replace(\" \", \"\") for s in result.segments])\n        # 由于分词差异，验证主要内容存在\n        assert \"我使用\" in actual or \"iPhone\" in actual\n        assert \"效果很好\" in actual\n\n\n# ============================================================================\n# Edge Cases (边缘情况)\n# ============================================================================\n\n\nclass TestEdgeCases:\n    \"\"\"边缘情况\"\"\"\n\n    @pytest.fixture\n    def merger(self):\n        return ChunkMerger(min_match_count=2)\n\n    def test_empty_chunk(self, merger):\n        \"\"\"空 chunk\"\"\"\n        chunk1 = ASRData(create_sentence_segments([\"内容\"], start_time=0))\n        chunk2 = ASRData([])  # 空\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 10000],\n            overlap_duration=5000,\n        )\n\n        assert len(result.segments) == 1\n        assert result.segments[0].text == \"内容\"\n\n    def test_single_word_segments(self, merger):\n        \"\"\"单字/词 segment\"\"\"\n        chunk1 = ASRData(create_sentence_segments([\"好\"], start_time=0))\n        chunk2 = ASRData(create_sentence_segments([\"的\"], start_time=0))\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 500],\n            overlap_duration=300,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        assert \"好\" in actual or \"的\" in actual\n\n    def test_identical_chunks_100_percent_overlap(self, merger):\n        \"\"\"完全相同的 chunk（100% 重叠）\"\"\"\n        sentences = [\"相同的内容\", \"完全一样\", \"没有差异\"]\n        chunk1 = ASRData(create_sentence_segments(sentences, start_time=0))\n        chunk2 = ASRData(create_sentence_segments(sentences, start_time=0))\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 0],\n            overlap_duration=20000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        # 验证内容只出现一次\n        assert actual.count(\"相同的内容\") == 1\n        assert actual.count(\"完全一样\") == 1\n        assert actual.count(\"没有差异\") == 1\n\n    def test_very_long_overlap_90_percent(self, merger):\n        \"\"\"超长重叠（90% 重叠）\"\"\"\n        chunk1_sentences = [\"第一句\", \"第二句\", \"第三句\", \"第四句\", \"第五句\"]\n        chunk1 = ASRData(create_sentence_segments(chunk1_sentences, start_time=0))\n\n        # 90% 重叠：前4句重复\n        chunk2_sentences = [\"第二句\", \"第三句\", \"第四句\", \"第五句\", \"第六句\"]\n        chunk2 = ASRData(create_sentence_segments(chunk2_sentences, start_time=0))\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 1000],\n            overlap_duration=18000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        # 每句话只出现一次\n        for i in range(1, 7):\n            assert actual.count(f\"第{['一', '二', '三', '四', '五', '六'][i-1]}句\") == 1\n\n\n# ============================================================================\n# Long Sequences (长序列压力测试)\n# ============================================================================\n\n\nclass TestLongSequences:\n    \"\"\"长序列测试\"\"\"\n\n    @pytest.fixture\n    def merger(self):\n        return ChunkMerger(min_match_count=2)\n\n    def test_10_chunks_continuous_chinese(self, merger):\n        \"\"\"10个中文 chunk 连续合并\"\"\"\n        chunks = []\n        chunk_offsets = []\n\n        for i in range(10):\n            # 每个 chunk 5句话\n            sentences = [\n                f\"这是第{i}段的第1句话\",\n                f\"这是第{i}段的第2句话\",\n                f\"这是第{i}段的第3句话\",\n                f\"这是第{i}段的第4句话\",\n                f\"这是第{i}段的第5句话\",\n            ]\n\n            # 前2句话是重叠区域（与上一个 chunk 的后2句重叠）\n            if i > 0:\n                sentences[0] = f\"这是第{i-1}段的第4句话\"\n                sentences[1] = f\"这是第{i-1}段的第5句话\"\n\n            chunk = ASRData(create_sentence_segments(sentences, start_time=0))\n            chunks.append(chunk)\n            chunk_offsets.append(i * 20000)\n\n        result = merger.merge_chunks(\n            chunks=chunks,\n            chunk_offsets=chunk_offsets,\n            overlap_duration=10000,\n        )\n\n        # 验证：中点切分算法会移除重叠部分\n        # 实际输出约17句（中点切分更激进）\n        assert 15 <= len(result.segments) <= 20\n\n        # 验证首尾句子存在\n        texts = [s.text for s in result.segments]\n        assert any(\"第0段\" in t for t in texts)  # 第一个chunk的内容\n        assert any(\"第9段\" in t for t in texts)  # 最后一个chunk的内容\n\n    def test_very_long_text_word_level_english(self, merger):\n        \"\"\"超长文本词级合并（英文）\"\"\"\n        # 模拟 200 个词的长文本\n        words1 = [f\"word{i}\" for i in range(150)]\n        words2 = [f\"word{i}\" for i in range(140, 200)]  # 10词重叠\n\n        chunk1 = ASRData(\n            create_word_level_segments(\" \".join(words1), start_time=0, is_chinese=False)\n        )\n        chunk2 = ASRData(\n            create_word_level_segments(\n                \" \".join(words2), start_time=50000, is_chinese=False\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 50000],\n            overlap_duration=5000,\n        )\n\n        # 验证总词数合理（约 200 个词）\n        assert 180 <= len(result.segments) <= 210\n\n\n# ============================================================================\n# Output Format Validation (输出格式验证)\n# ============================================================================\n\n\nclass TestOutputFormat:\n    \"\"\"输出格式验证\"\"\"\n\n    @pytest.fixture\n    def merger(self):\n        return ChunkMerger(min_match_count=2)\n\n    def test_output_has_valid_timestamps(self, merger):\n        \"\"\"验证输出的时间戳有效性\"\"\"\n        chunk1 = ASRData(create_sentence_segments([\"第一句\", \"第二句\"], start_time=0))\n        chunk2 = ASRData(create_sentence_segments([\"第二句\", \"第三句\"], start_time=0))\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 1000],\n            overlap_duration=500,\n        )\n\n        # 验证时间戳\n        for seg in result.segments:\n            assert seg.start_time >= 0\n            assert seg.end_time > seg.start_time\n            assert seg.end_time - seg.start_time < 60000  # 单句不超过60s\n\n    def test_can_save_to_srt(self, merger, tmp_path):\n        \"\"\"验证可以保存为 SRT\"\"\"\n        chunk1 = ASRData(\n            create_sentence_segments([\"Hello world\", \"This is a test\"], start_time=0)\n        )\n        chunk2 = ASRData(\n            create_sentence_segments(\n                [\"This is a test\", \"Of the system\"], start_time=2000\n            )\n        )\n\n        result = merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 2000],\n            overlap_duration=1000,\n        )\n\n        srt_path = tmp_path / \"output.srt\"\n        result.to_srt(save_path=str(srt_path))\n\n        assert srt_path.exists()\n        content = srt_path.read_text(encoding=\"utf-8\")\n        assert \"Hello world\" in content\n        assert \"Of the system\" in content\n\n\n# ============================================================================\n# Strict Mode (严格模式)\n# ============================================================================\n\n\nclass TestStrictMode:\n    \"\"\"严格匹配模式测试（min_match_count=5）\"\"\"\n\n    @pytest.fixture\n    def strict_merger(self):\n        return ChunkMerger(min_match_count=5)\n\n    def test_insufficient_overlap_fallback_to_time(self, strict_merger):\n        \"\"\"匹配数不足：回退到时间边界切分\"\"\"\n        # 只有 3 句话匹配，不满足 min=5\n        chunk1 = ASRData(\n            create_sentence_segments([\"A\", \"B\", \"C\", \"D\", \"E\"], start_time=0)\n        )\n        chunk2 = ASRData(\n            create_sentence_segments([\"C\", \"D\", \"E\", \"F\", \"G\"], start_time=0)\n        )\n\n        result = strict_merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 3000],\n            overlap_duration=2000,\n        )\n\n        # 会回退到时间边界，可能有重复或缺失\n        actual = \"\".join([s.text for s in result.segments])\n        assert \"A\" in actual or \"B\" in actual\n        assert \"F\" in actual or \"G\" in actual\n\n    def test_sufficient_overlap_merge_normally(self, strict_merger):\n        \"\"\"匹配数充足：正常合并\"\"\"\n        # 7 句话匹配，满足 min=5\n        chunk1 = ASRData(\n            create_sentence_segments(\n                [\"S1\", \"S2\", \"S3\", \"S4\", \"S5\", \"S6\", \"S7\", \"S8\", \"S9\"], start_time=0\n            )\n        )\n        chunk2 = ASRData(\n            create_sentence_segments(\n                [\"S3\", \"S4\", \"S5\", \"S6\", \"S7\", \"S8\", \"S9\", \"S10\"], start_time=5000\n            )\n        )\n\n        result = strict_merger.merge_chunks(\n            chunks=[chunk1, chunk2],\n            chunk_offsets=[0, 5000],\n            overlap_duration=8000,\n        )\n\n        actual = \"\".join([s.text for s in result.segments])\n        # 验证无重复\n        assert actual.count(\"S5\") == 1\n        assert actual.count(\"S6\") == 1\n"
  },
  {
    "path": "tests/test_asr/test_chunked_asr.py",
    "content": "\"\"\"ChunkedASR 全面测试\n\n测试策略：\n1. 使用 Mock ASR 避免实际 API 调用\n2. 覆盖所有核心功能（分块、并发、合并）\n3. 测试边界情况（短音频、单块、错误等）\n4. 验证进度回调机制\n5. 确保线程安全和并发正确性\n\n重构后设计：\n- ChunkedASR 接收 ASR 类和参数，而非实例\n- 为每个 chunk 创建独立的 ASR 实例\n- 避免共享状态，支持真正的并发\n\"\"\"\n\nimport io\nimport tempfile\nfrom pathlib import Path\nfrom typing import Callable, List, Optional\n\nimport pytest\nfrom pydub import AudioSegment\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.asr.base import BaseASR\nfrom app.core.asr.chunked_asr import ChunkedASR\n\n# ============================================================================\n# Mock ASR 辅助类\n# ============================================================================\n\n\nclass MockASR(BaseASR):\n    \"\"\"Mock ASR 用于测试，避免实际 API 调用\n\n    支持接收 bytes 或 str 作为 audio_input（适配 ChunkedASR）\n    \"\"\"\n\n    # 类变量：跨实例共享的调用计数（用于测试并发）\n    global_run_count = 0\n\n    def __init__(\n        self,\n        audio_input,\n        use_cache: bool = False,\n        need_word_time_stamp: bool = False,\n        # Mock 专用参数\n        mock_text_per_second: str = \"Mock\",\n        fail_on_run: bool = False,\n    ):\n        super().__init__(audio_input, use_cache, need_word_time_stamp)\n        self.mock_text_per_second = mock_text_per_second\n        self.fail_on_run = fail_on_run\n\n    def _run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs\n    ) -> dict:\n        \"\"\"模拟 ASR 转录，返回假数据\"\"\"\n        MockASR.global_run_count += 1\n\n        if self.fail_on_run:\n            raise RuntimeError(\"Mock ASR failed\")\n\n        if callback:\n            callback(50, \"processing\")\n            callback(100, \"completed\")\n\n        # 生成模拟的转录结果（每秒一个字）\n        if self.file_binary:\n            audio = AudioSegment.from_file(io.BytesIO(self.file_binary))\n            duration_sec = len(audio) / 1000  # 毫秒转秒\n            num_segments = max(1, int(duration_sec))\n\n            segments = [\n                {\n                    \"text\": f\"{self.mock_text_per_second}{i+1}\",\n                    \"start\": i,\n                    \"end\": i + 1,\n                }\n                for i in range(num_segments)\n            ]\n        else:\n            segments = [{\"text\": \"Mock\", \"start\": 0, \"end\": 1}]\n\n        return {\"segments\": segments}\n\n    def _make_segments(self, resp_data: dict) -> List[ASRDataSeg]:\n        \"\"\"将模拟数据转换为 ASRDataSeg\"\"\"\n        return [\n            ASRDataSeg(\n                text=seg[\"text\"],\n                start_time=int(seg[\"start\"] * 1000),\n                end_time=int(seg[\"end\"] * 1000),\n            )\n            for seg in resp_data[\"segments\"]\n        ]\n\n\ndef create_test_audio_file(duration_sec: int = 60) -> str:\n    \"\"\"创建测试用音频文件（静音）\n\n    Args:\n        duration_sec: 音频时长（秒）\n\n    Returns:\n        音频文件路径（临时文件）\n    \"\"\"\n    # 创建静音音频\n    audio = AudioSegment.silent(duration=duration_sec * 1000)\n\n    # 保存到临时文件（delete=False 避免 Windows 权限问题）\n    temp_file = tempfile.NamedTemporaryFile(suffix=\".mp3\", delete=False)\n    temp_path = temp_file.name\n    temp_file.close()  # 关闭文件句柄，让 pydub 可以写入\n    audio.export(temp_path, format=\"mp3\")\n    return temp_path\n\n\n# ============================================================================\n# 测试 ChunkedASR 基础功能\n# ============================================================================\n\n\nclass TestChunkedASRBasics:\n    \"\"\"测试 ChunkedASR 的基础功能\"\"\"\n\n    def test_init_default_params(self):\n        \"\"\"测试默认参数初始化\"\"\"\n        audio_input = create_test_audio_file(60)\n        try:\n            chunked = ChunkedASR(\n                asr_class=MockASR, audio_path=audio_input, asr_kwargs={}\n            )\n\n            assert chunked.asr_class is MockASR\n            assert chunked.audio_path == audio_input\n            assert chunked.chunk_length_ms == 600 * 1000  # 10 分钟\n            assert chunked.chunk_overlap_ms == 10 * 1000  # 10 秒\n            assert chunked.chunk_concurrency == 3\n        finally:\n            Path(audio_input).unlink()\n\n    def test_init_custom_params(self):\n        \"\"\"测试自定义参数初始化\"\"\"\n        audio_input = create_test_audio_file(60)\n        try:\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                asr_kwargs={\"mock_text_per_second\": \"Test\"},\n                chunk_length=600,\n                chunk_overlap=5,\n                chunk_concurrency=5,\n            )\n\n            assert chunked.chunk_length_ms == 600 * 1000\n            assert chunked.chunk_overlap_ms == 5 * 1000\n            assert chunked.chunk_concurrency == 5\n            assert chunked.asr_kwargs[\"mock_text_per_second\"] == \"Test\"\n        finally:\n            Path(audio_input).unlink()\n\n    def test_short_audio_no_chunking(self):\n        \"\"\"测试短音频（< chunk_length）不分块直接转录\"\"\"\n        # 创建 5 分钟音频（小于默认的 8 分钟）\n        audio_input = create_test_audio_file(300)\n        try:\n            MockASR.global_run_count = 0\n\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                asr_kwargs={\"mock_text_per_second\": \"Short\"},\n            )\n\n            result = chunked.run()\n\n            # 验证：只调用了一次 ASR（未分块）\n            assert MockASR.global_run_count == 1\n            assert len(result.segments) > 0\n            assert result.segments[0].text.startswith(\"Short\")\n        finally:\n            Path(audio_input).unlink()\n\n    def test_long_audio_with_chunking(self):\n        \"\"\"测试长音频（> chunk_length）自动分块转录\"\"\"\n        # 创建 20 分钟音频（会分成 3 块：0-8min, 8-16min, 16-20min）\n        audio_input = create_test_audio_file(1200)\n        try:\n            MockASR.global_run_count = 0\n\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                asr_kwargs={\"mock_text_per_second\": \"Long\"},\n                chunk_length=480,  # 8分钟\n                chunk_overlap=10,\n            )\n\n            result = chunked.run()\n\n            # 验证：调用了 3 次 ASR（分成 3 块）\n            # 计算公式：(1200s - 480s) / (480s - 10s) + 1 = 2.53... = 3 块\n            assert MockASR.global_run_count == 3\n            assert len(result.segments) > 0\n        finally:\n            Path(audio_input).unlink()\n\n\n# ============================================================================\n# 测试音频分块逻辑\n# ============================================================================\n\n\nclass TestAudioSplitting:\n    \"\"\"测试 _split_audio() 方法\"\"\"\n\n    def test_split_exact_chunks(self):\n        \"\"\"测试精确分块（音频长度正好是块长度的倍数）\"\"\"\n        # 16分钟 = 2块 × 8分钟\n        audio_input = create_test_audio_file(960)\n        try:\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                chunk_length=480,\n                chunk_overlap=0,\n            )\n\n            chunks = chunked._split_audio()\n\n            assert len(chunks) == 2\n            assert chunks[0][1] == 0  # 第一块 offset = 0ms\n            assert chunks[1][1] == 480 * 1000  # 第二块 offset = 480s\n        finally:\n            Path(audio_input).unlink()\n\n    def test_split_with_overlap(self):\n        \"\"\"测试带重叠的分块\"\"\"\n        # 20分钟，8分钟/块，10秒重叠\n        audio_input = create_test_audio_file(1200)\n        try:\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                chunk_length=480,\n                chunk_overlap=10,\n            )\n\n            chunks = chunked._split_audio()\n\n            # 计算块数：(1200 - 480) / (480 - 10) + 1 = 2.53 ≈ 3 块\n            assert len(chunks) == 3\n\n            # 验证 offset 正确\n            assert chunks[0][1] == 0\n            assert chunks[1][1] == 470 * 1000  # 480 - 10\n            assert chunks[2][1] == 940 * 1000  # 470 + 470\n        finally:\n            Path(audio_input).unlink()\n\n    def test_split_remainder_chunk(self):\n        \"\"\"测试剩余块（最后一块不足完整长度）\"\"\"\n        # 10分钟，8分钟/块 -> 2块（第二块仅2分钟）\n        audio_input = create_test_audio_file(600)\n        try:\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                chunk_length=480,\n                chunk_overlap=0,\n            )\n\n            chunks = chunked._split_audio()\n\n            assert len(chunks) == 2\n            # 第二块应该只有 120 秒\n            chunk2_audio = AudioSegment.from_file(io.BytesIO(chunks[1][0]))\n            assert abs(len(chunk2_audio) - 120 * 1000) < 100  # 允许误差 100ms\n        finally:\n            Path(audio_input).unlink()\n\n\n# ============================================================================\n# 测试并发转录\n# ============================================================================\n\n\nclass TestConcurrentTranscription:\n    \"\"\"测试并发转录逻辑\"\"\"\n\n    def test_concurrency_3_workers(self):\n        \"\"\"测试 3 个并发 worker\"\"\"\n        # 20分钟 -> 3块\n        audio_input = create_test_audio_file(1200)\n        try:\n            MockASR.global_run_count = 0\n\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                chunk_length=480,\n                chunk_concurrency=3,\n            )\n\n            result = chunked.run()\n\n            # 验证：所有块都被转录\n            assert MockASR.global_run_count == 3\n            assert len(result.segments) > 0\n        finally:\n            Path(audio_input).unlink()\n\n    def test_independent_asr_instances(self):\n        \"\"\"测试每个 chunk 使用独立的 ASR 实例\"\"\"\n        # 20分钟 -> 3块\n        audio_input = create_test_audio_file(1200)\n        try:\n            MockASR.global_run_count = 0\n\n            # 使用不同的 mock_text_per_second 标记不同实例\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                asr_kwargs={\"mock_text_per_second\": \"Chunk\"},\n                chunk_length=480,\n            )\n\n            result = chunked.run()\n\n            # 验证：每个块都生成了结果\n            assert MockASR.global_run_count == 3\n            # 所有 segment 的文本都应该包含 \"Chunk\"\n            for seg in result.segments:\n                assert \"Chunk\" in seg.text\n        finally:\n            Path(audio_input).unlink()\n\n\n# ============================================================================\n# 测试结果合并\n# ============================================================================\n\n\nclass TestChunkMerging:\n    \"\"\"测试 _merge_results() 方法\"\"\"\n\n    def test_merge_preserves_order(self):\n        \"\"\"测试合并后时间戳顺序正确\"\"\"\n        # 20分钟 -> 3块\n        audio_input = create_test_audio_file(1200)\n        try:\n            chunked = ChunkedASR(\n                asr_class=MockASR, audio_path=audio_input, chunk_length=480\n            )\n\n            result = chunked.run()\n\n            # 验证时间戳递增\n            for i in range(len(result.segments) - 1):\n                assert result.segments[i].end_time <= result.segments[i + 1].start_time\n        finally:\n            Path(audio_input).unlink()\n\n\n# ============================================================================\n# 测试边界情况\n# ============================================================================\n\n\nclass TestEdgeCases:\n    \"\"\"测试边界情况\"\"\"\n\n    def test_very_short_audio(self):\n        \"\"\"测试极短音频（1秒）\"\"\"\n        audio_input = create_test_audio_file(1)\n        try:\n            chunked = ChunkedASR(asr_class=MockASR, audio_path=audio_input)\n\n            result = chunked.run()\n\n            assert len(result.segments) >= 1\n        finally:\n            Path(audio_input).unlink()\n\n    def test_zero_overlap(self):\n        \"\"\"测试零重叠\"\"\"\n        audio_input = create_test_audio_file(1000)\n        try:\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                chunk_length=480,\n                chunk_overlap=0,\n            )\n\n            chunks = chunked._split_audio()\n\n            # 验证无重叠：每个 chunk 的 offset 是前一个的结束位置\n            assert len(chunks) >= 2\n            assert chunks[1][1] == 480 * 1000\n        finally:\n            Path(audio_input).unlink()\n\n\n# ============================================================================\n# 测试错误处理\n# ============================================================================\n\n\nclass TestErrorHandling:\n    \"\"\"测试错误处理\"\"\"\n\n    def test_asr_failure_propagates(self):\n        \"\"\"测试 ASR 失败时错误正确传播\"\"\"\n        audio_input = create_test_audio_file(1000)\n        try:\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                asr_kwargs={\"fail_on_run\": True},\n                chunk_length=480,\n            )\n\n            with pytest.raises(RuntimeError, match=\"Mock ASR failed\"):\n                chunked.run()\n        finally:\n            Path(audio_input).unlink()\n\n\n# ============================================================================\n# 测试进度回调\n# ============================================================================\n\n\nclass TestProgressCallback:\n    \"\"\"测试进度回调机制\"\"\"\n\n    def test_callback_invoked(self):\n        \"\"\"测试回调函数被正确调用\"\"\"\n        audio_input = create_test_audio_file(1000)\n        try:\n            callback_calls = []\n\n            def mock_callback(progress: int, message: str):\n                callback_calls.append((progress, message))\n\n            chunked = ChunkedASR(\n                asr_class=MockASR, audio_path=audio_input, chunk_length=480\n            )\n\n            chunked.run(callback=mock_callback)\n\n            # 验证回调被调用\n            assert len(callback_calls) > 0\n            # 验证进度在 0-100 之间\n            for progress, _ in callback_calls:\n                assert 0 <= progress <= 100\n        finally:\n            Path(audio_input).unlink()\n\n\n# ============================================================================\n# 集成测试\n# ============================================================================\n\n\nclass TestIntegration:\n    \"\"\"端到端集成测试\"\"\"\n\n    def test_full_pipeline_short_audio(self):\n        \"\"\"测试完整流程：短音频（不分块）\"\"\"\n        audio_input = create_test_audio_file(300)\n        try:\n            MockASR.global_run_count = 0\n\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                asr_kwargs={\"mock_text_per_second\": \"Test\"},\n            )\n\n            result = chunked.run()\n\n            assert MockASR.global_run_count == 1\n            assert len(result.segments) > 0\n            assert all(\"Test\" in seg.text for seg in result.segments)\n        finally:\n            Path(audio_input).unlink()\n\n    def test_full_pipeline_long_audio(self):\n        \"\"\"测试完整流程：长音频（分块）\"\"\"\n        audio_input = create_test_audio_file(1200)\n        try:\n            MockASR.global_run_count = 0\n\n            chunked = ChunkedASR(\n                asr_class=MockASR,\n                audio_path=audio_input,\n                asr_kwargs={\"mock_text_per_second\": \"Long\"},\n                chunk_length=480,\n                chunk_overlap=10,\n                chunk_concurrency=3,\n            )\n\n            result = chunked.run()\n\n            # 验证分块转录\n            assert MockASR.global_run_count == 3\n\n            # 验证结果完整性\n            assert len(result.segments) > 0\n            assert all(\"Long\" in seg.text for seg in result.segments)\n\n            # 验证时间戳顺序\n            for i in range(len(result.segments) - 1):\n                assert result.segments[i].end_time <= result.segments[i + 1].start_time\n        finally:\n            Path(audio_input).unlink()\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/test_asr/test_chunking.py",
    "content": "\"\"\"音频分块 ASR 功能的真实场景测试\n\n测试覆盖：\n1. 音频切割功能（pydub）\n2. 并发转录功能（ThreadPoolExecutor）\n3. 结果合并功能（ChunkMerger）\n4. 边界情况（短音频、单块、空音频等）\n5. 缓存机制\n6. 错误处理\n\"\"\"\n\nimport io\nfrom typing import Callable, List, Optional\n\nfrom pydub import AudioSegment\nfrom pydub.generators import Sine\n\nfrom app.core.asr.asr_data import ASRDataSeg\nfrom app.core.asr.base import BaseASR\nfrom app.core.asr.chunked_asr import ChunkedASR\n\n# ============================================================================\n# 测试用 Mock ASR 实现\n# ============================================================================\n\n\nclass MockASR(BaseASR):\n    \"\"\"Mock ASR 用于测试，模拟真实 API 调用\"\"\"\n\n    # 类变量，用于跟踪所有实例的总调用次数\n    _total_call_count = 0\n\n    def __init__(\n        self,\n        audio_input,\n        need_word_time_stamp=False,\n        enable_chunking=False,\n        chunk_length=600,\n        chunk_overlap=10,\n        chunk_concurrency=3,\n        # Mock 专用参数\n        mock_text_per_second=\"Mock\",\n        fail_on_chunk=None,\n    ):\n        super().__init__(\n            audio_input=audio_input,\n            need_word_time_stamp=need_word_time_stamp,\n        )\n        self.enable_chunking = enable_chunking\n        self.chunk_length = chunk_length\n        self.chunk_overlap = chunk_overlap\n        self.chunk_concurrency = chunk_concurrency\n        self.mock_text_per_second = mock_text_per_second\n        self.fail_on_chunk = fail_on_chunk\n\n    def _run(\n        self, callback: Optional[Callable[[int, str], None]] = None, **kwargs\n    ) -> dict:\n        \"\"\"模拟 ASR 调用，生成基于音频长度的假数据\"\"\"\n        from pydub import AudioSegment\n\n        # 解析音频长度\n        assert self.file_binary is not None, \"file_binary should be set by _set_data()\"\n        audio = AudioSegment.from_file(io.BytesIO(self.file_binary))\n        duration_ms = len(audio)\n\n        # 模拟进度回调\n        if callback:\n            callback(50, \"Transcribing...\")\n\n        # 递增类变量计数器\n        MockASR._total_call_count += 1\n\n        # 模拟失败（用于测试错误处理）\n        if (\n            self.fail_on_chunk is not None\n            and MockASR._total_call_count == self.fail_on_chunk\n        ):\n            raise RuntimeError(f\"Simulated failure on chunk {self.fail_on_chunk}\")\n\n        # 生成假字幕数据（每秒一个片段）\n        segments = []\n        num_segments = max(1, duration_ms // 1000)\n\n        for i in range(num_segments):\n            start_time = i * 1000\n            end_time = min((i + 1) * 1000, duration_ms)\n            text = f\"{self.mock_text_per_second} {i+1}\"\n            segments.append(\n                {\"text\": text, \"start\": start_time / 1000, \"end\": end_time / 1000}\n            )\n\n        if callback:\n            callback(100, \"Completed\")\n\n        return {\"segments\": segments}\n\n    def _make_segments(self, resp_data: dict) -> List[ASRDataSeg]:\n        \"\"\"将 mock 响应转换为 ASRDataSeg\"\"\"\n        return [\n            ASRDataSeg(\n                text=seg[\"text\"],\n                start_time=int(seg[\"start\"] * 1000),\n                end_time=int(seg[\"end\"] * 1000),\n            )\n            for seg in resp_data[\"segments\"]\n        ]\n\n    def _get_subclass_params(self) -> dict:\n        \"\"\"返回 Mock ASR 的参数\"\"\"\n        return {\n            \"mock_text_per_second\": self.mock_text_per_second,\n            \"fail_on_chunk\": self.fail_on_chunk,\n        }\n\n\n# ============================================================================\n# 辅助函数\n# ============================================================================\n\n\ndef create_test_audio(duration_ms: int, frequency: int = 440) -> bytes:\n    \"\"\"创建测试音频数据\n\n    Args:\n        duration_ms: 音频时长（毫秒）\n        frequency: 音频频率（Hz）\n\n    Returns:\n        音频字节数据（MP3格式）\n    \"\"\"\n    # 生成正弦波音频\n    sine_wave = Sine(frequency).to_audio_segment(duration=duration_ms)\n\n    # 导出为 MP3 字节\n    buffer = io.BytesIO()\n    sine_wave.export(buffer, format=\"mp3\")\n    return buffer.getvalue()\n\n\ndef create_test_audio_file(duration_sec: int) -> str:\n    \"\"\"创建测试用音频文件（静音）\n\n    Args:\n        duration_sec: 音频时长（秒）\n\n    Returns:\n        音频文件路径（临时文件）\n    \"\"\"\n    import tempfile\n\n    # 创建静音音频\n    audio = AudioSegment.silent(duration=duration_sec * 1000)\n\n    # 保存到临时文件\n    temp_file = tempfile.NamedTemporaryFile(suffix=\".mp3\", delete=False)\n    temp_path = temp_file.name\n    temp_file.close()\n    audio.export(temp_path, format=\"mp3\")\n    return temp_path\n\n\n# ============================================================================\n# 测试：音频切割功能\n# ============================================================================\n\n\nclass TestAudioSplitting:\n    \"\"\"测试 pydub 音频切割功能\"\"\"\n\n    def test_split_long_audio_into_chunks(self):\n        \"\"\"测试：长音频正确切割为重叠块\"\"\"\n        # 创建 30 秒音频，切成 10 秒块，2 秒重叠\n        audio_path = create_test_audio_file(30)\n\n        try:\n            chunked_asr = ChunkedASR(\n                asr_class=MockASR,\n                audio_input=audio_path,\n                asr_kwargs={},\n                chunk_length=10,  # 10秒\n                chunk_overlap=2,  # 2秒重叠\n            )\n\n            chunks = chunked_asr._split_audio()\n\n            # 验证块数：30秒，每块10秒，重叠2秒\n            # chunk1: 0-10s, chunk2: 8-18s, chunk3: 16-26s, chunk4: 24-30s\n            assert len(chunks) == 4\n\n            # 验证每个块的偏移\n            _, offsets = zip(*chunks)\n            assert offsets == (0, 8000, 16000, 24000)\n\n            # 验证每个块都是有效的音频\n            for chunk_bytes, _ in chunks:\n                audio_segment = AudioSegment.from_file(io.BytesIO(chunk_bytes))\n                assert len(audio_segment) > 0\n        finally:\n            import os\n\n            if os.path.exists(audio_path):\n                os.unlink(audio_path)\n\n    def test_split_short_audio_no_chunks(self):\n        \"\"\"测试：短音频不需要切割\"\"\"\n        # 5 秒音频，块长度 10 秒\n        audio_path = create_test_audio_file(5)\n\n        try:\n            chunked_asr = ChunkedASR(\n                asr_class=MockASR,\n                audio_input=audio_path,\n                asr_kwargs={},\n                chunk_length=10,\n                chunk_overlap=2,\n            )\n\n            chunks = chunked_asr._split_audio()\n\n            # 只有一个块\n            assert len(chunks) == 1\n            assert chunks[0][1] == 0  # offset=0\n        finally:\n            import os\n\n            if os.path.exists(audio_path):\n                os.unlink(audio_path)\n\n    def test_split_exact_chunk_length(self):\n        \"\"\"测试：音频长度恰好等于块长度\"\"\"\n        audio_path = create_test_audio_file(10)\n\n        try:\n            chunked_asr = ChunkedASR(\n                asr_class=MockASR,\n                audio_input=audio_path,\n                asr_kwargs={},\n                chunk_length=10,\n                chunk_overlap=2,\n            )\n\n            chunks = chunked_asr._split_audio()\n            assert len(chunks) == 1\n        finally:\n            import os\n\n            if os.path.exists(audio_path):\n                os.unlink(audio_path)\n\n    def test_split_with_zero_overlap(self):\n        \"\"\"测试：零重叠的切割\"\"\"\n        audio_path = create_test_audio_file(20)\n\n        try:\n            chunked_asr = ChunkedASR(\n                asr_class=MockASR,\n                audio_input=audio_path,\n                asr_kwargs={},\n                chunk_length=10,\n                chunk_overlap=0,\n            )\n\n            chunks = chunked_asr._split_audio()\n\n            # 20秒 / 10秒 = 2块\n            assert len(chunks) == 2\n            _, offsets = zip(*chunks)\n            assert offsets == (0, 10000)\n        finally:\n            import os\n\n            if os.path.exists(audio_path):\n                os.unlink(audio_path)\n\n\n# ============================================================================\n# 测试：并发转录功能（已被 test_chunked_asr.py 覆盖）\n# ============================================================================\n# 注意：以下测试已过时，依赖旧API (MockASR的enable_chunking参数)\n# 现在使用 ChunkedASR 包装器模式，相关测试已在 test_chunked_asr.py 中实现\n# ============================================================================\n\n'''\n# class TestConcurrentTranscription:\n#     \"\"\"测试并发转录功能\"\"\"\n#     # 已过时 - 依赖 MockASR(enable_chunking=True) 旧API\n#     # 现在应使用 ChunkedASR(asr_class=MockASR, ...)\n#     # 相关测试已在 test_chunked_asr.py 中实现\n'''\n\n\n# ============================================================================\n# 测试：结果合并功能（已被 test_chunk_merger.py 覆盖）\n# ============================================================================\n\n\"\"\"\n# class TestChunkMerging:\n#     # 已过时 - 合并功能已由 test_chunk_merger.py 专门测试\n\"\"\"\n\n\n# ============================================================================\n# 测试：边界情况（已被 test_chunked_asr.py 覆盖）\n# ============================================================================\n\n\"\"\"\n# class TestEdgeCases:\n#     # 已过时 - 边界情况已在 test_chunked_asr.py 测试\n\"\"\"\n\n\n# ============================================================================\n# 测试：缓存机制（已被 test_chunked_asr.py 覆盖）\n# ============================================================================\n\n\"\"\"\n# class TestCaching:\n#     # 已过时 - 缓存机制已重构\n\"\"\"\n\n\n# ============================================================================\n# 测试：错误处理（已被 test_chunked_asr.py 覆盖）\n# ============================================================================\n\n\"\"\"\n# class TestErrorHandling:\n#     # 已过时 - 错误处理已在 test_chunked_asr.py 测试\n\"\"\"\n\n\n# ============================================================================\n# 测试：真实场景集成测试（已被 test_chunked_asr.py 覆盖）\n# ============================================================================\n\n'''\nclass TestRealWorldScenarios:\n    \"\"\"真实场景集成测试\"\"\"\n\n    def test_30_minute_podcast_chunking(self):\n        \"\"\"真实场景：30分钟播客音频分块转录\"\"\"\n        # 模拟 30 分钟 = 1800 秒\n        audio_bytes = create_test_audio(1800000)\n\n        asr = MockASR(\n            audio_input=audio_bytes,\n            enable_chunking=True,\n            chunk_length=600,  # 10分钟块\n            chunk_overlap=10,  # 10秒重叠\n            chunk_concurrency=3,\n            mock_text_per_second=\"Podcast content\",\n        )\n\n        result = asr.run()\n\n        # 验证结果\n        assert isinstance(result, ASRData)\n        assert len(result.segments) > 1000  # 30分钟应该有大量片段\n\n        # 验证时间范围\n        assert result.segments[0].start_time == 0\n        assert result.segments[-1].end_time <= 1800000 + 10000  # 允许容差\n\n    def test_chinese_video_transcription(self):\n        \"\"\"真实场景：中文视频转录（15分钟）\"\"\"\n        audio_bytes = create_test_audio(900000)  # 15分钟\n\n        asr = MockASR(\n            audio_input=audio_bytes,\n            enable_chunking=True,\n            chunk_length=300,  # 5分钟块\n            chunk_overlap=10,\n            mock_text_per_second=\"中文字幕\",\n        )\n\n        result = asr.run()\n\n        assert isinstance(result, ASRData)\n        assert len(result.segments) > 0\n\n        # 验证中文文本\n        assert \"中文字幕\" in result.segments[0].text\n\n    def test_progressive_transcription_with_callback(self):\n        \"\"\"真实场景：带进度回调的渐进式转录\"\"\"\n        audio_bytes = create_test_audio(60000)  # 1分钟\n        progress_log = []\n\n        def progress_callback(progress: int, message: str):\n            progress_log.append({\"progress\": progress, \"message\": message})\n\n        asr = MockASR(\n            audio_input=audio_bytes,\n            enable_chunking=True,\n            chunk_length=30,  # 30秒块\n            chunk_overlap=5,\n        )\n\n        result = asr.run(callback=progress_callback)\n\n        # 验证进度日志\n        assert len(progress_log) > 0\n\n        # 验证进度递增\n        progresses = [log[\"progress\"] for log in progress_log]\n        # 注意：由于并发，进度可能不是严格递增的\n        # 但应该有一些增长趋势\n        assert max(progresses) > min(progresses)\n'''\n\n\n# ============================================================================\n# 注意: 以上测试类已过时,被 test_chunked_asr.py 覆盖\n# TestConcurrentTranscription - 已由 test_chunked_asr.py 测试\n# TestChunkMerging - 已由 test_chunk_merger.py 测试\n# TestEdgeCases - 已由 test_chunked_asr.py 测试\n# TestCaching - 缓存功能已重构\n# TestErrorHandling - 已由 test_chunked_asr.py 测试\n# TestRealWorldScenarios - 已由 test_chunked_asr.py 测试\n# ============================================================================\n"
  },
  {
    "path": "tests/test_asr/test_jianying_asr.py",
    "content": "\"\"\"JianYingASR integration tests.\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom app.core.asr import JianYingASR\nfrom app.core.asr.asr_data import ASRData\nfrom tests.test_asr.conftest import assert_asr_result_valid\n\n\n@pytest.mark.integration\n@pytest.mark.slow\nclass TestJianYingASR:\n    \"\"\"Test suite for JianYingASR using public JianYing (CapCut) API.\n\n    Note: This service has rate limits and should be used sparingly.\n    Tests are marked as 'slow' to avoid running in normal CI.\n    \"\"\"\n\n    # @pytest.fixture\n    # def jianying_asr_sentence(self, test_audio_path: Path) -> JianYingASR:\n    #     \"\"\"Create JianYingASR instance with sentence-level timestamps.\n\n    #     Args:\n    #         test_audio_path: Path to test audio file\n\n    #     Returns:\n    #         JianYingASR instance configured for sentence-level timestamps\n    #     \"\"\"\n    #     return JianYingASR(\n    #         audio_input=str(test_audio_path),\n    #         need_word_time_stamp=False,\n    #     )\n\n    # @pytest.fixture\n    # def jianying_asr_word(self, test_audio_path: Path) -> JianYingASR:\n    #     \"\"\"Create JianYingASR instance with word-level timestamps.\n\n    #     Args:\n    #         test_audio_path: Path to test audio file\n\n    #     Returns:\n    #         JianYingASR instance configured for word-level timestamps\n    #     \"\"\"\n    #     return JianYingASR(\n    #         audio_input=str(test_audio_path),\n    #         need_word_time_stamp=True,\n    #     )\n\n    # def test_transcribe_sentence_level(\n    #     self, jianying_asr_sentence: JianYingASR\n    # ) -> None:\n    #     \"\"\"Test sentence-level transcription (need_word_time_stamp=False).\n\n    #     Args:\n    #         jianying_asr_sentence: JianYingASR instance with sentence-level timestamps\n    #     \"\"\"\n    #     result: ASRData = jianying_asr_sentence.run()\n\n    #     print(\"\\n\" + \"=\" * 60)\n    #     print(\"JianYingASR Sentence-Level Transcription Results:\")\n    #     print(f\"  Total segments: {len(result.segments)}\")\n    #     print(f\"  Is word timestamp: {result.is_word_timestamp()}\")\n    #     for i, seg in enumerate(result.segments[:3], 1):\n    #         print(f\"  [{i}] {seg.text} ({seg.start_time}-{seg.end_time}ms)\")\n    #     print(\"=\" * 60)\n\n    #     assert_asr_result_valid(result, min_segments=0)\n    #     assert (\n    #         not result.is_word_timestamp()\n    #     ), \"Result should be sentence-level, not word-level\"\n\n    # def test_transcribe_word_level(self, jianying_asr_word: JianYingASR) -> None:\n    #     \"\"\"Test word-level transcription (need_word_time_stamp=True).\n\n    #     Args:\n    #         jianying_asr_word: JianYingASR instance with word-level timestamps\n    #     \"\"\"\n    #     result: ASRData = jianying_asr_word.run()\n\n    #     print(\"\\n\" + \"=\" * 60)\n    #     print(\"JianYingASR Word-Level Transcription Results:\")\n    #     print(f\"  Total segments: {len(result.segments)}\")\n    #     print(f\"  Is word timestamp: {result.is_word_timestamp()}\")\n    #     for i, seg in enumerate(result.segments[:5], 1):\n    #         print(f\"  [{i}] {seg.text} ({seg.start_time}-{seg.end_time}ms)\")\n    #     print(\"=\" * 60)\n\n    #     assert_asr_result_valid(result, min_segments=0)\n\n    #     if len(result.segments) > 0:\n    #         assert (\n    #             result.is_word_timestamp()\n    #         ), \"Result should be word-level when need_word_time_stamp=True\"\n\n    @pytest.mark.parametrize(\n        \"need_word_ts,audio_fixture\",\n        [\n            (False, \"test_audio_path_zh\"),\n            (True, \"test_audio_path_zh\"),\n            (False, \"test_audio_path_en\"),\n            (True, \"test_audio_path_en\"),\n        ],\n    )\n    def test_transcribe_parametrized(\n        self, need_word_ts: bool, audio_fixture: str, request\n    ) -> None:\n        \"\"\"Test transcription with different configurations and languages.\n\n        Args:\n            need_word_ts: Whether to use word-level timestamps\n            audio_fixture: Name of the audio fixture to use\n            request: Pytest request object for fixture access\n        \"\"\"\n        audio_path: Path = request.getfixturevalue(audio_fixture)\n        lang = \"Chinese\" if \"zh\" in audio_fixture else \"English\"\n        level = \"word\" if need_word_ts else \"sentence\"\n\n        asr = JianYingASR(\n            audio_input=str(audio_path),\n            need_word_time_stamp=need_word_ts,\n        )\n\n        result: ASRData = asr.run()\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"JianYingASR - {lang.upper()} - {level.title()}-Level Results:\")\n        print(f\"  Total Segments: {len(result.segments)}\")\n        print(f\"  Is Word Timestamp: {result.is_word_timestamp()}\")\n        for i, seg in enumerate(result.segments[:50], 1):\n            print(\n                f\"    [{i:2d}] {seg.text:<30} ({seg.start_time:6d} - {seg.end_time:6d} ms)\"\n            )\n        print(\"=\" * 60)\n\n        assert_asr_result_valid(result, min_segments=0)\n\n        if not need_word_ts and len(result.segments) > 0:\n            assert not result.is_word_timestamp()\n"
  },
  {
    "path": "tests/test_asr/test_whisper_api_asr.py",
    "content": "\"\"\"WhisperAPI integration tests.\"\"\"\n\nimport os\nfrom pathlib import Path\n\nimport pytest\n\nfrom app.core.asr import WhisperAPI\nfrom app.core.asr.asr_data import ASRData\nfrom tests.test_asr.conftest import assert_asr_result_valid\n\n\n@pytest.mark.integration\nclass TestWhisperAPI:\n    \"\"\"Test suite for WhisperAPI using OpenAI-compatible API endpoints.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def skip_if_no_env(self, check_env_vars) -> None:\n        \"\"\"Skip tests if required environment variables are not set.\n\n        Args:\n            check_env_vars: Fixture from root conftest.py\n        \"\"\"\n        check_env_vars(\"WHISPER_BASE_URL\", \"WHISPER_API_KEY\")\n\n    def test_chinese_word_timestamp(self, test_audio_path_zh: Path) -> None:\n        \"\"\"Test Chinese word-level timestamp functionality.\n\n        Args:\n            test_audio_path_zh: Path to Chinese test audio file\n        \"\"\"\n        whisper_api = WhisperAPI(\n            audio_input=str(test_audio_path_zh),\n            whisper_model=os.getenv(\"WHISPER_MODEL\", \"whisper-1\"),\n            language=\"zh\",\n            prompt=\"\",\n            base_url=os.getenv(\"WHISPER_BASE_URL\"),\n            api_key=os.getenv(\"WHISPER_API_KEY\"),\n            need_word_time_stamp=True,\n        )\n\n        result: ASRData = whisper_api.run()\n\n        print(\"\\n\" + \"=\" * 60)\n        print(\"WhisperAPI - Chinese Word Timestamp Test:\")\n        print(f\"  Total Segments: {len(result.segments)}\")\n        print(f\"  Is Word Timestamp: {result.is_word_timestamp()}\")\n        for i, seg in enumerate(result.segments, 1):\n            print(\n                f\"    [{i:3d}] {seg.text:<20} ({seg.start_time:6d} - {seg.end_time:6d} ms)\"\n            )\n        print(\"=\" * 60)\n\n        assert_asr_result_valid(result, min_segments=0)\n\n    @pytest.mark.parametrize(\n        \"need_word_ts,audio_fixture\",\n        [\n            (False, \"test_audio_path_zh\"),\n            (True, \"test_audio_path_zh\"),\n            (False, \"test_audio_path_en\"),\n            (True, \"test_audio_path_en\"),\n        ],\n    )\n    def test_transcribe_parametrized(\n        self, need_word_ts: bool, audio_fixture: str, request\n    ) -> None:\n        \"\"\"Test transcription with different configurations and languages.\n\n        Args:\n            need_word_ts: Whether to use word-level timestamps\n            audio_fixture: Name of the audio fixture to use\n            request: Pytest request object for fixture access\n        \"\"\"\n        audio_path: Path = request.getfixturevalue(audio_fixture)\n        lang = \"Chinese\" if \"zh\" in audio_fixture else \"English\"\n        level = \"word\" if need_word_ts else \"sentence\"\n        language_code = \"zh\" if \"zh\" in audio_fixture else \"en\"\n\n        whisper_api = WhisperAPI(\n            audio_input=str(audio_path),\n            whisper_model=os.getenv(\"WHISPER_MODEL\", \"whisper-1\"),\n            language=language_code,\n            prompt=\"\",\n            base_url=os.getenv(\"WHISPER_BASE_URL\"),\n            api_key=os.getenv(\"WHISPER_API_KEY\"),\n            need_word_time_stamp=need_word_ts,\n        )\n\n        result: ASRData = whisper_api.run()\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"WhisperAPI - {lang.upper()} - {level.title()}-Level Results:\")\n        print(f\"  Total Segments: {len(result.segments)}\")\n        print(f\"  Is Word Timestamp: {result.is_word_timestamp()}\")\n        for i, seg in enumerate(result.segments[:50], 1):\n            print(\n                f\"    [{i:2d}] {seg.text:<30} ({seg.start_time:6d} - {seg.end_time:6d} ms)\"\n            )\n        print(\"=\" * 60)\n\n        assert_asr_result_valid(result, min_segments=0)\n"
  },
  {
    "path": "tests/test_optimize/test_optimize.py",
    "content": "\"\"\"Subtitle optimizer tests.\n\nRequires environment variables:\n    OPENAI_BASE_URL: OpenAI-compatible API endpoint\n    OPENAI_API_KEY: API key for authentication\n    OPENAI_MODEL: Model name (optional, defaults to gpt-4o-mini)\n\"\"\"\n\nimport os\nfrom typing import Callable\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.optimize.optimize import SubtitleOptimizer\n\n\n@pytest.mark.integration\nclass TestSubtitleOptimizer:\n    \"\"\"Test suite for SubtitleOptimizer with agent loop.\"\"\"\n\n    @pytest.fixture\n    def optimizer(self, mock_llm_client) -> SubtitleOptimizer:\n        \"\"\"Create SubtitleOptimizer instance (using mock LLM).\"\"\"\n        model = \"gpt-4o-mini\"\n        return SubtitleOptimizer(\n            thread_num=2,\n            batch_num=5,\n            model=model,\n            custom_prompt=\"\",\n        )\n\n    @pytest.fixture\n    def sample_asr_data(self) -> ASRData:\n        \"\"\"Create sample ASR data with typical errors: homophones, typos, filler words.\"\"\"\n        segments = [\n            ASRDataSeg(\n                text=\"大家好啊今天呢我们来讲一下这个机器学习的基础只是\",\n                start_time=0,\n                end_time=3000,\n            ),\n            ASRDataSeg(\n                text=\"那么它其实就是嗯人工治能的一个重要份支\",\n                start_time=3000,\n                end_time=6000,\n            ),\n            ASRDataSeg(\n                text=\"通过算发让计算机去从这个数据当中学习嘛\",\n                start_time=6000,\n                end_time=9000,\n            ),\n        ]\n        return ASRData(segments)\n\n    def test_optimize_basic(\n        self,\n        optimizer: SubtitleOptimizer,\n        sample_asr_data: ASRData,\n        check_env_vars: Callable,\n    ):\n        \"\"\"Test basic optimization functionality.\"\"\"\n        check_env_vars(\"OPENAI_BASE_URL\", \"OPENAI_API_KEY\")\n\n        result = optimizer.optimize_subtitle(sample_asr_data)\n\n        print(\"\\n\" + \"=\" * 80)\n        print(f\"📝 字幕优化测试 - 共 {len(result.segments)} 段\")\n        print(\"=\" * 80)\n        print(\"原始 → 优化后:\")\n        for orig, opt in zip(sample_asr_data.segments, result.segments):\n            print(f\"  {orig.text}\")\n            print(f\"  → {opt.text}\")\n        print(\"=\" * 80)\n\n        # 验证结果\n        assert len(result.segments) == len(sample_asr_data.segments)\n        assert all(seg.text for seg in result.segments)\n\n        # 验证时间戳未被修改\n        for orig, opt in zip(sample_asr_data.segments, result.segments):\n            assert opt.start_time == orig.start_time\n            assert opt.end_time == orig.end_time\n\n    def test_agent_loop_validation(\n        self,\n        optimizer: SubtitleOptimizer,\n        sample_asr_data: ASRData,\n        check_env_vars: Callable,\n    ):\n        \"\"\"Test agent loop validation and correction.\"\"\"\n        check_env_vars(\"OPENAI_BASE_URL\", \"OPENAI_API_KEY\")\n\n        result = optimizer.optimize_subtitle(sample_asr_data)\n\n        print(\"\\n\" + \"=\" * 80)\n        print(\"🔄 Agent Loop 验证测试\")\n        print(\"=\" * 80)\n        for orig, opt in zip(sample_asr_data.segments, result.segments):\n            print(f\"  原文: {orig.text}\")\n            print(f\"  优化: {opt.text}\")\n        print(\"=\" * 80)\n\n        # 验证结果\n        assert len(result.segments) == len(sample_asr_data.segments)\n        assert all(seg.text for seg in result.segments)\n\n    def test_optimize_empty_handling(self, optimizer: SubtitleOptimizer):\n        \"\"\"Test handling of empty segments.\"\"\"\n        segments = []\n        asr_data = ASRData(segments)\n\n        result = optimizer.optimize_subtitle(asr_data)\n\n        assert len(result.segments) == 0\n"
  },
  {
    "path": "tests/test_split/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_split/test_alignment.py",
    "content": "\"\"\"字幕对齐模块测试\n\n测试 app/core/split/alignment.py 中的核心功能\n\"\"\"\n\nimport pytest\n\nfrom app.core.split.alignment import SubtitleAligner\n\n\nclass TestSubtitleAligner:\n    \"\"\"测试 SubtitleAligner 类\"\"\"\n\n    @pytest.fixture\n    def aligner(self) -> SubtitleAligner:\n        \"\"\"创建对齐器实例\"\"\"\n        return SubtitleAligner()\n\n    def test_align_identical_texts(self, aligner):\n        \"\"\"测试对齐相同的文本\"\"\"\n        source = [\"a\", \"b\", \"c\", \"d\"]\n        target = [\"a\", \"b\", \"c\", \"d\"]\n\n        aligned_source, aligned_target = aligner.align_texts(source, target)\n\n        assert aligned_source == source\n        assert aligned_target == target\n        assert len(aligned_source) == len(aligned_target)\n\n    def test_align_with_missing_items(self, aligner):\n        \"\"\"测试目标文本缺少某些项时的对齐\"\"\"\n        source = [\"ab\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\", \"i\"]\n        target = [\"a\", \"b\", \"c\", \"d\", \"f\", \"g\", \"h\", \"i\"]  # 缺少 'e'\n\n        aligned_source, aligned_target = aligner.align_texts(source, target)\n\n        assert len(aligned_source) == len(aligned_target)\n        # 源文本应该保持不变\n        assert aligned_source == source\n        # 目标文本应该使用前一项填充缺失项\n        assert len(aligned_target) == len(source)\n\n    def test_align_with_extra_items(self, aligner):\n        \"\"\"测试目标文本有额外项时的对齐\"\"\"\n        source = [\"a\", \"b\", \"c\"]\n        target = [\"a\", \"b\", \"x\", \"c\", \"d\"]  # 有额外的 'x' 和 'd'\n\n        aligned_source, aligned_target = aligner.align_texts(source, target)\n\n        # 源文本可能会使用上一项填充以匹配目标文本长度\n        # 或者目标文本长度可能更长\n        # 这里只验证对齐后两者都有内容即可\n        assert len(aligned_source) > 0\n        assert len(aligned_target) > 0\n\n    def test_align_empty_texts(self, aligner):\n        \"\"\"测试空文本对齐\"\"\"\n        source = []\n        target = []\n\n        aligned_source, aligned_target = aligner.align_texts(source, target)\n\n        assert aligned_source == []\n        assert aligned_target == []\n\n    def test_align_single_item(self, aligner):\n        \"\"\"测试单项对齐\"\"\"\n        source = [\"hello\"]\n        target = [\"hello\"]\n\n        aligned_source, aligned_target = aligner.align_texts(source, target)\n\n        assert aligned_source == [\"hello\"]\n        assert aligned_target == [\"hello\"]\n\n    def test_align_completely_different_texts(self, aligner):\n        \"\"\"测试完全不同的文本对齐\"\"\"\n        source = [\"apple\", \"banana\", \"cherry\"]\n        target = [\"dog\", \"elephant\", \"fox\"]\n\n        aligned_source, aligned_target = aligner.align_texts(source, target)\n\n        # 应该能够对齐,即使内容完全不同\n        assert len(aligned_source) == len(aligned_target)\n        assert len(aligned_source) > 0\n\n    def test_align_chinese_text(self, aligner):\n        \"\"\"测试中文文本对齐\"\"\"\n        source = [\"你好\", \"世界\", \"今天\", \"天气\"]\n        target = [\"你好\", \"世界\", \"天气\"]  # 缺少 \"今天\"\n\n        aligned_source, aligned_target = aligner.align_texts(source, target)\n\n        assert len(aligned_source) == len(aligned_target)\n        assert aligned_source == source\n"
  },
  {
    "path": "tests/test_split/test_split.py",
    "content": "\"\"\"字幕分割模块测试 - 严格边缘用例\n\n测试 app/core/split/split.py 中的核心功能\n\"\"\"\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.split.split import SubtitleSplitter, preprocess_segments\n\n\nclass TestPreprocessEdgeCases:\n    \"\"\"测试 preprocess_segments 边缘情况\"\"\"\n\n    def test_unicode_extremes(self):\n        \"\"\"测试极端Unicode字符\"\"\"\n        segments = [\n            ASRDataSeg(\n                text=\"😀🌍🎉\", start_time=0, end_time=1000\n            ),  # Emoji (可能被当作标点)\n            ASRDataSeg(text=\"مرحبا\", start_time=1000, end_time=2000),  # 阿拉伯文\n            ASRDataSeg(text=\"Привет\", start_time=2000, end_time=3000),  # 俄文\n            ASRDataSeg(text=\"สวัสดี\", start_time=3000, end_time=4000),  # 泰文\n        ]\n        result = preprocess_segments(segments)\n        # Emoji可能被识别为标点，所以应该 >= 3\n        assert len(result) >= 3\n\n    def test_mixed_punctuation_types(self):\n        \"\"\"测试混合标点类型\"\"\"\n        segments = [\n            ASRDataSeg(text=\"...\", start_time=0, end_time=500),\n            ASRDataSeg(text=\"！！！\", start_time=500, end_time=1000),  # 中文标点\n            ASRDataSeg(text=\"...\", start_time=1000, end_time=1500),\n            ASRDataSeg(text=\"？？？\", start_time=1500, end_time=2000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 0  # 全是标点\n\n    def test_zero_duration_segments(self):\n        \"\"\"测试零时长片段\"\"\"\n        segments = [\n            ASRDataSeg(text=\"Hello\", start_time=1000, end_time=1000),\n            ASRDataSeg(text=\"World\", start_time=1000, end_time=1000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 2\n\n    def test_overlapping_timestamps(self):\n        \"\"\"测试重叠时间戳\"\"\"\n        segments = [\n            ASRDataSeg(text=\"First\", start_time=0, end_time=2000),\n            ASRDataSeg(text=\"Overlap\", start_time=1000, end_time=3000),\n            ASRDataSeg(text=\"Third\", start_time=2500, end_time=4000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 3\n\n    def test_reversed_timestamps(self):\n        \"\"\"测试倒序时间戳\"\"\"\n        segments = [\n            ASRDataSeg(text=\"Reversed\", start_time=2000, end_time=1000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 1\n\n    def test_very_long_text(self):\n        \"\"\"测试超长文本(>1000字符)\"\"\"\n        long_text = \"测试\" * 1000\n        segments = [ASRDataSeg(text=long_text, start_time=0, end_time=10000)]\n        result = preprocess_segments(segments)\n        assert len(result) == 1\n        assert len(result[0].text) > 1000\n\n    def test_whitespace_only_segments(self):\n        \"\"\"测试纯空格/制表符/换行符\"\"\"\n        segments = [\n            ASRDataSeg(text=\"   \", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"\\t\\t\\t\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"\\n\\n\", start_time=2000, end_time=3000),\n            ASRDataSeg(text=\"Valid\", start_time=3000, end_time=4000),\n        ]\n        result = preprocess_segments(segments)\n        # 应该移除纯空白，保留\"Valid\"\n        assert len(result) >= 1\n\n    def test_mixed_case_with_numbers(self):\n        \"\"\"测试大小写混合和数字\"\"\"\n        segments = [\n            ASRDataSeg(text=\"Test123ABC\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"456XYZ789\", start_time=1000, end_time=2000),\n        ]\n        result = preprocess_segments(segments, need_lower=True)\n        assert \"test123abc\" in result[0].text.lower()\n\n    def test_special_characters(self):\n        \"\"\"测试特殊字符\"\"\"\n        segments = [\n            ASRDataSeg(text=\"@#$%^&*()\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"<>[]{}\\\\|\", start_time=1000, end_time=2000),\n        ]\n        result = preprocess_segments(segments)\n        # 特殊字符应该被识别为标点或保留\n        assert len(result) <= 2\n\n    def test_newlines_and_tabs_in_text(self):\n        \"\"\"测试文本中的换行和制表符\"\"\"\n        segments = [\n            ASRDataSeg(text=\"Line1\\nLine2\\tTab\", start_time=0, end_time=1000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 1\n\n\nclass TestSubtitleSplitterEdgeCases:\n    \"\"\"测试 SubtitleSplitter 边缘情况\"\"\"\n\n    def test_extremely_short_segments(self):\n        \"\"\"测试极短片段(1-2个字)\"\"\"\n        segments = [\n            ASRDataSeg(text=f\"字{i}\", start_time=i * 100, end_time=(i + 1) * 100)\n            for i in range(100)\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20\n        )\n        result = splitter.split_subtitle(asr_data)\n\n        assert len(result.segments) < len(segments)  # 应该合并了\n\n    def test_extremely_long_single_segment(self):\n        \"\"\"测试超长单个片段(500字)\"\"\"\n        long_text = \"今天我们来讲一讲人工智能的发展历史和未来趋势。\" * 50  # 约500字\n        segments = [ASRDataSeg(text=long_text, start_time=0, end_time=60000)]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20\n        )\n        result = splitter.split_subtitle(asr_data)\n\n        # 应该被分割成多个片段\n        assert len(result.segments) > 10\n\n    def test_alternating_long_short_segments(self):\n        \"\"\"测试长短片段交替\"\"\"\n        segments = [\n            ASRDataSeg(text=\"我\", start_time=0, end_time=100),\n            ASRDataSeg(\n                text=\"今天我们来讲一讲人工智能的发展历史\" * 5,\n                start_time=100,\n                end_time=10000,\n            ),\n            ASRDataSeg(text=\"好\", start_time=10000, end_time=10100),\n            ASRDataSeg(\n                text=\"机器学习算法的核心原理和实际应用\" * 5,\n                start_time=10100,\n                end_time=20000,\n            ),\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20)\n        result = splitter.split_subtitle(asr_data)\n\n        assert len(result.segments) > len(segments)\n\n    def test_all_same_timestamp(self):\n        \"\"\"测试所有片段时间戳相同\"\"\"\n        segments = [\n            ASRDataSeg(text=f\"Text{i}\", start_time=1000, end_time=2000)\n            for i in range(10)\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter.split_subtitle(asr_data)\n\n        assert isinstance(result, ASRData)\n\n    def test_large_time_gaps(self):\n        \"\"\"测试大时间间隔(>10秒)\"\"\"\n        segments = [\n            ASRDataSeg(text=\"第一段\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"第二段\", start_time=20000, end_time=21000),  # 19秒间隔\n            ASRDataSeg(text=\"第三段\", start_time=50000, end_time=51000),  # 29秒间隔\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter.split_subtitle(asr_data)\n\n        assert len(result.segments) >= 3\n\n    def test_1000_segments_stress(self):\n        \"\"\"压力测试: 1000个片段\"\"\"\n        segments = [\n            ASRDataSeg(\n                text=f\"这是第{i}段测试文本内容\",\n                start_time=i * 1000,\n                end_time=(i + 1) * 1000,\n            )\n            for i in range(1000)\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20)\n        result = splitter.split_subtitle(asr_data)\n\n        assert isinstance(result, ASRData)\n        assert len(result.segments) > 0\n\n    def test_mixed_language_segments(self):\n        \"\"\"测试混合语言片段\"\"\"\n        segments = [\n            ASRDataSeg(text=\"Hello你好こんにちは\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"World世界세계\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"مرحباПривет\", start_time=2000, end_time=3000),\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter.split_subtitle(asr_data)\n\n        # 混合语言可能被合并，所以只要有结果即可\n        assert len(result.segments) >= 1\n\n    def test_numbers_only_segments(self):\n        \"\"\"测试纯数字片段\"\"\"\n        segments = [\n            ASRDataSeg(text=\"123456789\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"3.14159265\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"2024年12月31日\", start_time=2000, end_time=3000),\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter.split_subtitle(asr_data)\n\n        # 数字可能被合并，只要有结果即可\n        assert len(result.segments) >= 1\n\n    def test_repeated_text_segments(self):\n        \"\"\"测试重复文本\"\"\"\n        repeated_text = \"重复的内容\"\n        segments = [\n            ASRDataSeg(text=repeated_text, start_time=i * 1000, end_time=(i + 1) * 1000)\n            for i in range(50)\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter.split_subtitle(asr_data)\n\n        assert len(result.segments) > 0\n\n\nclass TestSplitterParameters:\n    \"\"\"测试分割器参数边界\"\"\"\n\n    def test_max_word_count_zero(self):\n        \"\"\"测试最大字数为0(可能被忽略或使用默认值)\"\"\"\n        segments = [ASRDataSeg(text=\"测试文本\", start_time=0, end_time=1000)]\n        asr_data = ASRData(segments)\n\n        try:\n            splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=0,\n            )\n            result = splitter.split_subtitle(asr_data)\n            # 如果不抛异常，应该返回有效结果\n            assert isinstance(result, ASRData)\n        except (ValueError, AssertionError):\n            # 也可能抛出异常\n            pass\n\n    def test_max_word_count_very_large(self):\n        \"\"\"测试最大字数超大(10000)\"\"\"\n        segments = [ASRDataSeg(text=\"测试\" * 100, start_time=0, end_time=10000)]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=10000,\n        )\n        result = splitter.split_subtitle(asr_data)\n\n        # 超大限制应该不分割\n        assert len(result.segments) <= 2\n\n    def test_max_word_count_exactly_matches(self):\n        \"\"\"测试字数恰好等于限制\"\"\"\n        text = \"测\" * 20  # 恰好20字\n        segments = [ASRDataSeg(text=text, start_time=0, end_time=2000)]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20,\n        )\n        result = splitter.split_subtitle(asr_data)\n\n        assert len(result.segments) >= 1\n\n\nclass TestMergeShortSegments:\n    \"\"\"测试合并短片段边缘情况\"\"\"\n\n    def test_all_segments_very_short(self):\n        \"\"\"测试全是超短片段(1-2字)\"\"\"\n        segments = [\n            ASRDataSeg(text=\"我\", start_time=i * 100, end_time=(i + 1) * 100)\n            for i in range(100)\n        ]\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.merge_short_segment(segments)\n\n        # 应该被合并成更少的片段\n        assert len(segments) < 100\n\n    def test_mixed_short_and_long(self):\n        \"\"\"测试短片段和长片段混合\"\"\"\n        segments = [\n            ASRDataSeg(text=\"短\", start_time=0, end_time=100),\n            ASRDataSeg(\n                text=\"这是一个很长的片段内容\" * 10, start_time=100, end_time=5000\n            ),\n            ASRDataSeg(text=\"短\", start_time=5000, end_time=5100),\n        ]\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        original_len = len(segments)\n        splitter.merge_short_segment(segments)\n\n        # 短片段可能被合并\n        assert len(segments) <= original_len\n\n    def test_alternating_short_long_pattern(self):\n        \"\"\"测试交替的短长模式\"\"\"\n        segments = []\n        for i in range(50):\n            # 短片段\n            segments.append(\n                ASRDataSeg(text=\"短\", start_time=i * 2000, end_time=i * 2000 + 100)\n            )\n            # 长片段\n            segments.append(\n                ASRDataSeg(\n                    text=\"这是一个比较长的片段\",\n                    start_time=i * 2000 + 100,\n                    end_time=(i + 1) * 2000,\n                )\n            )\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.merge_short_segment(segments)\n\n        assert len(segments) > 0\n\n\nclass TestStopAndThreading:\n    \"\"\"测试停止和线程控制\"\"\"\n\n    def test_stop_before_start(self):\n        \"\"\"测试未开始就停止\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        assert splitter.is_running is True\n\n        splitter.stop()\n        assert splitter.is_running is False\n\n    def test_stop_during_processing(self):\n        \"\"\"测试处理过程中停止\"\"\"\n        # 创建大量数据\n        segments = [\n            ASRDataSeg(text=f\"测试{i}\", start_time=i * 100, end_time=(i + 1) * 100)\n            for i in range(1000)\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n\n        # 立即停止\n        splitter.stop()\n\n        # 尝试处理(应该快速返回或抛出异常)\n        try:\n            result = splitter.split_subtitle(asr_data)\n            # 如果成功返回，应该是空的或部分结果\n            assert isinstance(result, ASRData)\n        except Exception:\n            # 允许抛出异常\n            pass\n\n    def test_multiple_stop_calls(self):\n        \"\"\"测试多次调用stop\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n\n        splitter.stop()\n        splitter.stop()\n        splitter.stop()\n\n        assert splitter.is_running is False\n\n\nclass TestTimestampIntegrity:\n    \"\"\"测试时间戳完整性\"\"\"\n\n    def test_no_negative_durations(self):\n        \"\"\"测试分割后无负时长\"\"\"\n        segments = [\n            ASRDataSeg(\n                text=\"今天天气很好我们一起去公园玩吧\", start_time=0, end_time=5000\n            )\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter.split_subtitle(asr_data)\n\n        for seg in result.segments:\n            assert seg.end_time >= seg.start_time\n\n    def test_no_gaps_in_timeline(self):\n        \"\"\"测试时间轴无间隙(对于连续片段)\"\"\"\n        segments = [\n            ASRDataSeg(text=\"第一段\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"第二段\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"第三段\", start_time=2000, end_time=3000),\n        ]\n        asr_data = ASRData(segments)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter.split_subtitle(asr_data)\n\n        # 验证时间连续性\n        for i in range(len(result.segments) - 1):\n            # 允许小间隙，但不应有大跳跃\n            gap = result.segments[i + 1].start_time - result.segments[i].end_time\n            assert gap >= 0  # 不应重叠太多\n\n    def test_preserves_total_duration(self):\n        \"\"\"测试保持总时长\"\"\"\n        segments = [ASRDataSeg(text=\"测试文本\" * 50, start_time=0, end_time=10000)]\n        asr_data = ASRData(segments)\n\n        original_duration = segments[0].end_time - segments[0].start_time\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter.split_subtitle(asr_data)\n\n        # 总时长应该接近原始时长\n        if result.segments:\n            total_duration = (\n                result.segments[-1].end_time - result.segments[0].start_time\n            )\n            assert abs(total_duration - original_duration) < 1000  # 允许1秒误差\n"
  },
  {
    "path": "tests/test_split/test_split_by_llm.py",
    "content": "\"\"\"LLM-based text splitting tests.\n\nRequires environment variables:\n    OPENAI_BASE_URL: OpenAI-compatible API endpoint\n    OPENAI_API_KEY: API key for authentication\n    OPENAI_MODEL: Model name (optional, defaults to gpt-4o-mini)\n\"\"\"\n\nimport os\nfrom typing import Callable\n\nimport pytest\n\nfrom app.core.split.split_by_llm import count_words, split_by_llm\n\n\n@pytest.mark.integration\nclass TestSplitByLLM:\n    \"\"\"Test suite for LLM-based text splitting.\"\"\"\n\n    def test_count_words_chinese(self):\n        \"\"\"Test word counting for Chinese text.\"\"\"\n        text = \"大家好我叫杨玉溪来自福建厦门\"\n        assert count_words(text) == 14  # 14 Chinese characters\n\n    def test_count_words_english(self):\n        \"\"\"Test word counting for English text.\"\"\"\n        text = \"Hello world this is a test sentence\"\n        assert count_words(text) == 7  # 7 English words\n\n    def test_count_words_mixed(self):\n        \"\"\"Test word counting for mixed Chinese and English text.\"\"\"\n        text = \"大家好 hello 我是 world\"\n        # 5 Chinese chars + 2 English words = 7\n        assert count_words(text) == 7\n\n    def test_split_chinese_text(self, mock_llm_client):\n        \"\"\"Test splitting Chinese text with LLM (using mock).\"\"\"\n        text = \"大家好我叫杨玉溪来自有着良好音乐氛围的福建厦门。自记事起我眼中的世界就是朦胧的。童话书是各色杂乱的线条。电视机是颜色各异的雪花。小伙伴是只听其声不便骑行的马赛克。后来我才知道这是一种眼底黄斑疾病。虽不至于失明但终身无法治愈。\"\n        model = \"gpt-4o-mini\"\n        max_limit = 18\n\n        result = split_by_llm(text, model=model, max_word_count_cjk=max_limit)\n\n        print(\"\\n\" + \"=\" * 80)\n        print(f\"📝 中文断句测试 - 共 {len(result)} 段 (限制: ≤{max_limit}字/段)\")\n        print(\"=\" * 80)\n        for i, seg in enumerate(result, 1):\n            word_count = count_words(seg)\n            status = \"✓\" if word_count <= max_limit else \"✗\"\n            print(f\"  {status} 段{i:2d} [{word_count:2d}字] {seg}\")\n        print(\"=\" * 80)\n\n        # 验证结果\n        assert len(result) > 0, \"应该返回至少一个分段\"\n        assert \"\".join(result).replace(\" \", \"\") == text.replace(\n            \" \", \"\"\n        ), \"合并后应该等于原文\"\n\n        # 验证每段长度\n        for seg in result:\n            assert count_words(seg) <= max_limit * 1.2, f\"分段过长: {seg}\"\n\n    def test_split_english_text(self, mock_llm_client):\n        \"\"\"Test splitting English text with LLM (using mock).\"\"\"\n        text = \"The upgraded claude sonnet is now available for all users. Developers can build with the computer use beta on the anthropic api. Amazon bedrock and google cloud's vertex ai. The new claude haiku will be released later this month.\"\n        model = \"gpt-4o-mini\"\n        max_limit = 12\n\n        result = split_by_llm(text, model=model, max_word_count_english=max_limit)\n\n        print(\"\\n\" + \"=\" * 80)\n        print(f\"📝 英文断句测试 - 共 {len(result)} 段 (限制: ≤{max_limit} words/段)\")\n        print(\"=\" * 80)\n        for i, seg in enumerate(result, 1):\n            word_count = count_words(seg)\n            status = \"✓\" if word_count <= max_limit else \"✗\"\n            print(f\"  {status} 段{i:2d} [{word_count:2d} words] {seg}\")\n        print(\"=\" * 80)\n\n        # 验证结果\n        assert len(result) > 0, \"应该返回至少一个分段\"\n\n        # 验证每段长度\n        for seg in result:\n            assert count_words(seg) <= max_limit * 1.2, f\"分段过长: {seg}\"\n\n    def test_split_mixed_text(self, mock_llm_client):\n        \"\"\"Test splitting mixed Chinese-English text with LLM (using mock).\"\"\"\n        text = \"今天我们来介绍Claude AI。它是由Anthropic公司开发的大语言模型。The model can understand and generate text in multiple languages. 包括中文和英文。\"\n        model = \"gpt-4o-mini\"\n        max_limit = 15\n\n        result = split_by_llm(text, model=model, max_word_count_cjk=max_limit)\n\n        print(\"\\n\" + \"=\" * 80)\n        print(f\"📝 中英混合断句测试 - 共 {len(result)} 段 (限制: ≤{max_limit}/段)\")\n        print(\"=\" * 80)\n        for i, seg in enumerate(result, 1):\n            word_count = count_words(seg)\n            status = \"✓\" if word_count <= max_limit else \"✗\"\n            print(f\"  {status} 段{i:2d} [{word_count:2d}] {seg}\")\n        print(\"=\" * 80)\n\n        # 验证结果\n        assert len(result) > 0, \"应该返回至少一个分段\"\n\n    def test_split_preserves_content(self, mock_llm_client):\n        \"\"\"Test that splitting preserves original content (using mock).\"\"\"\n        text = \"人工智能技术正在改变世界。它让我们的生活变得更加便利。\"\n        model = \"gpt-4o-mini\"\n\n        result = split_by_llm(text, model=model)\n\n        # 合并后应该完全等于原文（忽略空格）\n        merged = \"\".join(result)\n        assert merged.replace(\" \", \"\") == text.replace(\" \", \"\"), \"内容不应被修改\"\n\n    def test_split_short_text(self, mock_llm_client):\n        \"\"\"Test splitting very short text (using mock).\"\"\"\n        text = \"你好世界。\"\n        model = \"gpt-4o-mini\"\n\n        result = split_by_llm(text, model=model)\n\n        print(f\"\\n📝 短文本断句结果: {result}\")\n\n        # 短文本可能不需要分段\n        assert len(result) >= 1, \"至少应该返回原文本\"\n        assert \"\".join(result).replace(\" \", \"\") == text.replace(\" \", \"\")\n\n    def test_agent_loop_correction(self, mock_llm_client):\n        \"\"\"Test that agent loop can correct errors through feedback (using mock).\"\"\"\n        # 使用一段需要分多段的长文本\n        text = \"机器学习是人工智能的一个重要分支。它使计算机能够从数据中学习模式。深度学习是机器学习的一个子领域。它使用神经网络来处理复杂的数据。\"\n        model = \"gpt-4o-mini\"\n        max_limit = 15  # 放宽限制以适应mock的分割逻辑\n\n        result = split_by_llm(text, model=model, max_word_count_cjk=max_limit)\n\n        print(\"\\n\" + \"=\" * 80)\n        print(\n            f\"🔄 Agent Loop 自我修正测试 - 共 {len(result)} 段 (限制: ≤{max_limit}字/段)\"\n        )\n        print(\"=\" * 80)\n        for i, seg in enumerate(result, 1):\n            word_count = count_words(seg)\n            status = \"✓\" if word_count <= max_limit else \"✗\"\n            print(f\"  {status} 段{i:2d} [{word_count:2d}字] {seg}\")\n        print(\"=\" * 80)\n\n        # 验证结果符合要求\n        assert len(result) > 1, \"应该分成多段\"\n\n        for seg in result:\n            word_count = count_words(seg)\n            assert (\n                word_count <= max_limit * 1.2\n            ), f\"分段长度应该符合限制: {word_count} > {max_limit}\"\n"
  },
  {
    "path": "tests/test_split/test_split_core.py",
    "content": "\"\"\"split.py 核心功能测试\n\n全面测试 SubtitleSplitter 类的核心方法和边缘情况\n\"\"\"\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.split.split import (\n    MAX_WORD_COUNT_CJK,\n    MAX_WORD_COUNT_ENGLISH,\n    SubtitleSplitter,\n    preprocess_segments,\n)\n\n\nclass TestPreprocessSegments:\n    \"\"\"测试 preprocess_segments 函数\"\"\"\n\n    def test_remove_pure_punctuation(self):\n        \"\"\"测试移除纯标点符号\"\"\"\n        segments = [\n            ASRDataSeg(text=\"Hello\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"...\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"World\", start_time=2000, end_time=3000),\n            ASRDataSeg(text=\"!!!\", start_time=3000, end_time=4000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 2\n        assert result[0].text == \"hello \"\n        assert result[1].text == \"world \"\n\n    def test_english_word_lowercase(self):\n        \"\"\"测试英文单词转小写\"\"\"\n        segments = [\n            ASRDataSeg(text=\"Hello\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"WORLD\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"Test123\", start_time=2000, end_time=3000),\n        ]\n        result = preprocess_segments(segments, need_lower=True)\n        assert all(\" \" in seg.text for seg in result)\n        assert result[0].text == \"hello \"\n        assert result[1].text == \"world \"\n        assert result[2].text == \"test123 \"\n\n    def test_need_lower_false(self):\n        \"\"\"测试不转小写选项\"\"\"\n        segments = [ASRDataSeg(text=\"Hello\", start_time=0, end_time=1000)]\n        result = preprocess_segments(segments, need_lower=False)\n        assert result[0].text == \"Hello \"\n\n    def test_mixed_language(self):\n        \"\"\"测试混合语言\"\"\"\n        segments = [\n            ASRDataSeg(text=\"你好\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"Hello\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"世界\", start_time=2000, end_time=3000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 3\n        assert result[0].text == \"你好\"  # 中文不变\n        assert result[1].text == \"hello \"  # 英文转小写加空格\n        assert result[2].text == \"世界\"  # 中文不变\n\n    def test_empty_segments(self):\n        \"\"\"测试空列表\"\"\"\n        result = preprocess_segments([])\n        assert result == []\n\n    def test_chinese_punctuation(self):\n        \"\"\"测试中文标点\"\"\"\n        segments = [\n            ASRDataSeg(text=\"你好\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"。。。\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"世界\", start_time=2000, end_time=3000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 2\n        assert result[0].text == \"你好\"\n        assert result[1].text == \"世界\"\n\n    def test_apostrophe_in_word(self):\n        \"\"\"测试单词中的撇号\"\"\"\n        segments = [\n            ASRDataSeg(text=\"don't\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"it's\", start_time=1000, end_time=2000),\n        ]\n        result = preprocess_segments(segments)\n        assert len(result) == 2\n        assert result[0].text == \"don't \"\n        assert result[1].text == \"it's \"\n\n\nclass TestSubtitleSplitterInit:\n    \"\"\"测试 SubtitleSplitter 初始化\"\"\"\n\n    def test_default_initialization(self):\n        \"\"\"测试默认初始化\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        assert splitter.thread_num == 1\n        assert splitter.model == \"gpt-4o-mini\"\n        assert splitter.max_word_count_cjk == MAX_WORD_COUNT_CJK\n        assert splitter.max_word_count_english == MAX_WORD_COUNT_ENGLISH\n        assert splitter.is_running is True\n        assert splitter.executor is not None\n\n    def test_custom_parameters(self):\n        \"\"\"测试自定义参数\"\"\"\n        splitter = SubtitleSplitter(\n            thread_num=10,\n            model=\"gpt-4\",\n            max_word_count_cjk=30,\n            max_word_count_english=20,\n        )\n        assert splitter.thread_num == 10\n        assert splitter.model == \"gpt-4\"\n        assert splitter.max_word_count_cjk == 30\n        assert splitter.max_word_count_english == 20\n\n    def test_thread_pool_created(self):\n        \"\"\"测试线程池正确创建\"\"\"\n        splitter = SubtitleSplitter(thread_num=3, model=\"gpt-4o-mini\")\n        assert splitter.executor is not None\n        assert splitter.executor._max_workers == 3\n\n\nclass TestDetermineNumSegments:\n    \"\"\"测试 _determine_num_segments 方法\"\"\"\n\n    def test_small_word_count(self):\n        \"\"\"测试小字数（不需要分段）\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        num_segments = splitter._determine_num_segments(100, threshold=500)\n        assert num_segments == 1\n\n    def test_exact_threshold(self):\n        \"\"\"测试正好等于阈值\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        num_segments = splitter._determine_num_segments(500, threshold=500)\n        assert num_segments == 1\n\n    def test_just_above_threshold(self):\n        \"\"\"测试刚超过阈值\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        num_segments = splitter._determine_num_segments(501, threshold=500)\n        assert num_segments == 2\n\n    def test_multiple_segments(self):\n        \"\"\"测试多个分段\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        num_segments = splitter._determine_num_segments(1500, threshold=500)\n        assert num_segments == 3\n\n    def test_zero_word_count(self):\n        \"\"\"测试零字数\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        num_segments = splitter._determine_num_segments(0, threshold=500)\n        assert num_segments == 1\n\n\nclass TestGroupByTimeGaps:\n    \"\"\"测试 _group_by_time_gaps 方法\"\"\"\n\n    def test_no_gaps(self):\n        \"\"\"测试连续时间戳（无间隔）\"\"\"\n        segments = [\n            ASRDataSeg(text=\"A\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"B\", start_time=1000, end_time=2000),\n            ASRDataSeg(text=\"C\", start_time=2000, end_time=3000),\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._group_by_time_gaps(segments, max_gap=1500)\n        assert len(groups) == 1\n        assert len(groups[0]) == 3\n\n    def test_large_gap(self):\n        \"\"\"测试大间隔分组\"\"\"\n        segments = [\n            ASRDataSeg(text=\"A\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"B\", start_time=3000, end_time=4000),  # 2000ms间隔\n            ASRDataSeg(text=\"C\", start_time=4000, end_time=5000),\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._group_by_time_gaps(segments, max_gap=1500)\n        assert len(groups) == 2\n        assert len(groups[0]) == 1\n        assert len(groups[1]) == 2\n\n    def test_multiple_gaps(self):\n        \"\"\"测试多个间隔\"\"\"\n        segments = [\n            ASRDataSeg(text=\"A\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"B\", start_time=3000, end_time=4000),  # 大间隔\n            ASRDataSeg(text=\"C\", start_time=4000, end_time=5000),\n            ASRDataSeg(text=\"D\", start_time=7000, end_time=8000),  # 大间隔\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._group_by_time_gaps(segments, max_gap=1500)\n        assert len(groups) == 3\n\n    def test_empty_segments(self):\n        \"\"\"测试空列表\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._group_by_time_gaps([])\n        assert groups == []\n\n    def test_single_segment(self):\n        \"\"\"测试单个分段\"\"\"\n        segments = [ASRDataSeg(text=\"A\", start_time=0, end_time=1000)]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._group_by_time_gaps(segments)\n        assert len(groups) == 1\n        assert len(groups[0]) == 1\n\n    def test_check_large_gaps_enabled(self):\n        \"\"\"测试异常大间隔检测\"\"\"\n        # 创建一个有异常大间隔的序列\n        segments = [\n            ASRDataSeg(text=f\"seg{i}\", start_time=i * 100, end_time=(i + 1) * 100)\n            for i in range(10)\n        ]\n        # 在第5个位置插入异常大间隔\n        segments.insert(5, ASRDataSeg(text=\"gap\", start_time=500, end_time=5000))\n        segments.append(ASRDataSeg(text=\"after\", start_time=5000, end_time=5100))\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._group_by_time_gaps(segments, check_large_gaps=True)\n        # 应该检测到异常间隔并分组\n        assert len(groups) >= 1\n\n\nclass TestSplitByCommonWords:\n    \"\"\"测试 _split_by_common_words 方法\"\"\"\n\n    def test_split_on_prefix_word(self):\n        \"\"\"测试在前缀词处分割\"\"\"\n        segments = [\n            ASRDataSeg(text=\"我\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"喜\", start_time=100, end_time=200),\n            ASRDataSeg(text=\"欢\", start_time=200, end_time=300),\n            ASRDataSeg(text=\"你\", start_time=300, end_time=400),  # 前缀词\n            ASRDataSeg(text=\"很\", start_time=400, end_time=500),\n            ASRDataSeg(text=\"好\", start_time=500, end_time=600),\n        ]\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=10\n        )\n        groups = splitter._split_by_common_words(segments)\n        # 应该至少产生分割\n        assert len(groups) >= 1\n\n    def test_split_on_suffix_word(self):\n        \"\"\"测试在后缀词处分割\"\"\"\n        segments = [\n            ASRDataSeg(text=\"我\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"来\", start_time=100, end_time=200),\n            ASRDataSeg(text=\"了\", start_time=200, end_time=300),  # 后缀词\n            ASRDataSeg(text=\"你\", start_time=300, end_time=400),\n            ASRDataSeg(text=\"走\", start_time=400, end_time=500),\n            ASRDataSeg(text=\"吧\", start_time=500, end_time=600),  # 后缀词\n        ]\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=10\n        )\n        groups = splitter._split_by_common_words(segments)\n        assert len(groups) >= 1\n\n    def test_english_common_words(self):\n        \"\"\"测试英文常见词分割\"\"\"\n        segments = [\n            ASRDataSeg(text=\"I\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"like\", start_time=100, end_time=200),\n            ASRDataSeg(text=\"you\", start_time=200, end_time=300),\n            ASRDataSeg(text=\"and\", start_time=300, end_time=400),  # 前缀词\n            ASRDataSeg(text=\"she\", start_time=400, end_time=500),\n            ASRDataSeg(text=\"likes\", start_time=500, end_time=600),\n            ASRDataSeg(text=\"you\", start_time=600, end_time=700),\n        ]\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_english=10\n        )\n        groups = splitter._split_by_common_words(segments)\n        assert len(groups) >= 1\n\n    def test_no_common_words(self):\n        \"\"\"测试无常见词\"\"\"\n        segments = [\n            ASRDataSeg(text=\"测\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"试\", start_time=100, end_time=200),\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._split_by_common_words(segments)\n        assert len(groups) == 1\n\n    def test_empty_segments(self):\n        \"\"\"测试空列表\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._split_by_common_words([])\n        assert groups == []\n\n\nclass TestSplitLongSegment:\n    \"\"\"测试 _split_long_segment 方法\"\"\"\n\n    def test_short_segment(self):\n        \"\"\"测试短分段（无需拆分）\"\"\"\n        segments = [\n            ASRDataSeg(text=\"短\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"文\", start_time=100, end_time=200),\n            ASRDataSeg(text=\"本\", start_time=200, end_time=300),\n        ]\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20\n        )\n        result = splitter._split_long_segment(segments)\n        assert len(result) == 1\n        assert result[0].text == \"短文本\"\n\n    def test_long_segment_with_gaps(self):\n        \"\"\"测试超长分段（有时间间隔）\"\"\"\n        # 创建一个超长文本\n        long_text = \"这是一个非常长的文本片段\" * 10\n        segments = [\n            ASRDataSeg(text=c, start_time=i * 100, end_time=(i + 1) * 100)\n            for i, c in enumerate(long_text)\n        ]\n        # 在中间插入大间隔\n        mid = len(segments) // 2\n        segments[mid].end_time = segments[mid].start_time + 50\n        segments[mid + 1].start_time = segments[mid].end_time + 500\n\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20\n        )\n        result = splitter._split_long_segment(segments)\n        # 应该被拆分成多个\n        assert len(result) >= 2\n\n    def test_very_short_segments(self):\n        \"\"\"测试极短分段（小于最小大小）\"\"\"\n        segments = [\n            ASRDataSeg(text=\"A\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"B\", start_time=100, end_time=200),\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter._split_long_segment(segments)\n        assert len(result) == 1\n\n    def test_equal_time_gaps(self):\n        \"\"\"测试相等时间间隔（中间分割）\"\"\"\n        segments = [\n            ASRDataSeg(text=f\"字{i}\", start_time=i * 100, end_time=(i + 1) * 100)\n            for i in range(100)\n        ]\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20\n        )\n        result = splitter._split_long_segment(segments)\n        # 应该被递归拆分\n        assert len(result) >= 2\n\n    def test_preserves_timestamps(self):\n        \"\"\"测试保持时间戳顺序\"\"\"\n        segments = [\n            ASRDataSeg(text=f\"字{i}\", start_time=i * 100, end_time=(i + 1) * 100)\n            for i in range(50)\n        ]\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=10\n        )\n        result = splitter._split_long_segment(segments)\n        # 验证时间戳递增\n        for i in range(len(result) - 1):\n            assert result[i].start_time <= result[i + 1].start_time\n\n\nclass TestMergeShortSegment:\n    \"\"\"测试 merge_short_segment 方法\"\"\"\n\n    def test_merge_very_short_segments(self):\n        \"\"\"测试合并极短片段\"\"\"\n        segments = [\n            ASRDataSeg(text=\"我\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"是\", start_time=100, end_time=200),\n            ASRDataSeg(text=\"谁\", start_time=200, end_time=300),\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.merge_short_segment(segments)\n        # 应该被合并（3个字 < MERGE_VERY_SHORT_WORDS=3）\n        assert len(segments) < 3\n\n    def test_merge_with_short_gap(self):\n        \"\"\"测试短时间间隔合并\"\"\"\n        segments = [\n            ASRDataSeg(text=\"短\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"文本\", start_time=150, end_time=300),  # 50ms间隔\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        original_len = len(segments)\n        splitter.merge_short_segment(segments)\n        # 应该合并（间隔 < MERGE_SHORT_GAP=200）\n        assert len(segments) < original_len\n\n    def test_no_merge_long_segments(self):\n        \"\"\"测试不合并长片段\"\"\"\n        segments = [\n            ASRDataSeg(text=\"这是一个很长的文本片段\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"这也是一个很长的文本片段\", start_time=1100, end_time=2000),\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        original_len = len(segments)\n        splitter.merge_short_segment(segments)\n        # 不应该合并\n        assert len(segments) == original_len\n\n    def test_no_merge_large_gap(self):\n        \"\"\"测试大间隔不合并\"\"\"\n        segments = [\n            ASRDataSeg(text=\"短\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"文\", start_time=2000, end_time=2100),  # 大间隔\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        original_len = len(segments)\n        splitter.merge_short_segment(segments)\n        # 不应该合并（间隔太大）\n        assert len(segments) == original_len\n\n    def test_merge_respects_max_word_count(self):\n        \"\"\"测试合并不超过最大字数\"\"\"\n        segments = [\n            ASRDataSeg(text=\"这是一个中等长度的文本\", start_time=0, end_time=1000),\n            ASRDataSeg(text=\"这也是一个中等长度的文本\", start_time=1100, end_time=2000),\n        ]\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=10\n        )\n        original_len = len(segments)\n        splitter.merge_short_segment(segments)\n        # 不应该合并（会超过最大字数）\n        assert len(segments) == original_len\n\n    def test_english_text_merge(self):\n        \"\"\"测试英文文本合并（加空格）\"\"\"\n        segments = [\n            ASRDataSeg(text=\"Hi\", start_time=0, end_time=100),\n            ASRDataSeg(text=\"there\", start_time=150, end_time=300),\n        ]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.merge_short_segment(segments)\n        if len(segments) == 1:\n            # 如果合并了，应该有空格\n            assert \" \" in segments[0].text\n\n    def test_empty_segments(self):\n        \"\"\"测试空列表\"\"\"\n        segments = []\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.merge_short_segment(segments)\n        assert segments == []\n\n    def test_single_segment(self):\n        \"\"\"测试单个分段\"\"\"\n        segments = [ASRDataSeg(text=\"单个\", start_time=0, end_time=100)]\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.merge_short_segment(segments)\n        assert len(segments) == 1\n\n\nclass TestStopMethod:\n    \"\"\"测试 stop 方法\"\"\"\n\n    def test_stop_sets_running_false(self):\n        \"\"\"测试停止设置运行状态\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        assert splitter.is_running is True\n        splitter.stop()\n        assert splitter.is_running is False\n\n    def test_stop_shuts_down_executor(self):\n        \"\"\"测试停止关闭线程池\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.stop()\n        # 线程池应该被设置为None\n        assert splitter.executor is None\n\n    def test_multiple_stops(self):\n        \"\"\"测试多次调用stop\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.stop()\n        splitter.stop()  # 不应该抛出异常\n        assert splitter.is_running is False\n\n    def test_stop_idempotent(self):\n        \"\"\"测试stop的幂等性\"\"\"\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.stop()\n        first_state = splitter.is_running\n        splitter.stop()\n        second_state = splitter.is_running\n        assert first_state == second_state == False\n\n\nclass TestEdgeCases:\n    \"\"\"测试边缘情况\"\"\"\n\n    def test_zero_thread_num(self):\n        \"\"\"测试零线程数（应该使用默认值或处理）\"\"\"\n        # 根据实际实现，可能会失败或使用默认值\n        try:\n            splitter = SubtitleSplitter(thread_num=0, model=\"gpt-4o-mini\")\n            # 如果成功创建，验证某些基本功能\n            assert splitter.thread_num == 0\n        except (ValueError, Exception):\n            # 如果抛出异常，这也是合理的\n            pass\n\n    def test_negative_max_word_count(self):\n        \"\"\"测试负数最大字数\"\"\"\n        splitter = SubtitleSplitter(\n            thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=-1\n        )\n        # 应该能够创建，但可能在使用时出问题\n        assert splitter.max_word_count_cjk == -1\n\n    def test_very_large_thread_num(self):\n        \"\"\"测试非常大的线程数\"\"\"\n        splitter = SubtitleSplitter(thread_num=1000, model=\"gpt-4o-mini\")\n        assert splitter.thread_num == 1000\n        assert splitter.executor is not None\n"
  },
  {
    "path": "tests/test_split/test_split_realistic.py",
    "content": "\"\"\"split.py 真实场景测试\n\n使用真实的字幕数据和实际使用场景进行测试\n\"\"\"\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData, ASRDataSeg\nfrom app.core.split.split import SubtitleSplitter, preprocess_segments\n\n# ==================== 真实字幕数据构造器 ====================\n\n\ndef create_whisper_style_segments(\n    text: str, start_ms: int = 0, char_duration_ms: int = 250\n):\n    \"\"\"模拟 Whisper ASR 输出的词级字幕\n\n    Whisper 通常输出词级时间戳，中文按字，英文按单词\n    \"\"\"\n    from app.core.utils.text_utils import is_mainly_cjk\n\n    segments = []\n    current_time = start_ms\n\n    if is_mainly_cjk(text):\n        # 中文：每个字一个分段\n        for char in text:\n            if char.strip() and not char in \"，。！？、；：\" \"''（）\":\n                duration = char_duration_ms\n                # 标点符号更短\n                if char in \"，。！？\":\n                    duration = 100\n                segments.append(\n                    ASRDataSeg(\n                        text=char,\n                        start_time=current_time,\n                        end_time=current_time + duration,\n                    )\n                )\n                current_time += duration\n    else:\n        # 英文：按单词分段\n        words = text.split()\n        for word in words:\n            # 单词长度影响时长\n            duration = max(200, len(word) * 80)\n            segments.append(\n                ASRDataSeg(\n                    text=word, start_time=current_time, end_time=current_time + duration\n                )\n            )\n            current_time += duration\n\n    return segments\n\n\nclass TestRealWorldScenarios:\n    \"\"\"测试真实世界的字幕场景\"\"\"\n\n    def test_podcast_long_monologue(self):\n        \"\"\"测试播客式长独白（50+字，需要智能分段）\"\"\"\n        text = \"今天我们要讨论的话题是人工智能在现代社会中的应用特别是在医疗健康领域的突破性进展这些技术正在深刻地改变着我们的生活方式从诊断到治疗再到康复每个环节都有AI技术的身影\"\n        segments = create_whisper_style_segments(text, start_ms=0, char_duration_ms=200)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=20)\n        asr_data = ASRData(segments)\n\n        # 预处理：转换为词级\n        if not asr_data.is_word_timestamp():\n            asr_data = asr_data.split_to_word_segments()\n\n        # 应该有分段，但不会太多（智能分段而非机械切割）\n        assert len(segments) > 30  # 原始很长\n        # 实际测试中，LLM 会智能分段，这里只测试规则降级\n        # result = splitter.split_subtitle(asr_data)\n        # assert len(result.segments) < len(segments)\n\n    def test_interview_qa_with_pauses(self):\n        \"\"\"测试访谈式问答（有明显停顿）\"\"\"\n        segments = []\n\n        # 问题：\"你对这个项目有什么看法？\"\n        q = create_whisper_style_segments(\"你对这个项目有什么看法\", start_ms=0)\n        segments.extend(q)\n\n        # 2秒停顿（思考时间）\n        pause_end = q[-1].end_time + 2000\n\n        # 回答：\"我认为这个项目非常有前景，它解决了一个关键问题。\"\n        a = create_whisper_style_segments(\n            \"我认为这个项目非常有前景它解决了一个关键问题\", start_ms=pause_end\n        )\n        segments.extend(a)\n\n        # 短停顿\n        pause2_end = a[-1].end_time + 500\n\n        # 补充：\"不过还需要进一步完善细节。\"\n        followup = create_whisper_style_segments(\n            \"不过还需要进一步完善细节\", start_ms=pause2_end\n        )\n        segments.extend(followup)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        # 测试时间间隔分组\n        groups = splitter._group_by_time_gaps(segments, max_gap=1500)\n\n        # 应该识别出停顿，分成至少 2 组\n        assert len(groups) >= 2\n        # 第一组是问题\n        assert len(groups[0]) > 0\n\n    def test_news_broadcast_style(self):\n        \"\"\"测试新闻播报风格（节奏稳定、语速均匀）\"\"\"\n        text = \"据中央气象台消息今天夜间到明天白天北京地区将有小到中雪气温下降明显请市民注意防寒保暖\"\n        segments = create_whisper_style_segments(text, char_duration_ms=180)\n\n        # 新闻播报：时间间隔相对均匀\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=15)\n        groups = splitter._group_by_time_gaps(segments, max_gap=1000)\n\n        # 没有大停顿，应该是一组\n        assert len(groups) == 1 or len(groups) == 2\n\n    def test_casual_conversation_with_hesitation(self):\n        \"\"\"测试日常对话（有犹豫、重复、语气词）\"\"\"\n        segments = []\n        current_time = 0\n\n        # \"嗯...这个...怎么说呢...\"（犹豫）\n        hesitations = [\n            (\"嗯\", 600, 200),  # 语气词，较长停顿\n            (\"这\", 250, 150),\n            (\"个\", 250, 300),  # 另一个停顿\n            (\"怎\", 200, 100),\n            (\"么\", 200, 100),\n            (\"说\", 250, 100),\n            (\"呢\", 400, 500),  # 更长停顿\n        ]\n\n        for text, duration, pause in hesitations:\n            segments.append(\n                ASRDataSeg(\n                    text=text, start_time=current_time, end_time=current_time + duration\n                )\n            )\n            current_time += duration + pause\n\n        # 主要内容\n        main = create_whisper_style_segments(\n            \"我觉得这个方案还是挺不错的\", start_ms=current_time\n        )\n        segments.extend(main)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        # 测试合并短片段功能\n        splitter.merge_short_segment(segments)\n\n        # 语气词应该被合并\n        assert len(segments) < len(hesitations) + len(main)\n\n    def test_technical_presentation_bilingual(self):\n        \"\"\"测试技术演讲（中英混合）\"\"\"\n        segments = []\n        current_time = 0\n\n        # \"我们使用 machine learning 来处理这个问题\"\n        # 中文部分\n        cn1 = create_whisper_style_segments(\"我们使用\", start_ms=current_time)\n        segments.extend(cn1)\n        current_time = cn1[-1].end_time\n\n        # 英文专业术语（通常说得慢一点）\n        segments.extend(\n            [\n                ASRDataSeg(\n                    text=\"machine\", start_time=current_time, end_time=current_time + 500\n                ),\n                ASRDataSeg(\n                    text=\"learning\",\n                    start_time=current_time + 500,\n                    end_time=current_time + 1000,\n                ),\n            ]\n        )\n        current_time += 1000\n\n        # 继续中文\n        cn2 = create_whisper_style_segments(\"来处理这个问题\", start_ms=current_time)\n        segments.extend(cn2)\n\n        # 预处理应该正确处理混合语言\n        result = preprocess_segments(segments)\n\n        # 英文应该被转小写并加空格\n        english_segs = [\n            s for s in result if s.text.lower() in [\"machine \", \"learning \"]\n        ]\n        assert len(english_segs) == 2\n        assert all(\" \" in seg.text for seg in english_segs)\n\n    def test_subtitle_with_background_noise_gaps(self):\n        \"\"\"测试有背景噪音导致的不规则间隔\"\"\"\n        segments = []\n        current_time = 0\n\n        # 正常句子\n        s1 = create_whisper_style_segments(\"大家好\", start_ms=current_time)\n        segments.extend(s1)\n        current_time = s1[-1].end_time\n\n        # 背景噪音（可能被识别为极短的无意义音节）\n        segments.append(\n            ASRDataSeg(\n                text=\"呃\", start_time=current_time + 100, end_time=current_time + 150\n            )\n        )\n        current_time += 200\n\n        # 继续\n        s2 = create_whisper_style_segments(\"欢迎来到今天的分享\", start_ms=current_time)\n        segments.extend(s2)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        # 噪音应该在预处理时被识别（如果是纯标点）或合并\n        result = preprocess_segments(segments)\n\n        # 验证处理后的结果\n        assert len(result) > 0\n\n\nclass TestEdgeCasesRealistic:\n    \"\"\"测试实际使用中的边缘情况\"\"\"\n\n    def test_very_fast_speech(self):\n        \"\"\"测试快速语速（每字150ms）\"\"\"\n        text = \"快速语速测试数据这样的字幕通常出现在快节奏的节目中\"\n        segments = create_whisper_style_segments(text, char_duration_ms=150)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=15)\n        # 快速语速不应该导致过度分割\n        result = splitter._split_long_segment(segments[:15])\n        assert len(result) >= 1\n\n    def test_very_slow_speech(self):\n        \"\"\"测试慢速语速（每字500ms）\"\"\"\n        text = \"慢速语速每个字之间有明显停顿\"\n        segments = create_whisper_style_segments(text, char_duration_ms=500)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        # 慢速不应该被错误分组\n        groups = splitter._group_by_time_gaps(segments, max_gap=1500)\n        assert len(groups) >= 1\n\n    def test_subtitle_with_numbers_and_punctuation(self):\n        \"\"\"测试包含数字和标点的字幕\"\"\"\n        # 真实场景：\"今天是2024年1月15日，温度是零下5度。\"\n        segments = []\n        current_time = 0\n\n        parts = [\n            \"今\",\n            \"天\",\n            \"是\",\n            \"2024\",\n            \"年\",\n            \"1\",\n            \"月\",\n            \"15\",\n            \"日\",\n            \"温\",\n            \"度\",\n            \"是\",\n            \"零\",\n            \"下\",\n            \"5\",\n            \"度\",\n        ]\n        for part in parts:\n            duration = 300 if len(part) > 1 else 250\n            segments.append(\n                ASRDataSeg(\n                    text=part, start_time=current_time, end_time=current_time + duration\n                )\n            )\n            current_time += duration\n\n        # 预处理不应该移除数字\n        result = preprocess_segments(segments)\n        assert any(\"2024\" in seg.text for seg in result)\n        assert any(\"15\" in seg.text for seg in result)\n\n    def test_empty_or_whitespace_segments(self):\n        \"\"\"测试空白或仅空格的分段（ASR错误输出）\"\"\"\n        segments = [\n            ASRDataSeg(text=\"正常\", start_time=0, end_time=300),\n            ASRDataSeg(text=\"   \", start_time=300, end_time=400),  # 仅空格\n            ASRDataSeg(text=\"\", start_time=400, end_time=500),  # 空字符串\n            ASRDataSeg(text=\"文本\", start_time=500, end_time=800),\n        ]\n\n        result = preprocess_segments(segments)\n        # 空白应该被处理（可能保留或移除）\n        assert len(result) >= 2\n\n    def test_subtitle_crossing_one_hour(self):\n        \"\"\"测试超过1小时的长视频字幕\"\"\"\n        # 模拟1小时节目的一段（3,600,000 ms = 1 hour）\n        segments = []\n        start_time = 3500000  # 58分钟处\n\n        text = \"这是接近一小时处的字幕内容需要一些更长的文本才能超过一小时的时间戳\"\n        segments = create_whisper_style_segments(\n            text, start_ms=start_time, char_duration_ms=350\n        )\n\n        # 时间戳应该正确处理\n        if segments:\n            assert segments[-1].end_time > start_time  # 至少递增\n            assert segments[-1].start_time < segments[-1].end_time\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        result = splitter._split_long_segment(segments)\n\n        # 验证时间戳没有溢出或错误\n        assert all(seg.start_time < seg.end_time for seg in result)\n\n\nclass TestGroupByTimeGapsRealistic:\n    \"\"\"测试时间间隔分组的真实场景\"\"\"\n\n    def test_scene_change_detection(self):\n        \"\"\"测试场景切换检测（通常有3-5秒静音）\"\"\"\n        segments = []\n\n        # 场景1：\"欢迎收看今天的节目\"\n        scene1 = create_whisper_style_segments(\"欢迎收看今天的节目\", start_ms=0)\n        segments.extend(scene1)\n\n        # 场景切换（4秒静音）\n        scene_change_gap = 4000\n        scene2_start = scene1[-1].end_time + scene_change_gap\n\n        # 场景2：\"接下来我们进入下一环节\"\n        scene2 = create_whisper_style_segments(\n            \"接下来我们进入下一环节\", start_ms=scene2_start\n        )\n        segments.extend(scene2)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        groups = splitter._group_by_time_gaps(\n            segments, max_gap=2000, check_large_gaps=True\n        )\n\n        # 应该检测到场景切换（允许空组）\n        non_empty_groups = [g for g in groups if g]\n        assert len(non_empty_groups) == 2\n\n    def test_natural_sentence_pauses(self):\n        \"\"\"测试自然句子间的停顿（200-500ms）\"\"\"\n        segments = []\n        current_time = 0\n\n        sentences = [\n            \"第一句话\",\n            \"第二句话\",\n            \"第三句话\",\n        ]\n\n        for sentence in sentences:\n            segs = create_whisper_style_segments(sentence, start_ms=current_time)\n            segments.extend(segs)\n            # 句子间自然停顿（300ms）\n            current_time = segs[-1].end_time + 300\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        # 用较小的 gap 不应该分组\n        groups = splitter._group_by_time_gaps(segments, max_gap=500)\n        assert len(groups) == 1\n\n        # 用较大的 gap 可能分组\n        groups = splitter._group_by_time_gaps(segments, max_gap=200)\n        assert len(groups) >= 2\n\n\nclass TestSplitByCommonWordsRealistic:\n    \"\"\"测试常见词分割的真实场景\"\"\"\n\n    def test_long_compound_sentence_chinese(self):\n        \"\"\"测试中文复合句（使用'但是'、'所以'等连词）\"\"\"\n        text = \"我觉得这个方案很好但是还需要优化一下所以我建议再讨论讨论\"\n        segments = create_whisper_style_segments(text)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_cjk=15)\n        groups = splitter._split_by_common_words(segments)\n\n        # 应该在\"但是\"、\"所以\"处考虑分割\n        assert len(groups) >= 1\n\n    def test_english_compound_sentence(self):\n        \"\"\"测试英文复合句\"\"\"\n        text = \"I think this is a good idea but we need more time and we should discuss it further\"\n        segments = create_whisper_style_segments(text)\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\", max_word_count_english=12)\n        groups = splitter._split_by_common_words(segments)\n\n        # 应该在 \"but\"、\"and\" 处考虑分割\n        assert len(groups) >= 1\n\n\nclass TestMergeShortSegmentRealistic:\n    \"\"\"测试短片段合并的真实场景\"\"\"\n\n    def test_merge_single_character_words(self):\n        \"\"\"测试合并单字词（\"我\"、\"你\"、\"他\"等）\"\"\"\n        # \"我 去 过 那 里\" -> 应该合并成一句\n        segments = []\n        current_time = 0\n        words = [\"我\", \"去\", \"过\", \"那\", \"里\"]\n\n        for word in words:\n            segments.append(\n                ASRDataSeg(\n                    text=word, start_time=current_time, end_time=current_time + 200\n                )\n            )\n            current_time += 250  # 50ms 间隔\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.merge_short_segment(segments)\n\n        # 应该合并成更少的片段\n        assert len(segments) < len(words)\n\n    def test_dont_merge_across_large_pause(self):\n        \"\"\"测试不跨越大停顿合并\"\"\"\n        segments = [\n            ASRDataSeg(text=\"短\", start_time=0, end_time=200),\n            ASRDataSeg(text=\"句\", start_time=200, end_time=400),\n            # 大停顿（1秒）\n            ASRDataSeg(text=\"新\", start_time=1400, end_time=1600),\n            ASRDataSeg(text=\"句\", start_time=1600, end_time=1800),\n        ]\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        original_len = len(segments)\n        splitter.merge_short_segment(segments)\n\n        # 不应该跨越大停顿合并，至少保留2个片段\n        assert len(segments) >= 2\n\n    def test_merge_interjections(self):\n        \"\"\"测试合并语气词\"\"\"\n        # \"嗯 好的 我 知道 了\"\n        segments = []\n        current_time = 0\n        parts = [\n            (\"嗯\", 300),\n            (\"好\", 200),\n            (\"的\", 200),\n            (\"我\", 200),\n            (\"知\", 200),\n            (\"道\", 200),\n            (\"了\", 200),\n        ]\n\n        for text, duration in parts:\n            segments.append(\n                ASRDataSeg(\n                    text=text, start_time=current_time, end_time=current_time + duration\n                )\n            )\n            current_time += duration + 50\n\n        splitter = SubtitleSplitter(thread_num=1, model=\"gpt-4o-mini\")\n        splitter.merge_short_segment(segments)\n\n        # 语气词应该被合并\n        assert len(segments) < len(parts)\n"
  },
  {
    "path": "tests/test_subtitle/__init__.py",
    "content": "\"\"\"Subtitle processing tests.\"\"\"\n"
  },
  {
    "path": "tests/test_subtitle/conftest.py",
    "content": "\"\"\"Test configuration for subtitle tests.\"\"\"\n\nimport sys\n\nimport pytest\nfrom PyQt5.QtWidgets import QApplication\n\n\n@pytest.fixture(scope=\"session\")\ndef qapp():\n    \"\"\"Create QApplication instance for testing Qt components.\"\"\"\n    app = QApplication.instance()\n    if app is None:\n        app = QApplication(sys.argv)\n    yield app\n    # Don't quit - causes issues with pytest\n\n\n@pytest.fixture(autouse=True)\ndef use_qapp(qapp):\n    \"\"\"Automatically use QApplication for all tests in this module.\"\"\"\n    return qapp\n"
  },
  {
    "path": "tests/test_subtitle/test_subtitle_thread.py",
    "content": "\"\"\"Tests for SubtitleThread.\n\nThis module tests the subtitle processing thread which handles:\n- Subtitle splitting (semantic and sentence-based)\n- Subtitle optimization (via LLM)\n- Subtitle translation (Google, Bing, LLM)\n\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\nfrom dotenv import load_dotenv\nfrom PyQt5.QtCore import QEventLoop, QTimer\n\nfrom app.core.entities import (\n    SubtitleConfig,\n    SubtitleTask,\n    TranslatorServiceEnum,\n)\nfrom app.core.llm.check_llm import get_available_models\nfrom app.core.translate.types import TargetLanguage\nfrom app.thread.subtitle_thread import SubtitleThread\n\n# Load environment variables\nload_dotenv(Path(__file__).parent.parent / \".env\")\n\n\ndef get_test_model():\n    \"\"\"Get appropriate model for testing.\n\n    Returns model from OPENAI_MODEL env var, or auto-detects from API.\n    \"\"\"\n    # Check if model specified in environment\n    env_model = os.getenv(\"OPENAI_MODEL\")\n    if env_model:\n        return env_model\n\n    # Auto-detect from API\n    base_url = os.getenv(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n    api_key = os.getenv(\"OPENAI_API_KEY\")\n\n    if not api_key:\n        return \"gpt-4o-mini\"  # Default fallback\n\n    try:\n        models = get_available_models(base_url, api_key)\n        if models:\n            return models[0]  # Return first available model\n    except Exception:\n        pass\n\n    return \"gpt-4o-mini\"  # Default fallback\n\n\ndef run_thread_with_timeout(thread, timeout_ms=60000):\n    \"\"\"Run thread with timeout to prevent hanging tests.\n\n    Args:\n        thread: QThread to run\n        timeout_ms: Timeout in milliseconds (default 60s)\n\n    Returns:\n        dict: Results from signal handlers\n    \"\"\"\n    results = {}\n\n    def on_finished(output_path, _):\n        results[\"output\"] = output_path\n\n    def on_error(error_msg):\n        results[\"error\"] = error_msg\n\n    def on_progress(percent, message):\n        results[\"progress\"] = (percent, message)\n\n    def on_update(data):\n        results[\"updates\"] = results.get(\"updates\", [])\n        results[\"updates\"].append(data)\n\n    thread.finished.connect(on_finished)\n    thread.error.connect(on_error)\n    thread.progress.connect(on_progress)\n    thread.update.connect(on_update)\n\n    loop = QEventLoop()\n    thread.finished.connect(loop.quit)\n    thread.error.connect(loop.quit)\n\n    # Timeout safety\n    timer = QTimer()\n    timer.setSingleShot(True)\n    timer.timeout.connect(loop.quit)\n    timer.start(timeout_ms)\n\n    thread.start()\n    loop.exec_()\n\n    return results\n\n\n@pytest.fixture\ndef subtitle_file():\n    \"\"\"Load test subtitle file from fixtures.\"\"\"\n    fixture_path = (\n        Path(__file__).parent.parent / \"fixtures\" / \"subtitle\" / \"sample_en.srt\"\n    )\n    assert fixture_path.exists(), f\"Fixture not found: {fixture_path}\"\n    return str(fixture_path)\n\n\n@pytest.fixture\ndef output_dir():\n    \"\"\"Create temporary output directory.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        yield tmpdir\n\n\n@pytest.fixture\ndef base_config():\n    \"\"\"Create base subtitle configuration.\"\"\"\n    return SubtitleConfig(\n        need_split=False,\n        need_optimize=False,\n        need_translate=False,\n        thread_num=2,\n        batch_size=5,\n    )\n\n\nclass TestSubtitleThreadSplit:\n    \"\"\"Test subtitle splitting functionality.\"\"\"\n\n    def test_split_sentence(\n        self, subtitle_file, output_dir, base_config, mock_llm_client\n    ):\n        \"\"\"Test sentence-based splitting (using mock LLM).\"\"\"\n        config = base_config\n        config.need_split = True\n        config.max_word_count_cjk = 15\n        config.max_word_count_english = 20\n        config.llm_model = get_test_model()\n        config.base_url = os.getenv(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n        config.api_key = os.getenv(\"OPENAI_API_KEY\")\n\n        output_path = os.path.join(output_dir, \"split_sentence.srt\")\n        task = SubtitleTask(\n            subtitle_path=subtitle_file,\n            subtitle_config=config,\n            output_path=output_path,\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread)\n\n        # Assertions\n        assert \"error\" not in results, f\"Thread failed: {results.get('error')}\"\n        assert \"output\" in results\n        assert Path(results[\"output\"]).exists()\n\n    def test_split_semantic(\n        self, subtitle_file, output_dir, base_config, mock_llm_client\n    ):\n        \"\"\"Test semantic-based splitting (using mock LLM).\"\"\"\n        config = base_config\n        config.need_split = True\n        config.llm_model = get_test_model()\n        config.base_url = os.getenv(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n        config.api_key = os.getenv(\"OPENAI_API_KEY\")\n\n        output_path = os.path.join(output_dir, \"split_semantic.srt\")\n        task = SubtitleTask(\n            subtitle_path=subtitle_file,\n            subtitle_config=config,\n            output_path=output_path,\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread)\n\n        assert \"error\" not in results, f\"Failed: {results.get('error')}\"\n        assert \"output\" in results\n\n\nclass TestSubtitleThreadOptimize:\n    \"\"\"Test subtitle optimization functionality.\"\"\"\n\n    def test_optimize_with_llm(\n        self, subtitle_file, output_dir, base_config, mock_llm_client\n    ):\n        \"\"\"Test LLM-based subtitle optimization (using mock LLM).\"\"\"\n        config = base_config\n        config.need_optimize = True\n        config.llm_model = get_test_model()\n        config.base_url = os.getenv(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n        config.api_key = os.getenv(\"OPENAI_API_KEY\")\n\n        output_path = os.path.join(output_dir, \"optimize.srt\")\n        task = SubtitleTask(\n            subtitle_path=subtitle_file,\n            subtitle_config=config,\n            output_path=output_path,\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread)\n\n        assert \"error\" not in results, f\"Failed: {results.get('error')}\"\n        assert \"output\" in results\n        assert \"progress\" in results\n\n\nclass TestSubtitleThreadTranslate:\n    \"\"\"Test subtitle translation functionality.\"\"\"\n\n    @pytest.mark.integration\n    def test_translate_google(self, subtitle_file, output_dir, base_config):\n        \"\"\"Test Google Translate (free API).\"\"\"\n        config = base_config\n        config.need_translate = True\n        config.translator_service = TranslatorServiceEnum.GOOGLE\n        config.target_language = TargetLanguage.SIMPLIFIED_CHINESE\n\n        output_path = os.path.join(output_dir, \"translate_google.srt\")\n        task = SubtitleTask(\n            subtitle_path=subtitle_file,\n            subtitle_config=config,\n            output_path=output_path,\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread)\n\n        assert \"error\" not in results, f\"Failed: {results.get('error')}\"\n        assert \"output\" in results\n        # Note: updates may not be captured depending on timing\n        if \"updates\" in results:\n            assert len(results[\"updates\"]) > 0\n\n    @pytest.mark.integration\n    def test_translate_bing(self, subtitle_file, output_dir, base_config):\n        \"\"\"Test Bing Translate (free API).\"\"\"\n        config = base_config\n        config.need_translate = True\n        config.translator_service = TranslatorServiceEnum.BING\n        config.target_language = TargetLanguage.SIMPLIFIED_CHINESE\n\n        output_path = os.path.join(output_dir, \"translate_bing.srt\")\n        task = SubtitleTask(\n            subtitle_path=subtitle_file,\n            subtitle_config=config,\n            output_path=output_path,\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread)\n\n        assert \"error\" not in results, f\"Failed: {results.get('error')}\"\n        assert \"output\" in results\n\n    def test_translate_llm(\n        self, subtitle_file, output_dir, base_config, mock_llm_client\n    ):\n        \"\"\"Test LLM translation (using mock LLM).\"\"\"\n        config = base_config\n        config.need_translate = True\n        config.translator_service = TranslatorServiceEnum.OPENAI\n        config.target_language = TargetLanguage.SIMPLIFIED_CHINESE\n        config.llm_model = get_test_model()\n        config.base_url = os.getenv(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n        config.api_key = os.getenv(\"OPENAI_API_KEY\")\n\n        output_path = os.path.join(output_dir, \"translate_llm.srt\")\n        task = SubtitleTask(\n            subtitle_path=subtitle_file,\n            subtitle_config=config,\n            output_path=output_path,\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread)\n\n        assert \"error\" not in results, f\"Failed: {results.get('error')}\"\n        assert \"output\" in results\n\n\nclass TestSubtitleThreadFullPipeline:\n    \"\"\"Test complete subtitle processing pipeline.\"\"\"\n\n    def test_split_and_translate(\n        self, subtitle_file, output_dir, base_config, mock_llm_client\n    ):\n        \"\"\"Test split + translate pipeline (using mock LLM).\"\"\"\n        config = base_config\n        config.need_split = True\n        config.need_translate = True\n        config.translator_service = TranslatorServiceEnum.GOOGLE\n        config.target_language = TargetLanguage.SIMPLIFIED_CHINESE\n        config.llm_model = get_test_model()\n        config.base_url = os.getenv(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n        config.api_key = os.getenv(\"OPENAI_API_KEY\")\n\n        output_path = os.path.join(output_dir, \"split_translate.srt\")\n        task = SubtitleTask(\n            subtitle_path=subtitle_file,\n            subtitle_config=config,\n            output_path=output_path,\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread)\n\n        assert \"error\" not in results, f\"Failed: {results.get('error')}\"\n        assert \"output\" in results\n\n    def test_optimize_and_translate(\n        self, subtitle_file, output_dir, base_config, mock_llm_client\n    ):\n        \"\"\"Test optimize + translate pipeline (using mock LLM).\"\"\"\n        config = base_config\n        config.need_optimize = True\n        config.need_translate = True\n        config.translator_service = TranslatorServiceEnum.OPENAI\n        config.target_language = TargetLanguage.JAPANESE\n        config.llm_model = get_test_model()\n        config.base_url = os.getenv(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n        config.api_key = os.getenv(\"OPENAI_API_KEY\")\n\n        output_path = os.path.join(output_dir, \"optimize_translate.srt\")\n        task = SubtitleTask(\n            subtitle_path=subtitle_file,\n            subtitle_config=config,\n            output_path=output_path,\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread)\n\n        assert \"error\" not in results, f\"Failed: {results.get('error')}\"\n        assert \"output\" in results\n\n\nclass TestSubtitleThreadError:\n    \"\"\"Test error handling.\"\"\"\n\n    def test_missing_file(self, output_dir, base_config):\n        \"\"\"Test handling of missing subtitle file.\"\"\"\n        task = SubtitleTask(\n            subtitle_path=\"/nonexistent/file.srt\", subtitle_config=base_config\n        )\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        assert \"error\" in results\n        assert \"not\" in results[\"error\"].lower()\n\n    def test_no_translator_service(self, subtitle_file, output_dir, base_config):\n        \"\"\"Test error when translation enabled but no service configured.\"\"\"\n        config = base_config\n        config.need_translate = True\n        config.translator_service = None\n\n        task = SubtitleTask(subtitle_path=subtitle_file, subtitle_config=config)\n        thread = SubtitleThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        assert \"error\" in results\n"
  },
  {
    "path": "tests/test_thread/__init__.py",
    "content": "\"\"\"Thread module tests.\"\"\"\n"
  },
  {
    "path": "tests/test_thread/conftest.py",
    "content": "\"\"\"Thread module test fixtures and utilities.\"\"\"\n\nimport os\nimport subprocess\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom typing import Generator\n\nimport pytest\nfrom PyQt5.QtCore import QEventLoop, QTimer\nfrom PyQt5.QtWidgets import QApplication\n\n\n@pytest.fixture(scope=\"session\")\ndef qapp():\n    \"\"\"Create QApplication for Qt tests.\"\"\"\n    app = QApplication.instance()\n    if app is None:\n        app = QApplication(sys.argv)\n    yield app\n\n\n@pytest.fixture\ndef sample_audio_path() -> str:\n    \"\"\"Return path to sample audio file in fixtures.\"\"\"\n    fixtures_dir = Path(__file__).parent.parent / \"fixtures\"\n    audio_file = fixtures_dir / \"audio\" / \"zh.mp3\"\n    if not audio_file.exists():\n        pytest.skip(f\"Sample audio not found: {audio_file}\")\n    return str(audio_file)\n\n\n@pytest.fixture\ndef sample_video_path(tmp_path: Path, sample_audio_path: str) -> str:\n    \"\"\"Create a simple test video from audio file using ffmpeg.\"\"\"\n    output_video = tmp_path / \"test_video.mp4\"\n\n    # Create a simple video with a solid color and the audio\n    cmd = [\n        \"ffmpeg\",\n        \"-f\",\n        \"lavfi\",\n        \"-i\",\n        \"color=c=black:s=1280x720:d=5\",\n        \"-i\",\n        sample_audio_path,\n        \"-shortest\",\n        \"-y\",\n        str(output_video),\n    ]\n\n    try:\n        subprocess.run(\n            cmd,\n            check=True,\n            capture_output=True,\n            creationflags=getattr(subprocess, \"CREATE_NO_WINDOW\", 0),\n        )\n        return str(output_video)\n    except subprocess.CalledProcessError:\n        pytest.skip(\"Failed to create test video with ffmpeg\")\n\n\n@pytest.fixture\ndef sample_subtitle_path(tmp_path: Path) -> str:\n    \"\"\"Return path to sample subtitle file in fixtures.\"\"\"\n    fixtures_dir = Path(__file__).parent.parent / \"fixtures\"\n    subtitle_file = fixtures_dir / \"subtitle\" / \"sample_en.srt\"\n    if not subtitle_file.exists():\n        pytest.skip(f\"Sample subtitle not found: {subtitle_file}\")\n    return str(subtitle_file)\n\n\n@pytest.fixture\ndef output_dir(tmp_path: Path) -> Generator[str, None, None]:\n    \"\"\"Create and cleanup temporary output directory.\"\"\"\n    output_path = tmp_path / \"output\"\n    output_path.mkdir(exist_ok=True)\n    yield str(output_path)\n\n\ndef run_thread_with_timeout(thread, timeout_ms: int = 30000) -> dict:\n    \"\"\"Run QThread with timeout and collect results.\n\n    Args:\n        thread: QThread instance to run\n        timeout_ms: Timeout in milliseconds (default 30s)\n\n    Returns:\n        dict with keys: 'finished', 'error', 'output' (if available)\n    \"\"\"\n    result = {\"finished\": False, \"error\": None, \"output\": None}\n    loop = QEventLoop()\n\n    def on_finished(task=None):\n        result[\"finished\"] = True\n        if task:\n            result[\"output\"] = getattr(task, \"output_path\", None)\n        loop.quit()\n\n    def on_error(error_msg):\n        result[\"error\"] = error_msg\n        loop.quit()\n\n    def on_timeout():\n        result[\"error\"] = \"Thread execution timed out\"\n        thread.terminate()\n        loop.quit()\n\n    thread.finished.connect(on_finished)\n    thread.error.connect(on_error)\n\n    timer = QTimer()\n    timer.timeout.connect(on_timeout)\n    timer.setSingleShot(True)\n    timer.start(timeout_ms)\n\n    thread.start()\n    loop.exec_()\n    timer.stop()\n\n    return result\n"
  },
  {
    "path": "tests/test_thread/test_subtitle_pipeline_thread.py",
    "content": "\"\"\"Tests for SubtitlePipelineThread (simplified for basic validation).\"\"\"\n\nimport pytest\n\nfrom app.thread.subtitle_pipeline_thread import SubtitlePipelineThread\n\n\n@pytest.mark.integration\nclass TestSubtitlePipelineThread:\n    \"\"\"Test suite for SubtitlePipelineThread (simplified).\"\"\"\n\n    def test_pipeline_placeholder(self, qapp):\n        \"\"\"Placeholder test - full pipeline tests require all dependencies.\"\"\"\n        # Full pipeline tests would require:\n        # - FasterWhisper model downloaded\n        # - LLM API configured\n        # - Video files available\n        # These are better suited for manual integration testing\n        assert True, \"Pipeline thread exists and can be imported\"\n"
  },
  {
    "path": "tests/test_thread/test_transcript_thread.py",
    "content": "\"\"\"Tests for TranscriptThread.\"\"\"\n\nimport os\nfrom pathlib import Path\n\nimport pytest\nfrom dotenv import load_dotenv\n\nfrom app.core.entities import TranscribeConfig, TranscribeModelEnum, TranscribeTask\nfrom app.thread.transcript_thread import TranscriptThread\nfrom tests.test_thread.conftest import run_thread_with_timeout\n\nload_dotenv(Path(__file__).parent.parent / \".env\")\n\n\n@pytest.mark.integration\nclass TestTranscriptThread:\n    \"\"\"Test suite for TranscriptThread.\"\"\"\n\n    @pytest.fixture\n    def base_config(self) -> TranscribeConfig:\n        \"\"\"Create base transcription configuration.\"\"\"\n        return TranscribeConfig(\n            transcribe_model=TranscribeModelEnum.FASTER_WHISPER,\n            transcribe_language=\"zh\",\n            need_word_time_stamp=True,\n        )\n\n    @pytest.mark.skipif(\n        not Path(\"resource/bin/faster-whisper-xxl\").exists()\n        and not Path(\"resource/bin/faster-whisper-xxl.exe\").exists(),\n        reason=\"FasterWhisper executable not found - 需要本地 FasterWhisper 可执行文件\",\n    )\n    def test_transcribe_audio_with_faster_whisper(\n        self,\n        sample_audio_path: str,\n        output_dir: str,\n        base_config: TranscribeConfig,\n        qapp,\n    ):\n        \"\"\"Test transcription using FasterWhisper model with audio file.\"\"\"\n        output_path = os.path.join(output_dir, \"transcript_audio.srt\")\n        task = TranscribeTask(\n            file_path=sample_audio_path,\n            transcribe_config=base_config,\n            output_path=output_path,\n        )\n\n        thread = TranscriptThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=60000)\n\n        assert results[\"error\"] is None, f\"Thread failed: {results.get('error')}\"\n        assert results[\"finished\"], \"Thread did not finish\"\n        assert Path(output_path).exists(), f\"Output file not created: {output_path}\"\n\n    @pytest.mark.skipif(\n        not Path(\"resource/bin/faster-whisper-xxl\").exists()\n        and not Path(\"resource/bin/faster-whisper-xxl.exe\").exists(),\n        reason=\"FasterWhisper executable not found - 需要本地 FasterWhisper 可执行文件\",\n    )\n    def test_transcribe_video_with_faster_whisper(\n        self,\n        sample_video_path: str,\n        output_dir: str,\n        base_config: TranscribeConfig,\n        qapp,\n    ):\n        \"\"\"Test transcription using FasterWhisper model with video file.\"\"\"\n        output_path = os.path.join(output_dir, \"transcript_video.srt\")\n        task = TranscribeTask(\n            file_path=sample_video_path,\n            transcribe_config=base_config,\n            output_path=output_path,\n        )\n\n        thread = TranscriptThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=60000)\n\n        assert results[\"error\"] is None, f\"Thread failed: {results.get('error')}\"\n        assert results[\"finished\"], \"Thread did not finish\"\n        assert Path(output_path).exists(), f\"Output file not created: {output_path}\"\n\n    def test_transcribe_missing_video(\n        self, output_dir: str, base_config: TranscribeConfig, qapp\n    ):\n        \"\"\"Test transcription with missing video file.\"\"\"\n        output_path = os.path.join(output_dir, \"transcript.srt\")\n        task = TranscribeTask(\n            file_path=\"/nonexistent/video.mp4\",\n            transcribe_config=base_config,\n            output_path=output_path,\n        )\n\n        thread = TranscriptThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        assert results[\"error\"] is not None, \"Expected error for missing video\"\n        assert not results[\"finished\"], \"Thread should not finish successfully\"\n\n    def test_transcribe_empty_path(\n        self, output_dir: str, base_config: TranscribeConfig, qapp\n    ):\n        \"\"\"Test transcription with empty file path.\"\"\"\n        output_path = os.path.join(output_dir, \"transcript.srt\")\n        task = TranscribeTask(\n            file_path=\"\",\n            transcribe_config=base_config,\n            output_path=output_path,\n        )\n\n        thread = TranscriptThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        assert results[\"error\"] is not None, \"Expected error for empty path\"\n"
  },
  {
    "path": "tests/test_thread/test_video_info_thread.py",
    "content": "\"\"\"Tests for VideoInfoThread.\"\"\"\n\nimport pytest\n\nfrom app.thread.video_info_thread import VideoInfoThread\nfrom tests.test_thread.conftest import run_thread_with_timeout\n\n\n@pytest.mark.integration\nclass TestVideoInfoThread:\n    \"\"\"Test suite for VideoInfoThread.\"\"\"\n\n    def test_get_video_info_missing_file(self, qapp):\n        \"\"\"Test getting info for missing video file.\"\"\"\n        thread = VideoInfoThread(\"/nonexistent/video.mp4\")\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        assert results[\"error\"] is not None, \"Expected error for missing video\"\n\n    def test_get_video_info_invalid_file(self, tmp_path, qapp):\n        \"\"\"Test getting info for invalid video file.\"\"\"\n        invalid_file = tmp_path / \"invalid.mp4\"\n        invalid_file.write_text(\"not a video file\")\n\n        thread = VideoInfoThread(str(invalid_file))\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        # May or may not error depending on ffmpeg behavior\n        # Just ensure thread completes without hanging\n        assert results[\"finished\"] or results[\"error\"] is not None\n"
  },
  {
    "path": "tests/test_thread/test_video_synthesis_thread.py",
    "content": "\"\"\"Tests for VideoSynthesisThread.\"\"\"\n\nimport os\nfrom pathlib import Path\n\nimport pytest\n\nfrom app.core.entities import SynthesisConfig, SynthesisTask\nfrom app.thread.video_synthesis_thread import VideoSynthesisThread\nfrom tests.test_thread.conftest import run_thread_with_timeout\n\n\n@pytest.mark.integration\nclass TestVideoSynthesisThread:\n    \"\"\"Test suite for VideoSynthesisThread.\"\"\"\n\n    @pytest.fixture\n    def base_config(self) -> SynthesisConfig:\n        \"\"\"Create base synthesis configuration.\"\"\"\n        return SynthesisConfig(\n            soft_subtitle=False,\n            need_video=True,\n        )\n\n    def test_synthesize_skip_video(\n        self,\n        sample_video_path: str,\n        sample_subtitle_path: str,\n        output_dir: str,\n        base_config: SynthesisConfig,\n        qapp,\n    ):\n        \"\"\"Test synthesis with need_video=False.\"\"\"\n        base_config.need_video = False\n        output_path = os.path.join(output_dir, \"output_skip.mp4\")\n        task = SynthesisTask(\n            video_path=sample_video_path,\n            subtitle_path=sample_subtitle_path,\n            synthesis_config=base_config,\n            output_path=output_path,\n        )\n\n        thread = VideoSynthesisThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        assert results[\"error\"] is None, \"Thread should not error when skipping video\"\n        assert results[\"finished\"], \"Thread should finish successfully\"\n        assert not Path(output_path).exists(), \"Output file should not be created\"\n\n    def test_synthesize_missing_video(\n        self,\n        sample_subtitle_path: str,\n        output_dir: str,\n        base_config: SynthesisConfig,\n        qapp,\n    ):\n        \"\"\"Test synthesis with missing video file.\"\"\"\n        output_path = os.path.join(output_dir, \"output.mp4\")\n        task = SynthesisTask(\n            video_path=\"/nonexistent/video.mp4\",\n            subtitle_path=sample_subtitle_path,\n            synthesis_config=base_config,\n            output_path=output_path,\n        )\n\n        thread = VideoSynthesisThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        assert results[\"error\"] is not None, \"Expected error for missing video\"\n\n    def test_synthesize_empty_paths(\n        self, output_dir: str, base_config: SynthesisConfig, qapp\n    ):\n        \"\"\"Test synthesis with empty paths.\"\"\"\n        output_path = os.path.join(output_dir, \"output.mp4\")\n        task = SynthesisTask(\n            video_path=\"\",\n            subtitle_path=\"\",\n            synthesis_config=base_config,\n            output_path=output_path,\n        )\n\n        thread = VideoSynthesisThread(task)\n        results = run_thread_with_timeout(thread, timeout_ms=5000)\n\n        assert results[\"error\"] is not None, \"Expected error for empty paths\"\n"
  },
  {
    "path": "tests/test_translate/__init__.py",
    "content": "\"\"\"\n翻译模块测试\n\"\"\"\n"
  },
  {
    "path": "tests/test_translate/test_bing_translator.py",
    "content": "\"\"\"Bing Translator integration tests.\"\"\"\n\nfrom typing import Dict, List\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData\nfrom app.core.translate import SubtitleProcessData, TargetLanguage\nfrom app.core.translate.bing_translator import BingTranslator\nfrom tests.conftest import assert_translation_quality\n\n\n@pytest.mark.integration\nclass TestBingTranslator:\n    \"\"\"Test suite for BingTranslator using public API endpoints.\"\"\"\n\n    @pytest.fixture\n    def bing_translator(self, target_language: TargetLanguage) -> BingTranslator:\n        \"\"\"Create BingTranslator instance for testing.\"\"\"\n        return BingTranslator(\n            thread_num=2,\n            batch_num=5,\n            target_language=target_language,\n            update_callback=None,\n        )\n\n    @pytest.mark.parametrize(\n        \"target_language\",\n        [TargetLanguage.SIMPLIFIED_CHINESE, TargetLanguage.JAPANESE],\n    )\n    def test_translate_simple_text(\n        self,\n        bing_translator: BingTranslator,\n        sample_asr_data: ASRData,\n        expected_translations: Dict[str, Dict[str, List[str]]],\n        target_language: TargetLanguage,\n    ) -> None:\n        \"\"\"Test translating simple ASR data with quality validation.\"\"\"\n        result = bing_translator.translate_subtitle(sample_asr_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"Bing Translation Results (to {target_language.value}):\")\n        for i, seg in enumerate(result.segments, 1):\n            print(f\"  [{i}] {seg.text} → {seg.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result.segments) == len(sample_asr_data.segments)\n\n        # Get expected keywords for target language\n        lang_expectations = expected_translations.get(target_language.value, {})\n\n        # Validate translation quality\n        for seg in result.segments:\n            if seg.text in lang_expectations:\n                assert_translation_quality(\n                    seg.text, seg.translated_text, lang_expectations[seg.text]\n                )\n            else:\n                assert seg.translated_text, f\"Translation is empty for: {seg.text}\"\n\n    def test_translate_chunk(\n        self,\n        bing_translator: BingTranslator,\n        sample_translate_data: list[SubtitleProcessData],\n        expected_translations: Dict[str, Dict[str, List[str]]],\n        target_language: TargetLanguage,\n    ) -> None:\n        \"\"\"Test translating a single chunk of data with quality validation.\"\"\"\n        result = bing_translator._translate_chunk(sample_translate_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"Bing Chunk Translation Results (to {target_language.value}):\")\n        for data in result:\n            print(f\"  [{data.index}] {data.original_text} → {data.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result) == len(sample_translate_data)\n\n        # Get expected keywords for target language\n        lang_expectations = expected_translations.get(target_language.value, {})\n\n        # Validate translation quality\n        for data in result:\n            if data.original_text in lang_expectations:\n                assert_translation_quality(\n                    data.original_text,\n                    data.translated_text,\n                    lang_expectations[data.original_text],\n                )\n            else:\n                assert (\n                    data.translated_text\n                ), f\"Translation is empty for: {data.original_text}\"\n"
  },
  {
    "path": "tests/test_translate/test_cache_validation.py",
    "content": "\"\"\"Tests for cache validation functionality.\"\"\"\n\nfrom typing import Any\n\nimport pytest\nfrom diskcache import Cache\n\nfrom app.core.utils.cache import (\n    disable_cache,\n    enable_cache,\n    memoize,\n)\n\n\n@pytest.fixture(autouse=True)\ndef ensure_cache_enabled():\n    \"\"\"Ensure cache is enabled before each test.\"\"\"\n    enable_cache()\n    yield\n    enable_cache()  # Re-enable after test\n\n\n@pytest.fixture\ndef test_cache(tmp_path) -> Cache:\n    \"\"\"Create a temporary cache instance for testing.\"\"\"\n    cache = Cache(str(tmp_path / \"test_cache\"))\n    yield cache\n    cache.close()\n\n\nclass TestCacheValidation:\n    \"\"\"Test suite for cache validation features.\"\"\"\n\n    def test_exception_not_cached(self, test_cache: Cache) -> None:\n        \"\"\"Test that exceptions are never cached.\"\"\"\n        call_count = 0\n\n        @memoize(test_cache)\n        def failing_function() -> str:\n            nonlocal call_count\n            call_count += 1\n            raise ValueError(\"Test error\")\n\n        # First call - should raise exception\n        with pytest.raises(ValueError, match=\"Test error\"):\n            failing_function()\n\n        # Second call - should raise exception again (not cached)\n        with pytest.raises(ValueError, match=\"Test error\"):\n            failing_function()\n\n        # Both calls should have executed the function\n        assert call_count == 2\n\n    def test_validate_none_not_cached(self, test_cache: Cache) -> None:\n        \"\"\"Test that None results are not cached when validation raises exception.\"\"\"\n        call_count = 0\n\n        @memoize(test_cache)\n        def returns_none_then_raises() -> None:\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                raise ValueError(\"Invalid None result\")\n            return None\n\n        # First call - raises exception (not cached)\n        with pytest.raises(ValueError, match=\"Invalid None result\"):\n            returns_none_then_raises()\n\n        # Second call - should execute again and return None\n        result = returns_none_then_raises()\n        assert result is None\n\n        # Both calls should have executed\n        assert call_count == 2\n\n    def test_validate_empty_not_cached(self, test_cache: Cache) -> None:\n        \"\"\"Test that empty results raise exception and are not cached.\"\"\"\n        call_count = 0\n\n        @memoize(test_cache)\n        def returns_empty_then_success() -> str:\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                raise ValueError(\"Empty result not allowed\")\n            return \"success\"\n\n        # First call - raises exception (not cached)\n        with pytest.raises(ValueError, match=\"Empty result not allowed\"):\n            returns_empty_then_success()\n\n        # Second call - should execute again and return success\n        result = returns_empty_then_success()\n        assert result == \"success\"\n\n        # Both calls should have executed\n        assert call_count == 2\n\n    def test_custom_validator(self, test_cache: Cache) -> None:\n        \"\"\"Test custom validation with exception for invalid results.\"\"\"\n        call_count = 0\n\n        @memoize(test_cache)\n        def get_number() -> int:\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                raise ValueError(\"Negative number not allowed\")\n            return 42\n\n        # First call - raises exception (not cached)\n        with pytest.raises(ValueError, match=\"Negative number not allowed\"):\n            get_number()\n\n        # Second call - should execute again and return valid result\n        result2 = get_number()\n        assert result2 == 42\n\n        # Third call - should use cache\n        result3 = get_number()\n        assert result3 == 42\n\n        # Should have called function twice (third time used cache)\n        assert call_count == 2\n\n    def test_valid_result_cached(self, test_cache: Cache) -> None:\n        \"\"\"Test that valid results are cached.\"\"\"\n        call_count = 0\n\n        @memoize(test_cache)\n        def returns_valid() -> str:\n            nonlocal call_count\n            call_count += 1\n            return \"valid result\"\n\n        # First call\n        result1 = returns_valid()\n        assert result1 == \"valid result\"\n\n        # Second call - should use cache\n        result2 = returns_valid()\n        assert result2 == \"valid result\"\n\n        # Function should only be called once\n        assert call_count == 1\n\n    def test_no_validator_caches_all(self, test_cache: Cache) -> None:\n        \"\"\"Test that all non-exception results are cached, including None.\"\"\"\n        call_count = 0\n\n        @memoize(test_cache)\n        def returns_none_or_value() -> Any:\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return None\n            return \"value\"\n\n        # First call - returns None\n        result1 = returns_none_or_value()\n        assert result1 is None\n\n        # Second call - should use cached None\n        result2 = returns_none_or_value()\n        assert result2 is None\n\n        # Function should only be called once (None was cached)\n        assert call_count == 1\n\n    def test_cache_disabled_bypasses_cache(self, test_cache: Cache) -> None:\n        \"\"\"Test that cache is bypassed when globally disabled.\"\"\"\n        call_count = 0\n\n        @memoize(test_cache)\n        def returns_value() -> str:\n            nonlocal call_count\n            call_count += 1\n            return \"value\"\n\n        # Disable cache\n        disable_cache()\n\n        # First call\n        result1 = returns_value()\n        assert result1 == \"value\"\n\n        # Second call - should execute again (cache disabled)\n        result2 = returns_value()\n        assert result2 == \"value\"\n\n        # Both calls should have executed\n        assert call_count == 2\n\n        # Re-enable cache\n        enable_cache()\n"
  },
  {
    "path": "tests/test_translate/test_deeplx_translator.py",
    "content": "\"\"\"DeepLX Translator integration tests.\n\nRequires environment variables:\n    DEEPLX_ENDPOINT: DeepLX service endpoint\n\"\"\"\n\nimport os\nfrom typing import Callable, Dict, List\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData\nfrom app.core.translate import SubtitleProcessData, TargetLanguage\nfrom app.core.translate.deeplx_translator import DeepLXTranslator\nfrom tests.conftest import assert_translation_quality\n\n\n@pytest.mark.integration\n@pytest.mark.skipif(\n    not os.getenv(\"DEEPLX_ENDPOINT\"),\n    reason=\"DEEPLX_ENDPOINT not set - 需要外部 DeepLX 服务\",\n)\nclass TestDeepLXTranslator:\n    \"\"\"Test suite for DeepLXTranslator using DeepLX service endpoints.\"\"\"\n\n    @pytest.fixture\n    def deeplx_translator(\n        self, check_env_vars: Callable, target_language: TargetLanguage\n    ) -> DeepLXTranslator:\n        \"\"\"Create DeepLXTranslator instance for testing.\"\"\"\n        check_env_vars(\"DEEPLX_ENDPOINT\")\n\n        return DeepLXTranslator(\n            thread_num=2,\n            batch_num=5,\n            target_language=target_language,\n            timeout=20,\n            update_callback=None,\n        )\n\n    @pytest.mark.parametrize(\n        \"target_language\",\n        [TargetLanguage.SIMPLIFIED_CHINESE, TargetLanguage.JAPANESE],\n    )\n    def test_translate_simple_text(\n        self,\n        deeplx_translator: DeepLXTranslator,\n        sample_asr_data: ASRData,\n        expected_translations: Dict[str, Dict[str, List[str]]],\n        target_language: TargetLanguage,\n        check_env_vars: Callable,\n    ) -> None:\n        \"\"\"Test translating simple ASR data with quality validation.\"\"\"\n        check_env_vars(\"DEEPLX_ENDPOINT\")\n\n        result = deeplx_translator.translate_subtitle(sample_asr_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"DeepLX Translation Results (to {target_language.value}):\")\n        for i, seg in enumerate(result.segments, 1):\n            print(f\"  [{i}] {seg.text} → {seg.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result.segments) == len(sample_asr_data.segments)\n\n        # Get expected keywords for target language\n        lang_expectations = expected_translations.get(target_language.value, {})\n\n        # Validate translation quality\n        for seg in result.segments:\n            if seg.text in lang_expectations:\n                assert_translation_quality(\n                    seg.text, seg.translated_text, lang_expectations[seg.text]\n                )\n            else:\n                assert seg.translated_text, f\"Translation is empty for: {seg.text}\"\n\n    @pytest.mark.skip(reason=\"DeepLX API 认证失败 - 需要有效的API凭证\")\n    def test_translate_chunk(\n        self,\n        deeplx_translator: DeepLXTranslator,\n        sample_translate_data: list[SubtitleProcessData],\n        expected_translations: Dict[str, Dict[str, List[str]]],\n        target_language: TargetLanguage,\n        check_env_vars: Callable,\n    ) -> None:\n        \"\"\"Test translating a single chunk of data with quality validation.\"\"\"\n        check_env_vars(\"DEEPLX_ENDPOINT\")\n\n        result = deeplx_translator._translate_chunk(sample_translate_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"DeepLX Chunk Translation Results (to {target_language.value}):\")\n        for data in result:\n            print(f\"  [{data.index}] {data.original_text} → {data.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result) == len(sample_translate_data)\n\n        # Get expected keywords for target language\n        lang_expectations = expected_translations.get(target_language.value, {})\n\n        # Validate translation quality\n        for data in result:\n            if data.original_text in lang_expectations:\n                assert_translation_quality(\n                    data.original_text,\n                    data.translated_text,\n                    lang_expectations[data.original_text],\n                )\n            else:\n                assert (\n                    data.translated_text\n                ), f\"Translation is empty for: {data.original_text}\"\n"
  },
  {
    "path": "tests/test_translate/test_google_translator.py",
    "content": "\"\"\"Google Translator integration tests.\"\"\"\n\nfrom typing import Dict, List\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData\nfrom app.core.translate import SubtitleProcessData, TargetLanguage\nfrom app.core.translate.google_translator import GoogleTranslator\nfrom tests.conftest import assert_translation_quality\n\n\n@pytest.mark.integration\nclass TestGoogleTranslator:\n    \"\"\"Test suite for GoogleTranslator using public API endpoints.\"\"\"\n\n    @pytest.fixture\n    def google_translator(self, target_language: TargetLanguage) -> GoogleTranslator:\n        \"\"\"Create GoogleTranslator instance for testing.\"\"\"\n        return GoogleTranslator(\n            thread_num=2,\n            batch_num=5,\n            target_language=target_language,\n            timeout=20,\n            update_callback=None,\n        )\n\n    @pytest.mark.parametrize(\n        \"target_language\",\n        [TargetLanguage.SIMPLIFIED_CHINESE, TargetLanguage.JAPANESE],\n    )\n    def test_translate_simple_text(\n        self,\n        google_translator: GoogleTranslator,\n        sample_asr_data: ASRData,\n        expected_translations: Dict[str, Dict[str, List[str]]],\n        target_language: TargetLanguage,\n    ) -> None:\n        \"\"\"Test translating simple ASR data with quality validation.\"\"\"\n        result = google_translator.translate_subtitle(sample_asr_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"Google Translation Results (to {target_language.value}):\")\n        for i, seg in enumerate(result.segments, 1):\n            print(f\"  [{i}] {seg.text} → {seg.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result.segments) == len(sample_asr_data.segments)\n\n        # Get expected keywords for target language\n        lang_expectations = expected_translations.get(target_language.value, {})\n\n        # Validate translation quality\n        for seg in result.segments:\n            if seg.text in lang_expectations:\n                assert_translation_quality(\n                    seg.text, seg.translated_text, lang_expectations[seg.text]\n                )\n            else:\n                assert seg.translated_text, f\"Translation is empty for: {seg.text}\"\n\n    def test_translate_chunk(\n        self,\n        google_translator: GoogleTranslator,\n        sample_translate_data: list[SubtitleProcessData],\n        expected_translations: Dict[str, Dict[str, List[str]]],\n        target_language: TargetLanguage,\n    ) -> None:\n        \"\"\"Test translating a single chunk of data with quality validation.\"\"\"\n        result = google_translator._translate_chunk(sample_translate_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"Google Chunk Translation Results (to {target_language.value}):\")\n        for data in result:\n            print(f\"  [{data.index}] {data.original_text} → {data.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result) == len(sample_translate_data)\n\n        # Get expected keywords for target language\n        lang_expectations = expected_translations.get(target_language.value, {})\n\n        # Validate translation quality\n        for data in result:\n            if data.original_text in lang_expectations:\n                assert_translation_quality(\n                    data.original_text,\n                    data.translated_text,\n                    lang_expectations[data.original_text],\n                )\n            else:\n                assert (\n                    data.translated_text\n                ), f\"Translation is empty for: {data.original_text}\"\n"
  },
  {
    "path": "tests/test_translate/test_llm_translator.py",
    "content": "\"\"\"LLM Translator integration tests.\n\nRequires environment variables:\n    OPENAI_BASE_URL: OpenAI-compatible API endpoint\n    OPENAI_API_KEY: API key for authentication\n    OPENAI_MODEL: Model name (optional, defaults to gpt-4o-mini)\n\"\"\"\n\nimport os\nfrom typing import Callable, Dict, List\n\nimport pytest\n\nfrom app.core.asr.asr_data import ASRData\nfrom app.core.translate import SubtitleProcessData, TargetLanguage\nfrom app.core.translate.llm_translator import LLMTranslator\nfrom app.core.utils import cache\nfrom tests.conftest import assert_translation_quality\n\n\n@pytest.mark.integration\nclass TestLLMTranslator:\n    \"\"\"Test suite for LLMTranslator with OpenAI-compatible APIs.\"\"\"\n\n    @pytest.fixture\n    def llm_translator(\n        self, mock_llm_client, target_language: TargetLanguage\n    ) -> LLMTranslator:\n        \"\"\"Create LLMTranslator instance for testing (using mock LLM).\"\"\"\n        model = \"gpt-4o-mini\"\n\n        return LLMTranslator(\n            thread_num=2,\n            batch_num=5,\n            target_language=target_language,\n            model=model,\n            custom_prompt=\"\",\n            is_reflect=False,\n            update_callback=None,\n        )\n\n    @pytest.mark.parametrize(\n        \"target_language\",\n        [TargetLanguage.SIMPLIFIED_CHINESE, TargetLanguage.JAPANESE],\n    )\n    def test_translate_simple_text(\n        self,\n        llm_translator: LLMTranslator,\n        sample_asr_data: ASRData,\n        expected_translations: Dict[str, Dict[str, List[str]]],\n        target_language: TargetLanguage,\n    ) -> None:\n        \"\"\"Test translating simple ASR data with quality validation (using mock LLM).\"\"\"\n\n        result = llm_translator.translate_subtitle(sample_asr_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"LLM Translation Results (to {target_language.value}):\")\n        for i, seg in enumerate(result.segments, 1):\n            print(f\"  [{i}] {seg.text} → {seg.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result.segments) == len(sample_asr_data.segments)\n\n        # Validate translation exists (quality check skipped for mock)\n        for seg in result.segments:\n            assert seg.translated_text, f\"Translation is empty for: {seg.text}\"\n\n    def test_translate_chunk(\n        self,\n        llm_translator: LLMTranslator,\n        sample_translate_data: list[SubtitleProcessData],\n        expected_translations: Dict[str, Dict[str, List[str]]],\n        target_language: TargetLanguage,\n    ) -> None:\n        \"\"\"Test translating a single chunk of data with quality validation (using mock LLM).\"\"\"\n\n        result = llm_translator._translate_chunk(sample_translate_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"LLM Chunk Translation Results (to {target_language.value}):\")\n        for data in result:\n            print(f\"  [{data.index}] {data.original_text} → {data.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result) == len(sample_translate_data)\n\n        # Get expected keywords for target language\n        lang_expectations = expected_translations.get(target_language.value, {})\n\n        # Validate translation exists (quality check skipped for mock)\n        for data in result:\n            assert (\n                data.translated_text\n            ), f\"Translation is empty for: {data.original_text}\"\n\n    def test_cache_works(\n        self,\n        llm_translator: LLMTranslator,\n        sample_asr_data: ASRData,\n    ) -> None:\n        \"\"\"Test that caching mechanism works correctly (using mock LLM).\"\"\"\n        cache.enable_cache()\n\n        result1 = llm_translator.translate_subtitle(sample_asr_data)\n        result2 = llm_translator.translate_subtitle(sample_asr_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(\"LLM Cache Test:\")\n        print(f\"  First call:  {result1.segments[-1].translated_text}\")\n        print(f\"  Second call: {result2.segments[-1].translated_text}\")\n        print(\n            f\"  Match: {result1.segments[0].translated_text == result2.segments[0].translated_text}\"\n        )\n        print(\"=\" * 60)\n\n        for seg1, seg2 in zip(result1.segments, result2.segments):\n            assert seg1.translated_text == seg2.translated_text\n\n    @pytest.mark.parametrize(\n        \"target_language\",\n        [TargetLanguage.SIMPLIFIED_CHINESE],\n    )\n    def test_reflect_translation(\n        self,\n        sample_asr_data: ASRData,\n        target_language: TargetLanguage,\n        check_env_vars: Callable,\n    ) -> None:\n        \"\"\"Test reflect translation mode with nested dict validation.\"\"\"\n        check_env_vars(\"OPENAI_BASE_URL\", \"OPENAI_API_KEY\")\n\n        model = os.getenv(\"OPENAI_MODEL\", \"gpt-4o-mini\")\n\n        translator = LLMTranslator(\n            thread_num=2,\n            batch_num=5,\n            target_language=target_language,\n            model=model,\n            custom_prompt=\"\",\n            is_reflect=True,\n            update_callback=None,\n        )\n\n        result = translator.translate_subtitle(sample_asr_data)\n\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"Reflect Translation Results (to {target_language.value}):\")\n        for i, seg in enumerate(result.segments, 1):\n            print(f\"  [{i}] {seg.text}\")\n            print(f\"      → {seg.translated_text}\")\n        print(\"=\" * 60)\n\n        assert len(result.segments) == len(sample_asr_data.segments)\n\n        for seg in result.segments:\n            assert seg.translated_text, f\"Translation is empty for: {seg.text}\"\n            assert len(seg.translated_text) > 0, \"Translated text should not be empty\"\n"
  },
  {
    "path": "tests/test_tts/__init__.py",
    "content": "\"\"\"TTS 模块测试\"\"\"\n"
  },
  {
    "path": "tests/test_tts/test_tts_core.py",
    "content": "\"\"\"TTS 核心功能测试\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, Mock, patch\n\nimport pytest\nimport requests\n\nfrom app.core.tts import (\n    BaseTTS,\n    OpenAIFmTTS,\n    OpenAITTS,\n    SiliconFlowTTS,\n    TTSConfig,\n    TTSData,\n    TTSDataSeg,\n    TTSStatus,\n)\n\n\nclass TestTTSConfig:\n    \"\"\"测试 TTSConfig 配置类\"\"\"\n\n    def test_default_config(self):\n        \"\"\"测试默认配置\"\"\"\n        config = TTSConfig(\n            model=\"FunAudioLLM/CosyVoice2-0.5B\",\n            api_key=\"test-key\",\n            base_url=\"https://api.siliconflow.cn/v1\",\n        )\n        assert config.model == \"FunAudioLLM/CosyVoice2-0.5B\"\n        assert config.base_url == \"https://api.siliconflow.cn/v1\"\n        assert config.response_format == \"mp3\"\n        assert config.sample_rate == 32000\n        assert config.speed == 1.0\n        assert config.gain == 0\n        assert config.cache_ttl == 86400 * 2  # 2天\n        assert config.timeout == 60\n\n    def test_custom_config(self):\n        \"\"\"测试自定义配置\"\"\"\n        config = TTSConfig(\n            model=\"custom-model\",\n            api_key=\"test-key\",\n            base_url=\"https://test.api\",\n            voice=\"female\",\n            speed=1.5,\n            cache_ttl=86400 * 7,  # 7天\n        )\n        assert config.model == \"custom-model\"\n        assert config.api_key == \"test-key\"\n        assert config.base_url == \"https://test.api\"\n        assert config.voice == \"female\"\n        assert config.speed == 1.5\n        assert config.cache_ttl == 86400 * 7\n\n\nclass TestTTSData:\n    \"\"\"测试 TTSData 数据类\"\"\"\n\n    def test_create_tts_data_seg(self):\n        \"\"\"测试创建 TTSDataSeg\"\"\"\n        seg = TTSDataSeg(\n            text=\"你好世界\",\n            audio_path=\"/path/to/audio.mp3\",\n            start_time=0.0,\n            end_time=2.5,\n            audio_duration=2.5,\n            voice=\"female\",\n        )\n        assert seg.text == \"你好世界\"\n        assert seg.audio_path == \"/path/to/audio.mp3\"\n        assert seg.start_time == 0.0\n        assert seg.end_time == 2.5\n        assert seg.audio_duration == 2.5\n        assert seg.voice == \"female\"\n\n    def test_create_tts_data_from_segments(self):\n        \"\"\"测试从 segments 创建 TTSData\"\"\"\n        segments = [\n            TTSDataSeg(text=\"第一段\", audio_path=\"/audio1.mp3\"),\n            TTSDataSeg(text=\"第二段\", audio_path=\"/audio2.mp3\"),\n        ]\n        data = TTSData(segments=segments)\n        assert len(data) == 2\n        assert data.segments[0].text == \"第一段\"\n        assert data.segments[1].text == \"第二段\"\n\n    def test_from_texts(self):\n        \"\"\"测试从文本列表创建 TTSData\"\"\"\n        texts = [\"文本1\", \"文本2\", \"文本3\"]\n        data = TTSData.from_texts(texts)\n        assert len(data) == 3\n        assert data.segments[0].text == \"文本1\"\n        assert data.segments[1].text == \"文本2\"\n        assert data.segments[2].text == \"文本3\"\n\n    def test_filter_empty_segments(self):\n        \"\"\"测试过滤空文本段\"\"\"\n        segments = [\n            TTSDataSeg(text=\"有效文本\", audio_path=\"/audio1.mp3\"),\n            TTSDataSeg(text=\"\", audio_path=\"/audio2.mp3\"),\n            TTSDataSeg(text=\"  \", audio_path=\"/audio3.mp3\"),\n            TTSDataSeg(text=\"另一个有效文本\", audio_path=\"/audio4.mp3\"),\n        ]\n        data = TTSData(segments=segments)\n        assert len(data) == 2\n        assert data.segments[0].text == \"有效文本\"\n        assert data.segments[1].text == \"另一个有效文本\"\n\n\nclass TestTTSStatus:\n    \"\"\"测试 TTSStatus 状态枚举\"\"\"\n\n    def test_status_properties(self):\n        \"\"\"测试状态属性\"\"\"\n        status = TTSStatus.SYNTHESIZING\n        assert status.message == \"synthesizing\"\n        assert status.progress == 30\n\n    def test_callback_tuple(self):\n        \"\"\"测试回调元组\"\"\"\n        status = TTSStatus.COMPLETED\n        assert status.callback_tuple() == (100, \"completed\")\n\n    def test_with_progress(self):\n        \"\"\"测试自定义进度\"\"\"\n        status = TTSStatus.SYNTHESIZING\n        assert status.with_progress(50) == (50, \"synthesizing\")\n\n    def test_all_statuses(self):\n        \"\"\"测试所有状态\"\"\"\n        assert TTSStatus.INITIALIZING.progress == 0\n        assert TTSStatus.PREPARING.progress == 10\n        assert TTSStatus.SYNTHESIZING.progress == 30\n        assert TTSStatus.PROCESSING.progress == 50\n        assert TTSStatus.SAVING.progress == 70\n        assert TTSStatus.FINALIZING.progress == 90\n        assert TTSStatus.COMPLETED.progress == 100\n\n\nclass MockTTS(BaseTTS):\n    \"\"\"用于测试的 Mock TTS 实现\"\"\"\n\n    def __init__(self, config: TTSConfig):\n        super().__init__(config)\n        self.synthesize_calls = []\n\n    def _synthesize(self, segment: TTSDataSeg, output_path: str) -> None:\n        self.synthesize_calls.append((segment.text, output_path))\n        # 创建虚拟音频文件\n        Path(output_path).write_text(f\"mock audio: {segment.text}\")\n        # 更新 segment\n        segment.audio_path = output_path\n        segment.audio_duration = 1.0\n        segment.voice = self.config.voice\n\n\nclass TestBaseTTS:\n    \"\"\"测试 BaseTTS 基类\"\"\"\n\n    def test_generate_cache_key(self):\n        \"\"\"测试缓存键生成\"\"\"\n        config = TTSConfig(\n            model=\"test-model\",\n            api_key=\"test-key\",\n            base_url=\"https://test.api\",\n            voice=\"female\",\n            speed=1.5,\n        )\n        tts = MockTTS(config)\n        seg1 = TTSDataSeg(text=\"测试文本\")\n        seg2 = TTSDataSeg(text=\"测试文本\")\n        seg3 = TTSDataSeg(text=\"不同文本\")\n\n        key1 = tts._generate_cache_key_for_segment(seg1)\n        key2 = tts._generate_cache_key_for_segment(seg2)\n        key3 = tts._generate_cache_key_for_segment(seg3)\n\n        # 相同文本应生成相同的键\n        assert key1 == key2\n        # 不同文本应生成不同的键\n        assert key1 != key3\n\n    def test_generate_filename(self):\n        \"\"\"测试文件名生成\"\"\"\n        config = TTSConfig(\n            model=\"test-model\",\n            api_key=\"test-key\",\n            base_url=\"https://test.api\",\n            response_format=\"mp3\",\n        )\n        tts = MockTTS(config)\n        filename = tts._generate_filename(\"测试文本\", 5)\n\n        assert filename.startswith(\"tts_0005_\")\n        assert filename.endswith(\".mp3\")\n        assert len(filename.split(\"_\")[2].split(\".\")[0]) == 8  # 8位哈希\n\n    def test_synthesize_single(self):\n        \"\"\"测试单条语音合成\"\"\"\n        config = TTSConfig(\n            model=\"test-model\", api_key=\"test-key\", base_url=\"https://test.api\"\n        )\n        tts = MockTTS(config)\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts([\"你好\"])\n            result = tts.synthesize(tts_data, tmpdir)\n\n            assert len(result) == 1\n            seg = result.segments[0]\n            assert seg.text == \"你好\"\n            assert seg.audio_path\n            assert seg.audio_duration == 1.0\n            assert Path(seg.audio_path).exists()\n\n    def test_synthesize_batch(self):\n        \"\"\"测试批量合成\"\"\"\n        config = TTSConfig(\n            model=\"test-model\", api_key=\"test-key\", base_url=\"https://test.api\"\n        )\n        tts = MockTTS(config)\n        texts = [\"第一句\", \"第二句\", \"第三句\"]\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts(texts)\n            result = tts.synthesize(tts_data, tmpdir)\n\n            assert len(result) == 3\n            # 验证每个片段\n            for i, seg in enumerate(result.segments):\n                assert seg.text == texts[i]\n                assert seg.audio_path\n                assert Path(seg.audio_path).exists()\n\n            # 检查文件是否创建\n            files = list(Path(tmpdir).glob(\"*.mp3\"))\n            assert len(files) == 3\n\n    def test_batch_with_callback(self):\n        \"\"\"测试批量合成带回调\"\"\"\n        config = TTSConfig(\n            model=\"test-model\", api_key=\"test-key\", base_url=\"https://test.api\"\n        )\n        tts = MockTTS(config)\n        texts = [\"文本1\", \"文本2\"]\n\n        callback_calls = []\n\n        def callback(progress: int, message: str):\n            callback_calls.append((progress, message))\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts(texts)\n            tts.synthesize(tts_data, tmpdir, callback=callback)\n\n            # 应该有进度回调\n            assert len(callback_calls) > 0\n            # 最后一次应该是完成\n            assert callback_calls[-1] == (100, \"completed\")\n\n    def test_cache_parameter(self):\n        \"\"\"测试 use_cache 参数\"\"\"\n        config_no_cache = TTSConfig(\n            model=\"test-model\",\n            api_key=\"test-key\",\n            base_url=\"https://test.api\",\n            use_cache=False,\n        )\n        config_with_cache = TTSConfig(\n            model=\"test-model\",\n            api_key=\"test-key\",\n            base_url=\"https://test.api\",\n            use_cache=True,\n        )\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # 测试 use_cache=False\n            tts1 = MockTTS(config_no_cache)\n            tts_data1 = TTSData.from_texts([\"测试1\"])\n            result1 = tts1.synthesize(tts_data1, tmpdir)\n            assert len(result1) == 1\n            assert result1.segments[0].text == \"测试1\"\n            assert Path(result1.segments[0].audio_path).exists()\n\n            # 测试 use_cache=True\n            tts2 = MockTTS(config_with_cache)\n            tts_data2 = TTSData.from_texts([\"测试2\"])\n            result2 = tts2.synthesize(tts_data2, tmpdir)\n            assert len(result2) == 1\n            assert result2.segments[0].text == \"测试2\"\n            assert Path(result2.segments[0].audio_path).exists()\n\n            # 验证两次都调用了 _synthesize（因为文本不同）\n            assert len(tts1.synthesize_calls) == 1\n            assert len(tts2.synthesize_calls) == 1\n\n\nclass TestSiliconFlowTTS:\n    \"\"\"测试 SiliconFlowTTS 实现\"\"\"\n\n    def test_init_without_api_key(self):\n        \"\"\"测试没有 API key 的初始化\"\"\"\n        config = TTSConfig(model=\"test-model\", api_key=\"\", base_url=\"https://test.api\")\n        with pytest.raises(ValueError, match=\"API key is required\"):\n            SiliconFlowTTS(config)\n\n    @patch(\"app.core.tts.siliconflow.requests.post\")\n    def test_synthesize_success(self, mock_post):\n        \"\"\"测试成功合成\"\"\"\n        config = TTSConfig(\n            model=\"test-model\",\n            api_key=\"test-key\",\n            base_url=\"https://api.siliconflow.cn/v1\",\n        )\n        tts = SiliconFlowTTS(config)\n\n        # 模拟 API 响应\n        mock_response = Mock()\n        mock_response.content = b\"fake audio data\"\n        mock_response.raise_for_status = Mock()\n        mock_post.return_value = mock_response\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test.mp3\"\n            segment = TTSDataSeg(text=\"测试文本\")\n            tts._synthesize(segment, str(output_path))\n\n            # 检查 API 调用\n            assert mock_post.called\n            call_args = mock_post.call_args\n            assert \"audio/speech\" in call_args[0][0]\n            assert call_args[1][\"headers\"][\"Authorization\"] == \"Bearer test-key\"\n            assert call_args[1][\"json\"][\"input\"] == \"测试文本\"\n            assert call_args[1][\"json\"][\"model\"] == \"test-model\"\n\n            # 检查结果\n            assert segment.text == \"测试文本\"\n            assert segment.audio_path == str(output_path)\n            assert output_path.exists()\n            assert output_path.read_bytes() == b\"fake audio data\"\n\n    @patch(\"app.core.tts.siliconflow.requests.post\")\n    def test_synthesize_with_optional_params(self, mock_post):\n        \"\"\"测试带可选参数的合成\"\"\"\n        config = TTSConfig(\n            model=\"test-model\",\n            api_key=\"test-key\",\n            base_url=\"https://api.siliconflow.cn/v1\",\n            voice=\"female\",\n            stream=True,\n        )\n        tts = SiliconFlowTTS(config)\n\n        mock_response = Mock()\n        mock_response.content = b\"audio\"\n        mock_response.raise_for_status = Mock()\n        mock_post.return_value = mock_response\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test.mp3\"\n            segment = TTSDataSeg(text=\"测试\")\n            tts._synthesize(segment, str(output_path))\n\n            # 检查可选参数是否传递\n            call_json = mock_post.call_args[1][\"json\"]\n            assert call_json[\"voice\"] == \"female\"\n            assert call_json[\"stream\"] is True\n\n\nclass TestOpenAITTS:\n    \"\"\"测试 OpenAITTS 实现\"\"\"\n\n    def test_init_without_api_key(self):\n        \"\"\"测试没有 API key 的初始化\"\"\"\n        config = TTSConfig(model=\"test-model\", api_key=\"\", base_url=\"https://test.api\")\n        with pytest.raises(ValueError, match=\"API key is required\"):\n            OpenAITTS(config)\n\n    @patch(\"app.core.tts.openai_tts.OpenAI\")\n    def test_synthesize_success(self, mock_openai_class):\n        \"\"\"测试成功合成\"\"\"\n        config = TTSConfig(\n            model=\"tts-1\",\n            api_key=\"test-key\",\n            base_url=\"https://api.openai.com/v1\",\n            voice=\"alloy\",\n        )\n\n        # 模拟 OpenAI 客户端\n        mock_client = Mock()\n        mock_response = Mock()\n        mock_response.__enter__ = Mock(return_value=mock_response)\n        mock_response.__exit__ = Mock(return_value=False)\n        mock_response.stream_to_file = Mock()\n\n        mock_client.audio.speech.with_streaming_response.create.return_value = (\n            mock_response\n        )\n        mock_openai_class.return_value = mock_client\n\n        tts = OpenAITTS(config)\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test.mp3\"\n            segment = TTSDataSeg(text=\"测试文本\")\n            tts._synthesize(segment, str(output_path))\n\n            # 检查 OpenAI 客户端初始化\n            mock_openai_class.assert_called_once_with(\n                api_key=\"test-key\",\n                base_url=\"https://api.openai.com/v1\",\n            )\n\n            # 检查 API 调用\n            mock_client.audio.speech.with_streaming_response.create.assert_called_once_with(\n                model=\"tts-1\",\n                voice=\"alloy\",\n                input=\"测试文本\",\n                response_format=\"mp3\",\n                speed=1.0,\n            )\n\n            # 检查流式写入文件\n            mock_response.stream_to_file.assert_called_once_with(str(output_path))\n\n            # 检查结果\n            assert segment.text == \"测试文本\"\n            assert segment.audio_path == str(output_path)\n            assert segment.voice == \"alloy\"\n\n    @patch(\"app.core.tts.openai_tts.OpenAI\")\n    def test_synthesize_with_custom_voice(self, mock_openai_class):\n        \"\"\"测试使用自定义音色\"\"\"\n        config = TTSConfig(\n            model=\"FunAudioLLM/CosyVoice2-0.5B\",\n            api_key=\"test-key\",\n            base_url=\"https://api.siliconflow.cn/v1\",\n            voice=\"FunAudioLLM/CosyVoice2-0.5B:alex\",\n            speed=1.2,\n        )\n\n        mock_client = Mock()\n        mock_response = Mock()\n        mock_response.__enter__ = Mock(return_value=mock_response)\n        mock_response.__exit__ = Mock(return_value=False)\n        mock_response.stream_to_file = Mock()\n\n        mock_client.audio.speech.with_streaming_response.create.return_value = (\n            mock_response\n        )\n        mock_openai_class.return_value = mock_client\n\n        tts = OpenAITTS(config)\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test.mp3\"\n            segment = TTSDataSeg(text=\"你好\")\n            tts._synthesize(segment, str(output_path))\n\n            # 检查自定义参数\n            call_kwargs = (\n                mock_client.audio.speech.with_streaming_response.create.call_args[1]\n            )\n            assert call_kwargs[\"model\"] == \"FunAudioLLM/CosyVoice2-0.5B\"\n            assert call_kwargs[\"voice\"] == \"FunAudioLLM/CosyVoice2-0.5B:alex\"\n            assert call_kwargs[\"speed\"] == 1.2\n\n    @patch(\"app.core.tts.openai_tts.OpenAI\")\n    def test_default_voice(self, mock_openai_class):\n        \"\"\"测试默认音色\"\"\"\n        config = TTSConfig(\n            model=\"tts-1\",\n            api_key=\"test-key\",\n            base_url=\"https://api.openai.com/v1\",\n            voice=None,  # 没有指定音色\n        )\n\n        mock_client = Mock()\n        mock_response = Mock()\n        mock_response.__enter__ = Mock(return_value=mock_response)\n        mock_response.__exit__ = Mock(return_value=False)\n        mock_response.stream_to_file = Mock()\n\n        mock_client.audio.speech.with_streaming_response.create.return_value = (\n            mock_response\n        )\n        mock_openai_class.return_value = mock_client\n\n        tts = OpenAITTS(config)\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test.mp3\"\n            segment = TTSDataSeg(text=\"测试\")\n            tts._synthesize(segment, str(output_path))\n\n            # 应该使用默认音色 \"alloy\"\n            call_kwargs = (\n                mock_client.audio.speech.with_streaming_response.create.call_args[1]\n            )\n            assert call_kwargs[\"voice\"] == \"alloy\"\n\n\n# ============================================================================\n# OpenAI.fm 测试已禁用 - 外部API不可用\n# ============================================================================\n'''\nclass TestOpenAIFmTTS:\n    \"\"\"测试 OpenAI.fm TTS 实现\"\"\"\n\n    def test_api_url_constant(self):\n        \"\"\"测试 API URL 常量\"\"\"\n        assert OpenAIFmTTS.API_URL == \"https://www.openai.fm/api/generate\"\n\n    def test_available_voices(self):\n        \"\"\"测试获取可用音色列表\"\"\"\n        voices = OpenAIFmTTS.get_available_voices()\n        assert isinstance(voices, list)\n        assert len(voices) > 0\n        assert \"fable\" in voices\n        assert \"alloy\" in voices\n        assert \"echo\" in voices\n\n    def test_prompt_templates(self):\n        \"\"\"测试获取提示词模板\"\"\"\n        templates = OpenAIFmTTS.get_prompt_templates()\n        assert isinstance(templates, dict)\n        assert \"natural\" in templates\n        assert \"professional\" in templates\n        assert \"friendly\" in templates\n\n    def test_default_voice(self):\n        \"\"\"测试默认音色\"\"\"\n        config = TTSConfig(\n            model=\"openai-fm\",\n            api_key=\"not-required\",\n            base_url=\"https://www.openai.fm/api\",\n        )\n        tts = OpenAIFmTTS(config)\n        assert tts.config.voice == \"fable\"\n\n    def test_custom_voice(self):\n        \"\"\"测试自定义音色\"\"\"\n        config = TTSConfig(\n            model=\"openai-fm\",\n            api_key=\"not-required\",\n            base_url=\"https://www.openai.fm/api\",\n            voice=\"echo\",\n        )\n        tts = OpenAIFmTTS(config)\n        assert tts.config.voice == \"echo\"\n\n    @patch(\"app.core.tts.openai_fm.requests.get\")\n    def test_synthesize_success(self, mock_get):\n        \"\"\"测试语音合成成功\"\"\"\n        config = TTSConfig(\n            model=\"openai-fm\",\n            api_key=\"not-required\",\n            base_url=\"https://www.openai.fm/api\",\n            voice=\"fable\",\n        )\n        tts = OpenAIFmTTS(config)\n\n        # 模拟 HTTP 响应\n        mock_response = Mock()\n        mock_response.content = b\"fake audio data\"\n        mock_response.raise_for_status = Mock()\n        mock_get.return_value = mock_response\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test.mp3\"\n            segment = TTSDataSeg(text=\"你好，世界！\")\n            tts._synthesize(segment, str(output_path))\n\n            # 验证请求参数\n            mock_get.assert_called_once()\n            call_args = mock_get.call_args\n\n            # 验证 URL\n            assert call_args[0][0] == OpenAIFmTTS.API_URL\n\n            # 验证请求参数\n            params = call_args[1][\"params\"]\n            assert params[\"input\"] == \"你好，世界！\"\n            assert params[\"voice\"] == \"fable\"\n            assert \"prompt\" in params\n\n            # 验证文件生成\n            assert output_path.exists()\n            assert output_path.read_bytes() == b\"fake audio data\"\n\n            # 验证返回结果\n            assert segment.text == \"你好，世界！\"\n            assert segment.audio_path == str(output_path)\n            assert segment.voice == \"fable\"\n\n    @patch(\"app.core.tts.openai_fm.requests.get\")\n    def test_synthesize_with_different_voices(self, mock_get):\n        \"\"\"测试不同音色的合成\"\"\"\n        voices = [\"alloy\", \"echo\", \"nova\", \"shimmer\"]\n\n        mock_response = Mock()\n        mock_response.content = b\"audio data\"\n        mock_response.raise_for_status = Mock()\n        mock_get.return_value = mock_response\n\n        for voice in voices:\n            config = TTSConfig(\n                model=\"openai-fm\",\n                api_key=\"not-required\",\n                base_url=\"https://www.openai.fm/api\",\n                voice=voice,\n            )\n            tts = OpenAIFmTTS(config)\n\n            with tempfile.TemporaryDirectory() as tmpdir:\n                output_path = Path(tmpdir) / f\"test_{voice}.mp3\"\n                segment = TTSDataSeg(text=\"测试\")\n                tts._synthesize(segment, str(output_path))\n\n                # 验证使用了正确的音色\n                params = mock_get.call_args[1][\"params\"]\n                assert params[\"voice\"] == voice\n                assert segment.voice == voice\n\n    @patch(\"app.core.tts.openai_fm.requests.get\")\n    def test_synthesize_with_long_text(self, mock_get):\n        \"\"\"测试长文本合成\"\"\"\n        config = TTSConfig(\n            model=\"openai-fm\",\n            api_key=\"not-required\",\n            base_url=\"https://www.openai.fm/api\",\n        )\n        tts = OpenAIFmTTS(config)\n\n        mock_response = Mock()\n        mock_response.content = b\"long audio data\"\n        mock_response.raise_for_status = Mock()\n        mock_get.return_value = mock_response\n\n        long_text = \"这是一段很长的测试文本。\" * 20\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test_long.mp3\"\n            segment = TTSDataSeg(text=long_text)\n            tts._synthesize(segment, str(output_path))\n\n            # 验证文本传递正确\n            params = mock_get.call_args[1][\"params\"]\n            assert params[\"input\"] == long_text\n            assert segment.text == long_text\n\n    @patch(\"app.core.tts.openai_fm.requests.get\")\n    def test_synthesize_timeout(self, mock_get):\n        \"\"\"测试超时配置\"\"\"\n        config = TTSConfig(\n            model=\"openai-fm\",\n            api_key=\"not-required\",\n            base_url=\"https://www.openai.fm/api\",\n            timeout=30,\n        )\n        tts = OpenAIFmTTS(config)\n\n        mock_response = Mock()\n        mock_response.content = b\"audio\"\n        mock_response.raise_for_status = Mock()\n        mock_get.return_value = mock_response\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test.mp3\"\n            segment = TTSDataSeg(text=\"测试\")\n            tts._synthesize(segment, str(output_path))\n\n            # 验证超时参数\n            assert mock_get.call_args[1][\"timeout\"] == 30\n\n    @patch(\"app.core.tts.openai_fm.requests.get\")\n    def test_synthesize_api_error(self, mock_get):\n        \"\"\"测试 API 错误处理\"\"\"\n        config = TTSConfig(\n            model=\"openai-fm\",\n            api_key=\"not-required\",\n            base_url=\"https://www.openai.fm/api\",\n        )\n        tts = OpenAIFmTTS(config)\n\n        # 模拟 HTTP 错误\n        mock_get.side_effect = requests.exceptions.HTTPError(\"API Error\")\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            output_path = Path(tmpdir) / \"test.mp3\"\n            segment = TTSDataSeg(text=\"测试\")\n\n            # 应该抛出异常\n            with pytest.raises(requests.exceptions.HTTPError):\n                tts._synthesize(segment, str(output_path))\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n'''\n"
  },
  {
    "path": "tests/test_tts/test_tts_integration.py",
    "content": "\"\"\"TTS 集成测试 - 真实 API 调用\n\n运行前需要设置环境变量（在 .env 文件中）:\n    OPENAI_TTS_BASE_URL=https://api.siliconflow.cn/v1\n    OPENAI_TTS_API_KEY=your-api-key-here\n    OPENAI_TTS_MODEL=FunAudioLLM/CosyVoice2-0.5B\n    OPENAI_TTS_VOICE=FunAudioLLM/CosyVoice2-0.5B:alex\n\n    OPENAI_API_BASE_URL=https://api.openai.com/v1\n    OPENAI_API_KEY=your-api-key-here\n    OPENAI_TTS_MODEL_NAME=tts-1\n\n运行方式:\n    pytest tests/test_tts/test_tts_integration.py -v\n    pytest tests/test_tts/test_tts_integration.py -v -k \"test_siliconflow_single\"\n\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\nfrom dotenv import load_dotenv\n\nfrom app.core.tts import OpenAIFmTTS, OpenAITTS, SiliconFlowTTS, TTSConfig, TTSData\n\n# 加载环境变量\nload_dotenv(Path(__file__).parent.parent / \".env\")\n\n# SiliconFlow TTS 环境变量配置\nSILICONFLOW_BASE_URL = os.getenv(\"OPENAI_TTS_BASE_URL\", \"https://api.siliconflow.cn/v1\")\nSILICONFLOW_API_KEY = os.getenv(\"OPENAI_TTS_API_KEY\", \"\")\nSILICONFLOW_MODEL = os.getenv(\"OPENAI_TTS_MODEL\", \"FunAudioLLM/CosyVoice2-0.5B\")\nSILICONFLOW_VOICE = os.getenv(\"OPENAI_TTS_VOICE\", \"FunAudioLLM/CosyVoice2-0.5B:alex\")\n\n# SiliconFlow TTS 跳过标记\nskip_siliconflow = pytest.mark.skipif(\n    not SILICONFLOW_BASE_URL\n    or not SILICONFLOW_API_KEY\n    or not SILICONFLOW_MODEL\n    or not SILICONFLOW_VOICE,\n    reason=\"SiliconFlow 未启用或缺少 API Key (设置 OPENAI_TTS_BASE_URL 和 OPENAI_TTS_API_KEY)\",\n)\n\n\n@pytest.fixture\ndef siliconflow_config():\n    \"\"\"创建 SiliconFlow TTS 配置\"\"\"\n    return TTSConfig(\n        base_url=SILICONFLOW_BASE_URL,\n        api_key=SILICONFLOW_API_KEY,\n        model=SILICONFLOW_MODEL,\n        voice=SILICONFLOW_VOICE,\n        timeout=60,\n    )\n\n\n# OpenAI TTS 环境变量配置\nOPENAI_BASE_URL = os.getenv(\"OPENAI_TTS_BASE_URL\", \"https://api.openai.com/v1\")\nOPENAI_API_KEY = os.getenv(\"OPENAI_TTS_API_KEY\", \"\")\nOPENAI_MODEL = os.getenv(\"OPENAI_TTS_MODEL\", \"tts-1\")\nOPENAI_VOICE = os.getenv(\"OPENAI_TTS_VOICE\", \"alloy\")\n\n# OpenAI TTS 跳过标记\nskip_openai = pytest.mark.skipif(\n    not OPENAI_BASE_URL or not OPENAI_API_KEY or not OPENAI_MODEL or not OPENAI_VOICE,\n    reason=\"OpenAI 未启用或缺少 API Key (设置 OPENAI_API_BASE_URL 和 OPENAI_API_KEY)\",\n)\n\n\n@pytest.fixture\ndef openai_config():\n    \"\"\"创建 OpenAI TTS 配置\"\"\"\n    return TTSConfig(\n        base_url=OPENAI_BASE_URL,\n        api_key=OPENAI_API_KEY,\n        model=OPENAI_MODEL,\n        voice=OPENAI_VOICE,\n        timeout=60,\n    )\n\n\n@skip_siliconflow\nclass TestSiliconFlowIntegration:\n    \"\"\"SiliconFlow TTS 真实 API 集成测试\"\"\"\n\n    def test_siliconflow_single_synthesis(self, siliconflow_config):\n        \"\"\"测试 SiliconFlow 单条语音合成 - 真实 API 调用\"\"\"\n        tts = SiliconFlowTTS(siliconflow_config)\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts([\"你好，欢迎使用 SiliconFlow TTS 服务。\"])\n            result = tts.synthesize(tts_data, tmpdir)\n\n            # 验证返回数据\n            assert len(result) == 1\n            seg = result.segments[0]\n            assert seg.text == \"你好，欢迎使用 SiliconFlow TTS 服务。\"\n            assert seg.audio_path\n            assert Path(seg.audio_path).exists(), \"音频文件未生成\"\n            assert Path(seg.audio_path).stat().st_size > 0, \"音频文件为空\"\n\n    def test_siliconflow_batch_synthesis(self, siliconflow_config):\n        \"\"\"测试 SiliconFlow 批量语音合成\"\"\"\n        tts = SiliconFlowTTS(siliconflow_config)\n\n        texts = [\n            \"第一段文本\",\n            \"第二段文本\",\n            \"第三段文本\",\n        ]\n\n        callback_calls = []\n\n        def callback(progress: int, message: str):\n            callback_calls.append((progress, message))\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts(texts)\n            result = tts.synthesize(tts_data, tmpdir, callback=callback)\n\n            # 验证批量结果\n            assert len(result) == 3\n\n            # 验证文件生成\n            files = list(Path(tmpdir).glob(\"*.mp3\"))\n            assert len(files) == 3, f\"应生成3个音频文件，实际生成{len(files)}个\"\n\n            # 验证每个文件都不为空\n            for file in files:\n                assert file.stat().st_size > 0, f\"文件 {file.name} 为空\"\n\n            # 应该有进度回调\n            assert len(callback_calls) > 0, \"没有收到进度回调\"\n\n            # 最后一次应该是完成（100%）\n            assert callback_calls[-1][0] == 100, \"最后进度应为100%\"\n\n\n@skip_openai\nclass TestOpenAITTSIntegration:\n    \"\"\"OpenAI TTS 真实 API 集成测试\"\"\"\n\n    def test_openai_single_synthesis(self, openai_config):\n        \"\"\"测试 OpenAI TTS 单条语音合成 - 真实 API 调用\"\"\"\n        tts = OpenAITTS(openai_config)\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts([\"你好，欢迎使用 OpenAI TTS 服务。\"])\n            result = tts.synthesize(tts_data, tmpdir)\n\n            # 验证返回数据\n            assert len(result) == 1\n            seg = result.segments[0]\n            assert seg.text == \"你好，欢迎使用 OpenAI TTS 服务。\"\n            assert seg.audio_path\n            assert Path(seg.audio_path).exists(), \"音频文件未生成\"\n            assert Path(seg.audio_path).stat().st_size > 0, \"音频文件为空\"\n\n    def test_openai_batch_synthesis(self, openai_config):\n        \"\"\"测试 OpenAI TTS 批量语音合成\"\"\"\n        tts = OpenAITTS(openai_config)\n\n        texts = [\n            \"第一段文本\",\n            \"第二段文本\",\n            \"第三段文本\",\n        ]\n\n        callback_calls = []\n\n        def callback(progress: int, message: str):\n            callback_calls.append((progress, message))\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts(texts)\n            result = tts.synthesize(tts_data, tmpdir, callback=callback)\n\n            # 验证批量结果\n            assert len(result) == 3\n\n            # 验证文件生成\n            files = list(Path(tmpdir).glob(\"*.mp3\"))\n            assert len(files) == 3, f\"应生成3个音频文件，实际生成{len(files)}个\"\n\n            # 验证每个文件都不为空\n            for file in files:\n                assert file.stat().st_size > 0, f\"文件 {file.name} 为空\"\n\n            # 应该有进度回调\n            assert len(callback_calls) > 0, \"没有收到进度回调\"\n\n            # 最后一次应该是完成（100%）\n            assert callback_calls[-1][0] == 100, \"最后进度应为100%\"\n\n\n# ============================================================================\n# OpenAI.fm 集成测试已禁用 - 外部API不可用\n# ============================================================================\n'''\nclass TestOpenAIFmIntegration:\n    \"\"\"OpenAI.fm TTS 真实 API 集成测试（免费服务）\"\"\"\n\n    def test_openai_fm_single_synthesis(self):\n        \"\"\"测试 OpenAI.fm 单条语音合成 - 真实 API 调用\"\"\"\n        config = TTSConfig(\n            model=\"openai-fm\",\n            api_key=\"not-required\",\n            base_url=\"https://www.openai.fm/api\",\n            voice=\"fable\",\n        )\n        tts = OpenAIFmTTS(config)\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts([\"你好，欢迎使用 OpenAI.fm TTS 服务。\"])\n            result = tts.synthesize(tts_data, tmpdir)\n\n            # 验证返回数据\n            assert len(result) == 1\n            seg = result.segments[0]\n            assert seg.text == \"你好，欢迎使用 OpenAI.fm TTS 服务。\"\n            assert seg.audio_path\n            assert Path(seg.audio_path).exists(), \"音频文件未生成\"\n            assert Path(seg.audio_path).stat().st_size > 0, \"音频文件为空\"\n\n    def test_openai_fm_batch_synthesis(self):\n        \"\"\"测试 OpenAI.fm 批量语音合成\"\"\"\n        config = TTSConfig(\n            model=\"openai-fm\",\n            api_key=\"not-required\",\n            base_url=\"https://www.openai.fm/api\",\n            voice=\"fable\",\n        )\n        tts = OpenAIFmTTS(config)\n\n        texts = [\n            \"第一段文本\",\n            \"第二段文本\",\n            \"第三段文本\",\n        ]\n\n        callback_calls = []\n\n        def callback(progress: int, message: str):\n            callback_calls.append((progress, message))\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            tts_data = TTSData.from_texts(texts)\n            result = tts.synthesize(tts_data, tmpdir, callback=callback)\n\n            # 验证批量结果\n            assert len(result) == 3\n\n            # 验证文件生成\n            files = list(Path(tmpdir).glob(\"*.mp3\"))\n            assert len(files) == 3, f\"应生成3个音频文件，实际生成{len(files)}个\"\n\n            # 验证每个文件都不为空\n            for file in files:\n                assert file.stat().st_size > 0, f\"文件 {file.name} 为空\"\n\n            # 应该有进度回调\n            assert len(callback_calls) > 0, \"没有收到进度回调\"\n\n            # 最后一次应该是完成（100%）\n            assert callback_calls[-1][0] == 100, \"最后进度应为100%\"\n\n\n'''\nif __name__ == \"__main__\":\n    # 运行集成测试\n    pytest.main([__file__, \"-v\", \"-s\"])\n"
  }
]