Compare commits
No commits in common. "77199541cd60963832750d0647dd2e127db9b54a" and "d8759d56c698d439f5ef8ff266b79df258549983" have entirely different histories.
77199541cd
...
d8759d56c6
1
.gitignore
vendored
@ -3,4 +3,3 @@
|
||||
/src-tauri/target/
|
||||
**/*.rs.bk
|
||||
/src-tauri/model/
|
||||
/src-tauri/vendor/ffmpeg/
|
||||
|
||||
21
LICENSE
@ -1,21 +0,0 @@
|
||||
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
@ -1,178 +0,0 @@
|
||||
<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**
|
||||
|
||||
[](https://github.com/AndySkaura/crosssubtitle-ai/releases)
|
||||
[](https://github.com/AndySkaura/crosssubtitle-ai/blob/main/LICENSE)
|
||||
[](#)
|
||||
|
||||
**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 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
## 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
@ -1,178 +1,61 @@
|
||||
<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
|
||||
|
||||
**AI 驱动的本地优先字幕工作台**
|
||||
基于 `Tauri v2 + Vue 3 + Pinia + Tailwind CSS` 的本地优先字幕工作台,覆盖以下 MVP 链路:
|
||||
|
||||
[](https://github.com/AndySkaura/crosssubtitle-ai/releases)
|
||||
[](https://github.com/AndySkaura/crosssubtitle-ai/blob/main/LICENSE)
|
||||
[](#)
|
||||
- 导入音视频文件并创建任务队列
|
||||
- 使用 `ffmpeg` 抽取 16kHz 单声道 WAV
|
||||
- 执行基础 VAD 切分并生成语音片段时间轴
|
||||
- 进入 Whisper 转录/翻译环节
|
||||
- 可选接入 OpenAI-compatible 接口生成中文译文
|
||||
- 实时推送任务进度和字幕片段
|
||||
- 导出 `SRT / VTT / ASS`
|
||||
|
||||
[English](./README.en.md) · **简体中文**
|
||||
## 目录结构
|
||||
|
||||
</div>
|
||||
- `src/`: Vue 前端界面、Pinia 状态、字幕编辑器
|
||||
- `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 兼容接口进行智能翻译,帮助你将音视频文件快速转录并翻译为双语字幕。
|
||||
## 运行前准备
|
||||
|
||||
整个过程在本地完成语音识别,无需上传音视频文件到任何服务器,保护你的数据隐私。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **语音识别** — 基于 Whisper 的高精度语音转文字,支持中文、英文、日文、韩文、法文等 17 种源语言
|
||||
- **语音活动检测** — Silero VAD 精准切分语音片段,自动过滤静音区域
|
||||
- **智能翻译** — 接入 OpenAI 兼容接口(如智谱 GLM、DeepSeek、ChatGPT 等),将原文翻译为目标语言
|
||||
- **音频抽取** — 内置 FFmpeg 自动抽取音频并转换为 16kHz 单声道 WAV
|
||||
- **多种导出格式** — 支持 SRT、VTT、ASS 三种字幕格式导出
|
||||
- **双语导出** — 支持原文 + 译文并排显示的双语字幕导出
|
||||
- **字幕编辑器** — 内置字幕编辑器,支持逐条修改原文和译文
|
||||
- **拖拽导入** — 支持拖拽文件快速创建任务
|
||||
- **任务队列** — 批量处理多个音视频文件,实时查看处理进度
|
||||
- **双语界面** — 内置中文 / 英文界面切换
|
||||
- **本地优先** — 语音识别完全在本地运行,无需上传数据
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. **选择模式** — 选择「原文」仅做语音识别,或「翻译」模式在识别后自动翻译
|
||||
2. **添加任务** — 点击「添加任务」按钮或直接拖拽音视频文件到窗口
|
||||
3. **等待处理** — 任务将依次经历:音频抽取 → VAD 切分 → 语音识别 →(可选)翻译
|
||||
4. **编辑校对** — 在字幕编辑器中逐条查看、修改识别结果和译文
|
||||
5. **导出字幕** — 导出为 SRT、VTT 或 ASS 格式
|
||||
|
||||
## 截图
|
||||
|
||||
| 示例 | 字幕编辑器 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
## 安装
|
||||
|
||||
从 [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 需要)
|
||||
|
||||
### 本地开发
|
||||
1. 安装 Rust 工具链。
|
||||
2. 安装 `cmake`,`whisper-rs-sys` 在首次编译时需要它。
|
||||
3. 安装 `ffmpeg`,并确保可通过命令行直接调用。
|
||||
4. 安装前端依赖:
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/AndySkaura/crosssubtitle-ai.git
|
||||
cd crosssubtitle-ai
|
||||
|
||||
# 安装前端依赖
|
||||
npm install
|
||||
|
||||
# 启动开发模式
|
||||
npm run tauri-dev
|
||||
```
|
||||
|
||||
### 构建
|
||||
5. 如需中文翻译,配置环境变量:
|
||||
|
||||
```bash
|
||||
# macOS DMG 构建
|
||||
npm run tauri-build-dmg
|
||||
|
||||
# Windows NSIS 构建
|
||||
npm run tauri-build-windows
|
||||
export OPENAI_API_BASE=https://your-openai-compatible-endpoint/v1
|
||||
export OPENAI_API_KEY=your_api_key
|
||||
export OPENAI_MODEL=GLM-4-Flash-250414
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
6. 若要真正启用 ONNX Runtime 推理,请确保本机存在可被 `ort` 动态加载的 ONNX Runtime 库,或按你的部署方式提供运行库。
|
||||
|
||||
| 层级 | 技术 |
|
||||
|:---|:---|
|
||||
| 桌面框架 | [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 |
|
||||
7. 启动桌面应用:
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
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 应用状态
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 开源协议
|
||||
## 下一步建议
|
||||
|
||||
本项目基于 [MIT](./LICENSE) 协议开源。
|
||||
|
||||
## 致谢
|
||||
|
||||
- [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>
|
||||
- 为 `src-tauri/src/vad.rs` 补模型输入名自适应和更多异常日志。
|
||||
- 加入文件选择器、任务恢复、批量导出与测试用例。
|
||||
- 为 `whisper-rs` 增加硬件加速参数与模型配置面板。
|
||||
|
||||
@ -10,11 +10,9 @@
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"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",
|
||||
"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-windows": "tauri build --bundles nsis"
|
||||
"tauri-build-dmg": "sh ./scripts/build-macos-dmg.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
@ -1,83 +0,0 @@
|
||||
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
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 770 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 38 KiB |
@ -1,6 +1,5 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::BufRead,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
@ -10,29 +9,20 @@ use anyhow::{anyhow, Context, Result};
|
||||
pub struct AudioPipeline;
|
||||
|
||||
impl AudioPipeline {
|
||||
pub fn extract_to_wav<F: Fn(f32)>(
|
||||
ffmpeg_path: &Path,
|
||||
input_path: &str,
|
||||
workspace: &Path,
|
||||
on_progress: F,
|
||||
) -> Result<PathBuf> {
|
||||
pub fn extract_to_wav(ffmpeg_path: &Path, input_path: &str, workspace: &Path) -> Result<PathBuf> {
|
||||
fs::create_dir_all(workspace)
|
||||
.with_context(|| format!("failed to create workspace: {}", workspace.display()))?;
|
||||
|
||||
let output_path = workspace.join("normalized.wav");
|
||||
let mut command = Command::new(ffmpeg_path);
|
||||
#[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() {
|
||||
command.env("DYLD_FALLBACK_LIBRARY_PATH", &lib_dir);
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = command
|
||||
let output = command
|
||||
.arg("-y")
|
||||
.arg("-i")
|
||||
.arg(input_path)
|
||||
@ -43,51 +33,27 @@ impl AudioPipeline {
|
||||
.arg("-f")
|
||||
.arg("wav")
|
||||
.arg(&output_path)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.output()
|
||||
.with_context(|| format!("failed to launch ffmpeg: {}", ffmpeg_path.display()))?;
|
||||
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
let reader = std::io::BufReader::new(stderr);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
return Err(anyhow!("ffmpeg exited with status: {}", output.status));
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"ffmpeg exited with status: {} | stderr: {}",
|
||||
output.status,
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn load_wav_f32(path: &Path) -> Result<Vec<f32>> {
|
||||
let mut reader = hound::WavReader::open(path)
|
||||
.with_context(|| format!("failed to open {}", path.display()))?;
|
||||
let mut reader =
|
||||
hound::WavReader::open(path).with_context(|| format!("failed to open {}", path.display()))?;
|
||||
let spec = reader.spec();
|
||||
|
||||
if spec.channels != 1 {
|
||||
@ -110,37 +76,3 @@ impl AudioPipeline {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,24 +7,18 @@ mod translate;
|
||||
mod vad;
|
||||
mod whisper;
|
||||
|
||||
use models::{
|
||||
DefaultModelPaths, StartTaskPayload, SubtitleSegment, SubtitleTask, TranslationConfig,
|
||||
use models::{DefaultModelPaths, StartTaskPayload, SubtitleSegment, SubtitleTask};
|
||||
use state::AppState;
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
|
||||
AppHandle, Emitter, Manager, PhysicalSize, Size,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use objc2_app_kit::NSWindow;
|
||||
#[cfg(target_os = "macos")]
|
||||
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;
|
||||
#[cfg(target_os = "macos")]
|
||||
const WINDOW_RATIO_HEIGHT: f64 = 10.0;
|
||||
const DEFAULT_WINDOW_WIDTH: u32 = 1440;
|
||||
const DEFAULT_WINDOW_HEIGHT: u32 = 900;
|
||||
@ -56,44 +50,20 @@ fn update_segment_text(
|
||||
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]
|
||||
fn export_subtitles(
|
||||
state: tauri::State<'_, AppState>,
|
||||
task_id: String,
|
||||
format: String,
|
||||
output_path: String,
|
||||
) -> std::result::Result<String, String> {
|
||||
task::export_task(state, task_id, format, output_path).map_err(error_to_string)
|
||||
task::export_task(state, task_id, format).map_err(error_to_string)
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[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 {
|
||||
format!("{error:#}")
|
||||
}
|
||||
@ -113,10 +83,8 @@ pub fn run() {
|
||||
start_subtitle_task,
|
||||
list_tasks,
|
||||
update_segment_text,
|
||||
delete_task,
|
||||
export_subtitles,
|
||||
get_default_model_paths,
|
||||
retry_translation
|
||||
get_default_model_paths
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
@ -155,11 +123,7 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
.build()?;
|
||||
|
||||
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()
|
||||
.item(&MenuItemBuilder::with_id("export_srt", "导出 SRT").build(app)?)
|
||||
.item(&MenuItemBuilder::with_id("export_vtt", "导出 VTT").build(app)?)
|
||||
@ -226,6 +190,11 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn configure_macos_menu(_app: &AppHandle) -> tauri::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apply_macos_aspect_ratio(window: &tauri::WebviewWindow) -> tauri::Result<()> {
|
||||
let ns_window = window.ns_window()?;
|
||||
|
||||
@ -37,26 +37,6 @@ pub struct SubtitleSegment {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubtitleTask {
|
||||
@ -71,7 +51,6 @@ pub struct SubtitleTask {
|
||||
pub progress: f32,
|
||||
pub segments: Vec<SubtitleSegment>,
|
||||
pub error: Option<String>,
|
||||
pub sub_stage_progress: SubStageProgress,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -94,7 +73,6 @@ pub struct ProgressEvent {
|
||||
pub status: TaskStatus,
|
||||
pub progress: f32,
|
||||
pub message: String,
|
||||
pub sub_stage_progress: SubStageProgress,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@ -11,19 +11,13 @@ pub struct AppState {
|
||||
|
||||
impl AppState {
|
||||
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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
.get(task_id)
|
||||
.cloned()
|
||||
@ -31,29 +25,14 @@ impl AppState {
|
||||
}
|
||||
|
||||
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<_>>();
|
||||
tasks.sort_by(|left, right| right.id.cmp(&left.id));
|
||||
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> {
|
||||
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
|
||||
.get_mut(&segment.task_id)
|
||||
.ok_or_else(|| anyhow!("task not found: {}", segment.task_id))?;
|
||||
|
||||
@ -9,6 +9,16 @@ pub enum SubtitleFormat {
|
||||
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 {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use tauri::{Emitter, Manager, Window};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -16,8 +11,7 @@ use crate::{
|
||||
audio::AudioPipeline,
|
||||
models::{
|
||||
DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent,
|
||||
StartTaskPayload, SubStageProgress, SubtitleSegment, SubtitleTask, TargetLanguage,
|
||||
TaskStatus, TranslationConfig,
|
||||
StartTaskPayload, SubtitleSegment, SubtitleTask, TaskStatus, TranslationConfig,
|
||||
},
|
||||
state::AppState,
|
||||
subtitle::{render, SubtitleFormat},
|
||||
@ -46,11 +40,7 @@ pub async fn start_task(
|
||||
state: tauri::State<'_, AppState>,
|
||||
mut payload: StartTaskPayload,
|
||||
) -> 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);
|
||||
}
|
||||
if payload.vad_model_path.as_deref().is_none_or(str::is_empty) {
|
||||
@ -77,7 +67,6 @@ pub async fn start_task(
|
||||
progress: 0.0,
|
||||
segments: Vec::new(),
|
||||
error: None,
|
||||
sub_stage_progress: SubStageProgress::default(),
|
||||
};
|
||||
|
||||
state.upsert_task(task.clone())?;
|
||||
@ -90,21 +79,11 @@ pub async fn start_task(
|
||||
let task_id = task.id.clone();
|
||||
|
||||
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 Ok(mut failed_task) = app_handle_for_error.state::<AppState>().get_task(&task_id)
|
||||
{
|
||||
if let Err(error) = run_pipeline(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.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());
|
||||
}
|
||||
@ -186,157 +165,26 @@ async fn run_pipeline(
|
||||
}
|
||||
}
|
||||
|
||||
let ffmpeg_path = resolve_ffmpeg_path(&app).ok_or_else(|| {
|
||||
anyhow::anyhow!("未找到可用 ffmpeg,请重新执行打包命令或在系统中安装 ffmpeg")
|
||||
})?;
|
||||
let ffmpeg_path = resolve_ffmpeg_path(&app)
|
||||
.ok_or_else(|| anyhow::anyhow!("未找到可用 ffmpeg,请重新执行打包命令或在系统中安装 ffmpeg"))?;
|
||||
|
||||
set_status(
|
||||
&window,
|
||||
&app_state,
|
||||
&mut task,
|
||||
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::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()))?;
|
||||
let wav_path = AudioPipeline::extract_to_wav(&ffmpeg_path, &payload.file_path, &workspace)?;
|
||||
emit_log(&window, &task.id, format!("audio: normalized wav={}", wav_path.display()))?;
|
||||
|
||||
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,
|
||||
"正在分析语音片段",
|
||||
)?;
|
||||
set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 15.0, "正在分析语音片段")?;
|
||||
let samples = AudioPipeline::load_wav_f32(&wav_path)?;
|
||||
let vad = VadEngine::new(payload.vad_model_path.clone(), VadConfig::default())?;
|
||||
|
||||
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;
|
||||
let speech_ranges = vad.detect_segments(&samples).await;
|
||||
emit_log(
|
||||
&window,
|
||||
&task.id,
|
||||
format!("vad: detected {} speech ranges", speech_ranges.len()),
|
||||
)?;
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 30.0, "正在执行 Whisper")?;
|
||||
let whisper = WhisperEngine::new(payload.whisper_model_path.clone());
|
||||
let task_id_for_progress = task.id.clone();
|
||||
let task_id_for_segment = task.id.clone();
|
||||
@ -344,10 +192,7 @@ async fn run_pipeline(
|
||||
let task_id_for_log = task.id.clone();
|
||||
let app_state_for_segment = app_state.clone();
|
||||
let app_state_for_reset = app_state.clone();
|
||||
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(
|
||||
let mut segments = whisper.infer_segments(
|
||||
&wav_path,
|
||||
&task.id,
|
||||
task.source_lang.as_deref(),
|
||||
@ -355,19 +200,7 @@ async fn run_pipeline(
|
||||
should_translate,
|
||||
&speech_ranges,
|
||||
|ratio| {
|
||||
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,
|
||||
};
|
||||
let progress = 30.0 + ratio.clamp(0.0, 1.0) * 40.0;
|
||||
window.emit(
|
||||
"task:progress",
|
||||
ProgressEvent {
|
||||
@ -375,7 +208,6 @@ async fn run_pipeline(
|
||||
status: TaskStatus::Transcribing,
|
||||
progress,
|
||||
message: "正在执行 Whisper".to_string(),
|
||||
sub_stage_progress: sub,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
@ -394,15 +226,6 @@ async fn run_pipeline(
|
||||
Ok(())
|
||||
},
|
||||
|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) {
|
||||
upsert_segment(&mut current_task.segments, segment.clone());
|
||||
let _ = app_state_for_segment.upsert_task(current_task);
|
||||
@ -419,18 +242,69 @@ async fn run_pipeline(
|
||||
|message| emit_log(&window, &task_id_for_log, message),
|
||||
)?;
|
||||
|
||||
// Signal that transcribing is complete, then close channel to flush translation worker
|
||||
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:?}");
|
||||
});
|
||||
}
|
||||
task.segments = segments.clone();
|
||||
app_state.upsert_task(task.clone())?;
|
||||
|
||||
// Reload task from state (all segments and translations applied by callbacks)
|
||||
task = app_state.get_task(&task.id)?;
|
||||
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"))?;
|
||||
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.progress = 100.0;
|
||||
@ -439,223 +313,6 @@ async fn run_pipeline(
|
||||
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> {
|
||||
let api_base = std::env::var("OPENAI_API_BASE").ok()?;
|
||||
let api_key = std::env::var("OPENAI_API_KEY").ok()?;
|
||||
@ -679,34 +336,6 @@ fn set_status(
|
||||
) -> Result<()> {
|
||||
task.status = status.clone();
|
||||
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())?;
|
||||
window.emit(
|
||||
"task:progress",
|
||||
@ -715,16 +344,12 @@ fn set_status(
|
||||
status,
|
||||
progress,
|
||||
message: message.to_string(),
|
||||
sub_stage_progress: task.sub_stage_progress.clone(),
|
||||
},
|
||||
)?;
|
||||
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)
|
||||
}
|
||||
|
||||
@ -732,24 +357,24 @@ pub fn list_tasks(state: tauri::State<'_, AppState>) -> Result<Vec<SubtitleTask>
|
||||
state.list_tasks()
|
||||
}
|
||||
|
||||
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> {
|
||||
pub fn export_task(state: tauri::State<'_, AppState>, task_id: String, format: String) -> Result<String> {
|
||||
let task = state.get_task(&task_id)?;
|
||||
let format = SubtitleFormat::try_from(format.as_str())?;
|
||||
let content = render(&task.segments, format, task.bilingual_output);
|
||||
|
||||
let output_path = PathBuf::from(output_path);
|
||||
if let Some(output_dir) = output_path.parent() {
|
||||
fs::create_dir_all(output_dir)?;
|
||||
}
|
||||
let source_path = PathBuf::from(&task.file_path);
|
||||
let stem = source_path
|
||||
.file_stem()
|
||||
.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)?;
|
||||
|
||||
Ok(output_path.display().to_string())
|
||||
@ -777,125 +402,6 @@ fn emit_log(window: &Window, task_id: &str, message: String) -> Result<()> {
|
||||
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) {
|
||||
if let Some(existing) = segments.iter_mut().find(|item| item.id == segment.id) {
|
||||
*existing = segment;
|
||||
@ -907,11 +413,7 @@ fn upsert_segment(segments: &mut Vec<SubtitleSegment>, segment: SubtitleSegment)
|
||||
left.start
|
||||
.partial_cmp(&right.start)
|
||||
.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))
|
||||
});
|
||||
}
|
||||
|
||||
@ -50,9 +50,9 @@ struct TranslationResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TranslatedRow {
|
||||
pub(crate) id: String,
|
||||
pub(crate) text: String,
|
||||
struct TranslatedRow {
|
||||
id: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
pub struct Translator {
|
||||
@ -70,14 +70,6 @@ impl Translator {
|
||||
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>(
|
||||
&self,
|
||||
segments: &[SubtitleSegment],
|
||||
@ -91,7 +83,7 @@ impl Translator {
|
||||
PF: FnMut(f32),
|
||||
SF: FnMut(SubtitleSegment),
|
||||
{
|
||||
let batch_size = self.config.batch_size.clamp(3, 350);
|
||||
let batch_size = self.config.batch_size.clamp(10, 15);
|
||||
let context_size = self.config.context_size.min(5);
|
||||
let mut translated = segments.to_vec();
|
||||
let target_language_name = match target_language {
|
||||
@ -120,10 +112,7 @@ impl Translator {
|
||||
let rows = self
|
||||
.translate_batch_with_retries(context, batch, target_language_name)
|
||||
.await?;
|
||||
log(format!(
|
||||
"translation: batch done, translated={}",
|
||||
rows.len()
|
||||
));
|
||||
log(format!("translation: batch done, translated={}", rows.len()));
|
||||
|
||||
for row in rows {
|
||||
if let Some(segment) = translated.iter_mut().find(|item| item.id == row.id) {
|
||||
@ -136,7 +125,7 @@ impl Translator {
|
||||
Ok(translated)
|
||||
}
|
||||
|
||||
pub(crate) async fn translate_batch_with_retries(
|
||||
async fn translate_batch_with_retries(
|
||||
&self,
|
||||
context: &[SubtitleSegment],
|
||||
batch: &[SubtitleSegment],
|
||||
@ -260,9 +249,7 @@ impl Translator {
|
||||
|
||||
match 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")?;
|
||||
eprintln!("translation raw response:\n{}", raw_text);
|
||||
let payload: ChatCompletionResponse =
|
||||
@ -274,9 +261,8 @@ impl Translator {
|
||||
.message
|
||||
.content
|
||||
.clone();
|
||||
let rows = parse_translation_response(&content).with_context(|| {
|
||||
format!("translation json parse failed: {}", preview(&content))
|
||||
})?;
|
||||
let rows = parse_translation_response(&content)
|
||||
.with_context(|| format!("translation json parse failed: {}", preview(&content)))?;
|
||||
return Ok(rows);
|
||||
}
|
||||
Err(error) => {
|
||||
@ -368,7 +354,10 @@ fn strip_code_fence(content: &str) -> String {
|
||||
.trim_start_matches("```json")
|
||||
.trim_start_matches("```JSON")
|
||||
.trim_start_matches("```");
|
||||
without_prefix.trim_end_matches("```").trim().to_string()
|
||||
without_prefix
|
||||
.trim_end_matches("```")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn extract_json_object(content: &str) -> Option<String> {
|
||||
@ -406,11 +395,7 @@ fn mask_secret(secret: &str) -> 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> {
|
||||
|
||||
@ -47,17 +47,12 @@ impl VadEngine {
|
||||
Ok(Self { model_path, config })
|
||||
}
|
||||
|
||||
pub async fn detect_segments<F: Fn(f32) + Clone + Send + 'static>(
|
||||
&self,
|
||||
samples: &[f32],
|
||||
on_progress: F,
|
||||
) -> Vec<(f32, f32)> {
|
||||
pub async fn detect_segments(&self, samples: &[f32]) -> Vec<(f32, f32)> {
|
||||
if self.model_path.is_some() {
|
||||
let samples_owned = samples.to_vec();
|
||||
let model_path = self.model_path.clone().unwrap();
|
||||
let config = self.config.clone();
|
||||
let timeout_secs = self.config.timeout_seconds;
|
||||
let on_progress_onnx = on_progress.clone();
|
||||
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(timeout_secs),
|
||||
@ -69,8 +64,7 @@ impl VadEngine {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Self::detect_with_onnx(&mut session, &samples_owned, &config, on_progress_onnx)
|
||||
.ok()
|
||||
Self::detect_with_onnx(&mut session, &samples_owned, &config).ok()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@ -92,7 +86,7 @@ impl VadEngine {
|
||||
}
|
||||
}
|
||||
|
||||
let ranges = self.detect_segments_with_energy(samples, &on_progress);
|
||||
let ranges = self.detect_segments_with_energy(samples);
|
||||
eprintln!("vad: energy detection found {} speech ranges", ranges.len());
|
||||
ranges
|
||||
}
|
||||
@ -104,20 +98,17 @@ impl VadEngine {
|
||||
.with_context(|| format!("failed to load silero vad model: {}", model_path.display()))
|
||||
}
|
||||
|
||||
fn detect_with_onnx<F: Fn(f32)>(
|
||||
fn detect_with_onnx(
|
||||
session: &mut Session,
|
||||
samples: &[f32],
|
||||
config: &VadConfig,
|
||||
on_progress: F,
|
||||
) -> Result<Vec<(f32, f32)>> {
|
||||
let chunk_size = 512usize;
|
||||
let total_chunks = (samples.len() + chunk_size - 1) / chunk_size;
|
||||
let mut state = Array3::<f32>::zeros((2, 1, 128));
|
||||
let sr = Array1::<i64>::from_vec(vec![config.sample_rate as i64]);
|
||||
let mut speech_probabilities = Vec::new();
|
||||
let mut last_progress = 0.0f32;
|
||||
|
||||
for (chunk_idx, chunk) in samples.chunks(chunk_size).enumerate() {
|
||||
for chunk in samples.chunks(chunk_size) {
|
||||
let mut padded = vec![0.0_f32; chunk_size];
|
||||
padded[..chunk.len()].copy_from_slice(chunk);
|
||||
let input = Array2::from_shape_vec((1, chunk_size), padded)
|
||||
@ -148,68 +139,33 @@ impl VadEngine {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
on_progress(1.0);
|
||||
Ok(Self::merge_probabilities(
|
||||
&speech_probabilities,
|
||||
chunk_size,
|
||||
config,
|
||||
))
|
||||
Ok(Self::merge_probabilities(&speech_probabilities, chunk_size, config))
|
||||
}
|
||||
|
||||
fn detect_segments_with_energy<F: Fn(f32)>(
|
||||
&self,
|
||||
samples: &[f32],
|
||||
on_progress: &F,
|
||||
) -> Vec<(f32, f32)> {
|
||||
fn detect_segments_with_energy(&self, samples: &[f32]) -> Vec<(f32, f32)> {
|
||||
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 last_progress = 0.0f32;
|
||||
|
||||
for (frame_idx, chunk) in samples.chunks(frame_size).enumerate() {
|
||||
for chunk in samples.chunks(frame_size) {
|
||||
let energy = chunk.iter().map(|sample| sample.abs()).sum::<f32>() / chunk.len() as f32;
|
||||
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() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
on_progress(1.0);
|
||||
|
||||
let dynamic_threshold = self.dynamic_energy_threshold(&energies);
|
||||
eprintln!(
|
||||
"vad: using energy fallback, frames={}, threshold={:.5}",
|
||||
energies.len(),
|
||||
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)
|
||||
}
|
||||
|
||||
@ -242,8 +198,7 @@ impl VadEngine {
|
||||
let end_frame = index.saturating_sub(silent_frames);
|
||||
if end_frame.saturating_sub(start) >= min_speech_frames {
|
||||
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));
|
||||
}
|
||||
start_frame = None;
|
||||
|
||||
@ -48,12 +48,12 @@ impl WhisperEngine {
|
||||
let audio = load_audio_f32(wav_path)?;
|
||||
let total_seconds = audio.len() as f32 / 16_000.0;
|
||||
let normalized_ranges = normalize_speech_ranges(speech_ranges, audio.len());
|
||||
let context =
|
||||
WhisperContext::new_with_params(model_path, WhisperContextParameters::default())
|
||||
.with_context(|| format!("failed to load whisper model: {model_path}"))?;
|
||||
let mut state = context
|
||||
.create_state()
|
||||
.context("failed to create whisper state")?;
|
||||
let context = WhisperContext::new_with_params(
|
||||
model_path,
|
||||
WhisperContextParameters::default(),
|
||||
)
|
||||
.with_context(|| format!("failed to load whisper model: {model_path}"))?;
|
||||
let mut state = context.create_state().context("failed to create whisper state")?;
|
||||
let detected_language = resolve_source_language(&mut state, &audio, source_lang)
|
||||
.context("failed to resolve source language")?;
|
||||
|
||||
@ -109,88 +109,49 @@ impl WhisperEngine {
|
||||
|| (total_seconds > 60.0 && vad_text_len < (total_seconds / 3.0) as usize));
|
||||
|
||||
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;
|
||||
on_log(format!(
|
||||
"whisper: VAD result looks incomplete, retrying full audio (segments={}, chars={}, end={:.2}s/{:.2}s, coverage={:.1}%)",
|
||||
segments.len(),
|
||||
vad_text_len,
|
||||
vad_end,
|
||||
total_seconds,
|
||||
vad_coverage * 100.0
|
||||
))?;
|
||||
on_reset_segments()?;
|
||||
let full_audio_segments = transcribe_clip(
|
||||
&mut state,
|
||||
&audio,
|
||||
0,
|
||||
0.0,
|
||||
total_seconds,
|
||||
task_id,
|
||||
detected_language,
|
||||
target_lang,
|
||||
should_translate,
|
||||
0,
|
||||
&mut on_segment,
|
||||
&mut on_log,
|
||||
)?;
|
||||
|
||||
if is_tail_only {
|
||||
let tail_start = vad_end.max(0.0);
|
||||
if should_prefer_full_audio(&segments, &full_audio_segments, total_seconds) {
|
||||
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,
|
||||
"whisper: using full-audio transcript (vad_segments={}, full_segments={})",
|
||||
segments.len(),
|
||||
&mut on_segment,
|
||||
&mut on_log,
|
||||
)?;
|
||||
segments.extend(tail_segments);
|
||||
on_log(format!(
|
||||
"whisper: total segments after tail retry={}",
|
||||
segments.len()
|
||||
full_audio_segments.len()
|
||||
))?;
|
||||
segments = full_audio_segments;
|
||||
} else {
|
||||
on_log(format!(
|
||||
"whisper: VAD result looks incomplete, retrying full audio (segments={}, chars={}, end={:.2}s/{:.2}s, coverage={:.1}%)",
|
||||
"whisper: keeping VAD-based transcript (vad_segments={}, full_segments={})",
|
||||
segments.len(),
|
||||
vad_text_len,
|
||||
vad_end,
|
||||
total_seconds,
|
||||
vad_coverage * 100.0
|
||||
full_audio_segments.len()
|
||||
))?;
|
||||
on_reset_segments()?;
|
||||
let full_audio_segments = transcribe_clip(
|
||||
&mut state,
|
||||
&audio,
|
||||
0,
|
||||
0.0,
|
||||
total_seconds,
|
||||
task_id,
|
||||
detected_language,
|
||||
target_lang,
|
||||
should_translate,
|
||||
0,
|
||||
&mut on_segment,
|
||||
&mut on_log,
|
||||
)?;
|
||||
|
||||
if should_prefer_full_audio(&segments, &full_audio_segments, total_seconds) {
|
||||
on_log(format!(
|
||||
"whisper: using full-audio transcript (vad_segments={}, full_segments={})",
|
||||
segments.len(),
|
||||
full_audio_segments.len()
|
||||
))?;
|
||||
segments = full_audio_segments;
|
||||
} else {
|
||||
on_log(format!(
|
||||
"whisper: keeping VAD-based transcript (vad_segments={}, full_segments={})",
|
||||
segments.len(),
|
||||
full_audio_segments.len()
|
||||
))?;
|
||||
on_reset_segments()?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -229,9 +190,7 @@ 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();
|
||||
on_log(format!(
|
||||
@ -308,10 +267,7 @@ fn load_audio_f32(path: &Path) -> Result<Vec<f32>> {
|
||||
.with_context(|| format!("failed to open wav file: {}", path.display()))?;
|
||||
let spec = reader.spec();
|
||||
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 {
|
||||
return Err(anyhow!("whisper expects mono audio, got {}", spec.channels));
|
||||
@ -319,11 +275,7 @@ fn load_audio_f32(path: &Path) -> Result<Vec<f32>> {
|
||||
|
||||
let samples = reader
|
||||
.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<_>>>()?;
|
||||
|
||||
Ok(samples)
|
||||
@ -384,9 +336,7 @@ fn should_prefer_full_audio(
|
||||
full_text_len > vad_text_len + vad_text_len * 3 / 5
|
||||
|| full_audio_segments.len() > vad_segments.len() + 5
|
||||
|| 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>(
|
||||
|
||||
240
src/App.vue
@ -2,9 +2,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open, save } from '@tauri-apps/plugin-dialog'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import TaskQueue from './components/TaskQueue.vue'
|
||||
import SubtitleEditor from './components/SubtitleEditor.vue'
|
||||
import { useTaskStore } from './stores/tasks'
|
||||
@ -53,7 +52,6 @@ const MEDIA_EXTENSIONS = [
|
||||
'mpeg',
|
||||
'mpg',
|
||||
] as const
|
||||
const FREE_API_KEY_URL = 'https://docs.bigmodel.cn/cn/guide/models/free/glm-4.7-flash'
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const targetLang = ref<TargetLanguage>('zh')
|
||||
@ -66,21 +64,14 @@ const defaultModelPaths = ref<DefaultModelPaths | null>(null)
|
||||
const translationConfig = ref<TranslationConfig>({
|
||||
apiBase: localStorage.getItem('llm.apiBase') ?? 'https://open.bigmodel.cn/api/paas/v4',
|
||||
apiKey: localStorage.getItem('llm.apiKey') ?? '',
|
||||
model: localStorage.getItem('llm.model') ?? 'GLM-4.7-Flash',
|
||||
model: localStorage.getItem('llm.model') ?? 'GLM-4-Flash-250414',
|
||||
batchSize: Number(localStorage.getItem('llm.batchSize') ?? '12'),
|
||||
contextSize: Number(localStorage.getItem('llm.contextSize') ?? '5'),
|
||||
contextSize: Number(localStorage.getItem('llm.contextSize') ?? '3'),
|
||||
})
|
||||
const pending = ref(false)
|
||||
const feedback = ref('')
|
||||
const feedbackTone = ref<'normal' | 'error'>('normal')
|
||||
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 dragDropUnlistenFn: UnlistenFn | null = null
|
||||
let authorCopyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const selectedTask = computed(() => taskStore.selectedTask)
|
||||
const hasTranslationKey = computed(() => translationConfig.value.apiKey.trim().length > 0)
|
||||
@ -89,7 +80,6 @@ onMounted(() => {
|
||||
taskStore.initialize()
|
||||
void loadDefaultModelPaths()
|
||||
void bindMenuActions()
|
||||
void initDragDrop()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -97,33 +87,10 @@ onUnmounted(() => {
|
||||
unlistenMenuAction()
|
||||
unlistenMenuAction = null
|
||||
}
|
||||
if (dragDropUnlistenFn) {
|
||||
dragDropUnlistenFn()
|
||||
dragDropUnlistenFn = null
|
||||
}
|
||||
if (authorCopyTimer) {
|
||||
clearTimeout(authorCopyTimer)
|
||||
authorCopyTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(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() {
|
||||
@ -137,51 +104,9 @@ function persistTranslationConfig() {
|
||||
function resetModelPaths() {
|
||||
whisperModelPath.value = defaultModelPaths.value?.whisperModelPath ?? ''
|
||||
vadModelPath.value = defaultModelPaths.value?.vadModelPath ?? ''
|
||||
feedbackTone.value = 'normal'
|
||||
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() {
|
||||
try {
|
||||
const paths = await invoke<DefaultModelPaths>('get_default_model_paths')
|
||||
@ -236,29 +161,30 @@ async function bindMenuActions() {
|
||||
}
|
||||
|
||||
async function submitFiles(filePaths: string[]) {
|
||||
if (outputMode.value === 'translate' && !hasTranslationKey.value) {
|
||||
openApiKeyDialog()
|
||||
return
|
||||
}
|
||||
|
||||
pending.value = true
|
||||
feedbackTone.value = 'normal'
|
||||
feedback.value = ''
|
||||
|
||||
try {
|
||||
const fallbackToSource = outputMode.value === 'translate' && !hasTranslationKey.value
|
||||
const effectiveOutputMode: OutputMode = fallbackToSource ? 'source' : outputMode.value
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
await taskStore.startTask({
|
||||
filePath,
|
||||
sourceLang: sourceLang.value === 'auto' ? null : sourceLang.value,
|
||||
targetLang: targetLang.value,
|
||||
outputMode: outputMode.value,
|
||||
outputMode: effectiveOutputMode,
|
||||
bilingualOutput: bilingualOutput.value,
|
||||
translationConfig: outputMode.value === 'translate' ? translationConfig.value : null,
|
||||
translationConfig: effectiveOutputMode === 'translate' ? translationConfig.value : null,
|
||||
whisperModelPath: whisperModelPath.value || null,
|
||||
vadModelPath: vadModelPath.value || null,
|
||||
})
|
||||
}
|
||||
feedback.value = t('app.feedback.submitted', { count: filePaths.length })
|
||||
if (fallbackToSource) {
|
||||
feedback.value = t('app.feedback.fallbackSource')
|
||||
} else {
|
||||
feedback.value = t('app.feedback.submitted', { count: filePaths.length })
|
||||
}
|
||||
} catch (error) {
|
||||
feedback.value = error instanceof Error ? error.message : t('app.feedback.submitFailed')
|
||||
} finally {
|
||||
@ -268,7 +194,6 @@ async function submitFiles(filePaths: string[]) {
|
||||
|
||||
async function handlePickFiles() {
|
||||
try {
|
||||
feedbackTone.value = 'normal'
|
||||
feedback.value = ''
|
||||
persistTranslationConfig()
|
||||
const selected = await open({
|
||||
@ -294,43 +219,6 @@ 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) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
@ -348,114 +236,21 @@ async function handleFiles(event: Event) {
|
||||
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') {
|
||||
if (!selectedTask.value) return
|
||||
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)
|
||||
const output = await taskStore.exportTask(selectedTask.value.id, format)
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="toolbar-main">
|
||||
<div class="toolbar-title">
|
||||
<strong>CrossSubtitle</strong>
|
||||
<span class="credit-line">
|
||||
by <button class="author-link" type="button" @click="copyAuthorUrl">kuraa</button>
|
||||
<span v-if="authorUrlCopied" class="author-copy-hint">{{ $t('app.feedback.copied') }}</span>
|
||||
by <a href="https://kuraa.cc" target="_blank" rel="noreferrer">kuraa</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
@ -505,7 +300,7 @@ async function handleTranslateFromEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="feedback" class="feedback status-text" :class="{ error: feedbackTone === 'error' }">{{ feedback }}</p>
|
||||
<p v-if="feedback" class="feedback status-text">{{ feedback }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showAdvanced" class="advanced-shell">
|
||||
@ -557,14 +352,11 @@ async function handleTranslateFromEditor() {
|
||||
:selected-task-id="taskStore.selectedTaskId"
|
||||
@select="taskStore.selectTask"
|
||||
@retry="taskStore.retryTask"
|
||||
@retry-translate="handleRetryTranslate"
|
||||
@delete="taskStore.deleteTask"
|
||||
/>
|
||||
<SubtitleEditor
|
||||
:task="selectedTask"
|
||||
:logs="taskStore.selectedTaskLogs"
|
||||
@save="taskStore.updateSegment"
|
||||
@translate="handleTranslateFromEditor"
|
||||
@export="handleExport"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
const props = defineProps<{
|
||||
task: SubtitleTask | null
|
||||
logs: string[]
|
||||
@ -18,67 +16,20 @@ const canExport = computed(() => {
|
||||
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<{
|
||||
save: [segment: SubtitleSegment]
|
||||
export: [format: 'srt' | 'vtt' | 'ass']
|
||||
translate: []
|
||||
}>()
|
||||
|
||||
const sortedSegments = computed(() =>
|
||||
const segments = computed(() =>
|
||||
[...(props.task?.segments ?? [])].sort((left, right) => {
|
||||
if (left.start !== right.start) return left.start - right.start
|
||||
if (left.end !== right.end) return left.end - right.end
|
||||
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 logContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.logs.length, () => {
|
||||
nextTick(() => {
|
||||
if (logContainerRef.value) {
|
||||
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const ms = Math.round(seconds * 1000)
|
||||
const h = Math.floor(ms / 3600000)
|
||||
@ -99,16 +50,10 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
<div>
|
||||
<strong>{{ task?.fileName ?? $t('editor.title') }}</strong>
|
||||
<p class="panel-subtitle">
|
||||
{{ task ? $t('editor.segments', { count: sortedSegments.length }) : $t('editor.selectTask') }}
|
||||
{{ task ? $t('editor.segments', { count: segments.length }) : $t('editor.selectTask') }}
|
||||
</p>
|
||||
</div>
|
||||
<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', 'vtt')">VTT</button>
|
||||
<button class="button secondary small" :disabled="!canExport" @click="emit('export', 'ass')">ASS</button>
|
||||
@ -120,21 +65,15 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
<p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sortedSegments.length === 0" class="empty-state">
|
||||
<div v-else-if="segments.length === 0" class="empty-state">
|
||||
<template v-if="isProcessing">{{ $t('editor.processing') }}</template>
|
||||
<template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template>
|
||||
<template v-else>{{ $t('editor.noSegments') }}</template>
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="segment in visibleSegments"
|
||||
v-for="segment in segments"
|
||||
:key="segment.id"
|
||||
class="segment-item"
|
||||
>
|
||||
@ -151,11 +90,6 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</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 class="log-drawer" :class="{ expanded: logsExpanded }">
|
||||
@ -168,8 +102,8 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
<div v-if="logs.length === 0" class="empty-state">
|
||||
{{ $t('editor.noLogs') }}
|
||||
</div>
|
||||
<div v-else ref="logContainerRef" class="log-list">
|
||||
<pre v-for="(line, i) in logs" :key="i" class="log-line">{{ line }}</pre>
|
||||
<div v-else class="log-list">
|
||||
<pre v-for="(line, index) in logs" :key="`${index}-${line}`" class="log-line">{{ line }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { SubtitleTask } from '../lib/types'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
tasks: SubtitleTask[]
|
||||
selectedTaskId: string
|
||||
}>()
|
||||
@ -10,30 +9,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
select: [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>
|
||||
|
||||
<template>
|
||||
@ -51,129 +27,32 @@ function isActive(task: SubtitleTask): boolean {
|
||||
</div>
|
||||
|
||||
<div v-else class="list-stack">
|
||||
<div
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="task-item-wrapper"
|
||||
:class="{ processing: isActive(task) }"
|
||||
class="task-item"
|
||||
:class="{
|
||||
active: task.id === selectedTaskId,
|
||||
completed: task.status === 'completed',
|
||||
failed: task.status === 'failed',
|
||||
}"
|
||||
@click="emit('select', task.id)"
|
||||
>
|
||||
<button
|
||||
class="task-item"
|
||||
:class="{
|
||||
active: task.id === selectedTaskId,
|
||||
completed: task.status === 'completed',
|
||||
failed: task.status === 'failed',
|
||||
expanded: expandedTasks.has(task.id),
|
||||
}"
|
||||
@click="emit('select', task.id)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<span
|
||||
class="subtle"
|
||||
:class="{ 'status-active': isActive(task) }"
|
||||
>{{ $t(`taskQueue.status.${task.status}`) }}</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: `${task.progress}%` }" />
|
||||
</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">
|
||||
<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
|
||||
v-if="task.segments.length > 0"
|
||||
class="retry-button secondary"
|
||||
type="button"
|
||||
@click.stop="emit('retryTranslate', task.id)"
|
||||
>{{ $t('taskQueue.retryTranslate') }}</button>
|
||||
</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>
|
||||
</div>
|
||||
<div class="task-row">
|
||||
<strong class="truncate">{{ task.fileName }}</strong>
|
||||
<span
|
||||
class="subtle"
|
||||
:class="{ 'status-active': task.status !== 'completed' && task.status !== 'failed' && task.status !== 'queued' }"
|
||||
>{{ $t(`taskQueue.status.${task.status}`) }}</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: `${task.progress}%` }" />
|
||||
</div>
|
||||
<div v-if="task.status === 'failed'" class="failed-footer">
|
||||
<p class="error-text">{{ task.error }}</p>
|
||||
<button class="retry-button" type="button" @click.stop="emit('retry', task.id)">{{ $t('taskQueue.retry') }}</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@ -19,13 +19,6 @@ export interface SubtitleSegment {
|
||||
translatedText?: string | null
|
||||
}
|
||||
|
||||
export interface SubStageProgress {
|
||||
extracting: number
|
||||
vad: number
|
||||
transcribing: number
|
||||
translating: number
|
||||
}
|
||||
|
||||
export interface SubtitleTask {
|
||||
id: string
|
||||
filePath: string
|
||||
@ -38,7 +31,6 @@ export interface SubtitleTask {
|
||||
progress: number
|
||||
segments: SubtitleSegment[]
|
||||
error?: string | null
|
||||
subStageProgress: SubStageProgress
|
||||
}
|
||||
|
||||
export interface TranslationConfig {
|
||||
@ -70,7 +62,6 @@ export interface ProgressEvent {
|
||||
status: TaskStatus
|
||||
progress: number
|
||||
message: string
|
||||
subStageProgress: SubStageProgress
|
||||
}
|
||||
|
||||
export interface SegmentEvent {
|
||||
|
||||
@ -27,13 +27,6 @@ export default {
|
||||
submitFailed: 'Task submission failed',
|
||||
dialogError: 'Failed to open file dialog: {message}',
|
||||
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: {
|
||||
apiBase: 'LLM API Base',
|
||||
@ -56,9 +49,6 @@ export default {
|
||||
subtitle: 'Select a task to view subtitles',
|
||||
empty: 'No tasks',
|
||||
retry: 'Retry',
|
||||
retryTranslate: 'Retry Translation',
|
||||
delete: 'Delete',
|
||||
queuedHint: 'Waiting in queue...',
|
||||
status: {
|
||||
queued: 'Queued',
|
||||
extracting: 'Extracting',
|
||||
@ -68,12 +58,6 @@ export default {
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
},
|
||||
subStage: {
|
||||
extracting: 'Extract Audio',
|
||||
vad: 'Voice Detection',
|
||||
transcribing: 'Transcribe',
|
||||
translating: 'Translate',
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
title: 'Workspace',
|
||||
@ -85,11 +69,9 @@ export default {
|
||||
failed: 'Task failed, unable to generate subtitles',
|
||||
noSegments: 'No subtitle segments',
|
||||
waiting: 'Waiting for transcription...',
|
||||
translate: 'Translate',
|
||||
translation: 'Translation',
|
||||
source: 'Source',
|
||||
logs: 'Logs',
|
||||
noLogs: 'No logs',
|
||||
pageInfo: 'Page {current}/{total}',
|
||||
},
|
||||
}
|
||||
|
||||
@ -27,13 +27,6 @@ export default {
|
||||
submitFailed: '任务提交失败',
|
||||
dialogError: '打开文件对话框失败:{message}',
|
||||
dialogErrorFallback: '打开文件对话框失败',
|
||||
noApiKey: '请先配置 LLM API Key',
|
||||
translationStarted: '翻译任务已开始',
|
||||
translationFailed: '翻译失败',
|
||||
noMediaFiles: '拖入的文件中没有支持的音视频文件',
|
||||
someFilesSkipped: '{count} 个文件被跳过(不支持的格式)',
|
||||
dropHint: '松开以添加文件',
|
||||
copied: '复制成功🎉',
|
||||
},
|
||||
llm: {
|
||||
apiBase: 'LLM API Base',
|
||||
@ -56,9 +49,6 @@ export default {
|
||||
subtitle: '选择任务查看字幕',
|
||||
empty: '暂无任务',
|
||||
retry: '重试',
|
||||
retryTranslate: '重试翻译',
|
||||
delete: '移除',
|
||||
queuedHint: '正在排队等待...',
|
||||
status: {
|
||||
queued: '排队中',
|
||||
extracting: '抽取',
|
||||
@ -68,12 +58,6 @@ export default {
|
||||
completed: '完成',
|
||||
failed: '失败',
|
||||
},
|
||||
subStage: {
|
||||
extracting: '音频抽取',
|
||||
vad: '语音检测',
|
||||
transcribing: '语音识别',
|
||||
translating: '翻译',
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
title: '字幕工作区',
|
||||
@ -85,11 +69,9 @@ export default {
|
||||
failed: '任务处理失败,无法生成字幕',
|
||||
noSegments: '暂无字幕片段',
|
||||
waiting: '等待识别结果...',
|
||||
translate: '翻译',
|
||||
translation: '译文',
|
||||
source: '原文',
|
||||
logs: '日志',
|
||||
noLogs: '暂无日志',
|
||||
pageInfo: '第 {current}/{total} 页',
|
||||
},
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import type {
|
||||
StartTaskPayload,
|
||||
SubtitleSegment,
|
||||
SubtitleTask,
|
||||
TranslationConfig,
|
||||
} from '../lib/types'
|
||||
|
||||
type ExportFormat = 'srt' | 'vtt' | 'ass'
|
||||
@ -51,9 +50,6 @@ export const useTaskStore = defineStore('tasks', {
|
||||
if (!task) return
|
||||
task.status = payload.status
|
||||
task.progress = payload.progress
|
||||
if (payload.subStageProgress) {
|
||||
Object.assign(task.subStageProgress, payload.subStageProgress)
|
||||
}
|
||||
})
|
||||
|
||||
const segmentUnlisten = await listen<SegmentEvent>('task:segment', ({ payload }) => {
|
||||
@ -94,9 +90,9 @@ export const useTaskStore = defineStore('tasks', {
|
||||
|
||||
const doneUnlisten = await listen<SubtitleTask>('task:done', ({ payload }) => {
|
||||
sortSegments(payload.segments)
|
||||
const task = this.tasks.find((item) => item.id === payload.id)
|
||||
if (task) {
|
||||
Object.assign(task, payload)
|
||||
const index = this.tasks.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) {
|
||||
this.tasks[index] = payload
|
||||
} else {
|
||||
this.tasks.unshift(payload)
|
||||
}
|
||||
@ -134,20 +130,6 @@ export const useTaskStore = defineStore('tasks', {
|
||||
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) {
|
||||
this.selectedTaskId = taskId
|
||||
},
|
||||
@ -158,20 +140,8 @@ export const useTaskStore = defineStore('tasks', {
|
||||
if (index >= 0) this.tasks[index] = updated
|
||||
},
|
||||
|
||||
async deleteTask(taskId: string) {
|
||||
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 })
|
||||
async exportTask(taskId: string, format: ExportFormat) {
|
||||
return invoke<string>('export_subtitles', { taskId, format })
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
491
src/style.css
@ -157,27 +157,17 @@ textarea {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.credit-line a,
|
||||
.author-link {
|
||||
.credit-line a {
|
||||
color: var(--c-text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.credit-line a:hover,
|
||||
.author-link:hover {
|
||||
.credit-line a:hover {
|
||||
border-bottom-color: var(--c-text);
|
||||
}
|
||||
|
||||
.author-copy-hint {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workspace-toolbar,
|
||||
.advanced-shell {
|
||||
margin-top: 16px;
|
||||
@ -342,11 +332,6 @@ textarea {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
color: var(--c-error);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
@ -393,10 +378,14 @@ textarea {
|
||||
color: var(--c-text-tertiary);
|
||||
}
|
||||
|
||||
.list-stack {
|
||||
.list-stack,
|
||||
.segment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-stack {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
@ -404,13 +393,8 @@ textarea {
|
||||
.segment-list {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.segment-item + .segment-item {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.task-item,
|
||||
@ -458,50 +442,6 @@ textarea {
|
||||
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 {
|
||||
border-color: var(--c-error);
|
||||
background: rgba(220, 38, 38, 0.03);
|
||||
@ -543,129 +483,11 @@ textarea {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-item-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
position: relative;
|
||||
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 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@ -765,7 +587,6 @@ textarea {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--c-log-bg);
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
@ -776,7 +597,6 @@ textarea {
|
||||
line-height: 1.6;
|
||||
color: var(--c-log-text);
|
||||
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.log-line + .log-line {
|
||||
@ -829,7 +649,6 @@ textarea {
|
||||
border-left: 3px solid transparent;
|
||||
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
|
||||
position: relative;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.segment-item:hover {
|
||||
@ -848,41 +667,6 @@ textarea {
|
||||
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) {
|
||||
.content-grid {
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
@ -900,262 +684,3 @@ textarea {
|
||||
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
@ -1,983 +0,0 @@
|
||||
# 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"
|
||||