增加详细进度展示
This commit is contained in:
parent
47356a5ea9
commit
0247f7f510
@ -1,5 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
|
io::BufRead,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command,
|
process::Command,
|
||||||
};
|
};
|
||||||
@ -9,7 +10,12 @@ use anyhow::{anyhow, Context, Result};
|
|||||||
pub struct AudioPipeline;
|
pub struct AudioPipeline;
|
||||||
|
|
||||||
impl AudioPipeline {
|
impl AudioPipeline {
|
||||||
pub fn extract_to_wav(ffmpeg_path: &Path, input_path: &str, workspace: &Path) -> Result<PathBuf> {
|
pub fn extract_to_wav<F: Fn(f32)>(
|
||||||
|
ffmpeg_path: &Path,
|
||||||
|
input_path: &str,
|
||||||
|
workspace: &Path,
|
||||||
|
on_progress: F,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
fs::create_dir_all(workspace)
|
fs::create_dir_all(workspace)
|
||||||
.with_context(|| format!("failed to create workspace: {}", workspace.display()))?;
|
.with_context(|| format!("failed to create workspace: {}", workspace.display()))?;
|
||||||
|
|
||||||
@ -22,7 +28,7 @@ impl AudioPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = command
|
let mut child = command
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg("-i")
|
.arg("-i")
|
||||||
.arg(input_path)
|
.arg(input_path)
|
||||||
@ -33,21 +39,43 @@ impl AudioPipeline {
|
|||||||
.arg("-f")
|
.arg("-f")
|
||||||
.arg("wav")
|
.arg("wav")
|
||||||
.arg(&output_path)
|
.arg(&output_path)
|
||||||
.output()
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
.with_context(|| format!("failed to launch ffmpeg: {}", ffmpeg_path.display()))?;
|
.with_context(|| format!("failed to launch ffmpeg: {}", ffmpeg_path.display()))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
let stderr = child.stderr.take().unwrap();
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let reader = std::io::BufReader::new(stderr);
|
||||||
if stderr.is_empty() {
|
|
||||||
return Err(anyhow!("ffmpeg exited with status: {}", output.status));
|
let mut total_duration_secs: Option<f64> = None;
|
||||||
|
let mut last_progress = 0.0f32;
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = line?;
|
||||||
|
|
||||||
|
if total_duration_secs.is_none() {
|
||||||
|
if let Some(dur) = parse_ffmpeg_duration(&line) {
|
||||||
|
total_duration_secs = Some(dur);
|
||||||
}
|
}
|
||||||
return Err(anyhow!(
|
|
||||||
"ffmpeg exited with status: {} | stderr: {}",
|
|
||||||
output.status,
|
|
||||||
stderr
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(current_time) = parse_ffmpeg_time(&line) {
|
||||||
|
if let Some(total) = total_duration_secs {
|
||||||
|
let ratio = (current_time / total).clamp(0.0, 1.0) as f32;
|
||||||
|
if (ratio - last_progress).abs() >= 0.01 {
|
||||||
|
last_progress = ratio;
|
||||||
|
on_progress(ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child.wait().with_context(|| "ffmpeg process failed to wait")?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow!("ffmpeg exited with status: {}", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
on_progress(1.0);
|
||||||
Ok(output_path)
|
Ok(output_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,3 +104,35 @@ impl AudioPipeline {
|
|||||||
Ok(samples)
|
Ok(samples)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_ffmpeg_duration(line: &str) -> Option<f64> {
|
||||||
|
let pos = line.find("Duration: ")?;
|
||||||
|
let rest = &line[pos + 10..];
|
||||||
|
let end = rest.find(|c: char| c == ',' || c == ' ')?;
|
||||||
|
let time_str = &rest[..end];
|
||||||
|
let parts: Vec<&str> = time_str.split(':').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
let h: f64 = parts[0].parse().ok()?;
|
||||||
|
let m: f64 = parts[1].parse().ok()?;
|
||||||
|
let s: f64 = parts[2].parse().ok()?;
|
||||||
|
Some(h * 3600.0 + m * 60.0 + s)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ffmpeg_time(line: &str) -> Option<f64> {
|
||||||
|
let pos = line.find("time=")?;
|
||||||
|
let rest = &line[pos + 5..];
|
||||||
|
let end = rest.find(|c: char| !c.is_digit(10) && c != ':' && c != '.').unwrap_or(rest.len());
|
||||||
|
let time_str = &rest[..end];
|
||||||
|
let parts: Vec<&str> = time_str.split(':').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
let h: f64 = parts[0].parse().ok()?;
|
||||||
|
let m: f64 = parts[1].parse().ok()?;
|
||||||
|
let s: f64 = parts[2].parse().ok()?;
|
||||||
|
Some(h * 3600.0 + m * 60.0 + s)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -37,6 +37,26 @@ pub struct SubtitleSegment {
|
|||||||
pub translated_text: Option<String>,
|
pub translated_text: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubStageProgress {
|
||||||
|
pub extracting: f32,
|
||||||
|
pub vad: f32,
|
||||||
|
pub transcribing: f32,
|
||||||
|
pub translating: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubStageProgress {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
extracting: 0.0,
|
||||||
|
vad: 0.0,
|
||||||
|
transcribing: 0.0,
|
||||||
|
translating: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SubtitleTask {
|
pub struct SubtitleTask {
|
||||||
@ -51,6 +71,7 @@ pub struct SubtitleTask {
|
|||||||
pub progress: f32,
|
pub progress: f32,
|
||||||
pub segments: Vec<SubtitleSegment>,
|
pub segments: Vec<SubtitleSegment>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
pub sub_stage_progress: SubStageProgress,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -73,6 +94,7 @@ pub struct ProgressEvent {
|
|||||||
pub status: TaskStatus,
|
pub status: TaskStatus,
|
||||||
pub progress: f32,
|
pub progress: f32,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
pub sub_stage_progress: SubStageProgress,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@ -11,8 +11,8 @@ use crate::{
|
|||||||
audio::AudioPipeline,
|
audio::AudioPipeline,
|
||||||
models::{
|
models::{
|
||||||
DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent,
|
DefaultModelPaths, ErrorEvent, LogEvent, OutputMode, ProgressEvent,
|
||||||
ResetSegmentsEvent, StartTaskPayload, SubtitleSegment, SubtitleTask,
|
ResetSegmentsEvent, StartTaskPayload, SubStageProgress, SubtitleSegment,
|
||||||
TargetLanguage, TaskStatus, TranslationConfig,
|
SubtitleTask, TargetLanguage, TaskStatus, TranslationConfig,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
subtitle::{render, SubtitleFormat},
|
subtitle::{render, SubtitleFormat},
|
||||||
@ -68,6 +68,7 @@ pub async fn start_task(
|
|||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
segments: Vec::new(),
|
segments: Vec::new(),
|
||||||
error: None,
|
error: None,
|
||||||
|
sub_stage_progress: SubStageProgress::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
state.upsert_task(task.clone())?;
|
state.upsert_task(task.clone())?;
|
||||||
@ -172,13 +173,60 @@ async fn run_pipeline(
|
|||||||
set_status(&window, &app_state, &mut task, TaskStatus::Extracting, 5.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 window_for_extract = window.clone();
|
||||||
|
let task_id_for_extract = task.id.clone();
|
||||||
|
let wav_path = AudioPipeline::extract_to_wav(
|
||||||
|
&ffmpeg_path,
|
||||||
|
&payload.file_path,
|
||||||
|
&workspace,
|
||||||
|
move |ratio: f32| {
|
||||||
|
let overall = 5.0 + ratio.clamp(0.0, 1.0) * 10.0;
|
||||||
|
let sub = SubStageProgress {
|
||||||
|
extracting: ratio.clamp(0.0, 1.0) * 100.0,
|
||||||
|
vad: 0.0,
|
||||||
|
transcribing: 0.0,
|
||||||
|
translating: 0.0,
|
||||||
|
};
|
||||||
|
let _ = window_for_extract.emit(
|
||||||
|
"task:progress",
|
||||||
|
ProgressEvent {
|
||||||
|
task_id: task_id_for_extract.clone(),
|
||||||
|
status: TaskStatus::Extracting,
|
||||||
|
progress: overall,
|
||||||
|
message: "正在抽取音频".to_string(),
|
||||||
|
sub_stage_progress: sub,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)?;
|
||||||
emit_log(&window, &task.id, format!("audio: normalized wav={}", wav_path.display()))?;
|
emit_log(&window, &task.id, format!("audio: normalized wav={}", wav_path.display()))?;
|
||||||
|
|
||||||
set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 15.0, "正在分析语音片段")?;
|
set_status(&window, &app_state, &mut task, TaskStatus::VadProcessing, 15.0, "正在分析语音片段")?;
|
||||||
let samples = AudioPipeline::load_wav_f32(&wav_path)?;
|
let 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 window_for_vad = window.clone();
|
||||||
|
let task_id_for_vad = task.id.clone();
|
||||||
|
let speech_ranges = vad.detect_segments(&samples, move |ratio: f32| {
|
||||||
|
let overall = 15.0 + ratio.clamp(0.0, 1.0) * 15.0;
|
||||||
|
let sub = SubStageProgress {
|
||||||
|
extracting: 100.0,
|
||||||
|
vad: ratio.clamp(0.0, 1.0) * 100.0,
|
||||||
|
transcribing: 0.0,
|
||||||
|
translating: 0.0,
|
||||||
|
};
|
||||||
|
let _ = window_for_vad.emit(
|
||||||
|
"task:progress",
|
||||||
|
ProgressEvent {
|
||||||
|
task_id: task_id_for_vad.clone(),
|
||||||
|
status: TaskStatus::VadProcessing,
|
||||||
|
progress: overall,
|
||||||
|
message: "正在分析语音片段".to_string(),
|
||||||
|
sub_stage_progress: sub,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).await;
|
||||||
emit_log(
|
emit_log(
|
||||||
&window,
|
&window,
|
||||||
&task.id,
|
&task.id,
|
||||||
@ -239,6 +287,12 @@ async fn run_pipeline(
|
|||||||
&speech_ranges,
|
&speech_ranges,
|
||||||
|ratio| {
|
|ratio| {
|
||||||
let progress = 30.0 + ratio.clamp(0.0, 1.0) * 40.0;
|
let progress = 30.0 + ratio.clamp(0.0, 1.0) * 40.0;
|
||||||
|
let sub = SubStageProgress {
|
||||||
|
extracting: 100.0,
|
||||||
|
vad: 100.0,
|
||||||
|
transcribing: ratio.clamp(0.0, 1.0) * 100.0,
|
||||||
|
translating: 0.0,
|
||||||
|
};
|
||||||
window.emit(
|
window.emit(
|
||||||
"task:progress",
|
"task:progress",
|
||||||
ProgressEvent {
|
ProgressEvent {
|
||||||
@ -246,6 +300,7 @@ async fn run_pipeline(
|
|||||||
status: TaskStatus::Transcribing,
|
status: TaskStatus::Transcribing,
|
||||||
progress,
|
progress,
|
||||||
message: "正在执行 Whisper".to_string(),
|
message: "正在执行 Whisper".to_string(),
|
||||||
|
sub_stage_progress: sub,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -320,6 +375,33 @@ async fn incremental_translate(
|
|||||||
let context_size = translator.context_size().min(5);
|
let context_size = translator.context_size().min(5);
|
||||||
let mut all_segments: Vec<SubtitleSegment> = Vec::new();
|
let mut all_segments: Vec<SubtitleSegment> = Vec::new();
|
||||||
let mut buffer: Vec<SubtitleSegment> = Vec::new();
|
let mut buffer: Vec<SubtitleSegment> = Vec::new();
|
||||||
|
let mut translated_count: usize = 0;
|
||||||
|
|
||||||
|
let emit_translate_progress = |window: &Window, task_id: &str, done: usize, total: usize| -> Result<()> {
|
||||||
|
let ratio = if total > 0 {
|
||||||
|
(done as f32 / total as f32).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let overall = 70.0 + ratio * 25.0;
|
||||||
|
let sub = SubStageProgress {
|
||||||
|
extracting: 100.0,
|
||||||
|
vad: 100.0,
|
||||||
|
transcribing: 100.0,
|
||||||
|
translating: ratio * 100.0,
|
||||||
|
};
|
||||||
|
window.emit(
|
||||||
|
"task:progress",
|
||||||
|
ProgressEvent {
|
||||||
|
task_id: task_id.to_string(),
|
||||||
|
status: TaskStatus::Translating,
|
||||||
|
progress: overall,
|
||||||
|
message: "正在生成译文".to_string(),
|
||||||
|
sub_stage_progress: sub,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
while let Some(segment) = rx.recv().await {
|
while let Some(segment) = rx.recv().await {
|
||||||
all_segments.push(segment.clone());
|
all_segments.push(segment.clone());
|
||||||
@ -340,6 +422,8 @@ async fn incremental_translate(
|
|||||||
.translate_batch_with_retries(context, &batch, target_lang_name(target_lang))
|
.translate_batch_with_retries(context, &batch, target_lang_name(target_lang))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
translated_count += rows.len();
|
||||||
|
|
||||||
emit_log(window, task_id, format!("translation: batch done, translated={}", rows.len()))?;
|
emit_log(window, task_id, format!("translation: batch done, translated={}", rows.len()))?;
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@ -360,6 +444,8 @@ async fn incremental_translate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit_translate_progress(window, task_id, translated_count, all_segments.len())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,6 +465,8 @@ async fn incremental_translate(
|
|||||||
.translate_batch_with_retries(context, &batch, target_lang_name(target_lang))
|
.translate_batch_with_retries(context, &batch, target_lang_name(target_lang))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
translated_count += rows.len();
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
if let Some(original) = batch.iter().find(|item| item.id == row.id) {
|
if let Some(original) = batch.iter().find(|item| item.id == row.id) {
|
||||||
let mut emitted = original.clone();
|
let mut emitted = original.clone();
|
||||||
@ -397,8 +485,28 @@ async fn incremental_translate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit_translate_progress(window, task_id, translated_count, all_segments.len())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Translation complete
|
||||||
|
let sub = SubStageProgress {
|
||||||
|
extracting: 100.0,
|
||||||
|
vad: 100.0,
|
||||||
|
transcribing: 100.0,
|
||||||
|
translating: 100.0,
|
||||||
|
};
|
||||||
|
window.emit(
|
||||||
|
"task:progress",
|
||||||
|
ProgressEvent {
|
||||||
|
task_id: task_id.to_string(),
|
||||||
|
status: TaskStatus::Translating,
|
||||||
|
progress: 95.0,
|
||||||
|
message: "译文生成完毕".to_string(),
|
||||||
|
sub_stage_progress: sub,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,6 +540,34 @@ fn set_status(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
task.status = status.clone();
|
task.status = status.clone();
|
||||||
task.progress = progress;
|
task.progress = progress;
|
||||||
|
|
||||||
|
// Mark completed sub-stages as 100% based on current stage.
|
||||||
|
// Only mark stages that have fully finished before this one.
|
||||||
|
match &status {
|
||||||
|
TaskStatus::Extracting => {
|
||||||
|
// extracting just started, no previous stage to mark
|
||||||
|
}
|
||||||
|
TaskStatus::VadProcessing => {
|
||||||
|
task.sub_stage_progress.extracting = 100.0;
|
||||||
|
}
|
||||||
|
TaskStatus::Transcribing => {
|
||||||
|
task.sub_stage_progress.extracting = 100.0;
|
||||||
|
task.sub_stage_progress.vad = 100.0;
|
||||||
|
}
|
||||||
|
TaskStatus::Translating => {
|
||||||
|
task.sub_stage_progress.extracting = 100.0;
|
||||||
|
task.sub_stage_progress.vad = 100.0;
|
||||||
|
task.sub_stage_progress.transcribing = 100.0;
|
||||||
|
}
|
||||||
|
TaskStatus::Completed => {
|
||||||
|
task.sub_stage_progress.extracting = 100.0;
|
||||||
|
task.sub_stage_progress.vad = 100.0;
|
||||||
|
task.sub_stage_progress.transcribing = 100.0;
|
||||||
|
task.sub_stage_progress.translating = 100.0;
|
||||||
|
}
|
||||||
|
TaskStatus::Queued | TaskStatus::Failed => {}
|
||||||
|
}
|
||||||
|
|
||||||
state.upsert_task(task.clone())?;
|
state.upsert_task(task.clone())?;
|
||||||
window.emit(
|
window.emit(
|
||||||
"task:progress",
|
"task:progress",
|
||||||
@ -440,6 +576,7 @@ fn set_status(
|
|||||||
status,
|
status,
|
||||||
progress,
|
progress,
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
|
sub_stage_progress: task.sub_stage_progress.clone(),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -551,6 +688,12 @@ pub async fn retry_translation(
|
|||||||
},
|
},
|
||||||
|ratio| {
|
|ratio| {
|
||||||
let progress = 5.0 + ratio.clamp(0.0, 1.0) * 90.0;
|
let progress = 5.0 + ratio.clamp(0.0, 1.0) * 90.0;
|
||||||
|
let sub = SubStageProgress {
|
||||||
|
extracting: 100.0,
|
||||||
|
vad: 100.0,
|
||||||
|
transcribing: 100.0,
|
||||||
|
translating: ratio.clamp(0.0, 1.0) * 100.0,
|
||||||
|
};
|
||||||
let _ = window_for_progress.emit(
|
let _ = window_for_progress.emit(
|
||||||
"task:progress",
|
"task:progress",
|
||||||
ProgressEvent {
|
ProgressEvent {
|
||||||
@ -558,6 +701,7 @@ pub async fn retry_translation(
|
|||||||
status: TaskStatus::Translating,
|
status: TaskStatus::Translating,
|
||||||
progress,
|
progress,
|
||||||
message: "正在生成译文".to_string(),
|
message: "正在生成译文".to_string(),
|
||||||
|
sub_stage_progress: sub,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -47,12 +47,17 @@ impl VadEngine {
|
|||||||
Ok(Self { model_path, config })
|
Ok(Self { model_path, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn detect_segments(&self, samples: &[f32]) -> Vec<(f32, f32)> {
|
pub async fn detect_segments<F: Fn(f32) + Clone + Send + 'static>(
|
||||||
|
&self,
|
||||||
|
samples: &[f32],
|
||||||
|
on_progress: F,
|
||||||
|
) -> Vec<(f32, f32)> {
|
||||||
if self.model_path.is_some() {
|
if self.model_path.is_some() {
|
||||||
let samples_owned = samples.to_vec();
|
let samples_owned = samples.to_vec();
|
||||||
let model_path = self.model_path.clone().unwrap();
|
let model_path = self.model_path.clone().unwrap();
|
||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
let timeout_secs = self.config.timeout_seconds;
|
let timeout_secs = self.config.timeout_seconds;
|
||||||
|
let on_progress_onnx = on_progress.clone();
|
||||||
|
|
||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
Duration::from_secs(timeout_secs),
|
Duration::from_secs(timeout_secs),
|
||||||
@ -64,7 +69,7 @@ impl VadEngine {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Self::detect_with_onnx(&mut session, &samples_owned, &config).ok()
|
Self::detect_with_onnx(&mut session, &samples_owned, &config, on_progress_onnx).ok()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -86,7 +91,7 @@ impl VadEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ranges = self.detect_segments_with_energy(samples);
|
let ranges = self.detect_segments_with_energy(samples, &on_progress);
|
||||||
eprintln!("vad: energy detection found {} speech ranges", ranges.len());
|
eprintln!("vad: energy detection found {} speech ranges", ranges.len());
|
||||||
ranges
|
ranges
|
||||||
}
|
}
|
||||||
@ -98,17 +103,20 @@ impl VadEngine {
|
|||||||
.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(
|
fn detect_with_onnx<F: Fn(f32)>(
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
samples: &[f32],
|
samples: &[f32],
|
||||||
config: &VadConfig,
|
config: &VadConfig,
|
||||||
|
on_progress: F,
|
||||||
) -> Result<Vec<(f32, f32)>> {
|
) -> Result<Vec<(f32, f32)>> {
|
||||||
let chunk_size = 512usize;
|
let chunk_size = 512usize;
|
||||||
|
let total_chunks = (samples.len() + chunk_size - 1) / chunk_size;
|
||||||
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![config.sample_rate as i64]);
|
||||||
let mut speech_probabilities = Vec::new();
|
let mut speech_probabilities = Vec::new();
|
||||||
|
let mut last_progress = 0.0f32;
|
||||||
|
|
||||||
for chunk in samples.chunks(chunk_size) {
|
for (chunk_idx, chunk) in samples.chunks(chunk_size).enumerate() {
|
||||||
let mut padded = vec![0.0_f32; chunk_size];
|
let mut padded = vec![0.0_f32; chunk_size];
|
||||||
padded[..chunk.len()].copy_from_slice(chunk);
|
padded[..chunk.len()].copy_from_slice(chunk);
|
||||||
let input = Array2::from_shape_vec((1, chunk_size), padded)
|
let input = Array2::from_shape_vec((1, chunk_size), padded)
|
||||||
@ -139,23 +147,45 @@ impl VadEngine {
|
|||||||
.context("failed to rebuild vad state")?;
|
.context("failed to rebuild vad state")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ratio = (chunk_idx + 1) as f32 / total_chunks as f32;
|
||||||
|
if (ratio - last_progress).abs() >= 0.02 {
|
||||||
|
last_progress = ratio;
|
||||||
|
on_progress(ratio);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
on_progress(1.0);
|
||||||
Ok(Self::merge_probabilities(&speech_probabilities, chunk_size, config))
|
Ok(Self::merge_probabilities(&speech_probabilities, chunk_size, config))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_segments_with_energy(&self, samples: &[f32]) -> Vec<(f32, f32)> {
|
fn detect_segments_with_energy<F: Fn(f32)>(
|
||||||
|
&self,
|
||||||
|
samples: &[f32],
|
||||||
|
on_progress: &F,
|
||||||
|
) -> Vec<(f32, f32)> {
|
||||||
let frame_size = (self.config.sample_rate / 50).max(1);
|
let frame_size = (self.config.sample_rate / 50).max(1);
|
||||||
|
let total_frames = (samples.len() + frame_size - 1) / frame_size;
|
||||||
let mut energies = Vec::new();
|
let mut energies = Vec::new();
|
||||||
for chunk in samples.chunks(frame_size) {
|
let mut last_progress = 0.0f32;
|
||||||
|
|
||||||
|
for (frame_idx, chunk) in samples.chunks(frame_size).enumerate() {
|
||||||
let energy = chunk.iter().map(|sample| sample.abs()).sum::<f32>() / chunk.len() as f32;
|
let energy = chunk.iter().map(|sample| sample.abs()).sum::<f32>() / chunk.len() as f32;
|
||||||
energies.push(energy);
|
energies.push(energy);
|
||||||
|
|
||||||
|
let ratio = (frame_idx + 1) as f32 / total_frames as f32;
|
||||||
|
if (ratio - last_progress).abs() >= 0.02 {
|
||||||
|
last_progress = ratio;
|
||||||
|
on_progress(ratio);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if energies.is_empty() {
|
if energies.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
on_progress(1.0);
|
||||||
|
|
||||||
let dynamic_threshold = self.dynamic_energy_threshold(&energies);
|
let dynamic_threshold = self.dynamic_energy_threshold(&energies);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"vad: using energy fallback, frames={}, threshold={:.5}",
|
"vad: using energy fallback, frames={}, threshold={:.5}",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, nextTick, watch, onBeforeUpdate } from 'vue'
|
import { computed, ref, nextTick, watch } from 'vue'
|
||||||
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
|
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
|
||||||
|
|
||||||
const LOG_ROW_HEIGHT = 20
|
const LOG_ROW_HEIGHT = 20
|
||||||
@ -180,10 +180,6 @@ function onSegUnmount(segmentId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUpdate(() => {
|
|
||||||
segScrollTop.value = segContainerRef.value?.scrollTop ?? 0
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTime(seconds: number) {
|
function formatTime(seconds: number) {
|
||||||
const ms = Math.round(seconds * 1000)
|
const ms = Math.round(seconds * 1000)
|
||||||
const h = Math.floor(ms / 3600000)
|
const h = Math.floor(ms / 3600000)
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import type { SubtitleTask } from '../lib/types'
|
import type { SubtitleTask } from '../lib/types'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
tasks: SubtitleTask[]
|
tasks: SubtitleTask[]
|
||||||
selectedTaskId: string
|
selectedTaskId: string
|
||||||
}>()
|
}>()
|
||||||
@ -12,6 +13,27 @@ const emit = defineEmits<{
|
|||||||
retryTranslate: [taskId: string]
|
retryTranslate: [taskId: string]
|
||||||
delete: [taskId: string]
|
delete: [taskId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const expandedTasks = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
function toggleExpand(taskId: string, event: MouseEvent) {
|
||||||
|
event.stopPropagation()
|
||||||
|
const next = new Set(expandedTasks.value)
|
||||||
|
if (next.has(taskId)) {
|
||||||
|
next.delete(taskId)
|
||||||
|
} else {
|
||||||
|
next.add(taskId)
|
||||||
|
}
|
||||||
|
expandedTasks.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSubWork(task: SubtitleTask): boolean {
|
||||||
|
return task.status !== 'queued' && task.status !== 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(task: SubtitleTask): boolean {
|
||||||
|
return task.status !== 'completed' && task.status !== 'failed' && task.status !== 'queued'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -29,27 +51,102 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="list-stack">
|
<div v-else class="list-stack">
|
||||||
<button
|
<div
|
||||||
v-for="task in tasks"
|
v-for="task in tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
|
class="task-item-wrapper"
|
||||||
|
>
|
||||||
|
<button
|
||||||
class="task-item"
|
class="task-item"
|
||||||
:class="{
|
:class="{
|
||||||
active: task.id === selectedTaskId,
|
active: task.id === selectedTaskId,
|
||||||
completed: task.status === 'completed',
|
completed: task.status === 'completed',
|
||||||
failed: task.status === 'failed',
|
failed: task.status === 'failed',
|
||||||
|
expanded: expandedTasks.has(task.id),
|
||||||
}"
|
}"
|
||||||
@click="emit('select', task.id)"
|
@click="emit('select', task.id)"
|
||||||
>
|
>
|
||||||
<div class="task-row">
|
<div class="task-row">
|
||||||
|
<div class="task-name-row">
|
||||||
|
<button
|
||||||
|
class="expand-toggle"
|
||||||
|
:class="{ expanded: expandedTasks.has(task.id) }"
|
||||||
|
@click="toggleExpand(task.id, $event)"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<strong class="truncate">{{ task.fileName }}</strong>
|
<strong class="truncate">{{ task.fileName }}</strong>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
class="subtle"
|
class="subtle"
|
||||||
:class="{ 'status-active': task.status !== 'completed' && task.status !== 'failed' && task.status !== 'queued' }"
|
:class="{ 'status-active': isActive(task) }"
|
||||||
>{{ $t(`taskQueue.status.${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>
|
||||||
|
|
||||||
|
<div v-if="expandedTasks.has(task.id) && hasSubWork(task)" class="sub-stages">
|
||||||
|
<div class="sub-stage">
|
||||||
|
<div class="sub-stage-label">
|
||||||
|
<span>{{ $t('taskQueue.subStage.extracting') }}</span>
|
||||||
|
<span class="sub-stage-pct">{{ Math.round(task.subStageProgress.extracting) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress sub-progress">
|
||||||
|
<div
|
||||||
|
class="progress-bar sub-progress-bar"
|
||||||
|
:style="{ width: `${Math.round(task.subStageProgress.extracting)}%` }"
|
||||||
|
:class="{ done: task.subStageProgress.extracting >= 100 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sub-stage">
|
||||||
|
<div class="sub-stage-label">
|
||||||
|
<span>{{ $t('taskQueue.subStage.vad') }}</span>
|
||||||
|
<span class="sub-stage-pct">{{ Math.round(task.subStageProgress.vad) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress sub-progress">
|
||||||
|
<div
|
||||||
|
class="progress-bar sub-progress-bar"
|
||||||
|
:style="{ width: `${Math.round(task.subStageProgress.vad)}%` }"
|
||||||
|
:class="{ done: task.subStageProgress.vad >= 100 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sub-stage">
|
||||||
|
<div class="sub-stage-label">
|
||||||
|
<span>{{ $t('taskQueue.subStage.transcribing') }}</span>
|
||||||
|
<span class="sub-stage-pct">{{ Math.round(task.subStageProgress.transcribing) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress sub-progress">
|
||||||
|
<div
|
||||||
|
class="progress-bar sub-progress-bar"
|
||||||
|
:style="{ width: `${Math.round(task.subStageProgress.transcribing)}%` }"
|
||||||
|
:class="{ done: task.subStageProgress.transcribing >= 100 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sub-stage">
|
||||||
|
<div class="sub-stage-label">
|
||||||
|
<span>{{ $t('taskQueue.subStage.translating') }}</span>
|
||||||
|
<span class="sub-stage-pct">{{ Math.round(task.subStageProgress.translating) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress sub-progress">
|
||||||
|
<div
|
||||||
|
class="progress-bar sub-progress-bar"
|
||||||
|
:style="{ width: `${Math.round(task.subStageProgress.translating)}%` }"
|
||||||
|
:class="{ done: task.subStageProgress.translating >= 100 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="expandedTasks.has(task.id) && task.status === 'queued'" class="sub-stages">
|
||||||
|
<div class="sub-stage-label queued-hint">{{ $t('taskQueue.queuedHint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="task.status === 'failed'" class="failed-footer">
|
<div v-if="task.status === 'failed'" class="failed-footer">
|
||||||
<p class="error-text">{{ task.error }}</p>
|
<p class="error-text">{{ task.error }}</p>
|
||||||
<div class="retry-actions">
|
<div class="retry-actions">
|
||||||
@ -62,6 +159,7 @@ const emit = defineEmits<{
|
|||||||
>{{ $t('taskQueue.retryTranslate') }}</button>
|
>{{ $t('taskQueue.retryTranslate') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="delete-button"
|
class="delete-button"
|
||||||
type="button"
|
type="button"
|
||||||
@ -75,5 +173,6 @@ const emit = defineEmits<{
|
|||||||
</button>
|
</button>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -19,6 +19,13 @@ export interface SubtitleSegment {
|
|||||||
translatedText?: string | null
|
translatedText?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubStageProgress {
|
||||||
|
extracting: number
|
||||||
|
vad: number
|
||||||
|
transcribing: number
|
||||||
|
translating: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubtitleTask {
|
export interface SubtitleTask {
|
||||||
id: string
|
id: string
|
||||||
filePath: string
|
filePath: string
|
||||||
@ -31,6 +38,7 @@ export interface SubtitleTask {
|
|||||||
progress: number
|
progress: number
|
||||||
segments: SubtitleSegment[]
|
segments: SubtitleSegment[]
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
subStageProgress: SubStageProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranslationConfig {
|
export interface TranslationConfig {
|
||||||
@ -62,6 +70,7 @@ export interface ProgressEvent {
|
|||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
progress: number
|
progress: number
|
||||||
message: string
|
message: string
|
||||||
|
subStageProgress: SubStageProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SegmentEvent {
|
export interface SegmentEvent {
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export default {
|
|||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
retryTranslate: 'Retry Translation',
|
retryTranslate: 'Retry Translation',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
|
queuedHint: 'Waiting in queue...',
|
||||||
status: {
|
status: {
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
extracting: 'Extracting',
|
extracting: 'Extracting',
|
||||||
@ -63,6 +64,12 @@ export default {
|
|||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
},
|
},
|
||||||
|
subStage: {
|
||||||
|
extracting: 'Extract Audio',
|
||||||
|
vad: 'Voice Detection',
|
||||||
|
transcribing: 'Transcribe',
|
||||||
|
translating: 'Translate',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
title: 'Workspace',
|
title: 'Workspace',
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export default {
|
|||||||
retry: '重试',
|
retry: '重试',
|
||||||
retryTranslate: '重试翻译',
|
retryTranslate: '重试翻译',
|
||||||
delete: '移除',
|
delete: '移除',
|
||||||
|
queuedHint: '正在排队等待...',
|
||||||
status: {
|
status: {
|
||||||
queued: '排队中',
|
queued: '排队中',
|
||||||
extracting: '抽取',
|
extracting: '抽取',
|
||||||
@ -63,6 +64,12 @@ export default {
|
|||||||
completed: '完成',
|
completed: '完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
},
|
},
|
||||||
|
subStage: {
|
||||||
|
extracting: '音频抽取',
|
||||||
|
vad: '语音检测',
|
||||||
|
transcribing: '语音识别',
|
||||||
|
translating: '翻译',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
title: '字幕工作区',
|
title: '字幕工作区',
|
||||||
|
|||||||
@ -51,6 +51,9 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
if (!task) return
|
if (!task) return
|
||||||
task.status = payload.status
|
task.status = payload.status
|
||||||
task.progress = payload.progress
|
task.progress = payload.progress
|
||||||
|
if (payload.subStageProgress) {
|
||||||
|
Object.assign(task.subStageProgress, payload.subStageProgress)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const segmentUnlisten = await listen<SegmentEvent>('task:segment', ({ payload }) => {
|
const segmentUnlisten = await listen<SegmentEvent>('task:segment', ({ payload }) => {
|
||||||
|
|||||||
100
src/style.css
100
src/style.css
@ -378,14 +378,10 @@ textarea {
|
|||||||
color: var(--c-text-tertiary);
|
color: var(--c-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-stack,
|
.list-stack {
|
||||||
.segment-list {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.list-stack {
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@ -393,7 +389,7 @@ textarea {
|
|||||||
.segment-list {
|
.segment-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: auto;
|
overflow-y: auto;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
will-change: scroll-position;
|
will-change: scroll-position;
|
||||||
}
|
}
|
||||||
@ -402,6 +398,10 @@ textarea {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.segment-item + .segment-item {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.task-item,
|
.task-item,
|
||||||
.segment-item {
|
.segment-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -488,16 +488,104 @@ textarea {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-item-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.task-item {
|
.task-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-item.expanded {
|
||||||
|
border-color: var(--c-border-hover);
|
||||||
|
background: rgba(26, 26, 46, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
.task-item:hover .delete-button {
|
.task-item:hover .delete-button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: transform var(--transition), color var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle:hover {
|
||||||
|
color: var(--c-text);
|
||||||
|
background: var(--c-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stages {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stage-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-stage-pct {
|
||||||
|
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--c-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress.sub-progress {
|
||||||
|
height: 2px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar.sub-progress-bar.done {
|
||||||
|
background: var(--c-success);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queued-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--c-text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.delete-button {
|
.delete-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user