Compare commits
5 Commits
c855cf5be7
...
bba78d1dca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba78d1dca | ||
|
|
42d233c0d7 | ||
|
|
c3b69670df | ||
|
|
479049501b | ||
|
|
a7046eba8c |
67
package-lock.json
generated
67
package-lock.json
generated
@ -11,7 +11,8 @@
|
|||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13",
|
||||||
|
"vue-i18n": "^9.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.0.4",
|
"@tauri-apps/cli": "^2.0.4",
|
||||||
@ -525,6 +526,50 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "9.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz",
|
||||||
|
"integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/message-compiler": "9.14.4",
|
||||||
|
"@intlify/shared": "9.14.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "9.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz",
|
||||||
|
"integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "9.14.4",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "9.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz",
|
||||||
|
"integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@ -2848,6 +2893,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "9.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz",
|
||||||
|
"integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "9.14.4",
|
||||||
|
"@intlify/shared": "9.14.4",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-tsc": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "2.2.12",
|
"version": "2.2.12",
|
||||||
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
|
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
|
||||||
|
|||||||
@ -18,7 +18,8 @@
|
|||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13",
|
||||||
|
"vue-i18n": "^9.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.0.4",
|
"@tauri-apps/cli": "^2.0.4",
|
||||||
|
|||||||
@ -146,6 +146,11 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
.item(&MenuItemBuilder::with_id("reset_models", "恢复默认模型路径").build(app)?)
|
.item(&MenuItemBuilder::with_id("reset_models", "恢复默认模型路径").build(app)?)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
|
let language_menu = SubmenuBuilder::new(app, "语言")
|
||||||
|
.item(&MenuItemBuilder::with_id("set_lang_zh_cn", "中文").build(app)?)
|
||||||
|
.item(&MenuItemBuilder::with_id("set_lang_en", "English").build(app)?)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
let window_menu = SubmenuBuilder::new(app, "窗口")
|
let window_menu = SubmenuBuilder::new(app, "窗口")
|
||||||
.item(&PredefinedMenuItem::minimize(app, None)?)
|
.item(&PredefinedMenuItem::minimize(app, None)?)
|
||||||
.item(&PredefinedMenuItem::maximize(app, None)?)
|
.item(&PredefinedMenuItem::maximize(app, None)?)
|
||||||
@ -158,6 +163,7 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
.item(&file_menu)
|
.item(&file_menu)
|
||||||
.item(&edit_menu)
|
.item(&edit_menu)
|
||||||
.item(&settings_menu)
|
.item(&settings_menu)
|
||||||
|
.item(&language_menu)
|
||||||
.item(&window_menu)
|
.item(&window_menu)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
@ -171,6 +177,8 @@ fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
"toggle_advanced" => Some("toggle-advanced"),
|
"toggle_advanced" => Some("toggle-advanced"),
|
||||||
"toggle_bilingual" => Some("toggle-bilingual"),
|
"toggle_bilingual" => Some("toggle-bilingual"),
|
||||||
"reset_models" => Some("reset-models"),
|
"reset_models" => Some("reset-models"),
|
||||||
|
"set_lang_zh_cn" => Some("set-lang-zh-CN"),
|
||||||
|
"set_lang_en" => Some("set-lang-en"),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -146,26 +146,38 @@ async fn run_pipeline(
|
|||||||
let app_state = app.state::<AppState>();
|
let app_state = app.state::<AppState>();
|
||||||
let workspace = std::env::temp_dir().join("crosssubtitle-ai").join(&task.id);
|
let workspace = std::env::temp_dir().join("crosssubtitle-ai").join(&task.id);
|
||||||
let should_translate = matches!(payload.output_mode, OutputMode::Translate);
|
let should_translate = matches!(payload.output_mode, OutputMode::Translate);
|
||||||
|
|
||||||
|
if let Some(ref whisper_path) = payload.whisper_model_path {
|
||||||
|
if !Path::new(whisper_path).exists() {
|
||||||
|
return Err(anyhow::anyhow!("Whisper 模型文件不存在: {whisper_path}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref vad_path) = payload.vad_model_path {
|
||||||
|
if !Path::new(vad_path).exists() {
|
||||||
|
return Err(anyhow::anyhow!("VAD 模型文件不存在: {vad_path}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let ffmpeg_path = resolve_ffmpeg_path(&app)
|
let ffmpeg_path = resolve_ffmpeg_path(&app)
|
||||||
.ok_or_else(|| anyhow::anyhow!("未找到可用 ffmpeg,请重新执行打包命令或在系统中安装 ffmpeg"))?;
|
.ok_or_else(|| anyhow::anyhow!("未找到可用 ffmpeg,请重新执行打包命令或在系统中安装 ffmpeg"))?;
|
||||||
|
|
||||||
set_status(&window, &app_state, &mut task, TaskStatus::Extracting, 8.0, "正在抽取音频")?;
|
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!("task: input file={}", payload.file_path))?;
|
||||||
emit_log(&window, &task.id, format!("audio: ffmpeg={}", ffmpeg_path.display()))?;
|
emit_log(&window, &task.id, format!("audio: ffmpeg={}", ffmpeg_path.display()))?;
|
||||||
let wav_path = AudioPipeline::extract_to_wav(&ffmpeg_path, &payload.file_path, &workspace)?;
|
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()))?;
|
emit_log(&window, &task.id, format!("audio: normalized wav={}", wav_path.display()))?;
|
||||||
|
|
||||||
set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 22.0, "正在分析语音片段")?;
|
set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 15.0, "正在分析语音片段")?;
|
||||||
let samples = AudioPipeline::load_wav_f32(&wav_path)?;
|
let samples = AudioPipeline::load_wav_f32(&wav_path)?;
|
||||||
let vad = VadEngine::new(payload.vad_model_path.clone(), VadConfig::default())?;
|
let vad = VadEngine::new(payload.vad_model_path.clone(), VadConfig::default())?;
|
||||||
let speech_ranges = vad.detect_segments(&samples);
|
let speech_ranges = vad.detect_segments(&samples).await;
|
||||||
emit_log(
|
emit_log(
|
||||||
&window,
|
&window,
|
||||||
&task.id,
|
&task.id,
|
||||||
format!("vad: detected {} speech ranges", speech_ranges.len()),
|
format!("vad: detected {} speech ranges", speech_ranges.len()),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 45.0, "正在执行 Whisper")?;
|
set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 30.0, "正在执行 Whisper")?;
|
||||||
let whisper = WhisperEngine::new(payload.whisper_model_path.clone());
|
let whisper = WhisperEngine::new(payload.whisper_model_path.clone());
|
||||||
let task_id_for_progress = task.id.clone();
|
let task_id_for_progress = task.id.clone();
|
||||||
let task_id_for_segment = task.id.clone();
|
let task_id_for_segment = task.id.clone();
|
||||||
@ -181,7 +193,7 @@ async fn run_pipeline(
|
|||||||
should_translate,
|
should_translate,
|
||||||
&speech_ranges,
|
&speech_ranges,
|
||||||
|ratio| {
|
|ratio| {
|
||||||
let progress = 45.0 + ratio.clamp(0.0, 1.0) * 27.0;
|
let progress = 30.0 + ratio.clamp(0.0, 1.0) * 40.0;
|
||||||
window.emit(
|
window.emit(
|
||||||
"task:progress",
|
"task:progress",
|
||||||
ProgressEvent {
|
ProgressEvent {
|
||||||
@ -232,11 +244,13 @@ async fn run_pipeline(
|
|||||||
.clone()
|
.clone()
|
||||||
.or_else(load_translation_config)
|
.or_else(load_translation_config)
|
||||||
.ok_or_else(|| anyhow::anyhow!("翻译模式需要填写 LLM API 配置,或设置 OPENAI_API_BASE / OPENAI_API_KEY"))?;
|
.ok_or_else(|| anyhow::anyhow!("翻译模式需要填写 LLM API 配置,或设置 OPENAI_API_BASE / OPENAI_API_KEY"))?;
|
||||||
set_status(&window, &app_state, &mut task, TaskStatus::Translating, 72.0, "正在生成译文")?;
|
set_status(&window, &app_state, &mut task, TaskStatus::Translating, 70.0, "正在生成译文")?;
|
||||||
let translator = Translator::new(config)?;
|
let translator = Translator::new(config)?;
|
||||||
let task_id_for_translate = task.id.clone();
|
let task_id_for_translate = task.id.clone();
|
||||||
let app_state_for_translate = app_state.clone();
|
let app_state_for_translate = app_state.clone();
|
||||||
let window_for_translate = window.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
|
segments = translator
|
||||||
.translate_segments_with_progress(
|
.translate_segments_with_progress(
|
||||||
&segments,
|
&segments,
|
||||||
@ -244,6 +258,18 @@ async fn run_pipeline(
|
|||||||
|message| {
|
|message| {
|
||||||
let _ = emit_log(&window_for_translate, &task_id_for_translate, 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| {
|
|segment| {
|
||||||
if let Ok(mut current_task) = app_state_for_translate.get_task(&task_id_for_translate) {
|
if let Ok(mut current_task) = app_state_for_translate.get_task(&task_id_for_translate) {
|
||||||
upsert_segment(&mut current_task.segments, segment.clone());
|
upsert_segment(&mut current_task.segments, segment.clone());
|
||||||
|
|||||||
@ -70,15 +70,17 @@ impl Translator {
|
|||||||
Ok(Self { client, config })
|
Ok(Self { client, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn translate_segments_with_progress<LF, SF>(
|
pub async fn translate_segments_with_progress<LF, PF, SF>(
|
||||||
&self,
|
&self,
|
||||||
segments: &[SubtitleSegment],
|
segments: &[SubtitleSegment],
|
||||||
target_language: &TargetLanguage,
|
target_language: &TargetLanguage,
|
||||||
mut log: LF,
|
mut log: LF,
|
||||||
|
mut on_progress: PF,
|
||||||
mut emit_segment: SF,
|
mut emit_segment: SF,
|
||||||
) -> Result<Vec<SubtitleSegment>>
|
) -> Result<Vec<SubtitleSegment>>
|
||||||
where
|
where
|
||||||
LF: FnMut(String),
|
LF: FnMut(String),
|
||||||
|
PF: FnMut(f32),
|
||||||
SF: FnMut(SubtitleSegment),
|
SF: FnMut(SubtitleSegment),
|
||||||
{
|
{
|
||||||
let batch_size = self.config.batch_size.clamp(10, 15);
|
let batch_size = self.config.batch_size.clamp(10, 15);
|
||||||
@ -88,8 +90,9 @@ impl Translator {
|
|||||||
TargetLanguage::Zh => "简体中文",
|
TargetLanguage::Zh => "简体中文",
|
||||||
TargetLanguage::En => "英文",
|
TargetLanguage::En => "英文",
|
||||||
};
|
};
|
||||||
|
let total_batches = (segments.len() + batch_size - 1) / batch_size;
|
||||||
|
|
||||||
for batch_start in (0..segments.len()).step_by(batch_size) {
|
for (batch_index, batch_start) in (0..segments.len()).step_by(batch_size).enumerate() {
|
||||||
let batch_end = (batch_start + batch_size).min(segments.len());
|
let batch_end = (batch_start + batch_size).min(segments.len());
|
||||||
let context_start = batch_start.saturating_sub(context_size);
|
let context_start = batch_start.saturating_sub(context_size);
|
||||||
let context = &segments[context_start..batch_start];
|
let context = &segments[context_start..batch_start];
|
||||||
@ -104,6 +107,8 @@ impl Translator {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
));
|
));
|
||||||
|
let batch_progress = (batch_index + 1) as f32 / total_batches.max(1) as f32;
|
||||||
|
on_progress(batch_progress);
|
||||||
let rows = self
|
let rows = self
|
||||||
.translate_batch_with_retries(context, batch, target_language_name)
|
.translate_batch_with_retries(context, batch, target_language_name)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::mpsc,
|
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -15,6 +14,7 @@ pub struct VadConfig {
|
|||||||
pub min_speech_ms: usize,
|
pub min_speech_ms: usize,
|
||||||
pub min_silence_ms: usize,
|
pub min_silence_ms: usize,
|
||||||
pub pad_ms: usize,
|
pub pad_ms: usize,
|
||||||
|
pub timeout_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for VadConfig {
|
impl Default for VadConfig {
|
||||||
@ -25,6 +25,7 @@ impl Default for VadConfig {
|
|||||||
min_speech_ms: 180,
|
min_speech_ms: 180,
|
||||||
min_silence_ms: 320,
|
min_silence_ms: 320,
|
||||||
pad_ms: 220,
|
pad_ms: 220,
|
||||||
|
timeout_seconds: 60,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,49 +47,65 @@ impl VadEngine {
|
|||||||
Ok(Self { model_path, config })
|
Ok(Self { model_path, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn detect_segments(&self, samples: &[f32]) -> Vec<(f32, f32)> {
|
pub async fn detect_segments(&self, samples: &[f32]) -> Vec<(f32, f32)> {
|
||||||
if let Some(model_path) = &self.model_path {
|
if self.model_path.is_some() {
|
||||||
let model_path = model_path.clone();
|
let samples_owned = samples.to_vec();
|
||||||
let samples = samples.to_vec();
|
let model_path = self.model_path.clone().unwrap();
|
||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
let (sender, receiver) = mpsc::channel();
|
let timeout_secs = self.config.timeout_seconds;
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
match tokio::time::timeout(
|
||||||
let engine = VadEngine {
|
Duration::from_secs(timeout_secs),
|
||||||
model_path: Some(model_path.clone()),
|
tokio::task::spawn_blocking(move || {
|
||||||
config,
|
let mut session = match Self::load_onnx_session(&model_path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("vad: failed to load onnx session: {e:#}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let result = engine.detect_segments_with_onnx(&samples, &model_path);
|
Self::detect_with_onnx(&mut session, &samples_owned, &config).ok()
|
||||||
let _ = sender.send(result);
|
}),
|
||||||
});
|
)
|
||||||
|
.await
|
||||||
match receiver.recv_timeout(Duration::from_secs(3)) {
|
{
|
||||||
Ok(Ok(result)) if !result.is_empty() => return result,
|
Ok(Ok(Some(ranges))) if !ranges.is_empty() => {
|
||||||
|
eprintln!("vad: onnx detected {} speech ranges", ranges.len());
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
Ok(Ok(_)) => {}
|
Ok(Ok(_)) => {}
|
||||||
Ok(Err(error)) => {
|
Ok(Err(e)) => {
|
||||||
eprintln!("silero vad failed, falling back to energy detection: {error:#}");
|
eprintln!("vad: onnx error: {e:#}, falling back to energy detection");
|
||||||
}
|
}
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
Err(_) => {
|
||||||
eprintln!("silero vad timed out, falling back to energy detection");
|
eprintln!(
|
||||||
}
|
"vad: onnx timed out after {}s, falling back to energy detection",
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
timeout_secs
|
||||||
eprintln!("silero vad worker disconnected, falling back to energy detection");
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.detect_segments_with_energy(samples)
|
let ranges = self.detect_segments_with_energy(samples);
|
||||||
|
eprintln!("vad: energy detection found {} speech ranges", ranges.len());
|
||||||
|
ranges
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_segments_with_onnx(&self, samples: &[f32], model_path: &Path) -> Result<Vec<(f32, f32)>> {
|
fn load_onnx_session(model_path: &Path) -> Result<Session> {
|
||||||
let mut session = Session::builder()
|
Session::builder()
|
||||||
.context("failed to build onnx session")?
|
.context("failed to build onnx session")?
|
||||||
.commit_from_file(model_path)
|
.commit_from_file(model_path)
|
||||||
.with_context(|| format!("failed to load silero vad model: {}", model_path.display()))?;
|
.with_context(|| format!("failed to load silero vad model: {}", model_path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_with_onnx(
|
||||||
|
session: &mut Session,
|
||||||
|
samples: &[f32],
|
||||||
|
config: &VadConfig,
|
||||||
|
) -> Result<Vec<(f32, f32)>> {
|
||||||
let chunk_size = 512usize;
|
let chunk_size = 512usize;
|
||||||
let mut state = Array3::<f32>::zeros((2, 1, 128));
|
let mut state = Array3::<f32>::zeros((2, 1, 128));
|
||||||
let sr = Array1::<i64>::from_vec(vec![self.config.sample_rate as i64]);
|
let sr = Array1::<i64>::from_vec(vec![config.sample_rate as i64]);
|
||||||
let mut speech_probabilities = Vec::new();
|
let mut speech_probabilities = Vec::new();
|
||||||
|
|
||||||
for chunk in samples.chunks(chunk_size) {
|
for chunk in samples.chunks(chunk_size) {
|
||||||
@ -109,10 +126,7 @@ impl VadEngine {
|
|||||||
let (_, probs) = first
|
let (_, probs) = first
|
||||||
.try_extract_tensor::<f32>()
|
.try_extract_tensor::<f32>()
|
||||||
.context("failed to extract vad probabilities")?;
|
.context("failed to extract vad probabilities")?;
|
||||||
let probability = probs
|
let probability = probs.iter().copied().fold(0.0_f32, f32::max);
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.fold(0.0_f32, f32::max);
|
|
||||||
speech_probabilities.push(probability);
|
speech_probabilities.push(probability);
|
||||||
|
|
||||||
if outputs.len() > 1 {
|
if outputs.len() > 1 {
|
||||||
@ -127,7 +141,7 @@ impl VadEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(self.merge_probabilities(&speech_probabilities, chunk_size))
|
Ok(Self::merge_probabilities(&speech_probabilities, chunk_size, config))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_segments_with_energy(&self, samples: &[f32]) -> Vec<(f32, f32)> {
|
fn detect_segments_with_energy(&self, samples: &[f32]) -> Vec<(f32, f32)> {
|
||||||
@ -148,22 +162,22 @@ impl VadEngine {
|
|||||||
energies.len(),
|
energies.len(),
|
||||||
dynamic_threshold
|
dynamic_threshold
|
||||||
);
|
);
|
||||||
self.merge_probabilities_with_threshold(&energies, frame_size, dynamic_threshold)
|
Self::merge_probabilities_with_threshold(&energies, frame_size, dynamic_threshold, &self.config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_probabilities(&self, frames: &[f32], frame_size: usize) -> Vec<(f32, f32)> {
|
fn merge_probabilities(frames: &[f32], frame_size: usize, config: &VadConfig) -> Vec<(f32, f32)> {
|
||||||
self.merge_probabilities_with_threshold(frames, frame_size, self.config.threshold)
|
Self::merge_probabilities_with_threshold(frames, frame_size, config.threshold, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_probabilities_with_threshold(
|
fn merge_probabilities_with_threshold(
|
||||||
&self,
|
|
||||||
frames: &[f32],
|
frames: &[f32],
|
||||||
frame_size: usize,
|
frame_size: usize,
|
||||||
threshold: f32,
|
threshold: f32,
|
||||||
|
config: &VadConfig,
|
||||||
) -> Vec<(f32, f32)> {
|
) -> Vec<(f32, f32)> {
|
||||||
let min_speech_frames = (self.config.min_speech_ms / 20).max(1);
|
let min_speech_frames = (config.min_speech_ms / 20).max(1);
|
||||||
let min_silence_frames = (self.config.min_silence_ms / 20).max(1);
|
let min_silence_frames = (config.min_silence_ms / 20).max(1);
|
||||||
let pad_seconds = self.config.pad_ms as f32 / 1000.0;
|
let pad_seconds = config.pad_ms as f32 / 1000.0;
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut start_frame: Option<usize> = None;
|
let mut start_frame: Option<usize> = None;
|
||||||
@ -183,8 +197,8 @@ impl VadEngine {
|
|||||||
if silent_frames >= min_silence_frames {
|
if silent_frames >= min_silence_frames {
|
||||||
let end_frame = index.saturating_sub(silent_frames);
|
let end_frame = index.saturating_sub(silent_frames);
|
||||||
if end_frame.saturating_sub(start) >= min_speech_frames {
|
if end_frame.saturating_sub(start) >= min_speech_frames {
|
||||||
let start_sec = (start * frame_size) as f32 / self.config.sample_rate as f32;
|
let start_sec = (start * frame_size) as f32 / config.sample_rate as f32;
|
||||||
let end_sec = ((end_frame + 1) * frame_size) as f32 / self.config.sample_rate as f32;
|
let end_sec = ((end_frame + 1) * frame_size) as f32 / config.sample_rate as f32;
|
||||||
result.push(((start_sec - pad_seconds).max(0.0), end_sec + pad_seconds));
|
result.push(((start_sec - pad_seconds).max(0.0), end_sec + pad_seconds));
|
||||||
}
|
}
|
||||||
start_frame = None;
|
start_frame = None;
|
||||||
@ -196,14 +210,14 @@ impl VadEngine {
|
|||||||
if let Some(start) = start_frame {
|
if let Some(start) = start_frame {
|
||||||
let end_frame = frames.len().saturating_sub(1);
|
let end_frame = frames.len().saturating_sub(1);
|
||||||
if end_frame.saturating_sub(start) >= min_speech_frames {
|
if end_frame.saturating_sub(start) >= min_speech_frames {
|
||||||
let start_sec = (start * frame_size) as f32 / self.config.sample_rate as f32;
|
let start_sec = (start * frame_size) as f32 / config.sample_rate as f32;
|
||||||
let end_sec = ((end_frame + 1) * frame_size) as f32 / self.config.sample_rate as f32;
|
let end_sec = ((end_frame + 1) * frame_size) as f32 / config.sample_rate as f32;
|
||||||
result.push(((start_sec - pad_seconds).max(0.0), end_sec + pad_seconds));
|
result.push(((start_sec - pad_seconds).max(0.0), end_sec + pad_seconds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() && !frames.is_empty() {
|
if result.is_empty() && !frames.is_empty() {
|
||||||
let total_seconds = (frames.len() * frame_size) as f32 / self.config.sample_rate as f32;
|
let total_seconds = (frames.len() * frame_size) as f32 / config.sample_rate as f32;
|
||||||
result.push((0.0, total_seconds));
|
result.push((0.0, total_seconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -104,9 +104,9 @@ impl WhisperEngine {
|
|||||||
let vad_coverage = speech_coverage_ratio(&normalized_ranges, total_seconds);
|
let vad_coverage = speech_coverage_ratio(&normalized_ranges, total_seconds);
|
||||||
let should_retry_full_audio = !audio.is_empty()
|
let should_retry_full_audio = !audio.is_empty()
|
||||||
&& (segments.is_empty()
|
&& (segments.is_empty()
|
||||||
|| vad_coverage < 0.72
|
|| vad_coverage < 0.60
|
||||||
|| vad_end + 2.5 < total_seconds
|
|| vad_end + 5.0 < total_seconds
|
||||||
|| (total_seconds > 45.0 && vad_text_len < (total_seconds / 2.4) as usize));
|
|| (total_seconds > 60.0 && vad_text_len < (total_seconds / 3.0) as usize));
|
||||||
|
|
||||||
if should_retry_full_audio {
|
if should_retry_full_audio {
|
||||||
on_log(format!(
|
on_log(format!(
|
||||||
@ -141,6 +141,12 @@ impl WhisperEngine {
|
|||||||
))?;
|
))?;
|
||||||
segments = full_audio_segments;
|
segments = full_audio_segments;
|
||||||
} else {
|
} 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)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -327,10 +333,10 @@ fn should_prefer_full_audio(
|
|||||||
let vad_end = last_end(vad_segments);
|
let vad_end = last_end(vad_segments);
|
||||||
let full_end = last_end(full_audio_segments);
|
let full_end = last_end(full_audio_segments);
|
||||||
|
|
||||||
full_text_len > vad_text_len + vad_text_len / 5
|
full_text_len > vad_text_len + vad_text_len * 3 / 5
|
||||||
|| full_audio_segments.len() > vad_segments.len() + 2
|
|| full_audio_segments.len() > vad_segments.len() + 5
|
||||||
|| full_end > vad_end + 2.0
|
|| full_end > vad_end + 5.0
|
||||||
|| (total_seconds > 30.0 && full_end + 1.5 >= total_seconds && vad_end + 3.0 < total_seconds)
|
|| (total_seconds > 60.0 && full_end + 1.5 >= total_seconds && vad_end + 5.0 < total_seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_source_language<'a>(
|
fn resolve_source_language<'a>(
|
||||||
|
|||||||
81
src/App.vue
81
src/App.vue
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
@ -8,10 +9,8 @@ import SubtitleEditor from './components/SubtitleEditor.vue'
|
|||||||
import { useTaskStore } from './stores/tasks'
|
import { useTaskStore } from './stores/tasks'
|
||||||
import type { DefaultModelPaths, OutputMode, TargetLanguage, TranslationConfig } from './lib/types'
|
import type { DefaultModelPaths, OutputMode, TargetLanguage, TranslationConfig } from './lib/types'
|
||||||
|
|
||||||
const DEFAULT_WHISPER_MODEL = '应用内置 Whisper 模型'
|
const { t, locale } = useI18n()
|
||||||
const DEFAULT_VAD_MODEL = '应用内置 VAD 模型'
|
|
||||||
const SOURCE_LANGUAGE_OPTIONS = [
|
const SOURCE_LANGUAGE_OPTIONS = [
|
||||||
{ value: 'auto', label: '自动识别' },
|
|
||||||
{ value: 'zh', label: '中文' },
|
{ value: 'zh', label: '中文' },
|
||||||
{ value: 'en', label: 'English' },
|
{ value: 'en', label: 'English' },
|
||||||
{ value: 'ja', label: '日本語' },
|
{ value: 'ja', label: '日本語' },
|
||||||
@ -31,7 +30,7 @@ const SOURCE_LANGUAGE_OPTIONS = [
|
|||||||
{ value: 'uk', label: 'Українська' },
|
{ value: 'uk', label: 'Українська' },
|
||||||
{ value: 'pl', label: 'Polski' },
|
{ value: 'pl', label: 'Polski' },
|
||||||
{ value: 'nl', label: 'Nederlands' },
|
{ value: 'nl', label: 'Nederlands' },
|
||||||
] as const
|
]
|
||||||
const MEDIA_EXTENSIONS = [
|
const MEDIA_EXTENSIONS = [
|
||||||
'mp3',
|
'mp3',
|
||||||
'wav',
|
'wav',
|
||||||
@ -90,6 +89,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(locale, (newLocale) => {
|
||||||
|
localStorage.setItem('locale', newLocale)
|
||||||
|
})
|
||||||
|
|
||||||
function persistTranslationConfig() {
|
function persistTranslationConfig() {
|
||||||
localStorage.setItem('llm.apiBase', translationConfig.value.apiBase)
|
localStorage.setItem('llm.apiBase', translationConfig.value.apiBase)
|
||||||
localStorage.setItem('llm.apiKey', translationConfig.value.apiKey)
|
localStorage.setItem('llm.apiKey', translationConfig.value.apiKey)
|
||||||
@ -101,7 +104,7 @@ function persistTranslationConfig() {
|
|||||||
function resetModelPaths() {
|
function resetModelPaths() {
|
||||||
whisperModelPath.value = defaultModelPaths.value?.whisperModelPath ?? ''
|
whisperModelPath.value = defaultModelPaths.value?.whisperModelPath ?? ''
|
||||||
vadModelPath.value = defaultModelPaths.value?.vadModelPath ?? ''
|
vadModelPath.value = defaultModelPaths.value?.vadModelPath ?? ''
|
||||||
feedback.value = '已恢复默认模型路径'
|
feedback.value = t('app.feedback.restoredDefaults')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDefaultModelPaths() {
|
async function loadDefaultModelPaths() {
|
||||||
@ -116,7 +119,7 @@ async function loadDefaultModelPaths() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
feedback.value =
|
feedback.value =
|
||||||
error instanceof Error ? `读取默认模型路径失败:${error.message}` : '读取默认模型路径失败'
|
error instanceof Error ? `${t('app.feedback.loadModelError')}${error.message}` : t('app.feedback.loadModelErrorFallback')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,11 +143,17 @@ async function bindMenuActions() {
|
|||||||
break
|
break
|
||||||
case 'toggle-bilingual':
|
case 'toggle-bilingual':
|
||||||
bilingualOutput.value = !bilingualOutput.value
|
bilingualOutput.value = !bilingualOutput.value
|
||||||
feedback.value = bilingualOutput.value ? '已开启双语导出' : '已关闭双语导出'
|
feedback.value = bilingualOutput.value ? t('app.feedback.bilingualOn') : t('app.feedback.bilingualOff')
|
||||||
break
|
break
|
||||||
case 'reset-models':
|
case 'reset-models':
|
||||||
resetModelPaths()
|
resetModelPaths()
|
||||||
break
|
break
|
||||||
|
case 'set-lang-zh-CN':
|
||||||
|
locale.value = 'zh-CN'
|
||||||
|
break
|
||||||
|
case 'set-lang-en':
|
||||||
|
locale.value = 'en'
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -172,12 +181,12 @@ async function submitFiles(filePaths: string[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (fallbackToSource) {
|
if (fallbackToSource) {
|
||||||
feedback.value = '未填写 LLM API Key,已自动回退为原文转录'
|
feedback.value = t('app.feedback.fallbackSource')
|
||||||
} else {
|
} else {
|
||||||
feedback.value = `已提交 ${filePaths.length} 个任务`
|
feedback.value = t('app.feedback.submitted', { count: filePaths.length })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
feedback.value = error instanceof Error ? error.message : '任务提交失败'
|
feedback.value = error instanceof Error ? error.message : t('app.feedback.submitFailed')
|
||||||
} finally {
|
} finally {
|
||||||
pending.value = false
|
pending.value = false
|
||||||
}
|
}
|
||||||
@ -190,10 +199,10 @@ async function handlePickFiles() {
|
|||||||
const selected = await open({
|
const selected = await open({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
directory: false,
|
directory: false,
|
||||||
title: '选择音视频文件',
|
title: t('app.pickFiles'),
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
name: '媒体文件',
|
name: t('app.mediaFiles'),
|
||||||
extensions: [...MEDIA_EXTENSIONS],
|
extensions: [...MEDIA_EXTENSIONS],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -206,7 +215,7 @@ async function handlePickFiles() {
|
|||||||
|
|
||||||
await submitFiles(filePaths)
|
await submitFiles(filePaths)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
feedback.value = error instanceof Error ? `打开文件对话框失败:${error.message}` : '打开文件对话框失败'
|
feedback.value = error instanceof Error ? `${t('app.feedback.dialogError')}${error.message}` : t('app.feedback.dialogErrorFallback')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,7 +239,7 @@ async function handleFiles(event: Event) {
|
|||||||
async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
||||||
if (!selectedTask.value) return
|
if (!selectedTask.value) return
|
||||||
const output = await taskStore.exportTask(selectedTask.value.id, format)
|
const output = await taskStore.exportTask(selectedTask.value.id, format)
|
||||||
feedback.value = `已导出到 ${output}`
|
feedback.value = output
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -246,10 +255,10 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button class="button secondary toggle-button" type="button" @click="showAdvanced = !showAdvanced">
|
<button class="button secondary toggle-button" type="button" @click="showAdvanced = !showAdvanced">
|
||||||
{{ showAdvanced ? '收起' : '设置' }}
|
{{ showAdvanced ? $t('app.collapse') : $t('app.settings') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="button" type="button" :disabled="pending" @click="handlePickFiles">
|
<button class="button" type="button" :disabled="pending" @click="handlePickFiles">
|
||||||
{{ pending ? '提交中...' : '添加任务' }}
|
{{ pending ? $t('app.submitting') : $t('app.addTask') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -258,22 +267,23 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
|||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>模式</span>
|
<span>{{ $t('app.mode') }}</span>
|
||||||
<select v-model="outputMode">
|
<select v-model="outputMode">
|
||||||
<option value="source">原文</option>
|
<option value="source">{{ $t('app.source') }}</option>
|
||||||
<option value="translate">翻译</option>
|
<option value="translate">{{ $t('app.translate') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="outputMode === 'translate'" class="field">
|
<label v-if="outputMode === 'translate'" class="field">
|
||||||
<span>目标语言</span>
|
<span>{{ $t('app.targetLang') }}</span>
|
||||||
<select v-model="targetLang">
|
<select v-model="targetLang">
|
||||||
<option value="zh">中文</option>
|
<option value="zh">{{ $t('app.chinese') }}</option>
|
||||||
<option value="en">英文</option>
|
<option value="en">{{ $t('app.english') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>源语言</span>
|
<span>{{ $t('app.sourceLang') }}</span>
|
||||||
<select v-model="sourceLang">
|
<select v-model="sourceLang">
|
||||||
|
<option value="auto">{{ $t('app.autoDetect') }}</option>
|
||||||
<option
|
<option
|
||||||
v-for="option in SOURCE_LANGUAGE_OPTIONS"
|
v-for="option in SOURCE_LANGUAGE_OPTIONS"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@ -285,7 +295,7 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
|||||||
</label>
|
</label>
|
||||||
<label class="check desktop-check">
|
<label class="check desktop-check">
|
||||||
<input v-model="bilingualOutput" type="checkbox" />
|
<input v-model="bilingualOutput" type="checkbox" />
|
||||||
<span>双语导出</span>
|
<span>{{ $t('app.bilingualExport') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -294,42 +304,42 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showAdvanced" class="advanced-shell">
|
<div v-if="showAdvanced" class="advanced-shell">
|
||||||
<span class="group-title">高级设置</span>
|
<span class="group-title">{{ $t('app.advanced') }}</span>
|
||||||
<div class="advanced-grid">
|
<div class="advanced-grid">
|
||||||
<template v-if="outputMode === 'translate'">
|
<template v-if="outputMode === 'translate'">
|
||||||
<label class="field wide">
|
<label class="field wide">
|
||||||
<span>LLM API Base</span>
|
<span>{{ $t('app.llm.apiBase') }}</span>
|
||||||
<input v-model="translationConfig.apiBase" placeholder="https://api.openai.com/v1" />
|
<input v-model="translationConfig.apiBase" placeholder="https://api.openai.com/v1" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field wide">
|
<label class="field wide">
|
||||||
<span>LLM API Key</span>
|
<span>{{ $t('app.llm.apiKey') }}</span>
|
||||||
<input v-model="translationConfig.apiKey" type="password" placeholder="sk-..." />
|
<input v-model="translationConfig.apiKey" type="password" placeholder="sk-..." />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>LLM Model</span>
|
<span>{{ $t('app.llm.model') }}</span>
|
||||||
<input v-model="translationConfig.model" placeholder="GLM-4-Flash-250414" />
|
<input v-model="translationConfig.model" placeholder="GLM-4-Flash-250414" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>批大小</span>
|
<span>{{ $t('app.llm.batchSize') }}</span>
|
||||||
<input v-model.number="translationConfig.batchSize" type="number" min="10" max="15" />
|
<input v-model.number="translationConfig.batchSize" type="number" min="10" max="15" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>上下文</span>
|
<span>{{ $t('app.llm.contextSize') }}</span>
|
||||||
<input v-model.number="translationConfig.contextSize" type="number" min="0" max="5" />
|
<input v-model.number="translationConfig.contextSize" type="number" min="0" max="5" />
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
<label class="field wide">
|
<label class="field wide">
|
||||||
<span>Whisper 模型</span>
|
<span>{{ $t('app.model.whisper') }}</span>
|
||||||
<input
|
<input
|
||||||
v-model="whisperModelPath"
|
v-model="whisperModelPath"
|
||||||
:placeholder="defaultModelPaths?.whisperModelPath || DEFAULT_WHISPER_MODEL"
|
:placeholder="defaultModelPaths?.whisperModelPath || $t('app.model.defaultWhisper')"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field wide">
|
<label class="field wide">
|
||||||
<span>VAD 模型</span>
|
<span>{{ $t('app.model.vad') }}</span>
|
||||||
<input
|
<input
|
||||||
v-model="vadModelPath"
|
v-model="vadModelPath"
|
||||||
:placeholder="defaultModelPaths?.vadModelPath || DEFAULT_VAD_MODEL"
|
:placeholder="defaultModelPaths?.vadModelPath || $t('app.model.defaultVad')"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -341,6 +351,7 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
|||||||
:tasks="taskStore.tasks"
|
:tasks="taskStore.tasks"
|
||||||
:selected-task-id="taskStore.selectedTaskId"
|
:selected-task-id="taskStore.selectedTaskId"
|
||||||
@select="taskStore.selectTask"
|
@select="taskStore.selectTask"
|
||||||
|
@retry="taskStore.retryTask"
|
||||||
/>
|
/>
|
||||||
<SubtitleEditor
|
<SubtitleEditor
|
||||||
:task="selectedTask"
|
:task="selectedTask"
|
||||||
|
|||||||
@ -48,9 +48,9 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
|||||||
<section class="panel workspace-panel">
|
<section class="panel workspace-panel">
|
||||||
<div class="workspace-header">
|
<div class="workspace-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ task?.fileName ?? '字幕工作区' }}</strong>
|
<strong>{{ task?.fileName ?? $t('editor.title') }}</strong>
|
||||||
<p class="panel-subtitle">
|
<p class="panel-subtitle">
|
||||||
{{ task ? `${segments.length} 条片段` : '选择左侧任务后开始查看' }}
|
{{ task ? $t('editor.segments', { count: segments.length }) : $t('editor.selectTask') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="task" class="export-actions">
|
<div v-if="task" class="export-actions">
|
||||||
@ -61,14 +61,14 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!task" class="empty-state">
|
<div v-if="!task" class="empty-state">
|
||||||
<p>选择任务后显示字幕</p>
|
<p>{{ $t('editor.empty') }}</p>
|
||||||
<p style="margin-top: 6px; font-size: 11px;">点击左侧任务列表中的任务开始查看</p>
|
<p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="segments.length === 0" class="empty-state">
|
<div v-else-if="segments.length === 0" class="empty-state">
|
||||||
<template v-if="isProcessing">正在处理中,请稍候...</template>
|
<template v-if="isProcessing">{{ $t('editor.processing') }}</template>
|
||||||
<template v-else-if="task?.status === 'failed'">任务处理失败,无法生成字幕</template>
|
<template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template>
|
||||||
<template v-else>暂无字幕片段</template>
|
<template v-else>{{ $t('editor.noSegments') }}</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="segment-list">
|
<div v-else class="segment-list">
|
||||||
@ -81,11 +81,11 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
|||||||
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
|
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
|
||||||
<span class="segment-id">{{ segment.id }}</span>
|
<span class="segment-id">{{ segment.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="source-text">{{ segment.sourceText || '等待识别结果...' }}</p>
|
<p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p>
|
||||||
<textarea
|
<textarea
|
||||||
class="editor-input"
|
class="editor-input"
|
||||||
:value="segment.translatedText ?? ''"
|
:value="segment.translatedText ?? ''"
|
||||||
:placeholder="task.outputMode === 'translate' ? '译文' : '原文'"
|
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')"
|
||||||
:disabled="task.outputMode === 'source'"
|
:disabled="task.outputMode === 'source'"
|
||||||
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
||||||
/>
|
/>
|
||||||
@ -94,13 +94,13 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
|||||||
|
|
||||||
<div class="log-drawer" :class="{ expanded: logsExpanded }">
|
<div class="log-drawer" :class="{ expanded: logsExpanded }">
|
||||||
<button class="log-toggle" type="button" @click="logsExpanded = !logsExpanded">
|
<button class="log-toggle" type="button" @click="logsExpanded = !logsExpanded">
|
||||||
<span>日志</span>
|
<span>{{ $t('editor.logs') }}</span>
|
||||||
<span class="subtle">{{ logs.length }}</span>
|
<span class="subtle">{{ logs.length }}</span>
|
||||||
<span class="log-chevron">{{ logsExpanded ? '−' : '+' }}</span>
|
<span class="log-chevron">{{ logsExpanded ? '−' : '+' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="logsExpanded" class="log-panel">
|
<div v-if="logsExpanded" class="log-panel">
|
||||||
<div v-if="logs.length === 0" class="empty-state">
|
<div v-if="logs.length === 0" class="empty-state">
|
||||||
暂无日志
|
{{ $t('editor.noLogs') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="log-list">
|
<div v-else class="log-list">
|
||||||
<pre v-for="(line, index) in logs" :key="`${index}-${line}`" class="log-line">{{ line }}</pre>
|
<pre v-for="(line, index) in logs" :key="`${index}-${line}`" class="log-line">{{ line }}</pre>
|
||||||
|
|||||||
@ -8,31 +8,22 @@ defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [taskId: string]
|
select: [taskId: string]
|
||||||
|
retry: [taskId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const statusLabel: Record<SubtitleTask['status'], string> = {
|
|
||||||
queued: '排队中',
|
|
||||||
extracting: '抽取',
|
|
||||||
vad_processing: 'VAD',
|
|
||||||
transcribing: '识别',
|
|
||||||
translating: '翻译',
|
|
||||||
completed: '完成',
|
|
||||||
failed: '失败',
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="panel sidebar-panel">
|
<aside class="panel sidebar-panel">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<div>
|
<div>
|
||||||
<strong>任务</strong>
|
<strong>{{ $t('taskQueue.title') }}</strong>
|
||||||
<p class="panel-subtitle">选择任务查看字幕</p>
|
<p class="panel-subtitle">{{ $t('taskQueue.subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge">{{ tasks.length }}</span>
|
<span class="badge">{{ tasks.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tasks.length === 0" class="empty-state">
|
<div v-if="tasks.length === 0" class="empty-state">
|
||||||
暂无任务
|
{{ $t('taskQueue.empty') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="list-stack">
|
<div v-else class="list-stack">
|
||||||
@ -43,6 +34,7 @@ const statusLabel: Record<SubtitleTask['status'], string> = {
|
|||||||
:class="{
|
:class="{
|
||||||
active: task.id === selectedTaskId,
|
active: task.id === selectedTaskId,
|
||||||
completed: task.status === 'completed',
|
completed: task.status === 'completed',
|
||||||
|
failed: task.status === 'failed',
|
||||||
}"
|
}"
|
||||||
@click="emit('select', task.id)"
|
@click="emit('select', task.id)"
|
||||||
>
|
>
|
||||||
@ -51,12 +43,15 @@ const statusLabel: Record<SubtitleTask['status'], string> = {
|
|||||||
<span
|
<span
|
||||||
class="subtle"
|
class="subtle"
|
||||||
:class="{ 'status-active': task.status !== 'completed' && task.status !== 'failed' && task.status !== 'queued' }"
|
:class="{ 'status-active': task.status !== 'completed' && task.status !== 'failed' && task.status !== 'queued' }"
|
||||||
>{{ statusLabel[task.status] }}</span>
|
>{{ $t(`taskQueue.status.${task.status}`) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
<div class="progress-bar" :style="{ width: `${task.progress}%` }" />
|
<div class="progress-bar" :style="{ width: `${task.progress}%` }" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="task.error" class="error-text">{{ task.error }}</p>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
26
src/i18n.ts
Normal file
26
src/i18n.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from './locales/zh-CN'
|
||||||
|
import en from './locales/en'
|
||||||
|
|
||||||
|
function detectSystemLocale(): string {
|
||||||
|
const saved = localStorage.getItem('locale')
|
||||||
|
if (saved) return saved
|
||||||
|
|
||||||
|
const lang = navigator.language
|
||||||
|
if (lang.startsWith('zh')) return 'zh-CN'
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLocale = detectSystemLocale()
|
||||||
|
|
||||||
|
export type Locale = 'zh-CN' | 'en'
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: defaultLocale,
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages: {
|
||||||
|
'zh-CN': zhCN,
|
||||||
|
en,
|
||||||
|
},
|
||||||
|
})
|
||||||
77
src/locales/en.ts
Normal file
77
src/locales/en.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
title: 'CrossSubtitle',
|
||||||
|
credit: 'by {author}',
|
||||||
|
collapse: 'Collapse',
|
||||||
|
settings: 'Settings',
|
||||||
|
submitting: 'Submitting...',
|
||||||
|
addTask: 'Add Task',
|
||||||
|
mode: 'Mode',
|
||||||
|
source: 'Source',
|
||||||
|
translate: 'Translate',
|
||||||
|
targetLang: 'Target Language',
|
||||||
|
chinese: 'Chinese',
|
||||||
|
english: 'English',
|
||||||
|
sourceLang: 'Source Language',
|
||||||
|
bilingualExport: 'Bilingual Export',
|
||||||
|
autoDetect: 'Auto Detect',
|
||||||
|
advanced: 'Advanced Settings',
|
||||||
|
feedback: {
|
||||||
|
restoredDefaults: 'Restored default model paths',
|
||||||
|
loadModelError: 'Failed to load default model paths: {message}',
|
||||||
|
loadModelErrorFallback: 'Failed to load default model paths',
|
||||||
|
bilingualOn: 'Bilingual export enabled',
|
||||||
|
bilingualOff: 'Bilingual export disabled',
|
||||||
|
fallbackSource: 'LLM API Key not set, falling back to source transcription',
|
||||||
|
submitted: 'Submitted {count} tasks',
|
||||||
|
submitFailed: 'Task submission failed',
|
||||||
|
dialogError: 'Failed to open file dialog: {message}',
|
||||||
|
dialogErrorFallback: 'Failed to open file dialog',
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
apiBase: 'LLM API Base',
|
||||||
|
apiKey: 'LLM API Key',
|
||||||
|
model: 'LLM Model',
|
||||||
|
batchSize: 'Batch Size',
|
||||||
|
contextSize: 'Context Size',
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
whisper: 'Whisper Model',
|
||||||
|
vad: 'VAD Model',
|
||||||
|
defaultWhisper: 'Built-in Whisper Model',
|
||||||
|
defaultVad: 'Built-in VAD Model',
|
||||||
|
},
|
||||||
|
pickFiles: 'Select Media Files',
|
||||||
|
mediaFiles: 'Media Files',
|
||||||
|
},
|
||||||
|
taskQueue: {
|
||||||
|
title: 'Tasks',
|
||||||
|
subtitle: 'Select a task to view subtitles',
|
||||||
|
empty: 'No tasks',
|
||||||
|
retry: 'Retry',
|
||||||
|
status: {
|
||||||
|
queued: 'Queued',
|
||||||
|
extracting: 'Extracting',
|
||||||
|
vad_processing: 'VAD',
|
||||||
|
transcribing: 'Transcribing',
|
||||||
|
translating: 'Translating',
|
||||||
|
completed: 'Completed',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
title: 'Workspace',
|
||||||
|
segments: '{count} segments',
|
||||||
|
selectTask: 'Select a task from the left to start',
|
||||||
|
empty: 'Select a task to view subtitles',
|
||||||
|
emptyHint: 'Click a task in the list to start viewing',
|
||||||
|
processing: 'Processing, please wait...',
|
||||||
|
failed: 'Task failed, unable to generate subtitles',
|
||||||
|
noSegments: 'No subtitle segments',
|
||||||
|
waiting: 'Waiting for transcription...',
|
||||||
|
translation: 'Translation',
|
||||||
|
source: 'Source',
|
||||||
|
logs: 'Logs',
|
||||||
|
noLogs: 'No logs',
|
||||||
|
},
|
||||||
|
}
|
||||||
77
src/locales/zh-CN.ts
Normal file
77
src/locales/zh-CN.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
title: 'CrossSubtitle',
|
||||||
|
credit: 'by {author}',
|
||||||
|
collapse: '收起',
|
||||||
|
settings: '设置',
|
||||||
|
submitting: '提交中...',
|
||||||
|
addTask: '添加任务',
|
||||||
|
mode: '模式',
|
||||||
|
source: '原文',
|
||||||
|
translate: '翻译',
|
||||||
|
targetLang: '目标语言',
|
||||||
|
chinese: '中文',
|
||||||
|
english: '英文',
|
||||||
|
sourceLang: '源语言',
|
||||||
|
bilingualExport: '双语导出',
|
||||||
|
autoDetect: '自动识别',
|
||||||
|
advanced: '高级设置',
|
||||||
|
feedback: {
|
||||||
|
restoredDefaults: '已恢复默认模型路径',
|
||||||
|
loadModelError: '读取默认模型路径失败:{message}',
|
||||||
|
loadModelErrorFallback: '读取默认模型路径失败',
|
||||||
|
bilingualOn: '已开启双语导出',
|
||||||
|
bilingualOff: '已关闭双语导出',
|
||||||
|
fallbackSource: '未填写 LLM API Key,已自动回退为原文转录',
|
||||||
|
submitted: '已提交 {count} 个任务',
|
||||||
|
submitFailed: '任务提交失败',
|
||||||
|
dialogError: '打开文件对话框失败:{message}',
|
||||||
|
dialogErrorFallback: '打开文件对话框失败',
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
apiBase: 'LLM API Base',
|
||||||
|
apiKey: 'LLM API Key',
|
||||||
|
model: 'LLM Model',
|
||||||
|
batchSize: '批大小',
|
||||||
|
contextSize: '上下文',
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
whisper: 'Whisper 模型',
|
||||||
|
vad: 'VAD 模型',
|
||||||
|
defaultWhisper: '应用内置 Whisper 模型',
|
||||||
|
defaultVad: '应用内置 VAD 模型',
|
||||||
|
},
|
||||||
|
pickFiles: '选择音视频文件',
|
||||||
|
mediaFiles: '媒体文件',
|
||||||
|
},
|
||||||
|
taskQueue: {
|
||||||
|
title: '任务',
|
||||||
|
subtitle: '选择任务查看字幕',
|
||||||
|
empty: '暂无任务',
|
||||||
|
retry: '重试',
|
||||||
|
status: {
|
||||||
|
queued: '排队中',
|
||||||
|
extracting: '抽取',
|
||||||
|
vad_processing: 'VAD',
|
||||||
|
transcribing: '识别',
|
||||||
|
translating: '翻译',
|
||||||
|
completed: '完成',
|
||||||
|
failed: '失败',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
title: '字幕工作区',
|
||||||
|
segments: '{count} 条片段',
|
||||||
|
selectTask: '选择左侧任务后开始查看',
|
||||||
|
empty: '选择任务后显示字幕',
|
||||||
|
emptyHint: '点击左侧任务列表中的任务开始查看',
|
||||||
|
processing: '正在处理中,请稍候...',
|
||||||
|
failed: '任务处理失败,无法生成字幕',
|
||||||
|
noSegments: '暂无字幕片段',
|
||||||
|
waiting: '等待识别结果...',
|
||||||
|
translation: '译文',
|
||||||
|
source: '原文',
|
||||||
|
logs: '日志',
|
||||||
|
noLogs: '暂无日志',
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { i18n } from './i18n'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
createApp(App).use(createPinia()).mount('#app')
|
createApp(App).use(createPinia()).use(i18n).mount('#app')
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
selectedTaskId: '' as string,
|
selectedTaskId: '' as string,
|
||||||
ready: false,
|
ready: false,
|
||||||
unlisteners: [] as UnlistenFn[],
|
unlisteners: [] as UnlistenFn[],
|
||||||
|
pendingPayloads: {} as Record<string, StartTaskPayload>,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
selectedTask(state) {
|
selectedTask(state) {
|
||||||
@ -111,10 +112,24 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
async startTask(payload: StartTaskPayload) {
|
async startTask(payload: StartTaskPayload) {
|
||||||
const task = await invoke<SubtitleTask>('start_subtitle_task', { payload })
|
const task = await invoke<SubtitleTask>('start_subtitle_task', { payload })
|
||||||
this.tasks.unshift(task)
|
this.tasks.unshift(task)
|
||||||
|
this.pendingPayloads[task.id] = payload
|
||||||
this.logsByTaskId[task.id] = []
|
this.logsByTaskId[task.id] = []
|
||||||
this.selectedTaskId = task.id
|
this.selectedTaskId = task.id
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async retryTask(taskId: string) {
|
||||||
|
const payload = this.pendingPayloads[taskId]
|
||||||
|
if (!payload) return
|
||||||
|
|
||||||
|
const index = this.tasks.findIndex((t) => t.id === taskId)
|
||||||
|
if (index >= 0) {
|
||||||
|
this.tasks.splice(index, 1)
|
||||||
|
}
|
||||||
|
delete this.logsByTaskId[taskId]
|
||||||
|
|
||||||
|
await this.startTask(payload)
|
||||||
|
},
|
||||||
|
|
||||||
selectTask(taskId: string) {
|
selectTask(taskId: string) {
|
||||||
this.selectedTaskId = taskId
|
this.selectedTaskId = taskId
|
||||||
},
|
},
|
||||||
|
|||||||
136
src/style.css
136
src/style.css
@ -3,26 +3,30 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--c-bg: #fafafa;
|
--c-bg: #f5f5f7;
|
||||||
--c-surface: #ffffff;
|
--c-surface: #ffffff;
|
||||||
--c-border: #ebebeb;
|
--c-border: #d8d8e0;
|
||||||
--c-border-hover: #d4d4d4;
|
--c-border-hover: #c0c0cc;
|
||||||
--c-text: #1a1a2e;
|
--c-text: #1a1a2e;
|
||||||
--c-text-secondary: #6b6b7b;
|
--c-text-secondary: #505065;
|
||||||
--c-text-tertiary: #9999a8;
|
--c-text-tertiary: #7a7a8e;
|
||||||
--c-accent: #1a1a2e;
|
--c-accent: #1a1a2e;
|
||||||
--c-accent-hover: #2a2a3e;
|
--c-accent-hover: #353550;
|
||||||
--c-focus: rgba(26, 26, 46, 0.08);
|
--c-focus: rgba(26, 26, 46, 0.12);
|
||||||
--c-progress: #1a1a2e;
|
--c-progress: #1a1a2e;
|
||||||
--c-error: #dc2626;
|
--c-error: #dc2626;
|
||||||
|
--c-success: #2d6a4f;
|
||||||
--c-log-bg: #1a1a2e;
|
--c-log-bg: #1a1a2e;
|
||||||
--c-log-text: #d4d4d4;
|
--c-log-text: #d4d4d4;
|
||||||
|
--c-accent-gradient: linear-gradient(135deg, #1a1a2e, #2d2d4a);
|
||||||
|
--c-surface-gradient: linear-gradient(135deg, #ffffff, #f8f8fc);
|
||||||
|
--c-progress-gradient: linear-gradient(90deg, #1a1a2e, #4a4a6e);
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
--radius-md: 10px;
|
--radius-md: 10px;
|
||||||
--radius-lg: 14px;
|
--radius-lg: 14px;
|
||||||
--radius-full: 999px;
|
--radius-full: 999px;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
background: var(--c-bg);
|
background: var(--c-bg);
|
||||||
@ -91,15 +95,16 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--c-surface);
|
background: var(--c-surface-gradient);
|
||||||
border: 1px solid var(--c-border);
|
border: 1px solid var(--c-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: box-shadow var(--transition);
|
transition: box-shadow var(--transition), border-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel:hover {
|
.panel:hover {
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--c-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
@ -241,7 +246,7 @@ textarea {
|
|||||||
.editor-input:focus {
|
.editor-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--c-accent);
|
border-color: var(--c-accent);
|
||||||
box-shadow: 0 0 0 3px var(--c-focus);
|
box-shadow: 0 0 0 3px var(--c-focus), 0 1px 4px rgba(26, 26, 46, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
@ -283,16 +288,17 @@ textarea {
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border: 1px solid var(--c-accent);
|
border: 1px solid var(--c-accent);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--c-accent);
|
background: var(--c-accent-gradient);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
transition: background var(--transition), border-color var(--transition), transform 0.1s ease;
|
transition: background var(--transition), border-color var(--transition), transform 0.1s ease, box-shadow var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
background: var(--c-accent-hover);
|
background: var(--c-accent-hover);
|
||||||
|
box-shadow: 0 2px 8px rgba(26, 26, 46, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:active {
|
.button:active {
|
||||||
@ -355,15 +361,16 @@ textarea {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 24px;
|
min-width: 26px;
|
||||||
height: 24px;
|
height: 26px;
|
||||||
padding: 0 8px;
|
padding: 0 10px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
background: var(--c-bg);
|
background: var(--c-accent-gradient);
|
||||||
color: var(--c-text-secondary);
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
border: 1px solid var(--c-border);
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 1px 4px rgba(26, 26, 46, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@ -408,10 +415,15 @@ textarea {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
background: rgba(26, 26, 46, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
.task-item.active {
|
.task-item.active {
|
||||||
border-color: var(--c-accent);
|
border-color: var(--c-accent);
|
||||||
background: rgba(26, 26, 46, 0.03);
|
background: rgba(26, 26, 46, 0.04);
|
||||||
box-shadow: inset 0 0 0 1px var(--c-accent);
|
border-left-color: var(--c-accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--c-accent), 0 2px 8px rgba(26, 26, 46, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item.completed {
|
.task-item.completed {
|
||||||
@ -426,11 +438,54 @@ textarea {
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--c-success, #2d6a4f);
|
background: var(--c-success);
|
||||||
|
box-shadow: 0 0 6px rgba(45, 106, 79, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.failed {
|
||||||
|
border-color: var(--c-error);
|
||||||
|
background: rgba(220, 38, 38, 0.03);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failed-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failed-footer .error-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--c-error);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-error);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button:hover {
|
||||||
|
background: var(--c-error);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item {
|
.task-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.truncate {
|
.truncate {
|
||||||
@ -440,7 +495,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
height: 2px;
|
height: 3px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
background: var(--c-border);
|
background: var(--c-border);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
@ -449,8 +504,9 @@ textarea {
|
|||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--c-progress);
|
background: var(--c-progress-gradient);
|
||||||
transition: width 0.3s ease;
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text {
|
.error-text {
|
||||||
@ -468,11 +524,15 @@ textarea {
|
|||||||
|
|
||||||
.source-text {
|
.source-text {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
background: var(--c-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-left: 3px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-input {
|
.editor-input {
|
||||||
@ -503,15 +563,17 @@ textarea {
|
|||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
border: 1px solid var(--c-border);
|
border: 1px solid var(--c-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--c-surface);
|
background: var(--c-surface-gradient);
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color var(--transition), background var(--transition);
|
font-weight: 500;
|
||||||
|
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-toggle:hover {
|
.log-toggle:hover {
|
||||||
border-color: var(--c-border-hover);
|
border-color: var(--c-border-hover);
|
||||||
background: var(--c-bg);
|
background: var(--c-bg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-panel {
|
.log-panel {
|
||||||
@ -579,16 +641,26 @@ textarea {
|
|||||||
|
|
||||||
.task-item .status-active {
|
.task-item .status-active {
|
||||||
color: var(--c-accent);
|
color: var(--c-accent);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-item {
|
.segment-item {
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
|
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-item:hover {
|
.segment-item:hover {
|
||||||
border-left-color: var(--c-border-hover);
|
border-left-color: var(--c-accent);
|
||||||
|
background: rgba(26, 26, 46, 0.02);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-item:focus-within {
|
||||||
|
border-left-color: var(--c-accent);
|
||||||
|
background: rgba(26, 26, 46, 0.03);
|
||||||
|
box-shadow: 0 0 0 1px rgba(26, 26, 46, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-item .task-row {
|
.segment-item .task-row {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user