Compare commits

...

19 Commits

Author SHA1 Message Date
77199541cd 新增md 2026-05-04 15:02:40 +08:00
25a8cd077b 优化提示 2026-05-04 14:49:15 +08:00
5f5255830f 完善一些提示性内容 2026-05-02 16:48:32 +08:00
6eb2fc18b3 完善提示 2026-05-02 16:36:04 +08:00
763a14265d 新增日志导出选择 2026-05-02 16:18:45 +08:00
78c750bcbf 修复最后一批次的问题 2026-05-02 16:14:43 +08:00
28294a6eb2 修复图标问题 2026-05-02 16:10:27 +08:00
c32888796e 处理任务完成,进度条异常 2026-05-02 00:12:46 +08:00
75dfb67754 新增拖入功能 2026-05-01 23:38:09 +08:00
7e9abf5f07 更换icon 2026-05-01 23:37:56 +08:00
cbe5955c6e 新增分页,新增win打包 2026-05-01 22:31:32 +08:00
15ad4a1f07 修改进度条问题 2026-05-01 20:52:59 +08:00
68c1706e4a 移除虚拟滚动 2026-05-01 20:30:17 +08:00
0247f7f510 增加详细进度展示 2026-05-01 19:52:32 +08:00
47356a5ea9 新增实时流式翻译 避免等待 2026-05-01 18:46:23 +08:00
61d23c7d30 更新字段翻译 2026-05-01 17:30:31 +08:00
1348364aa3 处理不足 batch_size 的片段 2026-05-01 17:25:56 +08:00
38a94d0d87 将llm与转录区分开 2026-05-01 17:18:50 +08:00
508f28d092 新增移除任务 2026-05-01 15:20:12 +08:00
76 changed files with 3376 additions and 306 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
/src-tauri/target/ /src-tauri/target/
**/*.rs.bk **/*.rs.bk
/src-tauri/model/ /src-tauri/model/
/src-tauri/vendor/ffmpeg/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 kuraa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

178
README.en.md Normal file
View File

@ -0,0 +1,178 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="readme/screenshot-main.png">
<img src="readme/screenshot-main.png" alt="CrossSubtitle-AI Screenshot" width="100%">
</picture>
<div align="center">
# CrossSubtitle-AI
**AI-Powered, Local-First Subtitle Workbench**
[![GitHub Release](https://img.shields.io/github/v/release/AndySkaura/crosssubtitle-ai?style=flat-square)](https://github.com/AndySkaura/crosssubtitle-ai/releases)
[![GitHub License](https://img.shields.io/github/license/AndySkaura/crosssubtitle-ai?style=flat-square)](https://github.com/AndySkaura/crosssubtitle-ai/blob/main/LICENSE)
[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows-blue?style=flat-square)](#)
**English** · [简体中文](./README.md)
</div>
---
## About
CrossSubtitle-AI is a **local-first** audio/video subtitle processing tool. It uses [Whisper](https://github.com/ggerganov/whisper.cpp) for speech recognition, [Silero VAD](https://github.com/snakers4/silero-vad) for voice activity detection, and supports OpenAI-compatible APIs for intelligent translation — helping you quickly transcribe and translate media files into bilingual subtitles.
All speech recognition runs locally on your machine. No audio or video files are ever uploaded to any server, ensuring your data privacy.
## Features
- **Speech Recognition** — High-accuracy speech-to-text powered by Whisper, supporting 17 source languages including Chinese, English, Japanese, Korean, French, and more
- **Voice Activity Detection** — Silero VAD precisely splits speech segments and automatically filters out silence
- **Smart Translation** — Connect to any OpenAI-compatible API (GLM, DeepSeek, ChatGPT, etc.) to translate transcripts into your target language
- **Audio Extraction** — Built-in FFmpeg automatically extracts audio and converts to 16kHz mono WAV
- **Multiple Export Formats** — Export subtitles in SRT, VTT, and ASS formats
- **Bilingual Export** — Export side-by-side original + translated bilingual subtitles
- **Subtitle Editor** — Built-in editor for modifying both source text and translations line by line
- **Drag & Drop** — Drag and drop files to quickly create tasks
- **Task Queue** — Batch process multiple media files with real-time progress tracking
- **Bilingual UI** — Switch between Chinese and English interface languages
- **Local-First** — Speech recognition runs entirely locally, no data upload required
## Workflow
1. **Choose Mode** — Select "Source" for transcription only, or "Translate" mode for automatic translation after transcription
2. **Add Task** — Click "Add Task" or drag-and-drop media files onto the window
3. **Wait for Processing** — Tasks go through: Audio Extraction → VAD Segmentation → Speech Recognition → (Optional) Translation
4. **Review & Edit** — View and modify recognition results and translations in the subtitle editor
5. **Export Subtitles** — Export as SRT, VTT, or ASS format
## Screenshots
| Subtitle | Subtitle Editor |
|:---:|:---:|
| ![Subtitle](readme/screenshot-main.png) | ![Subtitle Editor](readme/screenshot-editor.png) |
## Installation
Download the installer for your platform from [GitHub Releases](https://github.com/AndySkaura/crosssubtitle-ai/releases):
| Platform | Package |
|:---:|:---:|
| macOS (Apple Silicon) | `.dmg` |
| Windows | `.exe` (NSIS Installer) |
> The Whisper model (~500MB) will be downloaded on first launch. An internet connection is required.
## Usage
### Quick Start
1. Open the app and select a mode from the top toolbar:
- **Source** — Speech recognition only, outputs source language subtitles
- **Translate** — Transcribes then translates via an LLM API
2. Click "Add Task" or drag-and-drop files onto the window
3. Wait for processing to complete
4. Review and edit results in the subtitle editor on the right
5. Click "Export" to save subtitles in your preferred format
### Translation Configuration
Before using the translation feature, configure the LLM API:
- Fill in the LLM API Base, API Key, and Model in "Advanced Settings"
- Works with any OpenAI-compatible service, including:
- **GLM (Zhipu AI)** — GLM-4.7-Flash available for free
- **DeepSeek**
- **ChatGPT**
- **Self-hosted** — Ollama, vLLM, etc.
### Advanced Settings
- **Whisper Model Path** — Path to a local ggml model file
- **VAD Model Path** — Path to a local Silero VAD ONNX model file
- **Batch Size** — Number of segments to translate per batch (10-15)
- **Context Size** — Number of preceding segments to include as context for translation (0-5)
## Development
### Prerequisites
- [Rust](https://www.rust-lang.org/) toolchain
- [Node.js](https://nodejs.org/) (18+)
- [FFmpeg](https://ffmpeg.org/) (must be available on the command line)
- [CMake](https://cmake.org/) (required for compiling whisper-rs)
### Local Development
```bash
# Clone the repository
git clone https://github.com/AndySkaura/crosssubtitle-ai.git
cd crosssubtitle-ai
# Install frontend dependencies
npm install
# Start development mode
npm run tauri-dev
```
### Build
```bash
# macOS DMG build
npm run tauri-build-dmg
# Windows NSIS build
npm run tauri-build-windows
```
## Tech Stack
| Layer | Technology |
|:---|:---|
| Desktop Framework | [Tauri v2](https://v2.tauri.app/) |
| Frontend | [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/) |
| State Management | [Pinia](https://pinia.vuejs.org/) |
| Styling | [Tailwind CSS](https://tailwindcss.com/) |
| Internationalization | [vue-i18n](https://vue-i18n.intlify.dev/) |
| Speech Recognition | [whisper-rs](https://github.com/tazz4843/whisper-rs) (Whisper) |
| Voice Detection | [ort](https://github.com/pykeio/ort) (Silero VAD ONNX) |
| Audio Processing | FFmpeg |
| LLM Translation | OpenAI-compatible API |
## Project Structure
```
src/ Vue frontend
components/ UI components (TaskQueue, SubtitleEditor)
stores/ Pinia state management
locales/ i18n locale files (zh-CN, en)
lib/ Type definitions
src-tauri/ Rust backend
src/
audio.rs Audio extraction & WAV reading
vad.rs Silero VAD voice activity detection
whisper.rs Whisper speech recognition interface
translate.rs OpenAI-compatible translation interface
subtitle.rs SRT / VTT / ASS export
task.rs Task orchestration & event broadcasting
state.rs Application state
```
## License
This project is licensed under the [MIT](./LICENSE) License.
## Acknowledgements
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) — High-performance Whisper inference implementation
- [Silero VAD](https://github.com/snakers4/silero-vad) — High-accuracy voice activity detection
- [Tauri](https://tauri.app/) — Lightweight desktop application framework
- All contributors and users
---
<div align="center">
Made by <a href="https://kuraa.cc">kuraa</a>
</div>

193
README.md
View File

@ -1,61 +1,178 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="readme/screenshot-main.png">
<img src="readme/screenshot-main.png" alt="CrossSubtitle-AI 截图" width="100%">
</picture>
<div align="center">
# CrossSubtitle-AI # CrossSubtitle-AI
基于 `Tauri v2 + Vue 3 + Pinia + Tailwind CSS` 的本地优先字幕工作台,覆盖以下 MVP 链路: **AI 驱动的本地优先字幕工作台**
- 导入音视频文件并创建任务队列 [![GitHub Release](https://img.shields.io/github/v/release/AndySkaura/crosssubtitle-ai?style=flat-square)](https://github.com/AndySkaura/crosssubtitle-ai/releases)
- 使用 `ffmpeg` 抽取 16kHz 单声道 WAV [![GitHub License](https://img.shields.io/github/license/AndySkaura/crosssubtitle-ai?style=flat-square)](https://github.com/AndySkaura/crosssubtitle-ai/blob/main/LICENSE)
- 执行基础 VAD 切分并生成语音片段时间轴 [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows-blue?style=flat-square)](#)
- 进入 Whisper 转录/翻译环节
- 可选接入 OpenAI-compatible 接口生成中文译文
- 实时推送任务进度和字幕片段
- 导出 `SRT / VTT / ASS`
## 目录结构 [English](./README.en.md) · **简体中文**
- `src/`: Vue 前端界面、Pinia 状态、字幕编辑器 </div>
- `src-tauri/src/audio.rs`: 音频抽取与 WAV 读取
- `src-tauri/src/vad.rs`: VAD API 与基础能量检测实现
- `src-tauri/src/whisper.rs`: Whisper 接口层
- `src-tauri/src/translate.rs`: OpenAI-compatible 滑动窗口翻译
- `src-tauri/src/subtitle.rs`: SRT / VTT / ASS 导出
- `src-tauri/src/task.rs`: 任务编排与事件广播
## 当前实现说明 ---
- 当前仓库已补齐完整工程骨架与核心数据流。 ## 简介
- 前端 `npm run build` 已通过Rust 侧 `cargo check` 已通过。
- `whisper.rs` 已接入真实 `whisper-rs`,会基于 VAD 片段逐段转录;目标语言为英文时启用 Whisper 原生 `translate`
- `vad.rs` 已接入 `ort` 版 Silero VAD 推理入口;当模型缺失或推理失败时,会自动回退到能量检测,保证链路不断。
## 运行前准备 CrossSubtitle-AI 是一款**本地优先**的音视频字幕处理工具。它利用 [Whisper](https://github.com/ggerganov/whisper.cpp) 进行语音识别,结合 [Silero VAD](https://github.com/snakers4/silero-vad) 进行语音活动检测,并支持接入 OpenAI 兼容接口进行智能翻译,帮助你将音视频文件快速转录并翻译为双语字幕。
1. 安装 Rust 工具链。 整个过程在本地完成语音识别,无需上传音视频文件到任何服务器,保护你的数据隐私。
2. 安装 `cmake``whisper-rs-sys` 在首次编译时需要它。
3. 安装 `ffmpeg`,并确保可通过命令行直接调用。 ## 功能特性
4. 安装前端依赖:
- **语音识别** — 基于 Whisper 的高精度语音转文字,支持中文、英文、日文、韩文、法文等 17 种源语言
- **语音活动检测** — Silero VAD 精准切分语音片段,自动过滤静音区域
- **智能翻译** — 接入 OpenAI 兼容接口(如智谱 GLM、DeepSeek、ChatGPT 等),将原文翻译为目标语言
- **音频抽取** — 内置 FFmpeg 自动抽取音频并转换为 16kHz 单声道 WAV
- **多种导出格式** — 支持 SRT、VTT、ASS 三种字幕格式导出
- **双语导出** — 支持原文 + 译文并排显示的双语字幕导出
- **字幕编辑器** — 内置字幕编辑器,支持逐条修改原文和译文
- **拖拽导入** — 支持拖拽文件快速创建任务
- **任务队列** — 批量处理多个音视频文件,实时查看处理进度
- **双语界面** — 内置中文 / 英文界面切换
- **本地优先** — 语音识别完全在本地运行,无需上传数据
## 使用流程
1. **选择模式** — 选择「原文」仅做语音识别,或「翻译」模式在识别后自动翻译
2. **添加任务** — 点击「添加任务」按钮或直接拖拽音视频文件到窗口
3. **等待处理** — 任务将依次经历:音频抽取 → VAD 切分 → 语音识别 →(可选)翻译
4. **编辑校对** — 在字幕编辑器中逐条查看、修改识别结果和译文
5. **导出字幕** — 导出为 SRT、VTT 或 ASS 格式
## 截图
| 示例 | 字幕编辑器 |
|:---:|:---:|
| ![示例](readme/screenshot-main.png) | ![字幕编辑器](readme/screenshot-editor.png) |
## 安装
从 [GitHub Releases](https://github.com/AndySkaura/crosssubtitle-ai/releases) 下载对应平台的安装包:
| 平台 | 安装包 |
|:---:|:---:|
| macOS (Apple Silicon) | `.dmg` |
| Windows | `.exe` (NSIS 安装包) |
> 首次启动时需要下载 Whisper 模型(约 500MB请确保网络通畅。
## 使用方式
### 快速开始
1. 打开应用,在顶部工具栏选择工作模式:
- **原文** — 仅进行语音识别,输出原文字幕
- **翻译** — 识别后调用 LLM 接口翻译为指定语言
2. 点击「添加任务」或拖拽文件到窗口
3. 等待任务处理完成
4. 在右侧字幕编辑器中查看和修改结果
5. 点击「导出」选择格式保存字幕文件
### 翻译模式配置
使用翻译功能前需要配置 LLM API
- 在「高级设置」中填入 LLM API Base、API Key 和 Model
- 支持任何兼容 OpenAI API 的服务,如:
- **智谱 GLM** — 推荐免费使用 GLM-4.7-Flash
- **DeepSeek**
- **ChatGPT**
- **自建服务** — 如 Ollama、vLLM 等
### 高级设置
- **Whisper 模型路径** — 指定本地 ggml 模型文件路径
- **VAD 模型路径** — 指定本地 Silero VAD ONNX 模型路径
- **批大小 (Batch Size)** — 每批翻译的片段数 (10-15)
- **上下文 (Context Size)** — 翻译时参考的上下文片段数 (0-5)
## 开发
### 环境要求
- [Rust](https://www.rust-lang.org/) 工具链
- [Node.js](https://nodejs.org/) (18+)
- [FFmpeg](https://ffmpeg.org/)(需在命令行中可用)
- [CMake](https://cmake.org/)(编译 whisper-rs 需要)
### 本地开发
```bash ```bash
# 克隆仓库
git clone https://github.com/AndySkaura/crosssubtitle-ai.git
cd crosssubtitle-ai
# 安装前端依赖
npm install npm install
# 启动开发模式
npm run tauri-dev
``` ```
5. 如需中文翻译,配置环境变量: ### 构建
```bash ```bash
export OPENAI_API_BASE=https://your-openai-compatible-endpoint/v1 # macOS DMG 构建
export OPENAI_API_KEY=your_api_key npm run tauri-build-dmg
export OPENAI_MODEL=GLM-4-Flash-250414
# Windows NSIS 构建
npm run tauri-build-windows
``` ```
6. 若要真正启用 ONNX Runtime 推理,请确保本机存在可被 `ort` 动态加载的 ONNX Runtime 库,或按你的部署方式提供运行库。 ## 技术栈
7. 启动桌面应用: | 层级 | 技术 |
|:---|:---|
| 桌面框架 | [Tauri v2](https://v2.tauri.app/) |
| 前端框架 | [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/) |
| 状态管理 | [Pinia](https://pinia.vuejs.org/) |
| 样式 | [Tailwind CSS](https://tailwindcss.com/) |
| 国际化 | [vue-i18n](https://vue-i18n.intlify.dev/) |
| 语音识别 | [whisper-rs](https://github.com/tazz4843/whisper-rs) (Whisper) |
| 语音检测 | [ort](https://github.com/pykeio/ort) (Silero VAD ONNX) |
| 音频处理 | FFmpeg |
| LLM 翻译 | OpenAI-compatible API |
```bash ## 项目结构
npm run dev
```
src/ Vue 前端界面
components/ 组件 (TaskQueue, SubtitleEditor)
stores/ Pinia 状态管理
locales/ 国际化文件 (zh-CN, en)
lib/ 类型定义
src-tauri/ Rust 后端
src/
audio.rs 音频抽取与 WAV 读取
vad.rs Silero VAD 语音活动检测
whisper.rs Whisper 语音识别接口
translate.rs OpenAI 兼容翻译接口
subtitle.rs SRT / VTT / ASS 导出
task.rs 任务编排与事件广播
state.rs 应用状态
``` ```
## 下一步建议 ## 开源协
- 为 `src-tauri/src/vad.rs` 补模型输入名自适应和更多异常日志。 本项目基于 [MIT](./LICENSE) 协议开源。
- 加入文件选择器、任务恢复、批量导出与测试用例。
- 为 `whisper-rs` 增加硬件加速参数与模型配置面板。 ## 致谢
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) — 高性能 Whisper 推理实现
- [Silero VAD](https://github.com/snakers4/silero-vad) — 高精度语音活动检测
- [Tauri](https://tauri.app/) — 轻量级桌面应用框架
- 所有贡献者和用户
---
<div align="center">
<a href="https://kuraa.cc">kuraa</a> 制作
</div>

View File

@ -10,9 +10,11 @@
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare-ffmpeg-macos": "sh ./scripts/prepare-bundled-ffmpeg.sh", "prepare-ffmpeg-macos": "sh ./scripts/prepare-bundled-ffmpeg.sh",
"prepare-ffmpeg-windows": "powershell -ExecutionPolicy Bypass -File ./scripts/prepare-ffmpeg-windows.ps1",
"prepare-licenses-macos": "python3 ./scripts/prepare-bundled-licenses.py", "prepare-licenses-macos": "python3 ./scripts/prepare-bundled-licenses.py",
"tauri-build-app": "npm run prepare-ffmpeg-macos && npm run prepare-licenses-macos && tauri build --bundles app", "tauri-build-app": "npm run prepare-ffmpeg-macos && npm run prepare-licenses-macos && tauri build --bundles app",
"tauri-build-dmg": "sh ./scripts/build-macos-dmg.sh" "tauri-build-dmg": "sh ./scripts/build-macos-dmg.sh",
"tauri-build-windows": "tauri build --bundles nsis"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

BIN
readme/screenshot-main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,83 @@
param(
[string]$FFmpegSource = ""
)
$ErrorActionPreference = "Stop"
$ROOT_DIR = Split-Path -Parent (Split-Path -Parent $PSCommandPath)
$VENDOR_DIR = Join-Path $ROOT_DIR "src-tauri\vendor\ffmpeg\windows-x86_64"
$BIN_DIR = Join-Path $VENDOR_DIR "bin"
$FFMPEG_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z"
# Ensure target directory exists
if (-not (Test-Path $BIN_DIR)) {
New-Item -ItemType Directory -Path $BIN_DIR -Force | Out-Null
}
# Strategy 1: if -FFmpegSource explicitly provided, use it
if ($FFmpegSource) {
if (-not (Test-Path $FFmpegSource)) {
Write-Host "ffmpeg not found at: $FFmpegSource" -ForegroundColor Red
exit 1
}
Write-Host "Copying ffmpeg from: $FFmpegSource"
Copy-Item -Path $FFmpegSource -Destination (Join-Path $BIN_DIR "ffmpeg.exe") -Force
Write-Host "ffmpeg bundled at: $BIN_DIR\ffmpeg.exe" -ForegroundColor Green
exit 0
}
# Strategy 2: download Gyan Essentials build via 7-Zip
$sevenZipPaths = @(
"${env:ProgramFiles}\7-Zip\7z.exe",
"${env:ProgramFiles(x86)}\7-Zip\7z.exe",
"${env:LOCALAPPDATA}\Programs\7-Zip\7z.exe"
)
$sevenZip = $sevenZipPaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($sevenZip) {
$tempDir = Join-Path $env:TEMP "ffmpeg-essentials"
$archivePath = Join-Path $tempDir "ffmpeg.7z"
try {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
Write-Host "Downloading ffmpeg essentials from: $FFMPEG_URL" -ForegroundColor Cyan
Invoke-WebRequest -Uri $FFMPEG_URL -OutFile $archivePath -UseBasicParsing
Write-Host "Extracting..." -ForegroundColor Cyan
& $sevenZip x $archivePath -o"$tempDir\out" -y -bso0 | Out-Null
$extractedExe = Get-ChildItem -Path "$tempDir\out" -Recurse -Filter "ffmpeg.exe" | Select-Object -First 1 -ExpandProperty FullName
if (-not $extractedExe) {
throw "ffmpeg.exe not found in extracted archive"
}
Copy-Item -Path $extractedExe -Destination (Join-Path $BIN_DIR "ffmpeg.exe") -Force
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
$size = (Get-Item (Join-Path $BIN_DIR "ffmpeg.exe")).Length / 1MB
Write-Host "ffmpeg essentials bundled at: $BIN_DIR\ffmpeg.exe ($([math]::Round($size)) MB)" -ForegroundColor Green
exit 0
}
catch {
Write-Host "Download/extraction failed: $_" -ForegroundColor Yellow
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
else {
Write-Host "7-Zip not found, recommend installing it for automatic download." -ForegroundColor Yellow
Write-Host "Download manually from: $FFMPEG_URL and extract ffmpeg.exe to $BIN_DIR" -ForegroundColor Yellow
}
# Strategy 3: fall back to copying system ffmpeg
$systemFFmpeg = (Get-Command ffmpeg -ErrorAction SilentlyContinue).Source
if ($systemFFmpeg) {
Write-Host "Falling back to system ffmpeg: $systemFFmpeg" -ForegroundColor Cyan
Copy-Item -Path $systemFFmpeg -Destination (Join-Path $BIN_DIR "ffmpeg.exe") -Force
$size = (Get-Item (Join-Path $BIN_DIR "ffmpeg.exe")).Length / 1MB
Write-Host "ffmpeg bundled at: $BIN_DIR\ffmpeg.exe ($([math]::Round($size)) MB)" -ForegroundColor Green
exit 0
}
Write-Host "ERROR: No ffmpeg found. Install ffmpeg or specify path via -FFmpegSource." -ForegroundColor Red
exit 1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -1,5 +1,6 @@
use std::{ use std::{
fs, fs,
io::BufRead,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
}; };
@ -9,20 +10,29 @@ use anyhow::{anyhow, Context, Result};
pub struct AudioPipeline; pub struct AudioPipeline;
impl AudioPipeline { impl AudioPipeline {
pub fn extract_to_wav(ffmpeg_path: &Path, input_path: &str, workspace: &Path) -> Result<PathBuf> { pub fn extract_to_wav<F: Fn(f32)>(
ffmpeg_path: &Path,
input_path: &str,
workspace: &Path,
on_progress: F,
) -> Result<PathBuf> {
fs::create_dir_all(workspace) fs::create_dir_all(workspace)
.with_context(|| format!("failed to create workspace: {}", workspace.display()))?; .with_context(|| format!("failed to create workspace: {}", workspace.display()))?;
let output_path = workspace.join("normalized.wav"); let output_path = workspace.join("normalized.wav");
let mut command = Command::new(ffmpeg_path); let mut command = Command::new(ffmpeg_path);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let Some(lib_dir) = ffmpeg_path.parent().and_then(|bin_dir| bin_dir.parent()).map(|root| root.join("lib")) { if let Some(lib_dir) = ffmpeg_path
.parent()
.and_then(|bin_dir| bin_dir.parent())
.map(|root| root.join("lib"))
{
if lib_dir.exists() { if lib_dir.exists() {
command.env("DYLD_FALLBACK_LIBRARY_PATH", &lib_dir); command.env("DYLD_FALLBACK_LIBRARY_PATH", &lib_dir);
} }
} }
let output = command let mut child = command
.arg("-y") .arg("-y")
.arg("-i") .arg("-i")
.arg(input_path) .arg(input_path)
@ -33,27 +43,51 @@ impl AudioPipeline {
.arg("-f") .arg("-f")
.arg("wav") .arg("wav")
.arg(&output_path) .arg(&output_path)
.output() .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.with_context(|| format!("failed to launch ffmpeg: {}", ffmpeg_path.display()))?; .with_context(|| format!("failed to launch ffmpeg: {}", ffmpeg_path.display()))?;
if !output.status.success() { let stderr = child.stderr.take().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let reader = std::io::BufReader::new(stderr);
if stderr.is_empty() {
return Err(anyhow!("ffmpeg exited with status: {}", output.status)); let mut total_duration_secs: Option<f64> = None;
let mut last_progress = 0.0f32;
for line in reader.lines() {
let line = line?;
if total_duration_secs.is_none() {
if let Some(dur) = parse_ffmpeg_duration(&line) {
total_duration_secs = Some(dur);
} }
return Err(anyhow!(
"ffmpeg exited with status: {} | stderr: {}",
output.status,
stderr
));
} }
if let Some(current_time) = parse_ffmpeg_time(&line) {
if let Some(total) = total_duration_secs {
let ratio = (current_time / total).clamp(0.0, 1.0) as f32;
if (ratio - last_progress).abs() >= 0.01 {
last_progress = ratio;
on_progress(ratio);
}
}
}
}
let status = child
.wait()
.with_context(|| "ffmpeg process failed to wait")?;
if !status.success() {
return Err(anyhow!("ffmpeg exited with status: {}", status));
}
on_progress(1.0);
Ok(output_path) Ok(output_path)
} }
pub fn load_wav_f32(path: &Path) -> Result<Vec<f32>> { pub fn load_wav_f32(path: &Path) -> Result<Vec<f32>> {
let mut reader = let mut reader = hound::WavReader::open(path)
hound::WavReader::open(path).with_context(|| format!("failed to open {}", path.display()))?; .with_context(|| format!("failed to open {}", path.display()))?;
let spec = reader.spec(); let spec = reader.spec();
if spec.channels != 1 { if spec.channels != 1 {
@ -76,3 +110,37 @@ impl AudioPipeline {
Ok(samples) Ok(samples)
} }
} }
fn parse_ffmpeg_duration(line: &str) -> Option<f64> {
let pos = line.find("Duration: ")?;
let rest = &line[pos + 10..];
let end = rest.find(|c: char| c == ',' || c == ' ')?;
let time_str = &rest[..end];
let parts: Vec<&str> = time_str.split(':').collect();
if parts.len() == 3 {
let h: f64 = parts[0].parse().ok()?;
let m: f64 = parts[1].parse().ok()?;
let s: f64 = parts[2].parse().ok()?;
Some(h * 3600.0 + m * 60.0 + s)
} else {
None
}
}
fn parse_ffmpeg_time(line: &str) -> Option<f64> {
let pos = line.find("time=")?;
let rest = &line[pos + 5..];
let end = rest
.find(|c: char| !c.is_digit(10) && c != ':' && c != '.')
.unwrap_or(rest.len());
let time_str = &rest[..end];
let parts: Vec<&str> = time_str.split(':').collect();
if parts.len() == 3 {
let h: f64 = parts[0].parse().ok()?;
let m: f64 = parts[1].parse().ok()?;
let s: f64 = parts[2].parse().ok()?;
Some(h * 3600.0 + m * 60.0 + s)
} else {
None
}
}

View File

@ -7,18 +7,24 @@ mod translate;
mod vad; mod vad;
mod whisper; mod whisper;
use models::{DefaultModelPaths, StartTaskPayload, SubtitleSegment, SubtitleTask}; use models::{
use state::AppState; DefaultModelPaths, StartTaskPayload, SubtitleSegment, SubtitleTask, TranslationConfig,
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
AppHandle, Emitter, Manager, PhysicalSize, Size,
}; };
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use objc2_app_kit::NSWindow; use objc2_app_kit::NSWindow;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use objc2_foundation::NSSize; use objc2_foundation::NSSize;
use state::AppState;
#[cfg(target_os = "macos")]
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
Emitter,
};
use tauri::{AppHandle, Manager, PhysicalSize, Size};
#[cfg(target_os = "macos")]
const WINDOW_RATIO_WIDTH: f64 = 16.0; const WINDOW_RATIO_WIDTH: f64 = 16.0;
#[cfg(target_os = "macos")]
const WINDOW_RATIO_HEIGHT: f64 = 10.0; const WINDOW_RATIO_HEIGHT: f64 = 10.0;
const DEFAULT_WINDOW_WIDTH: u32 = 1440; const DEFAULT_WINDOW_WIDTH: u32 = 1440;
const DEFAULT_WINDOW_HEIGHT: u32 = 900; const DEFAULT_WINDOW_HEIGHT: u32 = 900;
@ -50,20 +56,44 @@ fn update_segment_text(
task::update_segment_text(state, segment).map_err(error_to_string) task::update_segment_text(state, segment).map_err(error_to_string)
} }
#[tauri::command]
fn delete_task(
state: tauri::State<'_, AppState>,
task_id: String,
) -> std::result::Result<(), String> {
task::delete_task(state, task_id).map_err(error_to_string)
}
#[tauri::command] #[tauri::command]
fn export_subtitles( fn export_subtitles(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
task_id: String, task_id: String,
format: String, format: String,
output_path: String,
) -> std::result::Result<String, String> { ) -> std::result::Result<String, String> {
task::export_task(state, task_id, format).map_err(error_to_string) task::export_task(state, task_id, format, output_path).map_err(error_to_string)
} }
#[tauri::command] #[tauri::command]
fn get_default_model_paths(app: tauri::AppHandle) -> std::result::Result<DefaultModelPaths, String> { fn get_default_model_paths(
app: tauri::AppHandle,
) -> std::result::Result<DefaultModelPaths, String> {
task::get_default_model_paths(&app).map_err(error_to_string) task::get_default_model_paths(&app).map_err(error_to_string)
} }
#[tauri::command]
async fn retry_translation(
app: tauri::AppHandle,
window: tauri::Window,
state: tauri::State<'_, AppState>,
task_id: String,
translation_config: TranslationConfig,
) -> std::result::Result<SubtitleTask, String> {
task::retry_translation(app, window, state, task_id, translation_config)
.await
.map_err(error_to_string)
}
fn error_to_string(error: anyhow::Error) -> String { fn error_to_string(error: anyhow::Error) -> String {
format!("{error:#}") format!("{error:#}")
} }
@ -83,8 +113,10 @@ pub fn run() {
start_subtitle_task, start_subtitle_task,
list_tasks, list_tasks,
update_segment_text, update_segment_text,
delete_task,
export_subtitles, export_subtitles,
get_default_model_paths get_default_model_paths,
retry_translation
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
@ -123,7 +155,11 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
.build()?; .build()?;
let file_menu = SubmenuBuilder::new(app, "文件") let file_menu = SubmenuBuilder::new(app, "文件")
.item(&MenuItemBuilder::with_id("pick_files", "选择媒体文件").accelerator("CmdOrCtrl+O").build(app)?) .item(
&MenuItemBuilder::with_id("pick_files", "选择媒体文件")
.accelerator("CmdOrCtrl+O")
.build(app)?,
)
.separator() .separator()
.item(&MenuItemBuilder::with_id("export_srt", "导出 SRT").build(app)?) .item(&MenuItemBuilder::with_id("export_srt", "导出 SRT").build(app)?)
.item(&MenuItemBuilder::with_id("export_vtt", "导出 VTT").build(app)?) .item(&MenuItemBuilder::with_id("export_vtt", "导出 VTT").build(app)?)
@ -190,11 +226,6 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
Ok(()) Ok(())
} }
#[cfg(not(target_os = "macos"))]
fn configure_macos_menu(_app: &AppHandle) -> tauri::Result<()> {
Ok(())
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn apply_macos_aspect_ratio(window: &tauri::WebviewWindow) -> tauri::Result<()> { fn apply_macos_aspect_ratio(window: &tauri::WebviewWindow) -> tauri::Result<()> {
let ns_window = window.ns_window()?; let ns_window = window.ns_window()?;

View File

@ -37,6 +37,26 @@ pub struct SubtitleSegment {
pub translated_text: Option<String>, pub translated_text: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubStageProgress {
pub extracting: f32,
pub vad: f32,
pub transcribing: f32,
pub translating: f32,
}
impl Default for SubStageProgress {
fn default() -> Self {
Self {
extracting: 0.0,
vad: 0.0,
transcribing: 0.0,
translating: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SubtitleTask { pub struct SubtitleTask {
@ -51,6 +71,7 @@ pub struct SubtitleTask {
pub progress: f32, pub progress: f32,
pub segments: Vec<SubtitleSegment>, pub segments: Vec<SubtitleSegment>,
pub error: Option<String>, pub error: Option<String>,
pub sub_stage_progress: SubStageProgress,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -73,6 +94,7 @@ pub struct ProgressEvent {
pub status: TaskStatus, pub status: TaskStatus,
pub progress: f32, pub progress: f32,
pub message: String, pub message: String,
pub sub_stage_progress: SubStageProgress,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]

View File

@ -11,13 +11,19 @@ pub struct AppState {
impl AppState { impl AppState {
pub fn upsert_task(&self, task: SubtitleTask) -> Result<()> { pub fn upsert_task(&self, task: SubtitleTask) -> Result<()> {
let mut guard = self.tasks.lock().map_err(|_| anyhow!("task store poisoned"))?; let mut guard = self
.tasks
.lock()
.map_err(|_| anyhow!("task store poisoned"))?;
guard.insert(task.id.clone(), task); guard.insert(task.id.clone(), task);
Ok(()) Ok(())
} }
pub fn get_task(&self, task_id: &str) -> Result<SubtitleTask> { pub fn get_task(&self, task_id: &str) -> Result<SubtitleTask> {
let guard = self.tasks.lock().map_err(|_| anyhow!("task store poisoned"))?; let guard = self
.tasks
.lock()
.map_err(|_| anyhow!("task store poisoned"))?;
guard guard
.get(task_id) .get(task_id)
.cloned() .cloned()
@ -25,14 +31,29 @@ impl AppState {
} }
pub fn list_tasks(&self) -> Result<Vec<SubtitleTask>> { pub fn list_tasks(&self) -> Result<Vec<SubtitleTask>> {
let guard = self.tasks.lock().map_err(|_| anyhow!("task store poisoned"))?; let guard = self
.tasks
.lock()
.map_err(|_| anyhow!("task store poisoned"))?;
let mut tasks = guard.values().cloned().collect::<Vec<_>>(); let mut tasks = guard.values().cloned().collect::<Vec<_>>();
tasks.sort_by(|left, right| right.id.cmp(&left.id)); tasks.sort_by(|left, right| right.id.cmp(&left.id));
Ok(tasks) Ok(tasks)
} }
pub fn delete_task(&self, task_id: &str) -> Result<()> {
let mut guard = self
.tasks
.lock()
.map_err(|_| anyhow!("task store poisoned"))?;
guard.remove(task_id);
Ok(())
}
pub fn update_segment(&self, segment: SubtitleSegment) -> Result<SubtitleTask> { pub fn update_segment(&self, segment: SubtitleSegment) -> Result<SubtitleTask> {
let mut guard = self.tasks.lock().map_err(|_| anyhow!("task store poisoned"))?; let mut guard = self
.tasks
.lock()
.map_err(|_| anyhow!("task store poisoned"))?;
let task = guard let task = guard
.get_mut(&segment.task_id) .get_mut(&segment.task_id)
.ok_or_else(|| anyhow!("task not found: {}", segment.task_id))?; .ok_or_else(|| anyhow!("task not found: {}", segment.task_id))?;

View File

@ -9,16 +9,6 @@ pub enum SubtitleFormat {
Ass, Ass,
} }
impl SubtitleFormat {
pub fn extension(&self) -> &'static str {
match self {
Self::Srt => "srt",
Self::Vtt => "vtt",
Self::Ass => "ass",
}
}
}
impl TryFrom<&str> for SubtitleFormat { impl TryFrom<&str> for SubtitleFormat {
type Error = anyhow::Error; type Error = anyhow::Error;

View File

@ -1,9 +1,14 @@
use std::{ use std::{
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{
atomic::{AtomicU32, Ordering},
Arc,
},
time::Duration,
}; };
use anyhow::{Context, Result}; use anyhow::Result;
use tauri::{Emitter, Manager, Window}; use tauri::{Emitter, Manager, Window};
use uuid::Uuid; use uuid::Uuid;
@ -11,7 +16,8 @@ use crate::{
audio::AudioPipeline, audio::AudioPipeline,
models::{ models::{
DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent, DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent,
StartTaskPayload, SubtitleSegment, SubtitleTask, TaskStatus, TranslationConfig, StartTaskPayload, SubStageProgress, SubtitleSegment, SubtitleTask, TargetLanguage,
TaskStatus, TranslationConfig,
}, },
state::AppState, state::AppState,
subtitle::{render, SubtitleFormat}, subtitle::{render, SubtitleFormat},
@ -40,7 +46,11 @@ pub async fn start_task(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
mut payload: StartTaskPayload, mut payload: StartTaskPayload,
) -> Result<SubtitleTask> { ) -> Result<SubtitleTask> {
if payload.whisper_model_path.as_deref().is_none_or(str::is_empty) { if payload
.whisper_model_path
.as_deref()
.is_none_or(str::is_empty)
{
payload.whisper_model_path = resolve_default_model_path(&app, DEFAULT_WHISPER_MODEL); payload.whisper_model_path = resolve_default_model_path(&app, DEFAULT_WHISPER_MODEL);
} }
if payload.vad_model_path.as_deref().is_none_or(str::is_empty) { if payload.vad_model_path.as_deref().is_none_or(str::is_empty) {
@ -67,6 +77,7 @@ pub async fn start_task(
progress: 0.0, progress: 0.0,
segments: Vec::new(), segments: Vec::new(),
error: None, error: None,
sub_stage_progress: SubStageProgress::default(),
}; };
state.upsert_task(task.clone())?; state.upsert_task(task.clone())?;
@ -79,11 +90,21 @@ pub async fn start_task(
let task_id = task.id.clone(); let task_id = task.id.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
if let Err(error) = run_pipeline(app_handle, window_handle.clone(), task_for_spawn, payload_for_spawn).await { if let Err(error) = run_pipeline(
if let Ok(mut failed_task) = app_handle_for_error.state::<AppState>().get_task(&task_id) { app_handle,
window_handle.clone(),
task_for_spawn,
payload_for_spawn,
)
.await
{
if let Ok(mut failed_task) = app_handle_for_error.state::<AppState>().get_task(&task_id)
{
failed_task.status = TaskStatus::Failed; failed_task.status = TaskStatus::Failed;
failed_task.error = Some(error.to_string()); failed_task.error = Some(error.to_string());
let _ = app_handle_for_error.state::<AppState>().upsert_task(failed_task); let _ = app_handle_for_error
.state::<AppState>()
.upsert_task(failed_task);
} }
let _ = emit_error(&window_handle, &task_id, &error.to_string()); let _ = emit_error(&window_handle, &task_id, &error.to_string());
} }
@ -165,26 +186,157 @@ async fn run_pipeline(
} }
} }
let ffmpeg_path = resolve_ffmpeg_path(&app) let ffmpeg_path = resolve_ffmpeg_path(&app).ok_or_else(|| {
.ok_or_else(|| anyhow::anyhow!("未找到可用 ffmpeg请重新执行打包命令或在系统中安装 ffmpeg"))?; anyhow::anyhow!("未找到可用 ffmpeg请重新执行打包命令或在系统中安装 ffmpeg")
})?;
set_status(&window, &app_state, &mut task, TaskStatus::Extracting, 5.0, "正在抽取音频")?; set_status(
emit_log(&window, &task.id, format!("task: input file={}", payload.file_path))?; &window,
emit_log(&window, &task.id, format!("audio: ffmpeg={}", ffmpeg_path.display()))?; &app_state,
let wav_path = AudioPipeline::extract_to_wav(&ffmpeg_path, &payload.file_path, &workspace)?; &mut task,
emit_log(&window, &task.id, format!("audio: normalized wav={}", wav_path.display()))?; TaskStatus::Extracting,
5.0,
"正在抽取音频",
)?;
emit_log(
&window,
&task.id,
format!("task: input file={}", payload.file_path),
)?;
emit_log(
&window,
&task.id,
format!("audio: ffmpeg={}", ffmpeg_path.display()),
)?;
set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 15.0, "正在分析语音片段")?; let window_for_extract = window.clone();
let task_id_for_extract = task.id.clone();
let wav_path = AudioPipeline::extract_to_wav(
&ffmpeg_path,
&payload.file_path,
&workspace,
move |ratio: f32| {
let overall = 5.0 + ratio.clamp(0.0, 1.0) * 10.0;
let sub = SubStageProgress {
extracting: ratio.clamp(0.0, 1.0) * 100.0,
vad: 0.0,
transcribing: 0.0,
translating: 0.0,
};
let _ = window_for_extract.emit(
"task:progress",
ProgressEvent {
task_id: task_id_for_extract.clone(),
status: TaskStatus::Extracting,
progress: overall,
message: "正在抽取音频".to_string(),
sub_stage_progress: sub,
},
);
},
)?;
emit_log(
&window,
&task.id,
format!("audio: normalized wav={}", wav_path.display()),
)?;
set_status(
&window,
&app_state,
&mut task,
TaskStatus::VadProcessing,
15.0,
"正在分析语音片段",
)?;
let samples = AudioPipeline::load_wav_f32(&wav_path)?; let samples = AudioPipeline::load_wav_f32(&wav_path)?;
let vad = VadEngine::new(payload.vad_model_path.clone(), VadConfig::default())?; let vad = VadEngine::new(payload.vad_model_path.clone(), VadConfig::default())?;
let speech_ranges = vad.detect_segments(&samples).await;
let window_for_vad = window.clone();
let task_id_for_vad = task.id.clone();
let speech_ranges = vad
.detect_segments(&samples, move |ratio: f32| {
let overall = 15.0 + ratio.clamp(0.0, 1.0) * 15.0;
let sub = SubStageProgress {
extracting: 100.0,
vad: ratio.clamp(0.0, 1.0) * 100.0,
transcribing: 0.0,
translating: 0.0,
};
let _ = window_for_vad.emit(
"task:progress",
ProgressEvent {
task_id: task_id_for_vad.clone(),
status: TaskStatus::VadProcessing,
progress: overall,
message: "正在分析语音片段".to_string(),
sub_stage_progress: sub,
},
);
})
.await;
emit_log( emit_log(
&window, &window,
&task.id, &task.id,
format!("vad: detected {} speech ranges", speech_ranges.len()), format!("vad: detected {} speech ranges", speech_ranges.len()),
)?; )?;
set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 30.0, "正在执行 Whisper")?; set_status(
&window,
&app_state,
&mut task,
TaskStatus::Transcribing,
30.0,
"正在执行 Whisper",
)?;
// Shared progress state between concurrent transcribing and translating
let transcribing_pct: Arc<AtomicU32> = Arc::new(AtomicU32::new(0));
let translating_pct: Arc<AtomicU32> = Arc::new(AtomicU32::new(0));
// Setup concurrent translation: as whisper emits segments, send them to
// the translation worker so it can start translating immediately in batches
let app_handle = app.clone();
let (segment_tx, translate_join_handle) = if should_translate {
let config = payload
.translation_config
.clone()
.or_else(load_translation_config)
.ok_or_else(|| {
anyhow::anyhow!(
"翻译模式需要填写 LLM API 配置,或设置 OPENAI_API_BASE / OPENAI_API_KEY"
)
})?;
let translator = Translator::new(config)?;
let (tx, rx) = tokio::sync::mpsc::channel::<SubtitleSegment>(1024);
let window_for_worker = window.clone();
let task_id_for_worker = task.id.clone();
let target_lang_for_worker = task.target_lang.clone();
let app_handle_for_worker = app_handle.clone();
let tp_for_worker = transcribing_pct.clone();
let tap_for_worker = translating_pct.clone();
let handle = tauri::async_runtime::spawn(async move {
let state = app_handle_for_worker.state::<AppState>();
if let Err(error) = incremental_translate(
translator,
rx,
&window_for_worker,
&state,
&task_id_for_worker,
&target_lang_for_worker,
&tp_for_worker,
&tap_for_worker,
)
.await
{
eprintln!("incremental translation error: {error:#}");
}
});
(Some(tx), Some(handle))
} else {
(None, None)
};
let whisper = WhisperEngine::new(payload.whisper_model_path.clone()); let whisper = WhisperEngine::new(payload.whisper_model_path.clone());
let task_id_for_progress = task.id.clone(); let task_id_for_progress = task.id.clone();
let task_id_for_segment = task.id.clone(); let task_id_for_segment = task.id.clone();
@ -192,7 +344,10 @@ async fn run_pipeline(
let task_id_for_log = task.id.clone(); let task_id_for_log = task.id.clone();
let app_state_for_segment = app_state.clone(); let app_state_for_segment = app_state.clone();
let app_state_for_reset = app_state.clone(); let app_state_for_reset = app_state.clone();
let mut segments = whisper.infer_segments( let seg_tx_for_callback = segment_tx.clone();
let tp_for_callback = transcribing_pct.clone();
let tap_for_callback = translating_pct.clone();
let _segments = whisper.infer_segments(
&wav_path, &wav_path,
&task.id, &task.id,
task.source_lang.as_deref(), task.source_lang.as_deref(),
@ -200,7 +355,19 @@ async fn run_pipeline(
should_translate, should_translate,
&speech_ranges, &speech_ranges,
|ratio| { |ratio| {
let progress = 30.0 + ratio.clamp(0.0, 1.0) * 40.0; let ratio = ratio.clamp(0.0, 1.0);
tp_for_callback.store((ratio * 100.0) as u32, Ordering::Release);
let translating = tap_for_callback.load(Ordering::Acquire) as f32 / 100.0;
// translation progress must not exceed transcription progress
let translating_capped = translating.min(ratio);
let progress = 30.0 + ratio * 40.0 + translating_capped * 25.0;
let sub = SubStageProgress {
extracting: 100.0,
vad: 100.0,
transcribing: ratio * 100.0,
translating: translating_capped * 100.0,
};
window.emit( window.emit(
"task:progress", "task:progress",
ProgressEvent { ProgressEvent {
@ -208,6 +375,7 @@ async fn run_pipeline(
status: TaskStatus::Transcribing, status: TaskStatus::Transcribing,
progress, progress,
message: "正在执行 Whisper".to_string(), message: "正在执行 Whisper".to_string(),
sub_stage_progress: sub,
}, },
)?; )?;
Ok(()) Ok(())
@ -226,6 +394,15 @@ async fn run_pipeline(
Ok(()) Ok(())
}, },
|segment| { |segment| {
if let Some(ref tx) = seg_tx_for_callback {
let tx = tx.clone();
let seg = segment.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = tx.send(seg).await {
eprintln!("translation: send error: {e}");
}
});
}
if let Ok(mut current_task) = app_state_for_segment.get_task(&task_id_for_segment) { if let Ok(mut current_task) = app_state_for_segment.get_task(&task_id_for_segment) {
upsert_segment(&mut current_task.segments, segment.clone()); upsert_segment(&mut current_task.segments, segment.clone());
let _ = app_state_for_segment.upsert_task(current_task); let _ = app_state_for_segment.upsert_task(current_task);
@ -242,69 +419,18 @@ async fn run_pipeline(
|message| emit_log(&window, &task_id_for_log, message), |message| emit_log(&window, &task_id_for_log, message),
)?; )?;
task.segments = segments.clone(); // Signal that transcribing is complete, then close channel to flush translation worker
app_state.upsert_task(task.clone())?; transcribing_pct.store(100, Ordering::Release);
drop(segment_tx);
drop(seg_tx_for_callback);
if let Some(handle) = translate_join_handle {
handle.await.unwrap_or_else(|join_error| {
eprintln!("translation worker panicked: {join_error:?}");
});
}
if should_translate { // Reload task from state (all segments and translations applied by callbacks)
let config = payload task = app_state.get_task(&task.id)?;
.translation_config
.clone()
.or_else(load_translation_config)
.ok_or_else(|| anyhow::anyhow!("翻译模式需要填写 LLM API 配置,或设置 OPENAI_API_BASE / OPENAI_API_KEY"))?;
set_status(&window, &app_state, &mut task, TaskStatus::Translating, 70.0, "正在生成译文")?;
let translator = Translator::new(config)?;
let task_id_for_translate = task.id.clone();
let app_state_for_translate = app_state.clone();
let window_for_translate = window.clone();
let task_id_for_translate_progress = task.id.clone();
let window_for_translate_progress = window.clone();
segments = translator
.translate_segments_with_progress(
&segments,
&task.target_lang,
|message| {
let _ = emit_log(&window_for_translate, &task_id_for_translate, message);
},
|ratio| {
let progress = 70.0 + ratio.clamp(0.0, 1.0) * 25.0;
let _ = window_for_translate_progress.emit(
"task:progress",
ProgressEvent {
task_id: task_id_for_translate_progress.clone(),
status: TaskStatus::Translating,
progress,
message: "正在生成译文".to_string(),
},
);
},
|segment| {
if let Ok(mut current_task) = app_state_for_translate.get_task(&task_id_for_translate) {
upsert_segment(&mut current_task.segments, segment.clone());
let _ = app_state_for_translate.upsert_task(current_task);
}
let _ = window_for_translate.emit(
"task:segment",
crate::models::SegmentEvent {
task_id: task_id_for_translate.clone(),
segment,
},
);
},
)
.await?;
task.segments = segments.clone();
app_state.upsert_task(task.clone())?;
for segment in segments {
window.emit(
"task:segment",
crate::models::SegmentEvent {
task_id: task.id.clone(),
segment,
},
)?;
}
}
task.status = TaskStatus::Completed; task.status = TaskStatus::Completed;
task.progress = 100.0; task.progress = 100.0;
@ -313,6 +439,223 @@ async fn run_pipeline(
Ok(()) Ok(())
} }
async fn incremental_translate(
translator: Translator,
mut rx: tokio::sync::mpsc::Receiver<SubtitleSegment>,
window: &Window,
app_state: &AppState,
task_id: &str,
target_lang: &TargetLanguage,
transcribing_pct: &AtomicU32,
translating_pct: &AtomicU32,
) -> Result<()> {
let batch_size = translator.batch_size().clamp(3, 60);
let context_size = translator.context_size().min(5);
let mut all_segments: Vec<SubtitleSegment> = Vec::new();
let mut buffer: Vec<SubtitleSegment> = Vec::new();
let mut translated_count: usize = 0;
let idle_flush_after = Duration::from_secs(3);
let emit_translate_progress =
|window: &Window, task_id: &str, done: usize, total: usize| -> Result<()> {
let ratio = if total > 0 {
(done as f32 / total as f32).clamp(0.0, 1.0)
} else {
0.0
};
translating_pct.store((ratio * 100.0) as u32, Ordering::Release);
let transcribing = transcribing_pct.load(Ordering::Acquire) as f32 / 100.0;
// translation progress must not exceed transcription progress
let translating_capped = ratio.min(transcribing);
let overall = 30.0 + transcribing * 40.0 + translating_capped * 25.0;
let sub = SubStageProgress {
extracting: 100.0,
vad: 100.0,
transcribing: (transcribing * 100.0).min(100.0),
translating: translating_capped * 100.0,
};
let status = if transcribing >= 1.0 {
TaskStatus::Translating
} else {
TaskStatus::Transcribing
};
window.emit(
"task:progress",
ProgressEvent {
task_id: task_id.to_string(),
status,
progress: overall.min(95.0),
message: "正在生成译文".to_string(),
sub_stage_progress: sub,
},
)?;
Ok(())
};
loop {
match tokio::time::timeout(idle_flush_after, rx.recv()).await {
Ok(Some(segment)) => {
all_segments.push(segment.clone());
buffer.push(segment);
if buffer.len() >= batch_size {
translate_buffered_segments(
&translator,
window,
app_state,
task_id,
target_lang,
&all_segments,
&mut buffer,
&mut translated_count,
context_size,
"batch",
)
.await?;
emit_translate_progress(window, task_id, translated_count, all_segments.len())?;
}
}
Ok(None) => break,
Err(_) => {
if buffer.is_empty() {
continue;
}
translate_buffered_segments(
&translator,
window,
app_state,
task_id,
target_lang,
&all_segments,
&mut buffer,
&mut translated_count,
context_size,
"idle batch",
)
.await?;
emit_translate_progress(window, task_id, translated_count, all_segments.len())?;
}
}
}
// Flush remaining segments below batch_size
if !buffer.is_empty() {
translate_buffered_segments(
&translator,
window,
app_state,
task_id,
target_lang,
&all_segments,
&mut buffer,
&mut translated_count,
context_size,
"final batch",
)
.await?;
emit_translate_progress(window, task_id, translated_count, all_segments.len())?;
}
// Translation complete
translating_pct.store(100, Ordering::Release);
let sub = SubStageProgress {
extracting: 100.0,
vad: 100.0,
transcribing: 100.0,
translating: 100.0,
};
window.emit(
"task:progress",
ProgressEvent {
task_id: task_id.to_string(),
status: TaskStatus::Translating,
progress: 95.0,
message: "译文生成完毕".to_string(),
sub_stage_progress: sub,
},
)?;
Ok(())
}
async fn translate_buffered_segments(
translator: &Translator,
window: &Window,
app_state: &AppState,
task_id: &str,
target_lang: &TargetLanguage,
all_segments: &[SubtitleSegment],
buffer: &mut Vec<SubtitleSegment>,
translated_count: &mut usize,
context_size: usize,
label: &str,
) -> Result<()> {
if buffer.is_empty() {
return Ok(());
}
let batch = std::mem::take(buffer);
let context_end = all_segments.len().saturating_sub(batch.len());
let context_start = context_end.saturating_sub(context_size);
let context = &all_segments[context_start..context_end];
emit_log(
window,
task_id,
format!(
"translation: {} segments={}",
label,
batch
.iter()
.map(|segment| segment.id.as_str())
.collect::<Vec<_>>()
.join(", ")
),
)?;
let rows = translator
.translate_batch_with_retries(context, &batch, target_lang_name(target_lang))
.await?;
*translated_count += rows.len();
emit_log(
window,
task_id,
format!("translation: {} done, translated={}", label, rows.len()),
)?;
for row in rows {
if let Some(original) = batch.iter().find(|item| item.id == row.id) {
let mut emitted = original.clone();
emitted.translated_text = Some(row.text);
if let Ok(mut current_task) = app_state.get_task(task_id) {
upsert_segment(&mut current_task.segments, emitted.clone());
let _ = app_state.upsert_task(current_task);
}
let _ = window.emit(
"task:segment",
crate::models::SegmentEvent {
task_id: task_id.to_string(),
segment: emitted,
},
);
}
}
Ok(())
}
fn target_lang_name(target_lang: &TargetLanguage) -> &'static str {
match target_lang {
TargetLanguage::Zh => "简体中文",
TargetLanguage::En => "英文",
}
}
fn load_translation_config() -> Option<TranslationConfig> { fn load_translation_config() -> Option<TranslationConfig> {
let api_base = std::env::var("OPENAI_API_BASE").ok()?; let api_base = std::env::var("OPENAI_API_BASE").ok()?;
let api_key = std::env::var("OPENAI_API_KEY").ok()?; let api_key = std::env::var("OPENAI_API_KEY").ok()?;
@ -336,6 +679,34 @@ fn set_status(
) -> Result<()> { ) -> Result<()> {
task.status = status.clone(); task.status = status.clone();
task.progress = progress; task.progress = progress;
// Mark completed sub-stages as 100% based on current stage.
// Only mark stages that have fully finished before this one.
match &status {
TaskStatus::Extracting => {
// extracting just started, no previous stage to mark
}
TaskStatus::VadProcessing => {
task.sub_stage_progress.extracting = 100.0;
}
TaskStatus::Transcribing => {
task.sub_stage_progress.extracting = 100.0;
task.sub_stage_progress.vad = 100.0;
}
TaskStatus::Translating => {
task.sub_stage_progress.extracting = 100.0;
task.sub_stage_progress.vad = 100.0;
task.sub_stage_progress.transcribing = 100.0;
}
TaskStatus::Completed => {
task.sub_stage_progress.extracting = 100.0;
task.sub_stage_progress.vad = 100.0;
task.sub_stage_progress.transcribing = 100.0;
task.sub_stage_progress.translating = 100.0;
}
TaskStatus::Queued | TaskStatus::Failed => {}
}
state.upsert_task(task.clone())?; state.upsert_task(task.clone())?;
window.emit( window.emit(
"task:progress", "task:progress",
@ -344,12 +715,16 @@ fn set_status(
status, status,
progress, progress,
message: message.to_string(), message: message.to_string(),
sub_stage_progress: task.sub_stage_progress.clone(),
}, },
)?; )?;
Ok(()) Ok(())
} }
pub fn update_segment_text(state: tauri::State<'_, AppState>, segment: SubtitleSegment) -> Result<SubtitleTask> { pub fn update_segment_text(
state: tauri::State<'_, AppState>,
segment: SubtitleSegment,
) -> Result<SubtitleTask> {
state.update_segment(segment) state.update_segment(segment)
} }
@ -357,24 +732,24 @@ pub fn list_tasks(state: tauri::State<'_, AppState>) -> Result<Vec<SubtitleTask>
state.list_tasks() state.list_tasks()
} }
pub fn export_task(state: tauri::State<'_, AppState>, task_id: String, format: String) -> Result<String> { pub fn delete_task(state: tauri::State<'_, AppState>, task_id: String) -> Result<()> {
state.delete_task(&task_id)
}
pub fn export_task(
state: tauri::State<'_, AppState>,
task_id: String,
format: String,
output_path: String,
) -> Result<String> {
let task = state.get_task(&task_id)?; let task = state.get_task(&task_id)?;
let format = SubtitleFormat::try_from(format.as_str())?; let format = SubtitleFormat::try_from(format.as_str())?;
let content = render(&task.segments, format, task.bilingual_output); let content = render(&task.segments, format, task.bilingual_output);
let source_path = PathBuf::from(&task.file_path); let output_path = PathBuf::from(output_path);
let stem = source_path if let Some(output_dir) = output_path.parent() {
.file_stem() fs::create_dir_all(output_dir)?;
.and_then(|item| item.to_str()) }
.unwrap_or("subtitle");
let output_dir = source_path
.parent()
.map(PathBuf::from)
.unwrap_or(std::env::current_dir().context("failed to get current directory")?);
fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(format!("{stem}.{}", format.extension()));
fs::write(&output_path, content)?; fs::write(&output_path, content)?;
Ok(output_path.display().to_string()) Ok(output_path.display().to_string())
@ -402,6 +777,125 @@ fn emit_log(window: &Window, task_id: &str, message: String) -> Result<()> {
Ok(()) Ok(())
} }
pub async fn retry_translation(
app: tauri::AppHandle,
window: Window,
state: tauri::State<'_, AppState>,
task_id: String,
translation_config: TranslationConfig,
) -> Result<SubtitleTask> {
let task = state.get_task(&task_id)?;
if task.segments.is_empty() {
return Err(anyhow::anyhow!("任务没有可翻译的字幕片段,请重新添加任务"));
}
let mut initial_task = task.clone();
set_status(
&window,
&state,
&mut initial_task,
TaskStatus::Translating,
5.0,
"正在生成译文",
)?;
let app_handle = app.clone();
let window_handle = window.clone();
let task_id_for_spawn = task.id.clone();
let segments = task.segments.clone();
let target_lang = task.target_lang.clone();
tauri::async_runtime::spawn(async move {
let result = async {
let state = app_handle.state::<AppState>();
let translator = Translator::new(translation_config)?;
let task_id_for_progress = task_id_for_spawn.clone();
let window_for_progress = window_handle.clone();
let task_id_for_segment = task_id_for_spawn.clone();
let window_for_segment = window_handle.clone();
let app_handle_for_closures = app_handle.clone();
let translated_segments = translator
.translate_segments_with_progress(
&segments,
&target_lang,
|message| {
let _ = emit_log(&window_for_segment, &task_id_for_segment, message);
},
|ratio| {
let progress = 5.0 + ratio.clamp(0.0, 1.0) * 90.0;
let sub = SubStageProgress {
extracting: 100.0,
vad: 100.0,
transcribing: 100.0,
translating: ratio.clamp(0.0, 1.0) * 100.0,
};
let _ = window_for_progress.emit(
"task:progress",
ProgressEvent {
task_id: task_id_for_progress.clone(),
status: TaskStatus::Translating,
progress,
message: "正在生成译文".to_string(),
sub_stage_progress: sub,
},
);
},
|segment| {
let state = app_handle_for_closures.state::<AppState>();
if let Ok(mut current_task) = state.get_task(&task_id_for_segment) {
upsert_segment(&mut current_task.segments, segment.clone());
let _ = state.upsert_task(current_task);
}
let _ = window_for_segment.emit(
"task:segment",
crate::models::SegmentEvent {
task_id: task_id_for_segment.clone(),
segment,
},
);
},
)
.await?;
let mut current_task = state.get_task(&task_id_for_spawn)?;
current_task.segments = translated_segments.clone();
current_task.status = TaskStatus::Completed;
current_task.progress = 100.0;
state.upsert_task(current_task.clone())?;
for segment in translated_segments {
window_handle.emit(
"task:segment",
crate::models::SegmentEvent {
task_id: task_id_for_spawn.clone(),
segment,
},
)?;
}
window_handle.emit("task:done", current_task)?;
Ok::<_, anyhow::Error>(())
}
.await;
if let Err(error) = result {
let state = app_handle.state::<AppState>();
if let Ok(mut failed_task) = state.get_task(&task_id_for_spawn) {
failed_task.status = TaskStatus::Failed;
failed_task.error = Some(error.to_string());
let _ = state.upsert_task(failed_task);
}
let _ = emit_error(&window_handle, &task_id_for_spawn, &error.to_string());
}
});
Ok(task)
}
fn upsert_segment(segments: &mut Vec<SubtitleSegment>, segment: SubtitleSegment) { fn upsert_segment(segments: &mut Vec<SubtitleSegment>, segment: SubtitleSegment) {
if let Some(existing) = segments.iter_mut().find(|item| item.id == segment.id) { if let Some(existing) = segments.iter_mut().find(|item| item.id == segment.id) {
*existing = segment; *existing = segment;
@ -413,7 +907,11 @@ fn upsert_segment(segments: &mut Vec<SubtitleSegment>, segment: SubtitleSegment)
left.start left.start
.partial_cmp(&right.start) .partial_cmp(&right.start)
.unwrap_or(std::cmp::Ordering::Equal) .unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| left.end.partial_cmp(&right.end).unwrap_or(std::cmp::Ordering::Equal)) .then_with(|| {
left.end
.partial_cmp(&right.end)
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| left.id.cmp(&right.id)) .then_with(|| left.id.cmp(&right.id))
}); });
} }

View File

@ -50,9 +50,9 @@ struct TranslationResponse {
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
struct TranslatedRow { pub(crate) struct TranslatedRow {
id: String, pub(crate) id: String,
text: String, pub(crate) text: String,
} }
pub struct Translator { pub struct Translator {
@ -70,6 +70,14 @@ impl Translator {
Ok(Self { client, config }) Ok(Self { client, config })
} }
pub fn batch_size(&self) -> usize {
self.config.batch_size
}
pub fn context_size(&self) -> usize {
self.config.context_size
}
pub async fn translate_segments_with_progress<LF, PF, SF>( pub async fn translate_segments_with_progress<LF, PF, SF>(
&self, &self,
segments: &[SubtitleSegment], segments: &[SubtitleSegment],
@ -83,7 +91,7 @@ impl Translator {
PF: FnMut(f32), PF: FnMut(f32),
SF: FnMut(SubtitleSegment), SF: FnMut(SubtitleSegment),
{ {
let batch_size = self.config.batch_size.clamp(10, 15); let batch_size = self.config.batch_size.clamp(3, 350);
let context_size = self.config.context_size.min(5); let context_size = self.config.context_size.min(5);
let mut translated = segments.to_vec(); let mut translated = segments.to_vec();
let target_language_name = match target_language { let target_language_name = match target_language {
@ -112,7 +120,10 @@ impl Translator {
let rows = self let rows = self
.translate_batch_with_retries(context, batch, target_language_name) .translate_batch_with_retries(context, batch, target_language_name)
.await?; .await?;
log(format!("translation: batch done, translated={}", rows.len())); log(format!(
"translation: batch done, translated={}",
rows.len()
));
for row in rows { for row in rows {
if let Some(segment) = translated.iter_mut().find(|item| item.id == row.id) { if let Some(segment) = translated.iter_mut().find(|item| item.id == row.id) {
@ -125,7 +136,7 @@ impl Translator {
Ok(translated) Ok(translated)
} }
async fn translate_batch_with_retries( pub(crate) async fn translate_batch_with_retries(
&self, &self,
context: &[SubtitleSegment], context: &[SubtitleSegment],
batch: &[SubtitleSegment], batch: &[SubtitleSegment],
@ -249,7 +260,9 @@ impl Translator {
match response { match response {
Ok(response) => { Ok(response) => {
let response = response.error_for_status().context("translation http error")?; let response = response
.error_for_status()
.context("translation http error")?;
let raw_text = response.text().await.context("invalid response body")?; let raw_text = response.text().await.context("invalid response body")?;
eprintln!("translation raw response:\n{}", raw_text); eprintln!("translation raw response:\n{}", raw_text);
let payload: ChatCompletionResponse = let payload: ChatCompletionResponse =
@ -261,8 +274,9 @@ impl Translator {
.message .message
.content .content
.clone(); .clone();
let rows = parse_translation_response(&content) let rows = parse_translation_response(&content).with_context(|| {
.with_context(|| format!("translation json parse failed: {}", preview(&content)))?; format!("translation json parse failed: {}", preview(&content))
})?;
return Ok(rows); return Ok(rows);
} }
Err(error) => { Err(error) => {
@ -354,10 +368,7 @@ fn strip_code_fence(content: &str) -> String {
.trim_start_matches("```json") .trim_start_matches("```json")
.trim_start_matches("```JSON") .trim_start_matches("```JSON")
.trim_start_matches("```"); .trim_start_matches("```");
without_prefix without_prefix.trim_end_matches("```").trim().to_string()
.trim_end_matches("```")
.trim()
.to_string()
} }
fn extract_json_object(content: &str) -> Option<String> { fn extract_json_object(content: &str) -> Option<String> {
@ -395,7 +406,11 @@ fn mask_secret(secret: &str) -> String {
return "****".to_string(); return "****".to_string();
} }
format!("{}****{}", &secret[..4], &secret[secret.len().saturating_sub(4)..]) format!(
"{}****{}",
&secret[..4],
&secret[secret.len().saturating_sub(4)..]
)
} }
fn extract_rows_loose(content: &str) -> Vec<TranslatedRow> { fn extract_rows_loose(content: &str) -> Vec<TranslatedRow> {

View File

@ -47,12 +47,17 @@ impl VadEngine {
Ok(Self { model_path, config }) Ok(Self { model_path, config })
} }
pub async fn detect_segments(&self, samples: &[f32]) -> Vec<(f32, f32)> { pub async fn detect_segments<F: Fn(f32) + Clone + Send + 'static>(
&self,
samples: &[f32],
on_progress: F,
) -> Vec<(f32, f32)> {
if self.model_path.is_some() { if self.model_path.is_some() {
let samples_owned = samples.to_vec(); let samples_owned = samples.to_vec();
let model_path = self.model_path.clone().unwrap(); let model_path = self.model_path.clone().unwrap();
let config = self.config.clone(); let config = self.config.clone();
let timeout_secs = self.config.timeout_seconds; let timeout_secs = self.config.timeout_seconds;
let on_progress_onnx = on_progress.clone();
match tokio::time::timeout( match tokio::time::timeout(
Duration::from_secs(timeout_secs), Duration::from_secs(timeout_secs),
@ -64,7 +69,8 @@ impl VadEngine {
return None; return None;
} }
}; };
Self::detect_with_onnx(&mut session, &samples_owned, &config).ok() Self::detect_with_onnx(&mut session, &samples_owned, &config, on_progress_onnx)
.ok()
}), }),
) )
.await .await
@ -86,7 +92,7 @@ impl VadEngine {
} }
} }
let ranges = self.detect_segments_with_energy(samples); let ranges = self.detect_segments_with_energy(samples, &on_progress);
eprintln!("vad: energy detection found {} speech ranges", ranges.len()); eprintln!("vad: energy detection found {} speech ranges", ranges.len());
ranges ranges
} }
@ -98,17 +104,20 @@ impl VadEngine {
.with_context(|| format!("failed to load silero vad model: {}", model_path.display())) .with_context(|| format!("failed to load silero vad model: {}", model_path.display()))
} }
fn detect_with_onnx( fn detect_with_onnx<F: Fn(f32)>(
session: &mut Session, session: &mut Session,
samples: &[f32], samples: &[f32],
config: &VadConfig, config: &VadConfig,
on_progress: F,
) -> Result<Vec<(f32, f32)>> { ) -> Result<Vec<(f32, f32)>> {
let chunk_size = 512usize; let chunk_size = 512usize;
let total_chunks = (samples.len() + chunk_size - 1) / chunk_size;
let mut state = Array3::<f32>::zeros((2, 1, 128)); let mut state = Array3::<f32>::zeros((2, 1, 128));
let sr = Array1::<i64>::from_vec(vec![config.sample_rate as i64]); let sr = Array1::<i64>::from_vec(vec![config.sample_rate as i64]);
let mut speech_probabilities = Vec::new(); let mut speech_probabilities = Vec::new();
let mut last_progress = 0.0f32;
for chunk in samples.chunks(chunk_size) { for (chunk_idx, chunk) in samples.chunks(chunk_size).enumerate() {
let mut padded = vec![0.0_f32; chunk_size]; let mut padded = vec![0.0_f32; chunk_size];
padded[..chunk.len()].copy_from_slice(chunk); padded[..chunk.len()].copy_from_slice(chunk);
let input = Array2::from_shape_vec((1, chunk_size), padded) let input = Array2::from_shape_vec((1, chunk_size), padded)
@ -139,33 +148,68 @@ impl VadEngine {
.context("failed to rebuild vad state")?; .context("failed to rebuild vad state")?;
} }
} }
let ratio = (chunk_idx + 1) as f32 / total_chunks as f32;
if (ratio - last_progress).abs() >= 0.02 {
last_progress = ratio;
on_progress(ratio);
}
} }
Ok(Self::merge_probabilities(&speech_probabilities, chunk_size, config)) on_progress(1.0);
Ok(Self::merge_probabilities(
&speech_probabilities,
chunk_size,
config,
))
} }
fn detect_segments_with_energy(&self, samples: &[f32]) -> Vec<(f32, f32)> { fn detect_segments_with_energy<F: Fn(f32)>(
&self,
samples: &[f32],
on_progress: &F,
) -> Vec<(f32, f32)> {
let frame_size = (self.config.sample_rate / 50).max(1); let frame_size = (self.config.sample_rate / 50).max(1);
let total_frames = (samples.len() + frame_size - 1) / frame_size;
let mut energies = Vec::new(); let mut energies = Vec::new();
for chunk in samples.chunks(frame_size) { let mut last_progress = 0.0f32;
for (frame_idx, chunk) in samples.chunks(frame_size).enumerate() {
let energy = chunk.iter().map(|sample| sample.abs()).sum::<f32>() / chunk.len() as f32; let energy = chunk.iter().map(|sample| sample.abs()).sum::<f32>() / chunk.len() as f32;
energies.push(energy); energies.push(energy);
let ratio = (frame_idx + 1) as f32 / total_frames as f32;
if (ratio - last_progress).abs() >= 0.02 {
last_progress = ratio;
on_progress(ratio);
}
} }
if energies.is_empty() { if energies.is_empty() {
return Vec::new(); return Vec::new();
} }
on_progress(1.0);
let dynamic_threshold = self.dynamic_energy_threshold(&energies); let dynamic_threshold = self.dynamic_energy_threshold(&energies);
eprintln!( eprintln!(
"vad: using energy fallback, frames={}, threshold={:.5}", "vad: using energy fallback, frames={}, threshold={:.5}",
energies.len(), energies.len(),
dynamic_threshold dynamic_threshold
); );
Self::merge_probabilities_with_threshold(&energies, frame_size, dynamic_threshold, &self.config) Self::merge_probabilities_with_threshold(
&energies,
frame_size,
dynamic_threshold,
&self.config,
)
} }
fn merge_probabilities(frames: &[f32], frame_size: usize, config: &VadConfig) -> Vec<(f32, f32)> { fn merge_probabilities(
frames: &[f32],
frame_size: usize,
config: &VadConfig,
) -> Vec<(f32, f32)> {
Self::merge_probabilities_with_threshold(frames, frame_size, config.threshold, config) Self::merge_probabilities_with_threshold(frames, frame_size, config.threshold, config)
} }
@ -198,7 +242,8 @@ impl VadEngine {
let end_frame = index.saturating_sub(silent_frames); let end_frame = index.saturating_sub(silent_frames);
if end_frame.saturating_sub(start) >= min_speech_frames { if end_frame.saturating_sub(start) >= min_speech_frames {
let start_sec = (start * frame_size) as f32 / config.sample_rate as f32; let start_sec = (start * frame_size) as f32 / config.sample_rate as f32;
let end_sec = ((end_frame + 1) * frame_size) as f32 / config.sample_rate as f32; let end_sec =
((end_frame + 1) * frame_size) as f32 / config.sample_rate as f32;
result.push(((start_sec - pad_seconds).max(0.0), end_sec + pad_seconds)); result.push(((start_sec - pad_seconds).max(0.0), end_sec + pad_seconds));
} }
start_frame = None; start_frame = None;

View File

@ -48,12 +48,12 @@ impl WhisperEngine {
let audio = load_audio_f32(wav_path)?; let audio = load_audio_f32(wav_path)?;
let total_seconds = audio.len() as f32 / 16_000.0; let total_seconds = audio.len() as f32 / 16_000.0;
let normalized_ranges = normalize_speech_ranges(speech_ranges, audio.len()); let normalized_ranges = normalize_speech_ranges(speech_ranges, audio.len());
let context = WhisperContext::new_with_params( let context =
model_path, WhisperContext::new_with_params(model_path, WhisperContextParameters::default())
WhisperContextParameters::default(),
)
.with_context(|| format!("failed to load whisper model: {model_path}"))?; .with_context(|| format!("failed to load whisper model: {model_path}"))?;
let mut state = context.create_state().context("failed to create whisper state")?; let mut state = context
.create_state()
.context("failed to create whisper state")?;
let detected_language = resolve_source_language(&mut state, &audio, source_lang) let detected_language = resolve_source_language(&mut state, &audio, source_lang)
.context("failed to resolve source language")?; .context("failed to resolve source language")?;
@ -109,6 +109,41 @@ impl WhisperEngine {
|| (total_seconds > 60.0 && vad_text_len < (total_seconds / 3.0) as usize)); || (total_seconds > 60.0 && vad_text_len < (total_seconds / 3.0) as usize));
if should_retry_full_audio { if should_retry_full_audio {
// 如果 VAD 覆盖率足够高且仅尾部缺失,只转录尾部而非全音频
// 避免数小时的长音频被重新处理一遍
let is_tail_only = !segments.is_empty()
&& vad_coverage >= 0.60
&& vad_end + 5.0 < total_seconds
&& vad_text_len >= (total_seconds / 3.0) as usize;
if is_tail_only {
let tail_start = vad_end.max(0.0);
on_log(format!(
"whisper: VAD tail incomplete, retrying tail ({:.2}s-{:.2}s, existing_segments={})",
tail_start,
total_seconds,
segments.len()
))?;
let tail_segments = transcribe_clip(
&mut state,
&audio,
normalized_ranges.len(),
tail_start,
total_seconds,
task_id,
detected_language,
target_lang,
should_translate,
segments.len(),
&mut on_segment,
&mut on_log,
)?;
segments.extend(tail_segments);
on_log(format!(
"whisper: total segments after tail retry={}",
segments.len()
))?;
} else {
on_log(format!( on_log(format!(
"whisper: VAD result looks incomplete, retrying full audio (segments={}, chars={}, end={:.2}s/{:.2}s, coverage={:.1}%)", "whisper: VAD result looks incomplete, retrying full audio (segments={}, chars={}, end={:.2}s/{:.2}s, coverage={:.1}%)",
segments.len(), segments.len(),
@ -150,8 +185,12 @@ impl WhisperEngine {
segments.iter().cloned().try_for_each(&mut on_segment)?; segments.iter().cloned().try_for_each(&mut on_segment)?;
} }
} }
}
on_log(format!("whisper: total emitted segments={}", segments.len()))?; on_log(format!(
"whisper: total emitted segments={}",
segments.len()
))?;
Ok(segments) Ok(segments)
} }
} }
@ -190,7 +229,9 @@ fn transcribe_clip(
} }
} }
state.full(params, clip).context("whisper inference failed")?; state
.full(params, clip)
.context("whisper inference failed")?;
let num_segments = state.full_n_segments(); let num_segments = state.full_n_segments();
on_log(format!( on_log(format!(
@ -267,7 +308,10 @@ fn load_audio_f32(path: &Path) -> Result<Vec<f32>> {
.with_context(|| format!("failed to open wav file: {}", path.display()))?; .with_context(|| format!("failed to open wav file: {}", path.display()))?;
let spec = reader.spec(); let spec = reader.spec();
if spec.sample_rate != 16_000 { if spec.sample_rate != 16_000 {
return Err(anyhow!("whisper expects 16k audio, got {}", spec.sample_rate)); return Err(anyhow!(
"whisper expects 16k audio, got {}",
spec.sample_rate
));
} }
if spec.channels != 1 { if spec.channels != 1 {
return Err(anyhow!("whisper expects mono audio, got {}", spec.channels)); return Err(anyhow!("whisper expects mono audio, got {}", spec.channels));
@ -275,7 +319,11 @@ fn load_audio_f32(path: &Path) -> Result<Vec<f32>> {
let samples = reader let samples = reader
.into_samples::<i16>() .into_samples::<i16>()
.map(|sample| sample.map(|value| value as f32 / i16::MAX as f32).map_err(anyhow::Error::from)) .map(|sample| {
sample
.map(|value| value as f32 / i16::MAX as f32)
.map_err(anyhow::Error::from)
})
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
Ok(samples) Ok(samples)
@ -336,7 +384,9 @@ fn should_prefer_full_audio(
full_text_len > vad_text_len + vad_text_len * 3 / 5 full_text_len > vad_text_len + vad_text_len * 3 / 5
|| full_audio_segments.len() > vad_segments.len() + 5 || full_audio_segments.len() > vad_segments.len() + 5
|| full_end > vad_end + 5.0 || full_end > vad_end + 5.0
|| (total_seconds > 60.0 && full_end + 1.5 >= total_seconds && vad_end + 5.0 < total_seconds) || (total_seconds > 60.0
&& full_end + 1.5 >= total_seconds
&& vad_end + 5.0 < total_seconds)
} }
fn resolve_source_language<'a>( fn resolve_source_language<'a>(

View File

@ -2,8 +2,9 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog' import { open, save } from '@tauri-apps/plugin-dialog'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import TaskQueue from './components/TaskQueue.vue' import TaskQueue from './components/TaskQueue.vue'
import SubtitleEditor from './components/SubtitleEditor.vue' import SubtitleEditor from './components/SubtitleEditor.vue'
import { useTaskStore } from './stores/tasks' import { useTaskStore } from './stores/tasks'
@ -52,6 +53,7 @@ const MEDIA_EXTENSIONS = [
'mpeg', 'mpeg',
'mpg', 'mpg',
] as const ] as const
const FREE_API_KEY_URL = 'https://docs.bigmodel.cn/cn/guide/models/free/glm-4.7-flash'
const taskStore = useTaskStore() const taskStore = useTaskStore()
const targetLang = ref<TargetLanguage>('zh') const targetLang = ref<TargetLanguage>('zh')
@ -64,14 +66,21 @@ const defaultModelPaths = ref<DefaultModelPaths | null>(null)
const translationConfig = ref<TranslationConfig>({ const translationConfig = ref<TranslationConfig>({
apiBase: localStorage.getItem('llm.apiBase') ?? 'https://open.bigmodel.cn/api/paas/v4', apiBase: localStorage.getItem('llm.apiBase') ?? 'https://open.bigmodel.cn/api/paas/v4',
apiKey: localStorage.getItem('llm.apiKey') ?? '', apiKey: localStorage.getItem('llm.apiKey') ?? '',
model: localStorage.getItem('llm.model') ?? 'GLM-4-Flash-250414', model: localStorage.getItem('llm.model') ?? 'GLM-4.7-Flash',
batchSize: Number(localStorage.getItem('llm.batchSize') ?? '12'), batchSize: Number(localStorage.getItem('llm.batchSize') ?? '12'),
contextSize: Number(localStorage.getItem('llm.contextSize') ?? '3'), contextSize: Number(localStorage.getItem('llm.contextSize') ?? '5'),
}) })
const pending = ref(false) const pending = ref(false)
const feedback = ref('') const feedback = ref('')
const feedbackTone = ref<'normal' | 'error'>('normal')
const showAdvanced = ref(false) const showAdvanced = ref(false)
const isDragging = ref(false)
const showApiKeyDialog = ref(false)
const apiKeyUrlCopied = ref(false)
const authorUrlCopied = ref(false)
let unlistenMenuAction: UnlistenFn | null = null let unlistenMenuAction: UnlistenFn | null = null
let dragDropUnlistenFn: UnlistenFn | null = null
let authorCopyTimer: ReturnType<typeof setTimeout> | null = null
const selectedTask = computed(() => taskStore.selectedTask) const selectedTask = computed(() => taskStore.selectedTask)
const hasTranslationKey = computed(() => translationConfig.value.apiKey.trim().length > 0) const hasTranslationKey = computed(() => translationConfig.value.apiKey.trim().length > 0)
@ -80,6 +89,7 @@ onMounted(() => {
taskStore.initialize() taskStore.initialize()
void loadDefaultModelPaths() void loadDefaultModelPaths()
void bindMenuActions() void bindMenuActions()
void initDragDrop()
}) })
onUnmounted(() => { onUnmounted(() => {
@ -87,10 +97,33 @@ onUnmounted(() => {
unlistenMenuAction() unlistenMenuAction()
unlistenMenuAction = null unlistenMenuAction = null
} }
if (dragDropUnlistenFn) {
dragDropUnlistenFn()
dragDropUnlistenFn = null
}
if (authorCopyTimer) {
clearTimeout(authorCopyTimer)
authorCopyTimer = null
}
}) })
watch(locale, (newLocale) => { watch(locale, (newLocale) => {
localStorage.setItem('locale', newLocale) localStorage.setItem('locale', newLocale)
if (outputMode.value === 'translate' && !hasTranslationKey.value) {
showMissingApiKeyFeedback()
}
})
watch([outputMode, hasTranslationKey], ([mode, hasKey]) => {
if (mode === 'translate' && !hasKey) {
showMissingApiKeyFeedback()
return
}
if (feedbackTone.value === 'error' && feedback.value === t('app.feedback.noApiKey')) {
feedbackTone.value = 'normal'
feedback.value = ''
}
}) })
function persistTranslationConfig() { function persistTranslationConfig() {
@ -104,9 +137,51 @@ function persistTranslationConfig() {
function resetModelPaths() { function resetModelPaths() {
whisperModelPath.value = defaultModelPaths.value?.whisperModelPath ?? '' whisperModelPath.value = defaultModelPaths.value?.whisperModelPath ?? ''
vadModelPath.value = defaultModelPaths.value?.vadModelPath ?? '' vadModelPath.value = defaultModelPaths.value?.vadModelPath ?? ''
feedbackTone.value = 'normal'
feedback.value = t('app.feedback.restoredDefaults') feedback.value = t('app.feedback.restoredDefaults')
} }
function showMissingApiKeyFeedback() {
feedbackTone.value = 'error'
feedback.value = t('app.feedback.noApiKey')
}
function freeApiKeyHint() {
if (locale.value === 'zh-CN') {
return '开始翻译任务前需要先填写 LLM API Key。你可以选择下面任意一种方式。'
}
return 'An LLM API Key is required before starting a translation task. Choose any of the options below.'
}
function openApiKeyDialog() {
showMissingApiKeyFeedback()
apiKeyUrlCopied.value = false
showApiKeyDialog.value = true
}
async function copyFreeApiKeyUrl() {
await navigator.clipboard.writeText(FREE_API_KEY_URL)
apiKeyUrlCopied.value = true
}
async function copyAuthorUrl() {
await navigator.clipboard.writeText('https://kuraa.cc')
authorUrlCopied.value = true
if (authorCopyTimer) {
clearTimeout(authorCopyTimer)
}
authorCopyTimer = setTimeout(() => {
authorUrlCopied.value = false
authorCopyTimer = null
}, 1400)
}
function acknowledgeApiKeyDialog() {
showApiKeyDialog.value = false
outputMode.value = 'source'
}
async function loadDefaultModelPaths() { async function loadDefaultModelPaths() {
try { try {
const paths = await invoke<DefaultModelPaths>('get_default_model_paths') const paths = await invoke<DefaultModelPaths>('get_default_model_paths')
@ -161,30 +236,29 @@ async function bindMenuActions() {
} }
async function submitFiles(filePaths: string[]) { async function submitFiles(filePaths: string[]) {
if (outputMode.value === 'translate' && !hasTranslationKey.value) {
openApiKeyDialog()
return
}
pending.value = true pending.value = true
feedbackTone.value = 'normal'
feedback.value = '' feedback.value = ''
try { try {
const fallbackToSource = outputMode.value === 'translate' && !hasTranslationKey.value
const effectiveOutputMode: OutputMode = fallbackToSource ? 'source' : outputMode.value
for (const filePath of filePaths) { for (const filePath of filePaths) {
await taskStore.startTask({ await taskStore.startTask({
filePath, filePath,
sourceLang: sourceLang.value === 'auto' ? null : sourceLang.value, sourceLang: sourceLang.value === 'auto' ? null : sourceLang.value,
targetLang: targetLang.value, targetLang: targetLang.value,
outputMode: effectiveOutputMode, outputMode: outputMode.value,
bilingualOutput: bilingualOutput.value, bilingualOutput: bilingualOutput.value,
translationConfig: effectiveOutputMode === 'translate' ? translationConfig.value : null, translationConfig: outputMode.value === 'translate' ? translationConfig.value : null,
whisperModelPath: whisperModelPath.value || null, whisperModelPath: whisperModelPath.value || null,
vadModelPath: vadModelPath.value || null, vadModelPath: vadModelPath.value || null,
}) })
} }
if (fallbackToSource) {
feedback.value = t('app.feedback.fallbackSource')
} else {
feedback.value = t('app.feedback.submitted', { count: filePaths.length }) feedback.value = t('app.feedback.submitted', { count: filePaths.length })
}
} catch (error) { } catch (error) {
feedback.value = error instanceof Error ? error.message : t('app.feedback.submitFailed') feedback.value = error instanceof Error ? error.message : t('app.feedback.submitFailed')
} finally { } finally {
@ -194,6 +268,7 @@ async function submitFiles(filePaths: string[]) {
async function handlePickFiles() { async function handlePickFiles() {
try { try {
feedbackTone.value = 'normal'
feedback.value = '' feedback.value = ''
persistTranslationConfig() persistTranslationConfig()
const selected = await open({ const selected = await open({
@ -219,6 +294,43 @@ async function handlePickFiles() {
} }
} }
function getFileExtension(filePath: string): string {
const lastDot = filePath.lastIndexOf('.')
if (lastDot === -1) return ''
return filePath.slice(lastDot + 1).toLowerCase()
}
function isMediaFile(filePath: string): boolean {
const ext = getFileExtension(filePath)
return MEDIA_EXTENSIONS.includes(ext as typeof MEDIA_EXTENSIONS[number])
}
async function initDragDrop() {
try {
dragDropUnlistenFn = await getCurrentWebviewWindow().onDragDropEvent((event) => {
if (event.payload.type === 'enter' || event.payload.type === 'over') {
isDragging.value = true
} else if (event.payload.type === 'leave') {
isDragging.value = false
} else if (event.payload.type === 'drop') {
isDragging.value = false
const mediaFiles = event.payload.paths.filter(isMediaFile)
if (mediaFiles.length === 0) {
feedback.value = t('app.feedback.noMediaFiles')
return
}
if (mediaFiles.length !== event.payload.paths.length) {
const skipped = event.payload.paths.length - mediaFiles.length
feedback.value = t('app.feedback.someFilesSkipped', { count: skipped })
}
void submitFiles(mediaFiles)
}
})
} catch (error) {
console.error('Failed to initialize drag-drop:', error)
}
}
async function handleFiles(event: Event) { async function handleFiles(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? []) const files = Array.from(input.files ?? [])
@ -236,21 +348,114 @@ async function handleFiles(event: Event) {
input.value = '' input.value = ''
} }
function getDefaultExportPath(filePath: string, format: 'srt' | 'vtt' | 'ass') {
const separatorIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const directory = separatorIndex >= 0 ? filePath.slice(0, separatorIndex + 1) : ''
const fileName = separatorIndex >= 0 ? filePath.slice(separatorIndex + 1) : filePath
const stem = fileName.replace(/\.[^./\\]*$/, '') || 'subtitle'
return `${directory}${stem}.${format}`
}
async function handleExport(format: 'srt' | 'vtt' | 'ass') { async function handleExport(format: 'srt' | 'vtt' | 'ass') {
if (!selectedTask.value) return if (!selectedTask.value) return
const output = await taskStore.exportTask(selectedTask.value.id, format) const outputPath = await save({
title: t('app.exportSubtitles'),
defaultPath: getDefaultExportPath(selectedTask.value.filePath, format),
filters: [
{
name: format.toUpperCase(),
extensions: [format],
},
],
})
if (!outputPath) return
const output = await taskStore.exportTask(selectedTask.value.id, format, outputPath)
feedback.value = output feedback.value = output
} }
async function handleRetryTranslate(taskId: string) {
persistTranslationConfig()
if (!translationConfig.value.apiKey.trim()) {
feedbackTone.value = 'error'
feedback.value = t('app.feedback.noApiKey')
return
}
try {
await taskStore.retryTranslation(taskId, translationConfig.value)
feedbackTone.value = 'normal'
feedback.value = t('app.feedback.translationStarted')
} catch (error) {
feedback.value = error instanceof Error ? error.message : t('app.feedback.translationFailed')
}
}
async function handleTranslateFromEditor() {
if (!selectedTask.value) return
await handleRetryTranslate(selectedTask.value.id)
}
</script> </script>
<template> <template>
<main class="app-shell"> <main class="app-shell">
<Transition name="drag">
<div v-if="isDragging" class="drag-overlay">
<div class="drag-overlay-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span>{{ $t('app.feedback.dropHint') }}</span>
</div>
</div>
</Transition>
<Transition name="dialog">
<div v-if="showApiKeyDialog" class="dialog-overlay" @click.self="showApiKeyDialog = false">
<section class="api-key-dialog" role="dialog" aria-modal="true" :aria-label="$t('app.llm.apiKey')">
<div class="dialog-header">
<div>
<strong>{{ $t('app.llm.apiKey') }}</strong>
<p>{{ freeApiKeyHint() }}</p>
<ol class="api-key-options">
<li>
<span class="option-icon self-host"></span>
<span>{{ locale === 'zh-CN' ? '自建大模型服务,填入兼容 OpenAI 的 API 地址和 Key。' : 'Host your own model service with an OpenAI-compatible API URL and key.' }}</span>
</li>
<li>
<span class="option-icon provider"></span>
<span>{{ locale === 'zh-CN' ? '购买成熟服务DeepSeek、ChatGPT、Gemini 都可以。' : 'Use a mature paid service such as DeepSeek, ChatGPT, or Gemini.' }}</span>
</li>
<li>
<span class="option-icon free"></span>
<span>
{{ locale === 'zh-CN' ? '推荐免费的' : 'Recommended free option:' }}
<button class="api-key-link" type="button" @click="copyFreeApiKeyUrl">GLM-4.7-Flash</button>
<span class="copy-hint" :class="{ copied: apiKeyUrlCopied }">
{{ apiKeyUrlCopied ? (locale === 'zh-CN' ? '已复制链接' : 'Link copied') : (locale === 'zh-CN' ? '点击复制申请链接' : 'click to copy link') }}
</span>
</span>
</li>
</ol>
</div>
<button class="dialog-close" type="button" aria-label="Close" @click="showApiKeyDialog = false">×</button>
</div>
<div class="dialog-actions">
<button class="button" type="button" @click="acknowledgeApiKeyDialog">
{{ locale === 'zh-CN' ? '明白了,先转原文' : 'Got it, use source mode' }}
</button>
</div>
</section>
</div>
</Transition>
<section class="topbar panel"> <section class="topbar panel">
<div class="toolbar-main"> <div class="toolbar-main">
<div class="toolbar-title"> <div class="toolbar-title">
<strong>CrossSubtitle</strong> <strong>CrossSubtitle</strong>
<span class="credit-line"> <span class="credit-line">
by <a href="https://kuraa.cc" target="_blank" rel="noreferrer">kuraa</a> by <button class="author-link" type="button" @click="copyAuthorUrl">kuraa</button>
<span v-if="authorUrlCopied" class="author-copy-hint">{{ $t('app.feedback.copied') }}</span>
</span> </span>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
@ -300,7 +505,7 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
</div> </div>
</div> </div>
<p v-if="feedback" class="feedback status-text">{{ feedback }}</p> <p v-if="feedback" class="feedback status-text" :class="{ error: feedbackTone === 'error' }">{{ feedback }}</p>
</div> </div>
<div v-if="showAdvanced" class="advanced-shell"> <div v-if="showAdvanced" class="advanced-shell">
@ -352,11 +557,14 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
:selected-task-id="taskStore.selectedTaskId" :selected-task-id="taskStore.selectedTaskId"
@select="taskStore.selectTask" @select="taskStore.selectTask"
@retry="taskStore.retryTask" @retry="taskStore.retryTask"
@retry-translate="handleRetryTranslate"
@delete="taskStore.deleteTask"
/> />
<SubtitleEditor <SubtitleEditor
:task="selectedTask" :task="selectedTask"
:logs="taskStore.selectedTaskLogs" :logs="taskStore.selectedTaskLogs"
@save="taskStore.updateSegment" @save="taskStore.updateSegment"
@translate="handleTranslateFromEditor"
@export="handleExport" @export="handleExport"
/> />
</section> </section>

View File

@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, nextTick, watch } from 'vue'
import type { SubtitleSegment, SubtitleTask } from '../lib/types' import type { SubtitleSegment, SubtitleTask } from '../lib/types'
const PAGE_SIZE = 100
const props = defineProps<{ const props = defineProps<{
task: SubtitleTask | null task: SubtitleTask | null
logs: string[] logs: string[]
@ -16,20 +18,67 @@ const canExport = computed(() => {
return props.task?.status === 'completed' && (props.task.segments?.length ?? 0) > 0 return props.task?.status === 'completed' && (props.task.segments?.length ?? 0) > 0
}) })
const canTranslate = computed(() => {
if (!props.task) return false
return (
props.task.segments.length > 0 &&
props.task.segments.some((s) => s.sourceText)
)
})
const emit = defineEmits<{ const emit = defineEmits<{
save: [segment: SubtitleSegment] save: [segment: SubtitleSegment]
export: [format: 'srt' | 'vtt' | 'ass'] export: [format: 'srt' | 'vtt' | 'ass']
translate: []
}>() }>()
const segments = computed(() => const sortedSegments = computed(() =>
[...(props.task?.segments ?? [])].sort((left, right) => { [...(props.task?.segments ?? [])].sort((left, right) => {
if (left.start !== right.start) return left.start - right.start if (left.start !== right.start) return left.start - right.start
if (left.end !== right.end) return left.end - right.end if (left.end !== right.end) return left.end - right.end
return left.id.localeCompare(right.id) return left.id.localeCompare(right.id)
}), }),
) )
const currentPage = ref(1)
const totalPages = computed(() => Math.max(1, Math.ceil(sortedSegments.value.length / PAGE_SIZE)))
const hasPagination = computed(() => sortedSegments.value.length > PAGE_SIZE)
const visibleSegments = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return sortedSegments.value.slice(start, start + PAGE_SIZE)
})
watch(totalPages, (newTotal) => {
if (currentPage.value > newTotal) {
currentPage.value = newTotal
}
})
function goToPage(page: number) {
currentPage.value = Math.max(1, Math.min(page, totalPages.value))
}
function prevPage() {
goToPage(currentPage.value - 1)
}
function nextPage() {
goToPage(currentPage.value + 1)
}
const logsExpanded = ref(false) const logsExpanded = ref(false)
const logContainerRef = ref<HTMLElement | null>(null)
watch(() => props.logs.length, () => {
nextTick(() => {
if (logContainerRef.value) {
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
}
})
})
function formatTime(seconds: number) { function formatTime(seconds: number) {
const ms = Math.round(seconds * 1000) const ms = Math.round(seconds * 1000)
const h = Math.floor(ms / 3600000) const h = Math.floor(ms / 3600000)
@ -50,10 +99,16 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<div> <div>
<strong>{{ task?.fileName ?? $t('editor.title') }}</strong> <strong>{{ task?.fileName ?? $t('editor.title') }}</strong>
<p class="panel-subtitle"> <p class="panel-subtitle">
{{ task ? $t('editor.segments', { count: segments.length }) : $t('editor.selectTask') }} {{ task ? $t('editor.segments', { count: sortedSegments.length }) : $t('editor.selectTask') }}
</p> </p>
</div> </div>
<div v-if="task" class="export-actions"> <div v-if="task" class="export-actions">
<button
v-if="canTranslate"
class="button primary small"
:disabled="isProcessing"
@click="emit('translate')"
>{{ $t('editor.translate') }}</button>
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'srt')">SRT</button> <button class="button secondary small" :disabled="!canExport" @click="emit('export', 'srt')">SRT</button>
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'vtt')">VTT</button> <button class="button secondary small" :disabled="!canExport" @click="emit('export', 'vtt')">VTT</button>
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'ass')">ASS</button> <button class="button secondary small" :disabled="!canExport" @click="emit('export', 'ass')">ASS</button>
@ -65,15 +120,21 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p> <p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p>
</div> </div>
<div v-else-if="segments.length === 0" class="empty-state"> <div v-else-if="sortedSegments.length === 0" class="empty-state">
<template v-if="isProcessing">{{ $t('editor.processing') }}</template> <template v-if="isProcessing">{{ $t('editor.processing') }}</template>
<template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template> <template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template>
<template v-else>{{ $t('editor.noSegments') }}</template> <template v-else>{{ $t('editor.noSegments') }}</template>
</div> </div>
<div v-else class="segment-list"> <div v-else class="segment-list">
<div v-if="hasPagination" class="pagination-bar">
<button class="button small" :disabled="currentPage <= 1" @click="prevPage"></button>
<span class="pagination-info">{{ $t('editor.pageInfo', { current: currentPage, total: totalPages }) }}</span>
<button class="button small" :disabled="currentPage >= totalPages" @click="nextPage"></button>
<span class="pagination-count">{{ $t('editor.segments', { count: sortedSegments.length }) }}</span>
</div>
<article <article
v-for="segment in segments" v-for="segment in visibleSegments"
:key="segment.id" :key="segment.id"
class="segment-item" class="segment-item"
> >
@ -90,6 +151,11 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)" @change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
/> />
</article> </article>
<div v-if="hasPagination" class="pagination-bar bottom">
<button class="button small" :disabled="currentPage <= 1" @click="prevPage"></button>
<span class="pagination-info">{{ $t('editor.pageInfo', { current: currentPage, total: totalPages }) }}</span>
<button class="button small" :disabled="currentPage >= totalPages" @click="nextPage"></button>
</div>
</div> </div>
<div class="log-drawer" :class="{ expanded: logsExpanded }"> <div class="log-drawer" :class="{ expanded: logsExpanded }">
@ -102,8 +168,8 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<div v-if="logs.length === 0" class="empty-state"> <div v-if="logs.length === 0" class="empty-state">
{{ $t('editor.noLogs') }} {{ $t('editor.noLogs') }}
</div> </div>
<div v-else class="log-list"> <div v-else ref="logContainerRef" class="log-list">
<pre v-for="(line, index) in logs" :key="`${index}-${line}`" class="log-line">{{ line }}</pre> <pre v-for="(line, i) in logs" :key="i" class="log-line">{{ line }}</pre>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import type { SubtitleTask } from '../lib/types' import type { SubtitleTask } from '../lib/types'
defineProps<{ const props = defineProps<{
tasks: SubtitleTask[] tasks: SubtitleTask[]
selectedTaskId: string selectedTaskId: string
}>() }>()
@ -9,7 +10,30 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
select: [taskId: string] select: [taskId: string]
retry: [taskId: string] retry: [taskId: string]
retryTranslate: [taskId: string]
delete: [taskId: string]
}>() }>()
const expandedTasks = ref<Set<string>>(new Set())
function toggleExpand(taskId: string, event: MouseEvent) {
event.stopPropagation()
const next = new Set(expandedTasks.value)
if (next.has(taskId)) {
next.delete(taskId)
} else {
next.add(taskId)
}
expandedTasks.value = next
}
function hasSubWork(task: SubtitleTask): boolean {
return task.status !== 'queued' && task.status !== 'failed'
}
function isActive(task: SubtitleTask): boolean {
return task.status !== 'completed' && task.status !== 'failed' && task.status !== 'queued'
}
</script> </script>
<template> <template>
@ -27,32 +51,129 @@ const emit = defineEmits<{
</div> </div>
<div v-else class="list-stack"> <div v-else class="list-stack">
<button <div
v-for="task in tasks" v-for="task in tasks"
:key="task.id" :key="task.id"
class="task-item-wrapper"
:class="{ processing: isActive(task) }"
>
<button
class="task-item" class="task-item"
:class="{ :class="{
active: task.id === selectedTaskId, active: task.id === selectedTaskId,
completed: task.status === 'completed', completed: task.status === 'completed',
failed: task.status === 'failed', failed: task.status === 'failed',
expanded: expandedTasks.has(task.id),
}" }"
@click="emit('select', task.id)" @click="emit('select', task.id)"
> >
<div class="task-row"> <div class="task-row">
<div class="task-name-row">
<button
class="expand-toggle"
:class="{ expanded: expandedTasks.has(task.id) }"
@click="toggleExpand(task.id, $event)"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<strong class="truncate">{{ task.fileName }}</strong> <strong class="truncate">{{ task.fileName }}</strong>
</div>
<span <span
class="subtle" class="subtle"
:class="{ 'status-active': task.status !== 'completed' && task.status !== 'failed' && task.status !== 'queued' }" :class="{ 'status-active': isActive(task) }"
>{{ $t(`taskQueue.status.${task.status}`) }}</span> >{{ $t(`taskQueue.status.${task.status}`) }}</span>
</div> </div>
<div class="progress"> <div class="progress">
<div class="progress-bar" :style="{ width: `${task.progress}%` }" /> <div class="progress-bar" :style="{ width: `${task.progress}%` }" />
</div> </div>
<div v-if="expandedTasks.has(task.id) && hasSubWork(task)" class="sub-stages">
<div class="sub-stage">
<div class="sub-stage-label">
<span>{{ $t('taskQueue.subStage.extracting') }}</span>
<span class="sub-stage-pct">{{ Math.round(task.subStageProgress.extracting) }}%</span>
</div>
<div class="progress sub-progress">
<div
class="progress-bar sub-progress-bar"
:style="{ width: `${Math.round(task.subStageProgress.extracting)}%` }"
:class="{ done: task.subStageProgress.extracting >= 100 }"
/>
</div>
</div>
<div class="sub-stage">
<div class="sub-stage-label">
<span>{{ $t('taskQueue.subStage.vad') }}</span>
<span class="sub-stage-pct">{{ Math.round(task.subStageProgress.vad) }}%</span>
</div>
<div class="progress sub-progress">
<div
class="progress-bar sub-progress-bar"
:style="{ width: `${Math.round(task.subStageProgress.vad)}%` }"
:class="{ done: task.subStageProgress.vad >= 100 }"
/>
</div>
</div>
<div class="sub-stage">
<div class="sub-stage-label">
<span>{{ $t('taskQueue.subStage.transcribing') }}</span>
<span class="sub-stage-pct">{{ Math.round(task.subStageProgress.transcribing) }}%</span>
</div>
<div class="progress sub-progress">
<div
class="progress-bar sub-progress-bar"
:style="{ width: `${Math.round(task.subStageProgress.transcribing)}%` }"
:class="{ done: task.subStageProgress.transcribing >= 100 }"
/>
</div>
</div>
<div class="sub-stage">
<div class="sub-stage-label">
<span>{{ $t('taskQueue.subStage.translating') }}</span>
<span class="sub-stage-pct">{{ Math.round(task.subStageProgress.translating) }}%</span>
</div>
<div class="progress sub-progress">
<div
class="progress-bar sub-progress-bar"
:style="{ width: `${Math.round(task.subStageProgress.translating)}%` }"
:class="{ done: task.subStageProgress.translating >= 100 }"
/>
</div>
</div>
</div>
<div v-if="expandedTasks.has(task.id) && task.status === 'queued'" class="sub-stages">
<div class="sub-stage-label queued-hint">{{ $t('taskQueue.queuedHint') }}</div>
</div>
<div v-if="task.status === 'failed'" class="failed-footer"> <div v-if="task.status === 'failed'" class="failed-footer">
<p class="error-text">{{ task.error }}</p> <p class="error-text">{{ task.error }}</p>
<div class="retry-actions">
<button class="retry-button" type="button" @click.stop="emit('retry', task.id)">{{ $t('taskQueue.retry') }}</button> <button class="retry-button" type="button" @click.stop="emit('retry', task.id)">{{ $t('taskQueue.retry') }}</button>
<button
v-if="task.segments.length > 0"
class="retry-button secondary"
type="button"
@click.stop="emit('retryTranslate', task.id)"
>{{ $t('taskQueue.retryTranslate') }}</button>
</div> </div>
</div>
<button
class="delete-button"
type="button"
:title="$t('taskQueue.delete')"
@click.stop="emit('delete', task.id)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button> </button>
</button>
</div>
</div> </div>
</aside> </aside>
</template> </template>

View File

@ -19,6 +19,13 @@ export interface SubtitleSegment {
translatedText?: string | null translatedText?: string | null
} }
export interface SubStageProgress {
extracting: number
vad: number
transcribing: number
translating: number
}
export interface SubtitleTask { export interface SubtitleTask {
id: string id: string
filePath: string filePath: string
@ -31,6 +38,7 @@ export interface SubtitleTask {
progress: number progress: number
segments: SubtitleSegment[] segments: SubtitleSegment[]
error?: string | null error?: string | null
subStageProgress: SubStageProgress
} }
export interface TranslationConfig { export interface TranslationConfig {
@ -62,6 +70,7 @@ export interface ProgressEvent {
status: TaskStatus status: TaskStatus
progress: number progress: number
message: string message: string
subStageProgress: SubStageProgress
} }
export interface SegmentEvent { export interface SegmentEvent {

View File

@ -27,6 +27,13 @@ export default {
submitFailed: 'Task submission failed', submitFailed: 'Task submission failed',
dialogError: 'Failed to open file dialog: {message}', dialogError: 'Failed to open file dialog: {message}',
dialogErrorFallback: 'Failed to open file dialog', dialogErrorFallback: 'Failed to open file dialog',
noApiKey: 'Please configure LLM API Key first',
translationStarted: 'Translation task started',
translationFailed: 'Translation failed',
noMediaFiles: 'No supported media files found',
someFilesSkipped: '{count} file(s) skipped (unsupported format)',
dropHint: 'Drop to add files',
copied: 'Copied 🎉',
}, },
llm: { llm: {
apiBase: 'LLM API Base', apiBase: 'LLM API Base',
@ -49,6 +56,9 @@ export default {
subtitle: 'Select a task to view subtitles', subtitle: 'Select a task to view subtitles',
empty: 'No tasks', empty: 'No tasks',
retry: 'Retry', retry: 'Retry',
retryTranslate: 'Retry Translation',
delete: 'Delete',
queuedHint: 'Waiting in queue...',
status: { status: {
queued: 'Queued', queued: 'Queued',
extracting: 'Extracting', extracting: 'Extracting',
@ -58,6 +68,12 @@ export default {
completed: 'Completed', completed: 'Completed',
failed: 'Failed', failed: 'Failed',
}, },
subStage: {
extracting: 'Extract Audio',
vad: 'Voice Detection',
transcribing: 'Transcribe',
translating: 'Translate',
},
}, },
editor: { editor: {
title: 'Workspace', title: 'Workspace',
@ -69,9 +85,11 @@ export default {
failed: 'Task failed, unable to generate subtitles', failed: 'Task failed, unable to generate subtitles',
noSegments: 'No subtitle segments', noSegments: 'No subtitle segments',
waiting: 'Waiting for transcription...', waiting: 'Waiting for transcription...',
translate: 'Translate',
translation: 'Translation', translation: 'Translation',
source: 'Source', source: 'Source',
logs: 'Logs', logs: 'Logs',
noLogs: 'No logs', noLogs: 'No logs',
pageInfo: 'Page {current}/{total}',
}, },
} }

View File

@ -27,6 +27,13 @@ export default {
submitFailed: '任务提交失败', submitFailed: '任务提交失败',
dialogError: '打开文件对话框失败:{message}', dialogError: '打开文件对话框失败:{message}',
dialogErrorFallback: '打开文件对话框失败', dialogErrorFallback: '打开文件对话框失败',
noApiKey: '请先配置 LLM API Key',
translationStarted: '翻译任务已开始',
translationFailed: '翻译失败',
noMediaFiles: '拖入的文件中没有支持的音视频文件',
someFilesSkipped: '{count} 个文件被跳过(不支持的格式)',
dropHint: '松开以添加文件',
copied: '复制成功🎉',
}, },
llm: { llm: {
apiBase: 'LLM API Base', apiBase: 'LLM API Base',
@ -49,6 +56,9 @@ export default {
subtitle: '选择任务查看字幕', subtitle: '选择任务查看字幕',
empty: '暂无任务', empty: '暂无任务',
retry: '重试', retry: '重试',
retryTranslate: '重试翻译',
delete: '移除',
queuedHint: '正在排队等待...',
status: { status: {
queued: '排队中', queued: '排队中',
extracting: '抽取', extracting: '抽取',
@ -58,6 +68,12 @@ export default {
completed: '完成', completed: '完成',
failed: '失败', failed: '失败',
}, },
subStage: {
extracting: '音频抽取',
vad: '语音检测',
transcribing: '语音识别',
translating: '翻译',
},
}, },
editor: { editor: {
title: '字幕工作区', title: '字幕工作区',
@ -69,9 +85,11 @@ export default {
failed: '任务处理失败,无法生成字幕', failed: '任务处理失败,无法生成字幕',
noSegments: '暂无字幕片段', noSegments: '暂无字幕片段',
waiting: '等待识别结果...', waiting: '等待识别结果...',
translate: '翻译',
translation: '译文', translation: '译文',
source: '原文', source: '原文',
logs: '日志', logs: '日志',
noLogs: '暂无日志', noLogs: '暂无日志',
pageInfo: '第 {current}/{total} 页',
}, },
} }

View File

@ -10,6 +10,7 @@ import type {
StartTaskPayload, StartTaskPayload,
SubtitleSegment, SubtitleSegment,
SubtitleTask, SubtitleTask,
TranslationConfig,
} from '../lib/types' } from '../lib/types'
type ExportFormat = 'srt' | 'vtt' | 'ass' type ExportFormat = 'srt' | 'vtt' | 'ass'
@ -50,6 +51,9 @@ export const useTaskStore = defineStore('tasks', {
if (!task) return if (!task) return
task.status = payload.status task.status = payload.status
task.progress = payload.progress task.progress = payload.progress
if (payload.subStageProgress) {
Object.assign(task.subStageProgress, payload.subStageProgress)
}
}) })
const segmentUnlisten = await listen<SegmentEvent>('task:segment', ({ payload }) => { const segmentUnlisten = await listen<SegmentEvent>('task:segment', ({ payload }) => {
@ -90,9 +94,9 @@ export const useTaskStore = defineStore('tasks', {
const doneUnlisten = await listen<SubtitleTask>('task:done', ({ payload }) => { const doneUnlisten = await listen<SubtitleTask>('task:done', ({ payload }) => {
sortSegments(payload.segments) sortSegments(payload.segments)
const index = this.tasks.findIndex((item) => item.id === payload.id) const task = this.tasks.find((item) => item.id === payload.id)
if (index >= 0) { if (task) {
this.tasks[index] = payload Object.assign(task, payload)
} else { } else {
this.tasks.unshift(payload) this.tasks.unshift(payload)
} }
@ -130,6 +134,20 @@ export const useTaskStore = defineStore('tasks', {
await this.startTask(payload) await this.startTask(payload)
}, },
async retryTranslation(taskId: string, translationConfig: TranslationConfig) {
const task = await invoke<SubtitleTask>('retry_translation', {
taskId,
translationConfig,
})
const index = this.tasks.findIndex((t) => t.id === task.id)
if (index >= 0) {
this.tasks[index] = task
} else {
this.tasks.unshift(task)
}
this.selectedTaskId = task.id
},
selectTask(taskId: string) { selectTask(taskId: string) {
this.selectedTaskId = taskId this.selectedTaskId = taskId
}, },
@ -140,8 +158,20 @@ export const useTaskStore = defineStore('tasks', {
if (index >= 0) this.tasks[index] = updated if (index >= 0) this.tasks[index] = updated
}, },
async exportTask(taskId: string, format: ExportFormat) { async deleteTask(taskId: string) {
return invoke<string>('export_subtitles', { taskId, format }) await invoke('delete_task', { taskId })
const index = this.tasks.findIndex((t) => t.id === taskId)
if (index >= 0) {
this.tasks.splice(index, 1)
}
delete this.logsByTaskId[taskId]
if (this.selectedTaskId === taskId) {
this.selectedTaskId = ''
}
},
async exportTask(taskId: string, format: ExportFormat, outputPath: string) {
return invoke<string>('export_subtitles', { taskId, format, outputPath })
}, },
}, },
}) })

View File

@ -157,17 +157,27 @@ textarea {
align-items: center; align-items: center;
} }
.credit-line a { .credit-line a,
.author-link {
color: var(--c-text); color: var(--c-text);
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
text-decoration: none; text-decoration: none;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
transition: border-color var(--transition); transition: border-color var(--transition);
} }
.credit-line a:hover { .credit-line a:hover,
.author-link:hover {
border-bottom-color: var(--c-text); border-bottom-color: var(--c-text);
} }
.author-copy-hint {
line-height: 1;
}
.workspace-toolbar, .workspace-toolbar,
.advanced-shell { .advanced-shell {
margin-top: 16px; margin-top: 16px;
@ -332,6 +342,11 @@ textarea {
margin: 12px 0 0; margin: 12px 0 0;
} }
.status-text.error {
color: var(--c-error);
font-weight: 500;
}
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: 300px minmax(0, 1fr);
@ -378,14 +393,10 @@ textarea {
color: var(--c-text-tertiary); color: var(--c-text-tertiary);
} }
.list-stack, .list-stack {
.segment-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
}
.list-stack {
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
} }
@ -393,8 +404,13 @@ textarea {
.segment-list { .segment-list {
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 1 auto;
overflow: auto; overflow-y: auto;
padding-right: 2px; padding-right: 2px;
will-change: scroll-position;
}
.segment-item + .segment-item {
margin-top: 8px;
} }
.task-item, .task-item,
@ -442,6 +458,50 @@ textarea {
box-shadow: 0 0 6px rgba(45, 106, 79, 0.4); box-shadow: 0 0 6px rgba(45, 106, 79, 0.4);
} }
@keyframes rotate-glow {
to {
transform: rotate(360deg);
}
}
.task-item-wrapper.processing {
position: relative;
overflow: hidden;
border-radius: var(--radius-md);
}
.task-item-wrapper.processing::before {
content: '';
position: absolute;
width: 150%;
height: 300%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 0;
background: conic-gradient(
transparent 0deg,
#8a9bb5 90deg,
#d0d7e0 180deg,
transparent 270deg
);
animation: rotate-glow 2s linear infinite;
}
.task-item-wrapper.processing::after {
content: '';
position: absolute;
inset: 3px;
border-radius: calc(var(--radius-md) - 3px);
background: var(--c-bg);
z-index: 1;
}
.task-item-wrapper.processing .task-item {
position: relative;
z-index: 2;
}
.task-item.failed { .task-item.failed {
border-color: var(--c-error); border-color: var(--c-error);
background: rgba(220, 38, 38, 0.03); background: rgba(220, 38, 38, 0.03);
@ -483,11 +543,129 @@ textarea {
color: #fff; color: #fff;
} }
.task-item-wrapper {
width: 100%;
}
.task-item { .task-item {
position: relative; position: relative;
border-left: 3px solid transparent; border-left: 3px solid transparent;
} }
.task-item.expanded {
border-color: var(--c-border-hover);
background: rgba(26, 26, 46, 0.02);
}
.task-item:hover .delete-button {
opacity: 1;
pointer-events: auto;
}
.task-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.expand-toggle {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--c-text-tertiary);
cursor: pointer;
padding: 0;
border-radius: var(--radius-sm);
transition: transform var(--transition), color var(--transition), background var(--transition);
}
.expand-toggle:hover {
color: var(--c-text);
background: var(--c-focus);
}
.expand-toggle.expanded {
transform: rotate(90deg);
}
.sub-stages {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--c-border);
display: flex;
flex-direction: column;
gap: 6px;
}
.sub-stage {
display: flex;
flex-direction: column;
gap: 2px;
}
.sub-stage-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: var(--c-text-secondary);
}
.sub-stage-pct {
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 10px;
color: var(--c-text-tertiary);
}
.progress.sub-progress {
height: 2px;
margin-top: 0;
}
.progress-bar.sub-progress-bar.done {
background: var(--c-success);
opacity: 0.7;
}
.queued-hint {
font-size: 11px;
color: var(--c-text-tertiary);
font-style: italic;
padding: 2px 0;
}
.delete-button {
position: absolute;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-surface);
color: var(--c-text-tertiary);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition), color var(--transition), border-color var(--transition), background var(--transition);
}
.delete-button:hover {
color: var(--c-error);
border-color: var(--c-error);
background: rgba(220, 38, 38, 0.06);
}
.truncate { .truncate {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -587,6 +765,7 @@ textarea {
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--c-log-bg); background: var(--c-log-bg);
will-change: scroll-position;
} }
.log-line { .log-line {
@ -597,6 +776,7 @@ textarea {
line-height: 1.6; line-height: 1.6;
color: var(--c-log-text); color: var(--c-log-text);
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
content-visibility: auto;
} }
.log-line + .log-line { .log-line + .log-line {
@ -649,6 +829,7 @@ textarea {
border-left: 3px solid transparent; border-left: 3px solid transparent;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
position: relative; position: relative;
content-visibility: auto;
} }
.segment-item:hover { .segment-item:hover {
@ -667,6 +848,41 @@ textarea {
margin-bottom: 4px; margin-bottom: 4px;
} }
.pagination-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 0;
position: sticky;
top: 0;
background: var(--c-surface-gradient);
border-bottom: 1px solid var(--c-border);
z-index: 1;
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.pagination-bar.bottom {
position: static;
border-bottom: none;
border-top: 1px solid var(--c-border);
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.pagination-info {
font-size: 12px;
color: var(--c-text-secondary);
font-weight: 500;
min-width: 100px;
text-align: center;
}
.pagination-count {
font-size: 11px;
color: var(--c-text-tertiary);
margin-left: auto;
}
@media (max-width: 1360px) { @media (max-width: 1360px) {
.content-grid { .content-grid {
grid-template-columns: 280px minmax(0, 1fr); grid-template-columns: 280px minmax(0, 1fr);
@ -684,3 +900,262 @@ textarea {
grid-column: span 1; grid-column: span 1;
} }
} }
.drag-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(245, 245, 247, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
border: 3px dashed var(--c-accent);
margin: 12px;
border-radius: calc(var(--radius-lg) + 4px);
}
.drag-overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: var(--c-accent);
font-size: 18px;
font-weight: 500;
opacity: 0.8;
}
.drag-overlay-content svg {
animation: drag-bounce 1.2s ease-in-out infinite;
}
@keyframes drag-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
.drag-enter-active,
.drag-leave-active {
transition: opacity 0.2s ease;
}
.drag-enter-from,
.drag-leave-to {
opacity: 0;
}
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(26, 26, 46, 0.24);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.api-key-dialog {
width: min(520px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
padding: 20px;
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
background: var(--c-surface);
box-shadow: 0 18px 48px rgba(26, 26, 46, 0.18);
}
.dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.dialog-header strong {
display: block;
color: var(--c-text);
font-size: 16px;
font-weight: 600;
}
.dialog-header p {
margin: 8px 0 0;
color: var(--c-text-secondary);
font-size: 13px;
line-height: 1.6;
}
.dialog-close {
flex: 0 0 auto;
width: 30px;
height: 30px;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--c-text-secondary);
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.dialog-close:hover {
border-color: var(--c-border-hover);
color: var(--c-text);
background: var(--c-bg);
}
.api-key-options {
display: grid;
gap: 10px;
margin: 16px 0 0;
padding: 0;
list-style: none;
counter-reset: api-key-option;
}
.api-key-options li {
counter-increment: api-key-option;
display: grid;
grid-template-columns: 24px 28px minmax(0, 1fr);
align-items: center;
gap: 10px;
color: var(--c-text-secondary);
font-size: 13px;
line-height: 1.5;
}
.api-key-options li::before {
content: counter(api-key-option) ".";
color: var(--c-text);
font-weight: 600;
}
.option-icon {
width: 28px;
height: 28px;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-bg);
position: relative;
}
.option-icon.self-host::before {
content: "";
position: absolute;
left: 7px;
top: 7px;
width: 12px;
height: 12px;
border: 2px solid var(--c-text-secondary);
border-radius: 3px;
}
.option-icon.self-host::after {
content: "";
position: absolute;
left: 12px;
top: 3px;
width: 2px;
height: 20px;
background: var(--c-text-secondary);
}
.option-icon.provider::before,
.option-icon.provider::after {
content: "";
position: absolute;
left: 6px;
right: 6px;
height: 4px;
border-radius: 2px;
background: var(--c-text-secondary);
}
.option-icon.provider::before {
top: 8px;
}
.option-icon.provider::after {
top: 16px;
}
.option-icon.free::before {
content: "";
position: absolute;
left: 8px;
top: 6px;
width: 10px;
height: 14px;
border: 2px solid var(--c-success);
border-radius: 6px 6px 4px 4px;
}
.option-icon.free::after {
content: "";
position: absolute;
left: 10px;
top: 12px;
width: 6px;
height: 2px;
background: var(--c-success);
}
.api-key-link {
padding: 0;
border: 0;
background: transparent;
color: var(--c-accent);
cursor: pointer;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
}
.api-key-link:hover {
color: var(--c-accent-hover);
}
.copy-hint {
color: var(--c-text-tertiary);
font-size: 12px;
}
.copy-hint.copied {
color: var(--c-success);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
.dialog-enter-active,
.dialog-leave-active {
transition: opacity 0.16s ease;
}
.dialog-enter-active .api-key-dialog,
.dialog-leave-active .api-key-dialog {
transition: transform 0.16s ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
}
.dialog-enter-from .api-key-dialog,
.dialog-leave-to .api-key-dialog {
transform: translateY(8px);
}

983
yarn.lock Normal file
View File

@ -0,0 +1,983 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@alloc/quick-lru@^5.2.0":
version "5.2.0"
resolved "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz"
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
"@babel/helper-string-parser@^7.27.1":
version "7.27.1"
resolved "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-validator-identifier@^7.28.5":
version "7.28.5"
resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz"
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
"@babel/parser@^7.29.0":
version "7.29.2"
resolved "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz"
integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==
dependencies:
"@babel/types" "^7.29.0"
"@babel/types@^7.29.0":
version "7.29.0"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz"
integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
dependencies:
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
"@esbuild/win32-x64@0.25.12":
version "0.25.12"
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz"
integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==
"@intlify/core-base@9.14.4":
version "9.14.4"
resolved "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz"
integrity sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==
dependencies:
"@intlify/message-compiler" "9.14.4"
"@intlify/shared" "9.14.4"
"@intlify/message-compiler@9.14.4":
version "9.14.4"
resolved "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz"
integrity sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==
dependencies:
"@intlify/shared" "9.14.4"
source-map-js "^1.0.2"
"@intlify/shared@9.14.4":
version "9.14.4"
resolved "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz"
integrity sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==
"@jridgewell/gen-mapping@^0.3.2":
version "0.3.13"
resolved "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz"
integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.0"
"@jridgewell/trace-mapping" "^0.3.24"
"@jridgewell/resolve-uri@^3.1.0":
version "3.1.2"
resolved "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5":
version "1.5.5"
resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz"
integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
"@jridgewell/trace-mapping@^0.3.24":
version "0.3.31"
resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz"
integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
dependencies:
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
dependencies:
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5"
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
"@nodelib/fs.walk@^1.2.3":
version "1.2.8"
resolved "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz"
integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
dependencies:
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@rollup/rollup-win32-x64-gnu@4.59.0":
version "4.59.0"
resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz"
integrity sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==
"@rollup/rollup-win32-x64-msvc@4.59.0":
version "4.59.0"
resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz"
integrity sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==
"@tauri-apps/api@^2.0.0", "@tauri-apps/api@^2.8.0":
version "2.10.1"
resolved "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.10.1.tgz"
integrity sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==
"@tauri-apps/cli-win32-x64-msvc@2.10.1":
version "2.10.1"
resolved "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz"
integrity sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==
"@tauri-apps/cli@^2.0.4":
version "2.10.1"
resolved "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.10.1.tgz"
integrity sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==
optionalDependencies:
"@tauri-apps/cli-darwin-arm64" "2.10.1"
"@tauri-apps/cli-darwin-x64" "2.10.1"
"@tauri-apps/cli-linux-arm-gnueabihf" "2.10.1"
"@tauri-apps/cli-linux-arm64-gnu" "2.10.1"
"@tauri-apps/cli-linux-arm64-musl" "2.10.1"
"@tauri-apps/cli-linux-riscv64-gnu" "2.10.1"
"@tauri-apps/cli-linux-x64-gnu" "2.10.1"
"@tauri-apps/cli-linux-x64-musl" "2.10.1"
"@tauri-apps/cli-win32-arm64-msvc" "2.10.1"
"@tauri-apps/cli-win32-ia32-msvc" "2.10.1"
"@tauri-apps/cli-win32-x64-msvc" "2.10.1"
"@tauri-apps/plugin-dialog@^2.0.0":
version "2.6.0"
resolved "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz"
integrity sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==
dependencies:
"@tauri-apps/api" "^2.8.0"
"@types/estree@1.0.8":
version "1.0.8"
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@vitejs/plugin-vue@^5.2.1":
version "5.2.4"
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
"@volar/language-core@2.4.15":
version "2.4.15"
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz"
integrity sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==
dependencies:
"@volar/source-map" "2.4.15"
"@volar/source-map@2.4.15":
version "2.4.15"
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz"
integrity sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==
"@volar/typescript@2.4.15":
version "2.4.15"
resolved "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz"
integrity sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==
dependencies:
"@volar/language-core" "2.4.15"
path-browserify "^1.0.1"
vscode-uri "^3.0.8"
"@vue/compiler-core@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.30.tgz"
integrity sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==
dependencies:
"@babel/parser" "^7.29.0"
"@vue/shared" "3.5.30"
entities "^7.0.1"
estree-walker "^2.0.2"
source-map-js "^1.2.1"
"@vue/compiler-dom@^3.5.0", "@vue/compiler-dom@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz"
integrity sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==
dependencies:
"@vue/compiler-core" "3.5.30"
"@vue/shared" "3.5.30"
"@vue/compiler-sfc@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz"
integrity sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==
dependencies:
"@babel/parser" "^7.29.0"
"@vue/compiler-core" "3.5.30"
"@vue/compiler-dom" "3.5.30"
"@vue/compiler-ssr" "3.5.30"
"@vue/shared" "3.5.30"
estree-walker "^2.0.2"
magic-string "^0.30.21"
postcss "^8.5.8"
source-map-js "^1.2.1"
"@vue/compiler-ssr@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz"
integrity sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==
dependencies:
"@vue/compiler-dom" "3.5.30"
"@vue/shared" "3.5.30"
"@vue/compiler-vue2@^2.7.16":
version "2.7.16"
resolved "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz"
integrity sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==
dependencies:
de-indent "^1.0.2"
he "^1.2.0"
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.3":
version "6.6.4"
resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/language-core@2.2.12":
version "2.2.12"
resolved "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz"
integrity sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==
dependencies:
"@volar/language-core" "2.4.15"
"@vue/compiler-dom" "^3.5.0"
"@vue/compiler-vue2" "^2.7.16"
"@vue/shared" "^3.5.0"
alien-signals "^1.0.3"
minimatch "^9.0.3"
muggle-string "^0.4.1"
path-browserify "^1.0.1"
"@vue/reactivity@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.30.tgz"
integrity sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==
dependencies:
"@vue/shared" "3.5.30"
"@vue/runtime-core@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.30.tgz"
integrity sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==
dependencies:
"@vue/reactivity" "3.5.30"
"@vue/shared" "3.5.30"
"@vue/runtime-dom@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz"
integrity sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==
dependencies:
"@vue/reactivity" "3.5.30"
"@vue/runtime-core" "3.5.30"
"@vue/shared" "3.5.30"
csstype "^3.2.3"
"@vue/server-renderer@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.30.tgz"
integrity sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==
dependencies:
"@vue/compiler-ssr" "3.5.30"
"@vue/shared" "3.5.30"
"@vue/shared@^3.5.0", "@vue/shared@3.5.30":
version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz"
integrity sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==
alien-signals@^1.0.3:
version "1.0.13"
resolved "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz"
integrity sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==
any-promise@^1.0.0:
version "1.3.0"
resolved "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz"
integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
anymatch@~3.1.2:
version "3.1.3"
resolved "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz"
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
arg@^5.0.2:
version "5.0.2"
resolved "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz"
integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
autoprefixer@^10.4.20:
version "10.4.27"
resolved "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.27.tgz"
integrity sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==
dependencies:
browserslist "^4.28.1"
caniuse-lite "^1.0.30001774"
fraction.js "^5.3.4"
picocolors "^1.1.1"
postcss-value-parser "^4.2.0"
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
baseline-browser-mapping@^2.9.0:
version "2.10.8"
resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz"
integrity sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==
binary-extensions@^2.0.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz"
integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
brace-expansion@^2.0.2:
version "2.0.2"
resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
dependencies:
balanced-match "^1.0.0"
braces@^3.0.3, braces@~3.0.2:
version "3.0.3"
resolved "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.1.1"
browserslist@^4.28.1, "browserslist@>= 4.21.0":
version "4.28.1"
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz"
integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==
dependencies:
baseline-browser-mapping "^2.9.0"
caniuse-lite "^1.0.30001759"
electron-to-chromium "^1.5.263"
node-releases "^2.0.27"
update-browserslist-db "^1.2.0"
camelcase-css@^2.0.1:
version "2.0.1"
resolved "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz"
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001774:
version "1.0.30001780"
resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz"
integrity sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==
chokidar@^3.6.0:
version "3.6.0"
resolved "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
commander@^4.0.0:
version "4.1.1"
resolved "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz"
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
csstype@^3.2.3:
version "3.2.3"
resolved "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz"
integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
didyoumean@^1.2.2:
version "1.2.2"
resolved "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz"
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
dlv@^1.1.3:
version "1.1.3"
resolved "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz"
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
electron-to-chromium@^1.5.263:
version "1.5.313"
resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz"
integrity sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==
entities@^7.0.1:
version "7.0.1"
resolved "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz"
integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
esbuild@^0.25.0:
version "0.25.12"
resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz"
integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==
optionalDependencies:
"@esbuild/aix-ppc64" "0.25.12"
"@esbuild/android-arm" "0.25.12"
"@esbuild/android-arm64" "0.25.12"
"@esbuild/android-x64" "0.25.12"
"@esbuild/darwin-arm64" "0.25.12"
"@esbuild/darwin-x64" "0.25.12"
"@esbuild/freebsd-arm64" "0.25.12"
"@esbuild/freebsd-x64" "0.25.12"
"@esbuild/linux-arm" "0.25.12"
"@esbuild/linux-arm64" "0.25.12"
"@esbuild/linux-ia32" "0.25.12"
"@esbuild/linux-loong64" "0.25.12"
"@esbuild/linux-mips64el" "0.25.12"
"@esbuild/linux-ppc64" "0.25.12"
"@esbuild/linux-riscv64" "0.25.12"
"@esbuild/linux-s390x" "0.25.12"
"@esbuild/linux-x64" "0.25.12"
"@esbuild/netbsd-arm64" "0.25.12"
"@esbuild/netbsd-x64" "0.25.12"
"@esbuild/openbsd-arm64" "0.25.12"
"@esbuild/openbsd-x64" "0.25.12"
"@esbuild/openharmony-arm64" "0.25.12"
"@esbuild/sunos-x64" "0.25.12"
"@esbuild/win32-arm64" "0.25.12"
"@esbuild/win32-ia32" "0.25.12"
"@esbuild/win32-x64" "0.25.12"
escalade@^3.2.0:
version "3.2.0"
resolved "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
fast-glob@^3.3.2:
version "3.3.3"
resolved "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz"
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
merge2 "^1.3.0"
micromatch "^4.0.8"
fastq@^1.6.0:
version "1.20.1"
resolved "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz"
integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==
dependencies:
reusify "^1.0.4"
fdir@^6.4.4:
version "6.5.0"
resolved "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
fraction.js@^5.3.4:
version "5.3.4"
resolved "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz"
integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
glob-parent@^5.1.2:
version "5.1.2"
resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
dependencies:
is-glob "^4.0.1"
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
dependencies:
is-glob "^4.0.1"
hasown@^2.0.2:
version "2.0.2"
resolved "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz"
integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
dependencies:
function-bind "^1.1.2"
he@^1.2.0:
version "1.2.0"
resolved "https://registry.npmmirror.com/he/-/he-1.2.0.tgz"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz"
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
dependencies:
binary-extensions "^2.0.0"
is-core-module@^2.16.1:
version "2.16.1"
resolved "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz"
integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
dependencies:
hasown "^2.0.2"
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz"
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
dependencies:
is-extglob "^2.1.1"
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
jiti@^1.21.7, jiti@>=1.21.0:
version "1.21.7"
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
lilconfig@^3.1.1, lilconfig@^3.1.3:
version "3.1.3"
resolved "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz"
integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
lines-and-columns@^1.1.6:
version "1.2.4"
resolved "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
magic-string@^0.30.21:
version "0.30.21"
resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz"
integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5"
merge2@^1.3.0:
version "1.4.1"
resolved "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micromatch@^4.0.8:
version "4.0.8"
resolved "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies:
braces "^3.0.3"
picomatch "^2.3.1"
minimatch@^9.0.3:
version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
dependencies:
brace-expansion "^2.0.2"
muggle-string@^0.4.1:
version "0.4.1"
resolved "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz"
integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==
mz@^2.7.0:
version "2.7.0"
resolved "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz"
integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
dependencies:
any-promise "^1.0.0"
object-assign "^4.0.1"
thenify-all "^1.0.0"
nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
node-releases@^2.0.27:
version "2.0.36"
resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.36.tgz"
integrity sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
object-assign@^4.0.1:
version "4.1.1"
resolved "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-hash@^3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz"
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
path-browserify@^1.0.1:
version "1.0.1"
resolved "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz"
integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
pify@^2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
pinia@^2.1.7:
version "2.3.1"
resolved "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz"
integrity sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==
dependencies:
"@vue/devtools-api" "^6.6.3"
vue-demi "^0.14.10"
pirates@^4.0.1:
version "4.0.7"
resolved "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz"
integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==
postcss-import@^15.1.0:
version "15.1.0"
resolved "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz"
integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==
dependencies:
postcss-value-parser "^4.0.0"
read-cache "^1.0.0"
resolve "^1.1.7"
postcss-js@^4.0.1:
version "4.1.0"
resolved "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz"
integrity sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==
dependencies:
camelcase-css "^2.0.1"
"postcss-load-config@^4.0.2 || ^5.0 || ^6.0":
version "6.0.1"
resolved "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz"
integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==
dependencies:
lilconfig "^3.1.1"
postcss-nested@^6.2.0:
version "6.2.0"
resolved "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz"
integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==
dependencies:
postcss-selector-parser "^6.1.1"
postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2:
version "6.1.2"
resolved "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz"
integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.47, postcss@^8.4.49, postcss@^8.5.3, postcss@^8.5.8, postcss@>=8.0.9:
version "8.5.8"
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz"
integrity sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==
dependencies:
nanoid "^3.3.11"
picocolors "^1.1.1"
source-map-js "^1.2.1"
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
read-cache@^1.0.0:
version "1.0.0"
resolved "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz"
integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==
dependencies:
pify "^2.3.0"
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz"
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
dependencies:
picomatch "^2.2.1"
resolve@^1.1.7, resolve@^1.22.8:
version "1.22.11"
resolved "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz"
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
dependencies:
is-core-module "^2.16.1"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
reusify@^1.0.4:
version "1.1.0"
resolved "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz"
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
rollup@^4.34.9:
version "4.59.0"
resolved "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz"
integrity sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==
dependencies:
"@types/estree" "1.0.8"
optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.59.0"
"@rollup/rollup-android-arm64" "4.59.0"
"@rollup/rollup-darwin-arm64" "4.59.0"
"@rollup/rollup-darwin-x64" "4.59.0"
"@rollup/rollup-freebsd-arm64" "4.59.0"
"@rollup/rollup-freebsd-x64" "4.59.0"
"@rollup/rollup-linux-arm-gnueabihf" "4.59.0"
"@rollup/rollup-linux-arm-musleabihf" "4.59.0"
"@rollup/rollup-linux-arm64-gnu" "4.59.0"
"@rollup/rollup-linux-arm64-musl" "4.59.0"
"@rollup/rollup-linux-loong64-gnu" "4.59.0"
"@rollup/rollup-linux-loong64-musl" "4.59.0"
"@rollup/rollup-linux-ppc64-gnu" "4.59.0"
"@rollup/rollup-linux-ppc64-musl" "4.59.0"
"@rollup/rollup-linux-riscv64-gnu" "4.59.0"
"@rollup/rollup-linux-riscv64-musl" "4.59.0"
"@rollup/rollup-linux-s390x-gnu" "4.59.0"
"@rollup/rollup-linux-x64-gnu" "4.59.0"
"@rollup/rollup-linux-x64-musl" "4.59.0"
"@rollup/rollup-openbsd-x64" "4.59.0"
"@rollup/rollup-openharmony-arm64" "4.59.0"
"@rollup/rollup-win32-arm64-msvc" "4.59.0"
"@rollup/rollup-win32-ia32-msvc" "4.59.0"
"@rollup/rollup-win32-x64-gnu" "4.59.0"
"@rollup/rollup-win32-x64-msvc" "4.59.0"
fsevents "~2.3.2"
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz"
integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
dependencies:
queue-microtask "^1.2.2"
source-map-js@^1.0.2, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
sucrase@^3.35.0:
version "3.35.1"
resolved "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz"
integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==
dependencies:
"@jridgewell/gen-mapping" "^0.3.2"
commander "^4.0.0"
lines-and-columns "^1.1.6"
mz "^2.7.0"
pirates "^4.0.1"
tinyglobby "^0.2.11"
ts-interface-checker "^0.1.9"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
tailwindcss@^3.4.16:
version "3.4.19"
resolved "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz"
integrity sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==
dependencies:
"@alloc/quick-lru" "^5.2.0"
arg "^5.0.2"
chokidar "^3.6.0"
didyoumean "^1.2.2"
dlv "^1.1.3"
fast-glob "^3.3.2"
glob-parent "^6.0.2"
is-glob "^4.0.3"
jiti "^1.21.7"
lilconfig "^3.1.3"
micromatch "^4.0.8"
normalize-path "^3.0.0"
object-hash "^3.0.0"
picocolors "^1.1.1"
postcss "^8.4.47"
postcss-import "^15.1.0"
postcss-js "^4.0.1"
postcss-load-config "^4.0.2 || ^5.0 || ^6.0"
postcss-nested "^6.2.0"
postcss-selector-parser "^6.1.2"
resolve "^1.22.8"
sucrase "^3.35.0"
thenify-all@^1.0.0:
version "1.6.0"
resolved "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz"
integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
dependencies:
thenify ">= 3.1.0 < 4"
"thenify@>= 3.1.0 < 4":
version "3.3.1"
resolved "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz"
integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
dependencies:
any-promise "^1.0.0"
tinyglobby@^0.2.11, tinyglobby@^0.2.13:
version "0.2.15"
resolved "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz"
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.3"
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz"
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
dependencies:
is-number "^7.0.0"
ts-interface-checker@^0.1.9:
version "0.1.13"
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
typescript@*, typescript@^5.7.2, typescript@>=4.4.4, typescript@>=5.0.0:
version "5.9.3"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
update-browserslist-db@^1.2.0:
version "1.2.3"
resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz"
integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
dependencies:
escalade "^3.2.0"
picocolors "^1.1.1"
util-deprecate@^1.0.2:
version "1.0.2"
resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
"vite@^5.0.0 || ^6.0.0", vite@^6.0.3:
version "6.4.1"
resolved "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz"
integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==
dependencies:
esbuild "^0.25.0"
fdir "^6.4.4"
picomatch "^4.0.2"
postcss "^8.5.3"
rollup "^4.34.9"
tinyglobby "^0.2.13"
optionalDependencies:
fsevents "~2.3.3"
vscode-uri@^3.0.8:
version "3.1.0"
resolved "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz"
integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==
vue-demi@^0.14.10:
version "0.14.10"
resolved "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
vue-i18n@^9.14.4:
version "9.14.4"
resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz"
integrity sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==
dependencies:
"@intlify/core-base" "9.14.4"
"@intlify/shared" "9.14.4"
"@vue/devtools-api" "^6.5.0"
vue-tsc@^2.1.10:
version "2.2.12"
resolved "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz"
integrity sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==
dependencies:
"@volar/typescript" "2.4.15"
"@vue/language-core" "2.2.12"
"vue@^2.7.0 || ^3.5.11", vue@^3.0.0, "vue@^3.0.0-0 || ^2.6.0", vue@^3.2.25, vue@^3.5.13, vue@3.5.30:
version "3.5.30"
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz"
integrity sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==
dependencies:
"@vue/compiler-dom" "3.5.30"
"@vue/compiler-sfc" "3.5.30"
"@vue/runtime-dom" "3.5.30"
"@vue/server-renderer" "3.5.30"
"@vue/shared" "3.5.30"