This commit is contained in:
kura 2026-04-30 16:44:04 +08:00
parent 479049501b
commit c3b69670df
6 changed files with 100 additions and 8 deletions

View File

@ -146,16 +146,28 @@ async fn run_pipeline(
let app_state = app.state::<AppState>();
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());

View File

@ -70,15 +70,17 @@ impl Translator {
Ok(Self { client, config })
}
pub async fn translate_segments_with_progress<LF, SF>(
pub async fn translate_segments_with_progress<LF, PF, SF>(
&self,
segments: &[SubtitleSegment],
target_language: &TargetLanguage,
mut log: LF,
mut on_progress: PF,
mut emit_segment: SF,
) -> Result<Vec<SubtitleSegment>>
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::<Vec<_>>()
.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?;

View File

@ -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"
/>
<SubtitleEditor
:task="selectedTask"

View File

@ -8,6 +8,7 @@ defineProps<{
const emit = defineEmits<{
select: [taskId: string]
retry: [taskId: string]
}>()
const statusLabel: Record<SubtitleTask['status'], string> = {
@ -43,6 +44,7 @@ const statusLabel: Record<SubtitleTask['status'], string> = {
: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<SubtitleTask['status'], string> = {
<div class="progress">
<div class="progress-bar" :style="{ width: `${task.progress}%` }" />
</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)">重试</button>
</div>
</button>
</div>
</aside>

View File

@ -29,6 +29,7 @@ export const useTaskStore = defineStore('tasks', {
selectedTaskId: '' as string,
ready: false,
unlisteners: [] as UnlistenFn[],
pendingPayloads: {} as Record<string, StartTaskPayload>,
}),
getters: {
selectedTask(state) {
@ -111,10 +112,24 @@ export const useTaskStore = defineStore('tasks', {
async startTask(payload: StartTaskPayload) {
const task = await invoke<SubtitleTask>('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
},

View File

@ -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;