Compare commits

..

No commits in common. "bba78d1dcaded7c6fd934b6aa210617d8e8ffcd7" and "c855cf5be71ad45c73db6a91cf5522fe2fad3267" have entirely different histories.

16 changed files with 157 additions and 556 deletions

67
package-lock.json generated
View File

@ -11,8 +11,7 @@
"@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",
@ -526,50 +525,6 @@
"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",
@ -2893,26 +2848,6 @@
} }
} }
}, },
"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",

View File

@ -18,8 +18,7 @@
"@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",

View File

@ -146,11 +146,6 @@ 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)?)
@ -163,7 +158,6 @@ 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()?;
@ -177,8 +171,6 @@ 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,
}; };

View File

@ -146,38 +146,26 @@ 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, 5.0, "正在抽取音频")?; set_status(&window, &app_state, &mut task, TaskStatus::Extracting, 8.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, 15.0, "正在分析语音片段")?; set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 22.0, "正在分析语音片段")?;
let samples = AudioPipeline::load_wav_f32(&wav_path)?; let samples = AudioPipeline::load_wav_f32(&wav_path)?;
let vad = VadEngine::new(payload.vad_model_path.clone(), VadConfig::default())?; let vad = VadEngine::new(payload.vad_model_path.clone(), VadConfig::default())?;
let speech_ranges = vad.detect_segments(&samples).await; let speech_ranges = vad.detect_segments(&samples);
emit_log( emit_log(
&window, &window,
&task.id, &task.id,
format!("vad: detected {} speech ranges", speech_ranges.len()), format!("vad: detected {} speech ranges", speech_ranges.len()),
)?; )?;
set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 30.0, "正在执行 Whisper")?; set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 45.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();
@ -193,7 +181,7 @@ async fn run_pipeline(
should_translate, should_translate,
&speech_ranges, &speech_ranges,
|ratio| { |ratio| {
let progress = 30.0 + ratio.clamp(0.0, 1.0) * 40.0; let progress = 45.0 + ratio.clamp(0.0, 1.0) * 27.0;
window.emit( window.emit(
"task:progress", "task:progress",
ProgressEvent { ProgressEvent {
@ -244,13 +232,11 @@ 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, 70.0, "正在生成译文")?; set_status(&window, &app_state, &mut task, TaskStatus::Translating, 72.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,
@ -258,18 +244,6 @@ 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());

View File

@ -70,17 +70,15 @@ impl Translator {
Ok(Self { client, config }) Ok(Self { client, config })
} }
pub async fn translate_segments_with_progress<LF, PF, SF>( pub async fn translate_segments_with_progress<LF, 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);
@ -90,9 +88,8 @@ impl Translator {
TargetLanguage::Zh => "简体中文", TargetLanguage::Zh => "简体中文",
TargetLanguage::En => "英文", TargetLanguage::En => "英文",
}; };
let total_batches = (segments.len() + batch_size - 1) / batch_size;
for (batch_index, batch_start) in (0..segments.len()).step_by(batch_size).enumerate() { for batch_start in (0..segments.len()).step_by(batch_size) {
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];
@ -107,8 +104,6 @@ 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?;

View File

@ -1,5 +1,6 @@
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::mpsc,
time::Duration, time::Duration,
}; };
@ -14,7 +15,6 @@ 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,7 +25,6 @@ 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,
} }
} }
} }
@ -47,65 +46,49 @@ impl VadEngine {
Ok(Self { model_path, config }) Ok(Self { model_path, config })
} }
pub async fn detect_segments(&self, samples: &[f32]) -> Vec<(f32, f32)> { pub fn detect_segments(&self, samples: &[f32]) -> Vec<(f32, f32)> {
if self.model_path.is_some() { if let Some(model_path) = &self.model_path {
let samples_owned = samples.to_vec(); let model_path = model_path.clone();
let model_path = self.model_path.clone().unwrap(); let samples = samples.to_vec();
let config = self.config.clone(); let config = self.config.clone();
let timeout_secs = self.config.timeout_seconds; let (sender, receiver) = mpsc::channel();
match tokio::time::timeout( std::thread::spawn(move || {
Duration::from_secs(timeout_secs), let engine = VadEngine {
tokio::task::spawn_blocking(move || { model_path: Some(model_path.clone()),
let mut session = match Self::load_onnx_session(&model_path) { config,
Ok(s) => s,
Err(e) => {
eprintln!("vad: failed to load onnx session: {e:#}");
return None;
}
}; };
Self::detect_with_onnx(&mut session, &samples_owned, &config).ok() let result = engine.detect_segments_with_onnx(&samples, &model_path);
}), let _ = sender.send(result);
) });
.await
{ match receiver.recv_timeout(Duration::from_secs(3)) {
Ok(Ok(Some(ranges))) if !ranges.is_empty() => { Ok(Ok(result)) if !result.is_empty() => return result,
eprintln!("vad: onnx detected {} speech ranges", ranges.len());
return ranges;
}
Ok(Ok(_)) => {} Ok(Ok(_)) => {}
Ok(Err(e)) => { Ok(Err(error)) => {
eprintln!("vad: onnx error: {e:#}, falling back to energy detection"); eprintln!("silero vad failed, falling back to energy detection: {error:#}");
} }
Err(_) => { Err(mpsc::RecvTimeoutError::Timeout) => {
eprintln!( eprintln!("silero vad timed out, falling back to energy detection");
"vad: onnx timed out after {}s, falling back to energy detection", }
timeout_secs Err(mpsc::RecvTimeoutError::Disconnected) => {
); eprintln!("silero vad worker disconnected, falling back to energy detection");
} }
} }
} }
let ranges = self.detect_segments_with_energy(samples); self.detect_segments_with_energy(samples)
eprintln!("vad: energy detection found {} speech ranges", ranges.len());
ranges
} }
fn load_onnx_session(model_path: &Path) -> Result<Session> { fn detect_segments_with_onnx(&self, samples: &[f32], model_path: &Path) -> Result<Vec<(f32, f32)>> {
Session::builder() let mut session = 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![config.sample_rate as i64]); let sr = Array1::<i64>::from_vec(vec![self.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) {
@ -126,7 +109,10 @@ 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.iter().copied().fold(0.0_f32, f32::max); let probability = probs
.iter()
.copied()
.fold(0.0_f32, f32::max);
speech_probabilities.push(probability); speech_probabilities.push(probability);
if outputs.len() > 1 { if outputs.len() > 1 {
@ -141,7 +127,7 @@ impl VadEngine {
} }
} }
Ok(Self::merge_probabilities(&speech_probabilities, chunk_size, config)) Ok(self.merge_probabilities(&speech_probabilities, chunk_size))
} }
fn detect_segments_with_energy(&self, samples: &[f32]) -> Vec<(f32, f32)> { fn detect_segments_with_energy(&self, samples: &[f32]) -> Vec<(f32, f32)> {
@ -162,22 +148,22 @@ impl VadEngine {
energies.len(), energies.len(),
dynamic_threshold dynamic_threshold
); );
Self::merge_probabilities_with_threshold(&energies, frame_size, dynamic_threshold, &self.config) self.merge_probabilities_with_threshold(&energies, frame_size, dynamic_threshold)
} }
fn merge_probabilities(frames: &[f32], frame_size: usize, config: &VadConfig) -> Vec<(f32, f32)> { fn merge_probabilities(&self, frames: &[f32], frame_size: usize) -> Vec<(f32, f32)> {
Self::merge_probabilities_with_threshold(frames, frame_size, config.threshold, config) self.merge_probabilities_with_threshold(frames, frame_size, self.config.threshold)
} }
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 = (config.min_speech_ms / 20).max(1); let min_speech_frames = (self.config.min_speech_ms / 20).max(1);
let min_silence_frames = (config.min_silence_ms / 20).max(1); let min_silence_frames = (self.config.min_silence_ms / 20).max(1);
let pad_seconds = config.pad_ms as f32 / 1000.0; let pad_seconds = self.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;
@ -197,8 +183,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 / config.sample_rate as f32; let start_sec = (start * 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; let end_sec = ((end_frame + 1) * frame_size) as f32 / self.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;
@ -210,14 +196,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 / config.sample_rate as f32; let start_sec = (start * 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; let end_sec = ((end_frame + 1) * frame_size) as f32 / self.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 / config.sample_rate as f32; let total_seconds = (frames.len() * frame_size) as f32 / self.config.sample_rate as f32;
result.push((0.0, total_seconds)); result.push((0.0, total_seconds));
} }

View File

@ -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.60 || vad_coverage < 0.72
|| vad_end + 5.0 < total_seconds || vad_end + 2.5 < total_seconds
|| (total_seconds > 60.0 && vad_text_len < (total_seconds / 3.0) as usize)); || (total_seconds > 45.0 && vad_text_len < (total_seconds / 2.4) as usize));
if should_retry_full_audio { if should_retry_full_audio {
on_log(format!( on_log(format!(
@ -141,12 +141,6 @@ 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)?;
} }
} }
@ -333,10 +327,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 * 3 / 5 full_text_len > vad_text_len + vad_text_len / 5
|| full_audio_segments.len() > vad_segments.len() + 5 || full_audio_segments.len() > vad_segments.len() + 2
|| full_end > vad_end + 5.0 || full_end > vad_end + 2.0
|| (total_seconds > 60.0 && full_end + 1.5 >= total_seconds && vad_end + 5.0 < total_seconds) || (total_seconds > 30.0 && full_end + 1.5 >= total_seconds && vad_end + 3.0 < total_seconds)
} }
fn resolve_source_language<'a>( fn resolve_source_language<'a>(

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref } 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'
@ -9,8 +8,10 @@ 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 { t, locale } = useI18n() const DEFAULT_WHISPER_MODEL = '应用内置 Whisper 模型'
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: '日本語' },
@ -30,7 +31,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',
@ -89,10 +90,6 @@ 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)
@ -104,7 +101,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 = t('app.feedback.restoredDefaults') feedback.value = '已恢复默认模型路径'
} }
async function loadDefaultModelPaths() { async function loadDefaultModelPaths() {
@ -119,7 +116,7 @@ async function loadDefaultModelPaths() {
} }
} catch (error) { } catch (error) {
feedback.value = feedback.value =
error instanceof Error ? `${t('app.feedback.loadModelError')}${error.message}` : t('app.feedback.loadModelErrorFallback') error instanceof Error ? `读取默认模型路径失败:${error.message}` : '读取默认模型路径失败'
} }
} }
@ -143,17 +140,11 @@ async function bindMenuActions() {
break break
case 'toggle-bilingual': case 'toggle-bilingual':
bilingualOutput.value = !bilingualOutput.value bilingualOutput.value = !bilingualOutput.value
feedback.value = bilingualOutput.value ? t('app.feedback.bilingualOn') : t('app.feedback.bilingualOff') feedback.value = bilingualOutput.value ? '已开启双语导出' : '已关闭双语导出'
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
} }
@ -181,12 +172,12 @@ async function submitFiles(filePaths: string[]) {
}) })
} }
if (fallbackToSource) { if (fallbackToSource) {
feedback.value = t('app.feedback.fallbackSource') feedback.value = '未填写 LLM API Key已自动回退为原文转录'
} else { } else {
feedback.value = t('app.feedback.submitted', { count: filePaths.length }) feedback.value = `已提交 ${filePaths.length} 个任务`
} }
} catch (error) { } catch (error) {
feedback.value = error instanceof Error ? error.message : t('app.feedback.submitFailed') feedback.value = error instanceof Error ? error.message : '任务提交失败'
} finally { } finally {
pending.value = false pending.value = false
} }
@ -199,10 +190,10 @@ async function handlePickFiles() {
const selected = await open({ const selected = await open({
multiple: true, multiple: true,
directory: false, directory: false,
title: t('app.pickFiles'), title: '选择音视频文件',
filters: [ filters: [
{ {
name: t('app.mediaFiles'), name: '媒体文件',
extensions: [...MEDIA_EXTENSIONS], extensions: [...MEDIA_EXTENSIONS],
}, },
], ],
@ -215,7 +206,7 @@ async function handlePickFiles() {
await submitFiles(filePaths) await submitFiles(filePaths)
} catch (error) { } catch (error) {
feedback.value = error instanceof Error ? `${t('app.feedback.dialogError')}${error.message}` : t('app.feedback.dialogErrorFallback') feedback.value = error instanceof Error ? `打开文件对话框失败:${error.message}` : '打开文件对话框失败'
} }
} }
@ -239,7 +230,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>
@ -255,10 +246,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 ? $t('app.collapse') : $t('app.settings') }} {{ showAdvanced ? '收起' : '设置' }}
</button> </button>
<button class="button" type="button" :disabled="pending" @click="handlePickFiles"> <button class="button" type="button" :disabled="pending" @click="handlePickFiles">
{{ pending ? $t('app.submitting') : $t('app.addTask') }} {{ pending ? '提交中...' : '添加任务' }}
</button> </button>
</div> </div>
</div> </div>
@ -267,23 +258,22 @@ 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>{{ $t('app.mode') }}</span> <span>模式</span>
<select v-model="outputMode"> <select v-model="outputMode">
<option value="source">{{ $t('app.source') }}</option> <option value="source">原文</option>
<option value="translate">{{ $t('app.translate') }}</option> <option value="translate">翻译</option>
</select> </select>
</label> </label>
<label v-if="outputMode === 'translate'" class="field"> <label v-if="outputMode === 'translate'" class="field">
<span>{{ $t('app.targetLang') }}</span> <span>目标语言</span>
<select v-model="targetLang"> <select v-model="targetLang">
<option value="zh">{{ $t('app.chinese') }}</option> <option value="zh">中文</option>
<option value="en">{{ $t('app.english') }}</option> <option value="en">英文</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">
<span>{{ $t('app.sourceLang') }}</span> <span>源语言</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"
@ -295,7 +285,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>{{ $t('app.bilingualExport') }}</span> <span>双语导出</span>
</label> </label>
</div> </div>
</div> </div>
@ -304,42 +294,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">{{ $t('app.advanced') }}</span> <span class="group-title">高级设置</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>{{ $t('app.llm.apiBase') }}</span> <span>LLM API Base</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>{{ $t('app.llm.apiKey') }}</span> <span>LLM API Key</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>{{ $t('app.llm.model') }}</span> <span>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>{{ $t('app.llm.batchSize') }}</span> <span>批大小</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>{{ $t('app.llm.contextSize') }}</span> <span>上下文</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>{{ $t('app.model.whisper') }}</span> <span>Whisper 模型</span>
<input <input
v-model="whisperModelPath" v-model="whisperModelPath"
:placeholder="defaultModelPaths?.whisperModelPath || $t('app.model.defaultWhisper')" :placeholder="defaultModelPaths?.whisperModelPath || DEFAULT_WHISPER_MODEL"
/> />
</label> </label>
<label class="field wide"> <label class="field wide">
<span>{{ $t('app.model.vad') }}</span> <span>VAD 模型</span>
<input <input
v-model="vadModelPath" v-model="vadModelPath"
:placeholder="defaultModelPaths?.vadModelPath || $t('app.model.defaultVad')" :placeholder="defaultModelPaths?.vadModelPath || DEFAULT_VAD_MODEL"
/> />
</label> </label>
</div> </div>
@ -351,7 +341,6 @@ 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"

View File

@ -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 ?? $t('editor.title') }}</strong> <strong>{{ task?.fileName ?? '字幕工作区' }}</strong>
<p class="panel-subtitle"> <p class="panel-subtitle">
{{ task ? $t('editor.segments', { count: segments.length }) : $t('editor.selectTask') }} {{ task ? `${segments.length} 条片段` : '选择左侧任务后开始查看' }}
</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>{{ $t('editor.empty') }}</p> <p>选择任务后显示字幕</p>
<p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p> <p style="margin-top: 6px; font-size: 11px;">点击左侧任务列表中的任务开始查看</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">{{ $t('editor.processing') }}</template> <template v-if="isProcessing">正在处理中请稍候...</template>
<template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template> <template v-else-if="task?.status === 'failed'">任务处理失败无法生成字幕</template>
<template v-else>{{ $t('editor.noSegments') }}</template> <template v-else>暂无字幕片段</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 || $t('editor.waiting') }}</p> <p class="source-text">{{ segment.sourceText || '等待识别结果...' }}</p>
<textarea <textarea
class="editor-input" class="editor-input"
:value="segment.translatedText ?? ''" :value="segment.translatedText ?? ''"
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')" :placeholder="task.outputMode === 'translate' ? '译文' : '原文'"
: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>{{ $t('editor.logs') }}</span> <span>日志</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>

View File

@ -8,22 +8,31 @@ 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>{{ $t('taskQueue.title') }}</strong> <strong>任务</strong>
<p class="panel-subtitle">{{ $t('taskQueue.subtitle') }}</p> <p class="panel-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">
@ -34,7 +43,6 @@ const emit = defineEmits<{
: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)"
> >
@ -43,15 +51,12 @@ const emit = defineEmits<{
<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' }"
>{{ $t(`taskQueue.status.${task.status}`) }}</span> >{{ statusLabel[task.status] }}</span>
</div> </div>
<div class="progress"> <div class="progress">
<div class="progress-bar" :style="{ width: `${task.progress}%` }" /> <div class="progress-bar" :style="{ width: `${task.progress}%` }" />
</div> </div>
<div v-if="task.status === 'failed'" class="failed-footer"> <p v-if="task.error" class="error-text">{{ task.error }}</p>
<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>

View File

@ -1,26 +0,0 @@
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,
},
})

View File

@ -1,77 +0,0 @@
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',
},
}

View File

@ -1,77 +0,0 @@
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: '暂无日志',
},
}

View File

@ -1,7 +1,6 @@
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()).use(i18n).mount('#app') createApp(App).use(createPinia()).mount('#app')

View File

@ -29,7 +29,6 @@ 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) {
@ -112,24 +111,10 @@ 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
}, },

View File

@ -3,30 +3,26 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
--c-bg: #f5f5f7; --c-bg: #fafafa;
--c-surface: #ffffff; --c-surface: #ffffff;
--c-border: #d8d8e0; --c-border: #ebebeb;
--c-border-hover: #c0c0cc; --c-border-hover: #d4d4d4;
--c-text: #1a1a2e; --c-text: #1a1a2e;
--c-text-secondary: #505065; --c-text-secondary: #6b6b7b;
--c-text-tertiary: #7a7a8e; --c-text-tertiary: #9999a8;
--c-accent: #1a1a2e; --c-accent: #1a1a2e;
--c-accent-hover: #353550; --c-accent-hover: #2a2a3e;
--c-focus: rgba(26, 26, 46, 0.12); --c-focus: rgba(26, 26, 46, 0.08);
--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 3px rgba(0, 0, 0, 0.06); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08); --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
--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);
@ -95,16 +91,15 @@ textarea {
} }
.panel { .panel {
background: var(--c-surface-gradient); background: var(--c-surface);
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), border-color var(--transition); transition: box-shadow var(--transition);
} }
.panel:hover { .panel:hover {
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
border-color: var(--c-border-hover);
} }
.topbar { .topbar {
@ -246,7 +241,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), 0 1px 4px rgba(26, 26, 46, 0.06); box-shadow: 0 0 0 3px var(--c-focus);
} }
.check { .check {
@ -288,17 +283,16 @@ 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-gradient); background: var(--c-accent);
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 400;
letter-spacing: 0.01em; letter-spacing: 0.01em;
transition: background var(--transition), border-color var(--transition), transform 0.1s ease, box-shadow var(--transition); transition: background var(--transition), border-color var(--transition), transform 0.1s ease;
} }
.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 {
@ -361,16 +355,15 @@ textarea {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 26px; min-width: 24px;
height: 26px; height: 24px;
padding: 0 10px; padding: 0 8px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--c-accent-gradient); background: var(--c-bg);
color: #fff; color: var(--c-text-secondary);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 500;
letter-spacing: 0.02em; border: 1px solid var(--c-border);
box-shadow: 0 1px 4px rgba(26, 26, 46, 0.15);
} }
.empty-state { .empty-state {
@ -415,15 +408,10 @@ 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.04); background: rgba(26, 26, 46, 0.03);
border-left-color: var(--c-accent); box-shadow: inset 0 0 0 1px 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 {
@ -438,54 +426,11 @@ textarea {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--c-success); background: var(--c-success, #2d6a4f);
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 {
@ -495,7 +440,7 @@ textarea {
} }
.progress { .progress {
height: 3px; height: 2px;
margin-top: 8px; margin-top: 8px;
background: var(--c-border); background: var(--c-border);
border-radius: var(--radius-full); border-radius: var(--radius-full);
@ -504,9 +449,8 @@ textarea {
.progress-bar { .progress-bar {
height: 100%; height: 100%;
background: var(--c-progress-gradient); background: var(--c-progress);
border-radius: var(--radius-full); transition: width 0.3s ease;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
.error-text { .error-text {
@ -524,15 +468,11 @@ 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 {
@ -563,17 +503,15 @@ 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-gradient); background: var(--c-surface);
color: var(--c-text); color: var(--c-text);
cursor: pointer; cursor: pointer;
font-weight: 500; transition: border-color var(--transition), background var(--transition);
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 {
@ -641,26 +579,16 @@ textarea {
.task-item .status-active { .task-item .status-active {
color: var(--c-accent); color: var(--c-accent);
font-weight: 600; font-weight: 500;
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-accent); border-left-color: var(--c-border-hover);
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 {