diff --git a/src-tauri/src/task.rs b/src-tauri/src/task.rs index 4d9dc51..a532943 100644 --- a/src-tauri/src/task.rs +++ b/src-tauri/src/task.rs @@ -146,16 +146,28 @@ async fn run_pipeline( let app_state = app.state::(); let workspace = std::env::temp_dir().join("crosssubtitle-ai").join(&task.id); 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) .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!("audio: ffmpeg={}", ffmpeg_path.display()))?; let wav_path = AudioPipeline::extract_to_wav(&ffmpeg_path, &payload.file_path, &workspace)?; emit_log(&window, &task.id, format!("audio: normalized wav={}", wav_path.display()))?; - 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 vad = VadEngine::new(payload.vad_model_path.clone(), VadConfig::default())?; let speech_ranges = vad.detect_segments(&samples).await; @@ -165,7 +177,7 @@ async fn run_pipeline( 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 task_id_for_progress = task.id.clone(); let task_id_for_segment = task.id.clone(); @@ -181,7 +193,7 @@ async fn run_pipeline( should_translate, &speech_ranges, |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( "task:progress", ProgressEvent { @@ -232,11 +244,13 @@ async fn run_pipeline( .clone() .or_else(load_translation_config) .ok_or_else(|| anyhow::anyhow!("翻译模式需要填写 LLM API 配置,或设置 OPENAI_API_BASE / OPENAI_API_KEY"))?; - set_status(&window, &app_state, &mut task, TaskStatus::Translating, 72.0, "正在生成译文")?; + set_status(&window, &app_state, &mut task, TaskStatus::Translating, 70.0, "正在生成译文")?; let translator = Translator::new(config)?; let task_id_for_translate = task.id.clone(); let app_state_for_translate = app_state.clone(); let window_for_translate = window.clone(); + let task_id_for_translate_progress = task.id.clone(); + let window_for_translate_progress = window.clone(); segments = translator .translate_segments_with_progress( &segments, @@ -244,6 +258,18 @@ async fn run_pipeline( |message| { let _ = emit_log(&window_for_translate, &task_id_for_translate, message); }, + |ratio| { + let progress = 70.0 + ratio.clamp(0.0, 1.0) * 25.0; + let _ = window_for_translate_progress.emit( + "task:progress", + ProgressEvent { + task_id: task_id_for_translate_progress.clone(), + status: TaskStatus::Translating, + progress, + message: "正在生成译文".to_string(), + }, + ); + }, |segment| { if let Ok(mut current_task) = app_state_for_translate.get_task(&task_id_for_translate) { upsert_segment(&mut current_task.segments, segment.clone()); diff --git a/src-tauri/src/translate.rs b/src-tauri/src/translate.rs index ad218b9..b45e45a 100644 --- a/src-tauri/src/translate.rs +++ b/src-tauri/src/translate.rs @@ -70,15 +70,17 @@ impl Translator { Ok(Self { client, config }) } - pub async fn translate_segments_with_progress( + pub async fn translate_segments_with_progress( &self, segments: &[SubtitleSegment], target_language: &TargetLanguage, mut log: LF, + mut on_progress: PF, mut emit_segment: SF, ) -> Result> where LF: FnMut(String), + PF: FnMut(f32), SF: FnMut(SubtitleSegment), { let batch_size = self.config.batch_size.clamp(10, 15); @@ -88,8 +90,9 @@ impl Translator { TargetLanguage::Zh => "简体中文", 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 context_start = batch_start.saturating_sub(context_size); let context = &segments[context_start..batch_start]; @@ -104,6 +107,8 @@ impl Translator { .collect::>() .join(", ") )); + let batch_progress = (batch_index + 1) as f32 / total_batches.max(1) as f32; + on_progress(batch_progress); let rows = self .translate_batch_with_retries(context, batch, target_language_name) .await?; diff --git a/src/App.vue b/src/App.vue index ef6fcb9..6abacb5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -341,6 +341,7 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') { :tasks="taskStore.tasks" :selected-task-id="taskStore.selectedTaskId" @select="taskStore.selectTask" + @retry="taskStore.retryTask" /> () const statusLabel: Record = { @@ -43,6 +44,7 @@ const statusLabel: Record = { :class="{ active: task.id === selectedTaskId, completed: task.status === 'completed', + failed: task.status === 'failed', }" @click="emit('select', task.id)" > @@ -56,7 +58,10 @@ const statusLabel: Record = {
-

{{ task.error }}

+
diff --git a/src/stores/tasks.ts b/src/stores/tasks.ts index a0e32d4..b051e61 100644 --- a/src/stores/tasks.ts +++ b/src/stores/tasks.ts @@ -29,6 +29,7 @@ export const useTaskStore = defineStore('tasks', { selectedTaskId: '' as string, ready: false, unlisteners: [] as UnlistenFn[], + pendingPayloads: {} as Record, }), getters: { selectedTask(state) { @@ -111,10 +112,24 @@ export const useTaskStore = defineStore('tasks', { async startTask(payload: StartTaskPayload) { const task = await invoke('start_subtitle_task', { payload }) this.tasks.unshift(task) + this.pendingPayloads[task.id] = payload this.logsByTaskId[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) { this.selectedTaskId = taskId }, diff --git a/src/style.css b/src/style.css index df6c1db..cdc2bf0 100644 --- a/src/style.css +++ b/src/style.css @@ -418,6 +418,46 @@ textarea { opacity: 0.85; } +.task-item.failed { + border-color: var(--c-error); + 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.completed::after { content: ''; position: absolute;