mac打包
This commit is contained in:
parent
e0057c7060
commit
2a057e6917
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[env]
|
||||
MACOSX_DEPLOYMENT_TARGET = "10.15"
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET = "10.15"
|
||||
68
src-tauri/Cargo.lock
generated
68
src-tauri/Cargo.lock
generated
@ -479,6 +479,8 @@ dependencies = [
|
||||
"anyhow",
|
||||
"hound",
|
||||
"ndarray",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"ort",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@ -2219,8 +2221,38 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
@ -2248,6 +2280,41 @@ dependencies = [
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@ -2271,6 +2338,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
@ -26,3 +26,7 @@ tokio = { version = "1.42", features = ["macros", "rt-multi-thread", "time"] }
|
||||
uuid = { version = "1.11", features = ["serde", "v4"] }
|
||||
walkdir = "2.5"
|
||||
whisper-rs = "0.16"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-app-kit = "0.3.2"
|
||||
objc2-foundation = { version = "0.3.2", features = ["objc2-core-foundation"] }
|
||||
|
||||
@ -9,6 +9,21 @@ mod whisper;
|
||||
|
||||
use models::{StartTaskPayload, SubtitleSegment, SubtitleTask};
|
||||
use state::AppState;
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder},
|
||||
AppHandle, Emitter, Manager, PhysicalSize, Size,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use objc2_app_kit::NSWindow;
|
||||
#[cfg(target_os = "macos")]
|
||||
use objc2_foundation::NSSize;
|
||||
|
||||
const WINDOW_RATIO_WIDTH: f64 = 16.0;
|
||||
const WINDOW_RATIO_HEIGHT: f64 = 10.0;
|
||||
const DEFAULT_WINDOW_WIDTH: u32 = 1440;
|
||||
const DEFAULT_WINDOW_HEIGHT: u32 = 900;
|
||||
const MIN_WINDOW_WIDTH: u32 = 1280;
|
||||
const MIN_WINDOW_HEIGHT: u32 = 800;
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_subtitle_task(
|
||||
@ -53,6 +68,12 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState::default())
|
||||
.setup(|app| {
|
||||
configure_window(app.handle())?;
|
||||
#[cfg(target_os = "macos")]
|
||||
configure_macos_menu(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_subtitle_task,
|
||||
list_tasks,
|
||||
@ -62,3 +83,114 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn configure_window(app: &AppHandle) -> tauri::Result<()> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.set_min_size(Some(Size::Physical(PhysicalSize::new(
|
||||
MIN_WINDOW_WIDTH,
|
||||
MIN_WINDOW_HEIGHT,
|
||||
))))?;
|
||||
window.set_size(Size::Physical(PhysicalSize::new(
|
||||
DEFAULT_WINDOW_WIDTH,
|
||||
DEFAULT_WINDOW_HEIGHT,
|
||||
)))?;
|
||||
#[cfg(target_os = "macos")]
|
||||
apply_macos_aspect_ratio(&window)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn configure_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let app_name = app.package_info().name.clone();
|
||||
|
||||
let app_menu = SubmenuBuilder::new(app, app_name)
|
||||
.item(&PredefinedMenuItem::about(app, None, None)?)
|
||||
.separator()
|
||||
.item(&PredefinedMenuItem::services(app, None)?)
|
||||
.separator()
|
||||
.item(&PredefinedMenuItem::hide(app, None)?)
|
||||
.item(&PredefinedMenuItem::hide_others(app, None)?)
|
||||
.item(&PredefinedMenuItem::show_all(app, None)?)
|
||||
.separator()
|
||||
.item(&PredefinedMenuItem::quit(app, None)?)
|
||||
.build()?;
|
||||
|
||||
let file_menu = SubmenuBuilder::new(app, "文件")
|
||||
.item(&MenuItemBuilder::with_id("pick_files", "选择媒体文件").accelerator("CmdOrCtrl+O").build(app)?)
|
||||
.separator()
|
||||
.item(&MenuItemBuilder::with_id("export_srt", "导出 SRT").build(app)?)
|
||||
.item(&MenuItemBuilder::with_id("export_vtt", "导出 VTT").build(app)?)
|
||||
.item(&MenuItemBuilder::with_id("export_ass", "导出 ASS").build(app)?)
|
||||
.build()?;
|
||||
|
||||
let edit_menu = SubmenuBuilder::new(app, "编辑")
|
||||
.item(&PredefinedMenuItem::undo(app, None)?)
|
||||
.item(&PredefinedMenuItem::redo(app, None)?)
|
||||
.separator()
|
||||
.item(&PredefinedMenuItem::cut(app, None)?)
|
||||
.item(&PredefinedMenuItem::copy(app, None)?)
|
||||
.item(&PredefinedMenuItem::paste(app, None)?)
|
||||
.item(&PredefinedMenuItem::select_all(app, None)?)
|
||||
.build()?;
|
||||
|
||||
let settings_menu = SubmenuBuilder::new(app, "设置")
|
||||
.item(&MenuItemBuilder::with_id("toggle_advanced", "显示或隐藏高级设置").build(app)?)
|
||||
.item(&MenuItemBuilder::with_id("toggle_bilingual", "切换双语导出").build(app)?)
|
||||
.item(&MenuItemBuilder::with_id("reset_models", "恢复默认模型路径").build(app)?)
|
||||
.build()?;
|
||||
|
||||
let window_menu = SubmenuBuilder::new(app, "窗口")
|
||||
.item(&PredefinedMenuItem::minimize(app, None)?)
|
||||
.item(&PredefinedMenuItem::maximize(app, None)?)
|
||||
.separator()
|
||||
.item(&PredefinedMenuItem::close_window(app, None)?)
|
||||
.build()?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&app_menu)
|
||||
.item(&file_menu)
|
||||
.item(&edit_menu)
|
||||
.item(&settings_menu)
|
||||
.item(&window_menu)
|
||||
.build()?;
|
||||
|
||||
app.set_menu(menu)?;
|
||||
app.on_menu_event(|app, event| {
|
||||
let action = match event.id().0.as_str() {
|
||||
"pick_files" => Some("pick-files"),
|
||||
"export_srt" => Some("export-srt"),
|
||||
"export_vtt" => Some("export-vtt"),
|
||||
"export_ass" => Some("export-ass"),
|
||||
"toggle_advanced" => Some("toggle-advanced"),
|
||||
"toggle_bilingual" => Some("toggle-bilingual"),
|
||||
"reset_models" => Some("reset-models"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(action) = action {
|
||||
let _ = app.emit("menu:action", action);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn configure_macos_menu(_app: &AppHandle) -> tauri::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apply_macos_aspect_ratio(window: &tauri::WebviewWindow) -> tauri::Result<()> {
|
||||
let ns_window = window.ns_window()?;
|
||||
let ns_window = ns_window.cast::<NSWindow>();
|
||||
let aspect_ratio = NSSize::new(WINDOW_RATIO_WIDTH, WINDOW_RATIO_HEIGHT);
|
||||
|
||||
unsafe {
|
||||
let ns_window = &*ns_window;
|
||||
ns_window.setContentAspectRatio(aspect_ratio);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -89,6 +89,19 @@ pub struct ErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogEvent {
|
||||
pub task_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResetSegmentsEvent {
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranslationConfig {
|
||||
|
||||
@ -10,8 +10,8 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
audio::AudioPipeline,
|
||||
models::{
|
||||
ErrorEvent, OutputMode, ProgressEvent, StartTaskPayload, SubtitleSegment, SubtitleTask,
|
||||
TaskStatus, TranslationConfig,
|
||||
ErrorEvent, LogEvent, OutputMode, ProgressEvent, ResetSegmentsEvent, StartTaskPayload,
|
||||
SubtitleSegment, SubtitleTask, TaskStatus, TranslationConfig,
|
||||
},
|
||||
state::AppState,
|
||||
subtitle::{render, SubtitleFormat},
|
||||
@ -94,16 +94,28 @@ async fn run_pipeline(
|
||||
let should_translate = matches!(payload.output_mode, OutputMode::Translate);
|
||||
|
||||
set_status(&window, &app_state, &mut task, TaskStatus::Extracting, 8.0, "正在抽取音频")?;
|
||||
emit_log(&window, &task.id, format!("task: input file={}", payload.file_path))?;
|
||||
let wav_path = AudioPipeline::extract_to_wav(&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, "正在分析语音片段")?;
|
||||
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);
|
||||
emit_log(
|
||||
&window,
|
||||
&task.id,
|
||||
format!("vad: detected {} speech ranges", speech_ranges.len()),
|
||||
)?;
|
||||
|
||||
set_status(&window, &app_state, &mut task, TaskStatus::Transcribing, 45.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();
|
||||
let task_id_for_reset = task.id.clone();
|
||||
let task_id_for_log = task.id.clone();
|
||||
let app_state_for_segment = app_state.clone();
|
||||
let app_state_for_reset = app_state.clone();
|
||||
let mut segments = whisper.infer_segments(
|
||||
&wav_path,
|
||||
&task.id,
|
||||
@ -124,18 +136,44 @@ async fn run_pipeline(
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
if let Ok(mut current_task) = app_state_for_reset.get_task(&task_id_for_reset) {
|
||||
current_task.segments.clear();
|
||||
let _ = app_state_for_reset.upsert_task(current_task);
|
||||
}
|
||||
window.emit(
|
||||
"task:segments_reset",
|
||||
ResetSegmentsEvent {
|
||||
task_id: task_id_for_reset.clone(),
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|segment| {
|
||||
if let Ok(mut current_task) = app_state_for_segment.get_task(&task_id_for_segment) {
|
||||
if let Some(existing) = current_task
|
||||
.segments
|
||||
.iter_mut()
|
||||
.find(|item| item.id == segment.id)
|
||||
{
|
||||
*existing = segment.clone();
|
||||
} else {
|
||||
current_task.segments.push(segment.clone());
|
||||
}
|
||||
let _ = app_state_for_segment.upsert_task(current_task);
|
||||
}
|
||||
window.emit(
|
||||
"task:segment",
|
||||
crate::models::SegmentEvent {
|
||||
task_id: task_id_for_segment.clone(),
|
||||
segment,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|message| emit_log(&window, &task_id_for_log, message),
|
||||
)?;
|
||||
|
||||
for segment in &segments {
|
||||
window.emit(
|
||||
"task:segment",
|
||||
crate::models::SegmentEvent {
|
||||
task_id: task.id.clone(),
|
||||
segment: segment.clone(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
task.segments = segments.clone();
|
||||
app_state.upsert_task(task.clone())?;
|
||||
|
||||
@ -218,15 +256,16 @@ pub fn export_task(state: tauri::State<'_, AppState>, task_id: String, format: S
|
||||
let format = SubtitleFormat::try_from(format.as_str())?;
|
||||
let content = render(&task.segments, format, task.bilingual_output);
|
||||
|
||||
let file_name_path = PathBuf::from(&task.file_name);
|
||||
let stem = file_name_path
|
||||
let source_path = PathBuf::from(&task.file_path);
|
||||
let stem = source_path
|
||||
.file_stem()
|
||||
.and_then(|item| item.to_str())
|
||||
.unwrap_or("subtitle");
|
||||
|
||||
let output_dir = std::env::current_dir()
|
||||
.context("failed to get current directory")?
|
||||
.join("exports");
|
||||
let output_dir = source_path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or(std::env::current_dir().context("failed to get current directory")?);
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
let output_path = output_dir.join(format!("{stem}.{}", format.extension()));
|
||||
@ -245,3 +284,14 @@ fn emit_error(window: &Window, task_id: &str, message: &str) -> Result<()> {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_log(window: &Window, task_id: &str, message: String) -> Result<()> {
|
||||
window.emit(
|
||||
"task:log",
|
||||
LogEvent {
|
||||
task_id: task_id.to_string(),
|
||||
message,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -2,7 +2,8 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use whisper_rs::{
|
||||
FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters,
|
||||
get_lang_str, install_logging_hooks, FullParams, SamplingStrategy, WhisperContext,
|
||||
WhisperContextParameters,
|
||||
};
|
||||
|
||||
use crate::models::{SubtitleSegment, TargetLanguage};
|
||||
@ -13,6 +14,7 @@ pub struct WhisperEngine {
|
||||
|
||||
impl WhisperEngine {
|
||||
pub fn new(model_path: Option<String>) -> Self {
|
||||
install_logging_hooks();
|
||||
Self { model_path }
|
||||
}
|
||||
|
||||
@ -25,6 +27,9 @@ impl WhisperEngine {
|
||||
should_translate: bool,
|
||||
speech_ranges: &[(f32, f32)],
|
||||
mut on_progress: F,
|
||||
mut on_reset_segments: impl FnMut() -> Result<()>,
|
||||
mut on_segment: impl FnMut(SubtitleSegment) -> Result<()>,
|
||||
mut on_log: impl FnMut(String) -> Result<()>,
|
||||
) -> Result<Vec<SubtitleSegment>>
|
||||
where
|
||||
F: FnMut(f32) -> Result<()>,
|
||||
@ -49,14 +54,22 @@ impl WhisperEngine {
|
||||
)
|
||||
.with_context(|| format!("failed to load whisper model: {model_path}"))?;
|
||||
let mut state = context.create_state().context("failed to create whisper state")?;
|
||||
let detected_language = resolve_source_language(&mut state, &audio, source_lang)
|
||||
.context("failed to resolve source language")?;
|
||||
|
||||
if let Some(lang) = detected_language {
|
||||
on_log(format!("whisper: source language={lang}"))?;
|
||||
} else {
|
||||
on_log("whisper: source language unresolved, fallback to auto decode".to_string())?;
|
||||
}
|
||||
|
||||
let mut segments = Vec::new();
|
||||
eprintln!(
|
||||
on_log(format!(
|
||||
"whisper: processing {} speech ranges (normalized from {}), coverage={:.1}%",
|
||||
normalized_ranges.len(),
|
||||
speech_ranges.len(),
|
||||
speech_coverage_ratio(&normalized_ranges, total_seconds) * 100.0
|
||||
);
|
||||
))?;
|
||||
for (range_index, (start, end)) in normalized_ranges.iter().enumerate() {
|
||||
let clip = slice_audio(&audio, *start, *end);
|
||||
if clip.is_empty() {
|
||||
@ -74,10 +87,12 @@ impl WhisperEngine {
|
||||
*start,
|
||||
*end,
|
||||
task_id,
|
||||
source_lang,
|
||||
detected_language,
|
||||
target_lang,
|
||||
should_translate,
|
||||
segments.len(),
|
||||
&mut on_segment,
|
||||
&mut on_log,
|
||||
)?;
|
||||
segments.extend(clip_segments);
|
||||
|
||||
@ -94,14 +109,15 @@ impl WhisperEngine {
|
||||
|| (total_seconds > 45.0 && vad_text_len < (total_seconds / 2.4) as usize));
|
||||
|
||||
if should_retry_full_audio {
|
||||
eprintln!(
|
||||
on_log(format!(
|
||||
"whisper: VAD result looks incomplete, retrying full audio (segments={}, chars={}, end={:.2}s/{:.2}s, coverage={:.1}%)",
|
||||
segments.len(),
|
||||
vad_text_len,
|
||||
vad_end,
|
||||
total_seconds,
|
||||
vad_coverage * 100.0
|
||||
);
|
||||
))?;
|
||||
on_reset_segments()?;
|
||||
let full_audio_segments = transcribe_clip(
|
||||
&mut state,
|
||||
&audio,
|
||||
@ -109,23 +125,27 @@ impl WhisperEngine {
|
||||
0.0,
|
||||
total_seconds,
|
||||
task_id,
|
||||
source_lang,
|
||||
detected_language,
|
||||
target_lang,
|
||||
should_translate,
|
||||
0,
|
||||
&mut on_segment,
|
||||
&mut on_log,
|
||||
)?;
|
||||
|
||||
if should_prefer_full_audio(&segments, &full_audio_segments, total_seconds) {
|
||||
eprintln!(
|
||||
on_log(format!(
|
||||
"whisper: using full-audio transcript (vad_segments={}, full_segments={})",
|
||||
segments.len(),
|
||||
full_audio_segments.len()
|
||||
);
|
||||
))?;
|
||||
segments = full_audio_segments;
|
||||
} else {
|
||||
segments.iter().cloned().try_for_each(&mut on_segment)?;
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("whisper: total emitted segments={}", segments.len());
|
||||
on_log(format!("whisper: total emitted segments={}", segments.len()))?;
|
||||
Ok(segments)
|
||||
}
|
||||
}
|
||||
@ -142,6 +162,8 @@ fn transcribe_clip(
|
||||
_target_lang: &TargetLanguage,
|
||||
_should_translate: bool,
|
||||
segment_offset: usize,
|
||||
on_segment: &mut impl FnMut(SubtitleSegment) -> Result<()>,
|
||||
on_log: &mut impl FnMut(String) -> Result<()>,
|
||||
) -> Result<Vec<SubtitleSegment>> {
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_n_threads(4);
|
||||
@ -151,21 +173,28 @@ fn transcribe_clip(
|
||||
params.set_print_timestamps(false);
|
||||
params.set_token_timestamps(false);
|
||||
params.set_translate(false);
|
||||
if let Some(lang) = source_lang {
|
||||
params.set_language(Some(lang));
|
||||
match source_lang {
|
||||
Some(lang) => {
|
||||
params.set_detect_language(false);
|
||||
params.set_language(Some(lang));
|
||||
}
|
||||
None => {
|
||||
params.set_detect_language(true);
|
||||
params.set_language(None);
|
||||
}
|
||||
}
|
||||
|
||||
state.full(params, clip).context("whisper inference failed")?;
|
||||
|
||||
let num_segments = state.full_n_segments();
|
||||
eprintln!(
|
||||
on_log(format!(
|
||||
"whisper: range #{}, {:.2}-{:.2}s, samples={}, segments={}",
|
||||
range_index + 1,
|
||||
start,
|
||||
end,
|
||||
clip.len(),
|
||||
num_segments
|
||||
);
|
||||
))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for offset in 0..num_segments {
|
||||
@ -180,19 +209,21 @@ fn transcribe_clip(
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
eprintln!("whisper text: {}", text);
|
||||
on_log(format!("whisper text: {}", text))?;
|
||||
|
||||
let local_start = segment.start_timestamp() as f32 / 100.0;
|
||||
let local_end = segment.end_timestamp() as f32 / 100.0;
|
||||
|
||||
results.push(SubtitleSegment {
|
||||
let emitted = SubtitleSegment {
|
||||
id: format!("seg-{:04}", segment_offset + results.len() + 1),
|
||||
task_id: task_id.to_string(),
|
||||
start: start + local_start,
|
||||
end: start + local_end,
|
||||
source_text: text.clone(),
|
||||
translated_text: None,
|
||||
});
|
||||
};
|
||||
on_segment(emitted.clone())?;
|
||||
results.push(emitted);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
@ -301,3 +332,35 @@ fn should_prefer_full_audio(
|
||||
|| full_end > vad_end + 2.0
|
||||
|| (total_seconds > 30.0 && full_end + 1.5 >= total_seconds && vad_end + 3.0 < total_seconds)
|
||||
}
|
||||
|
||||
fn resolve_source_language<'a>(
|
||||
state: &mut whisper_rs::WhisperState,
|
||||
audio: &[f32],
|
||||
source_lang: Option<&'a str>,
|
||||
) -> Result<Option<&'a str>> {
|
||||
match source_lang.map(str::trim).filter(|lang| !lang.is_empty()) {
|
||||
Some("auto") | None => {
|
||||
let detect_samples = audio.len().min(16_000 * 30);
|
||||
let sample = &audio[..detect_samples];
|
||||
state
|
||||
.pcm_to_mel(sample, 4)
|
||||
.context("failed to build mel spectrogram for language detection")?;
|
||||
let (lang_id, probabilities) = state
|
||||
.lang_detect(0, 4)
|
||||
.context("whisper language detection failed")?;
|
||||
let lang = get_lang_str(lang_id)
|
||||
.ok_or_else(|| anyhow!("unknown whisper language id: {lang_id}"))?;
|
||||
let probability = probabilities
|
||||
.get(lang_id as usize)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
|
||||
if probability < 0.35 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(lang))
|
||||
}
|
||||
}
|
||||
Some(lang) => Ok(Some(lang)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,10 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "CrossSubtitle-AI",
|
||||
"width": 1480,
|
||||
"height": 920,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 800,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
@ -25,6 +27,9 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
"icon": [],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
260
src/App.vue
260
src/App.vue
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import TaskQueue from './components/TaskQueue.vue'
|
||||
import SubtitleEditor from './components/SubtitleEditor.vue'
|
||||
import { useTaskStore } from './stores/tasks'
|
||||
@ -8,6 +9,49 @@ import type { OutputMode, TargetLanguage, TranslationConfig } from './lib/types'
|
||||
|
||||
const DEFAULT_WHISPER_MODEL = '/Users/kura/Documents/work/tauri/CrossSubtitle/src-tauri/model/ggml-small-q5_1.bin'
|
||||
const DEFAULT_VAD_MODEL = '/Users/kura/Documents/work/tauri/CrossSubtitle/src-tauri/model/silero_vad.onnx'
|
||||
const SOURCE_LANGUAGE_OPTIONS = [
|
||||
{ value: 'auto', label: '自动识别' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'vi', label: 'Tiếng Việt' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||
{ value: 'th', label: 'ไทย' },
|
||||
{ value: 'hi', label: 'हिन्दी' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
] as const
|
||||
const MEDIA_EXTENSIONS = [
|
||||
'mp3',
|
||||
'wav',
|
||||
'm4a',
|
||||
'flac',
|
||||
'aac',
|
||||
'ogg',
|
||||
'opus',
|
||||
'mp4',
|
||||
'm4v',
|
||||
'mkv',
|
||||
'flv',
|
||||
'mov',
|
||||
'avi',
|
||||
'wmv',
|
||||
'webm',
|
||||
'ts',
|
||||
'm2ts',
|
||||
'mpeg',
|
||||
'mpg',
|
||||
] as const
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const targetLang = ref<TargetLanguage>('zh')
|
||||
@ -25,11 +69,21 @@ const translationConfig = ref<TranslationConfig>({
|
||||
})
|
||||
const pending = ref(false)
|
||||
const feedback = ref('')
|
||||
const showAdvanced = ref(false)
|
||||
let unlistenMenuAction: UnlistenFn | null = null
|
||||
|
||||
const selectedTask = computed(() => taskStore.selectedTask)
|
||||
|
||||
onMounted(() => {
|
||||
taskStore.initialize()
|
||||
void bindMenuActions()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlistenMenuAction) {
|
||||
unlistenMenuAction()
|
||||
unlistenMenuAction = null
|
||||
}
|
||||
})
|
||||
|
||||
function persistTranslationConfig() {
|
||||
@ -40,6 +94,43 @@ function persistTranslationConfig() {
|
||||
localStorage.setItem('llm.contextSize', String(translationConfig.value.contextSize))
|
||||
}
|
||||
|
||||
function resetModelPaths() {
|
||||
whisperModelPath.value = DEFAULT_WHISPER_MODEL
|
||||
vadModelPath.value = DEFAULT_VAD_MODEL
|
||||
feedback.value = '已恢复默认模型路径'
|
||||
}
|
||||
|
||||
async function bindMenuActions() {
|
||||
unlistenMenuAction = await listen<string>('menu:action', async ({ payload }) => {
|
||||
switch (payload) {
|
||||
case 'pick-files':
|
||||
await handlePickFiles()
|
||||
break
|
||||
case 'export-srt':
|
||||
await handleExport('srt')
|
||||
break
|
||||
case 'export-vtt':
|
||||
await handleExport('vtt')
|
||||
break
|
||||
case 'export-ass':
|
||||
await handleExport('ass')
|
||||
break
|
||||
case 'toggle-advanced':
|
||||
showAdvanced.value = !showAdvanced.value
|
||||
break
|
||||
case 'toggle-bilingual':
|
||||
bilingualOutput.value = !bilingualOutput.value
|
||||
feedback.value = bilingualOutput.value ? '已开启双语导出' : '已关闭双语导出'
|
||||
break
|
||||
case 'reset-models':
|
||||
resetModelPaths()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function submitFiles(filePaths: string[]) {
|
||||
pending.value = true
|
||||
feedback.value = ''
|
||||
@ -57,7 +148,7 @@ async function submitFiles(filePaths: string[]) {
|
||||
vadModelPath: vadModelPath.value || null,
|
||||
})
|
||||
}
|
||||
feedback.value = `已提交 ${filePaths.length} 个任务。`
|
||||
feedback.value = `已提交 ${filePaths.length} 个任务`
|
||||
} catch (error) {
|
||||
feedback.value = error instanceof Error ? error.message : '任务提交失败'
|
||||
} finally {
|
||||
@ -76,7 +167,7 @@ async function handlePickFiles() {
|
||||
filters: [
|
||||
{
|
||||
name: '媒体文件',
|
||||
extensions: ['mp3', 'wav', 'm4a', 'flac', 'mp4', 'mkv', 'mov', 'avi'],
|
||||
extensions: [...MEDIA_EXTENSIONS],
|
||||
},
|
||||
],
|
||||
})
|
||||
@ -112,102 +203,106 @@ async function handleFiles(event: Event) {
|
||||
async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
||||
if (!selectedTask.value) return
|
||||
const output = await taskStore.exportTask(selectedTask.value.id, format)
|
||||
feedback.value = `字幕已导出到 ${output}`
|
||||
feedback.value = `已导出到 ${output}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto min-h-screen max-w-7xl px-4 py-6 md:px-6">
|
||||
<section class="mb-6 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<div class="glass rounded-[2rem] p-6 shadow-float">
|
||||
<p class="mb-3 text-xs uppercase tracking-[0.4em] text-cyan-200/80">CrossSubtitle-AI</p>
|
||||
<h1 class="max-w-3xl font-display text-4xl font-semibold leading-tight text-white md:text-5xl">
|
||||
本地优先的视频转录与翻译工作台
|
||||
</h1>
|
||||
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-300 md:text-base">
|
||||
导入音视频文件后,应用会按“抽流 -> VAD -> Whisper -> 翻译 -> 导出”的链路执行,并把每一步状态实时推送到界面。
|
||||
</p>
|
||||
<main class="app-shell">
|
||||
<section class="topbar panel">
|
||||
<div class="toolbar-main">
|
||||
<div class="toolbar-title">
|
||||
<strong>CrossSubtitle</strong>
|
||||
<span>桌面字幕工作台</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button class="button" type="button" :disabled="pending" @click="handlePickFiles">
|
||||
{{ pending ? '提交中...' : '添加任务' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-[2rem] p-6 shadow-float">
|
||||
<h2 class="font-display text-2xl font-semibold text-white">新建任务</h2>
|
||||
<div class="mt-5 grid gap-4">
|
||||
<label class="text-sm text-slate-200">
|
||||
输出模式
|
||||
<select v-model="outputMode" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none">
|
||||
<option value="source">原文字幕</option>
|
||||
<option value="translate">翻译字幕</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-200">
|
||||
<input v-model="bilingualOutput" type="checkbox" class="h-4 w-4" />
|
||||
导出双语字幕
|
||||
</label>
|
||||
<label class="text-sm text-slate-200">
|
||||
目标语言
|
||||
<select
|
||||
v-model="targetLang"
|
||||
class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none"
|
||||
:disabled="outputMode === 'source'"
|
||||
>
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">英文</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm text-slate-200">
|
||||
源语言
|
||||
<input v-model="sourceLang" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none" placeholder="auto / zh / en / ja" />
|
||||
</label>
|
||||
<div class="workspace-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<span class="group-title">任务参数</span>
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>模式</span>
|
||||
<select v-model="outputMode">
|
||||
<option value="source">原文</option>
|
||||
<option value="translate">翻译</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>目标语言</span>
|
||||
<select v-model="targetLang" :disabled="outputMode === 'source'">
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">英文</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>源语言</span>
|
||||
<select v-model="sourceLang">
|
||||
<option
|
||||
v-for="option in SOURCE_LANGUAGE_OPTIONS"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="check desktop-check">
|
||||
<input v-model="bilingualOutput" type="checkbox" />
|
||||
<span>双语导出</span>
|
||||
</label>
|
||||
<button class="button secondary toggle-button" type="button" @click="showAdvanced = !showAdvanced">
|
||||
{{ showAdvanced ? '隐藏高级设置' : '显示高级设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="feedback status-text">{{ feedback }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showAdvanced" class="advanced-shell">
|
||||
<span class="group-title">高级设置</span>
|
||||
<div class="advanced-grid">
|
||||
<template v-if="outputMode === 'translate'">
|
||||
<label class="text-sm text-slate-200">
|
||||
LLM API Base
|
||||
<input v-model="translationConfig.apiBase" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none" placeholder="https://api.openai.com/v1" />
|
||||
<label class="field wide">
|
||||
<span>LLM API Base</span>
|
||||
<input v-model="translationConfig.apiBase" placeholder="https://api.openai.com/v1" />
|
||||
</label>
|
||||
<label class="text-sm text-slate-200">
|
||||
LLM API Key
|
||||
<input v-model="translationConfig.apiKey" type="password" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none" placeholder="sk-..." />
|
||||
<label class="field wide">
|
||||
<span>LLM API Key</span>
|
||||
<input v-model="translationConfig.apiKey" type="password" placeholder="sk-..." />
|
||||
</label>
|
||||
<label class="text-sm text-slate-200">
|
||||
LLM Model
|
||||
<input v-model="translationConfig.model" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none" placeholder="gpt-4o-mini" />
|
||||
<label class="field">
|
||||
<span>LLM Model</span>
|
||||
<input v-model="translationConfig.model" placeholder="gpt-4o-mini" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>批大小</span>
|
||||
<input v-model.number="translationConfig.batchSize" type="number" min="10" max="15" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>上下文</span>
|
||||
<input v-model.number="translationConfig.contextSize" type="number" min="0" max="5" />
|
||||
</label>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm text-slate-200">
|
||||
批大小
|
||||
<input v-model.number="translationConfig.batchSize" type="number" min="10" max="15" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label class="text-sm text-slate-200">
|
||||
上下文条数
|
||||
<input v-model.number="translationConfig.contextSize" type="number" min="0" max="5" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<label class="text-sm text-slate-200">
|
||||
Whisper 模型路径
|
||||
<input v-model="whisperModelPath" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none" :placeholder="DEFAULT_WHISPER_MODEL" />
|
||||
<label class="field wide">
|
||||
<span>Whisper 模型</span>
|
||||
<input v-model="whisperModelPath" :placeholder="DEFAULT_WHISPER_MODEL" />
|
||||
</label>
|
||||
<label class="text-sm text-slate-200">
|
||||
VAD 模型路径
|
||||
<input v-model="vadModelPath" class="mt-2 w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-white outline-none" :placeholder="DEFAULT_VAD_MODEL" />
|
||||
<label class="field wide">
|
||||
<span>VAD 模型</span>
|
||||
<input v-model="vadModelPath" :placeholder="DEFAULT_VAD_MODEL" />
|
||||
</label>
|
||||
<label class="rounded-[1.75rem] border border-dashed border-cyan-300/30 bg-cyan-300/5 p-5 text-center text-sm text-slate-200 transition hover:border-cyan-300/60 hover:bg-cyan-300/10">
|
||||
<input class="hidden" type="file" multiple @change="handleFiles" />
|
||||
<span v-if="pending">任务提交中...</span>
|
||||
<span v-else>点击选择音视频文件,支持多文件排队</span>
|
||||
</label>
|
||||
<button
|
||||
class="rounded-full bg-cyan-400 px-4 py-3 text-sm font-medium text-slate-950 transition hover:bg-cyan-300"
|
||||
type="button"
|
||||
@click="handlePickFiles"
|
||||
>
|
||||
使用原生文件对话框选择文件
|
||||
</button>
|
||||
<p class="min-h-6 text-sm text-emerald-300">{{ feedback }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
|
||||
<section class="content-grid">
|
||||
<TaskQueue
|
||||
:tasks="taskStore.tasks"
|
||||
:selected-task-id="taskStore.selectedTaskId"
|
||||
@ -215,6 +310,7 @@ async function handleExport(format: 'srt' | 'vtt' | 'ass') {
|
||||
/>
|
||||
<SubtitleEditor
|
||||
:task="selectedTask"
|
||||
:logs="taskStore.selectedTaskLogs"
|
||||
@save="taskStore.updateSegment"
|
||||
@export="handleExport"
|
||||
/>
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
|
||||
|
||||
const props = defineProps<{
|
||||
task: SubtitleTask | null
|
||||
logs: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -12,6 +13,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const segments = computed(() => props.task?.segments ?? [])
|
||||
const logsExpanded = ref(false)
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const ms = Math.round(seconds * 1000)
|
||||
@ -28,55 +30,63 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass rounded-3xl p-5 shadow-float">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<section class="panel workspace-panel">
|
||||
<div class="workspace-header">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.35em] text-orange-200/70">Editor</p>
|
||||
<h2 class="font-display text-2xl font-semibold text-white">字幕预览与编辑</h2>
|
||||
<strong>{{ task?.fileName ?? '字幕工作区' }}</strong>
|
||||
<p class="panel-subtitle">
|
||||
{{ task ? `${segments.length} 条片段` : '选择左侧任务后开始查看' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="rounded-full bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/15" @click="emit('export', 'srt')">导出 SRT</button>
|
||||
<button class="rounded-full bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/15" @click="emit('export', 'vtt')">导出 VTT</button>
|
||||
<button class="rounded-full bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/15" @click="emit('export', 'ass')">导出 ASS</button>
|
||||
<div class="export-actions">
|
||||
<button class="button secondary small" @click="emit('export', 'srt')">SRT</button>
|
||||
<button class="button secondary small" @click="emit('export', 'vtt')">VTT</button>
|
||||
<button class="button secondary small" @click="emit('export', 'ass')">ASS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!task" class="rounded-2xl border border-dashed border-white/10 p-6 text-sm text-slate-300">
|
||||
选择一个任务后,这里会展示实时字幕流和可编辑译文。
|
||||
<div v-if="!task" class="empty-state">
|
||||
选择任务后显示字幕
|
||||
</div>
|
||||
|
||||
<div v-else-if="segments.length === 0" class="rounded-2xl border border-dashed border-white/10 p-6 text-sm text-slate-300">
|
||||
当前任务还没有字幕片段,请等待转录结果推送。
|
||||
<div v-else-if="segments.length === 0" class="empty-state">
|
||||
暂无字幕片段
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[720px] space-y-3 overflow-auto pr-1">
|
||||
<div v-else class="segment-list">
|
||||
<article
|
||||
v-for="segment in segments"
|
||||
:key="segment.id"
|
||||
class="rounded-2xl border border-white/10 bg-white/5 p-4"
|
||||
class="segment-item"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<span class="rounded-full bg-cyan-400/10 px-3 py-1 text-xs text-cyan-100">
|
||||
{{ formatTime(segment.start) }} -> {{ formatTime(segment.end) }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ segment.id }}</span>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-2xl bg-slate-950/60 p-3">
|
||||
<p class="mb-2 text-xs uppercase tracking-[0.25em] text-slate-400">Source</p>
|
||||
<p class="min-h-16 whitespace-pre-wrap text-sm text-white">{{ segment.sourceText || '等待识别结果...' }}</p>
|
||||
</div>
|
||||
<label class="rounded-2xl bg-slate-950/60 p-3">
|
||||
<p class="mb-2 text-xs uppercase tracking-[0.25em] text-slate-400">Translation</p>
|
||||
<textarea
|
||||
class="min-h-16 w-full resize-y border-none bg-transparent text-sm text-white outline-none"
|
||||
:value="segment.translatedText ?? ''"
|
||||
placeholder="这里可以编辑最终字幕文本"
|
||||
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<div class="task-row subtle">
|
||||
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
|
||||
<span>{{ segment.id }}</span>
|
||||
</div>
|
||||
<p class="source-text">{{ segment.sourceText || '等待识别结果...' }}</p>
|
||||
<textarea
|
||||
class="editor-input"
|
||||
:value="segment.translatedText ?? ''"
|
||||
placeholder="译文"
|
||||
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-drawer" :class="{ expanded: logsExpanded }">
|
||||
<button class="log-toggle" type="button" @click="logsExpanded = !logsExpanded">
|
||||
<span>运行日志</span>
|
||||
<span class="subtle">{{ logs.length }} 条</span>
|
||||
<span>{{ logsExpanded ? '收起' : '展开' }}</span>
|
||||
</button>
|
||||
<div v-if="logsExpanded" class="log-panel">
|
||||
<div v-if="logs.length === 0" class="empty-state">
|
||||
暂无日志
|
||||
</div>
|
||||
<div v-else class="log-list">
|
||||
<pre v-for="(line, index) in logs" :key="`${index}-${line}`" class="log-line">{{ line }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@ -12,51 +12,50 @@ const emit = defineEmits<{
|
||||
|
||||
const statusLabel: Record<SubtitleTask['status'], string> = {
|
||||
queued: '排队中',
|
||||
extracting: '抽取音频',
|
||||
vad_processing: 'VAD 分析',
|
||||
transcribing: '语音识别',
|
||||
translating: '译文生成',
|
||||
completed: '已完成',
|
||||
extracting: '抽取',
|
||||
vad_processing: 'VAD',
|
||||
transcribing: '识别',
|
||||
translating: '翻译',
|
||||
completed: '完成',
|
||||
failed: '失败',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass rounded-3xl p-5 shadow-float">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<aside class="panel sidebar-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.35em] text-sky-200/70">Queue</p>
|
||||
<h2 class="font-display text-2xl font-semibold text-white">任务队列</h2>
|
||||
<strong>任务队列</strong>
|
||||
<p class="panel-subtitle">选择任务查看字幕和日志</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-white/10 px-3 py-1 text-xs text-slate-200">
|
||||
{{ tasks.length }} 个任务
|
||||
</span>
|
||||
<span class="badge">{{ tasks.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="tasks.length === 0" class="rounded-2xl border border-dashed border-white/10 p-5 text-sm text-slate-300">
|
||||
暂无任务。导入媒体文件后会在这里显示实时进度。
|
||||
<div v-if="tasks.length === 0" class="empty-state">
|
||||
暂无任务
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-else class="list-stack">
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="w-full rounded-2xl border p-4 text-left transition"
|
||||
:class="task.id === selectedTaskId ? 'border-cyan-400 bg-cyan-400/10' : 'border-white/10 bg-white/5 hover:border-white/25'"
|
||||
class="task-item"
|
||||
:class="{ active: task.id === selectedTaskId }"
|
||||
@click="emit('select', task.id)"
|
||||
>
|
||||
<div class="mb-2 flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-white">{{ task.fileName }}</p>
|
||||
<p class="text-xs text-slate-400">{{ statusLabel[task.status] }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-slate-300">{{ Math.round(task.progress) }}%</span>
|
||||
<div class="task-row">
|
||||
<strong class="truncate">{{ task.fileName }}</strong>
|
||||
<span>{{ Math.round(task.progress) }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-800">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-cyan-400 to-emerald-400" :style="{ width: `${task.progress}%` }" />
|
||||
<div class="task-row subtle">
|
||||
<span>{{ statusLabel[task.status] }}</span>
|
||||
<span>{{ task.segments.length }} 条</span>
|
||||
</div>
|
||||
<p v-if="task.error" class="mt-2 text-xs text-rose-300">{{ task.error }}</p>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: `${task.progress}%` }" />
|
||||
</div>
|
||||
<p v-if="task.error" class="error-text">{{ task.error }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@ -68,3 +68,12 @@ export interface ErrorEvent {
|
||||
taskId: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LogEvent {
|
||||
taskId: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ResetSegmentsEvent {
|
||||
taskId: string
|
||||
}
|
||||
|
||||
@ -3,7 +3,9 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type {
|
||||
ErrorEvent,
|
||||
LogEvent,
|
||||
ProgressEvent,
|
||||
ResetSegmentsEvent,
|
||||
SegmentEvent,
|
||||
StartTaskPayload,
|
||||
SubtitleSegment,
|
||||
@ -15,6 +17,7 @@ type ExportFormat = 'srt' | 'vtt' | 'ass'
|
||||
export const useTaskStore = defineStore('tasks', {
|
||||
state: () => ({
|
||||
tasks: [] as SubtitleTask[],
|
||||
logsByTaskId: {} as Record<string, string[]>,
|
||||
selectedTaskId: '' as string,
|
||||
ready: false,
|
||||
unlisteners: [] as UnlistenFn[],
|
||||
@ -23,6 +26,9 @@ export const useTaskStore = defineStore('tasks', {
|
||||
selectedTask(state) {
|
||||
return state.tasks.find((task) => task.id === state.selectedTaskId) ?? null
|
||||
},
|
||||
selectedTaskLogs(state) {
|
||||
return state.logsByTaskId[state.selectedTaskId] ?? []
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async initialize() {
|
||||
@ -49,6 +55,12 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
})
|
||||
|
||||
const resetSegmentsUnlisten = await listen<ResetSegmentsEvent>('task:segments_reset', ({ payload }) => {
|
||||
const task = this.tasks.find((item) => item.id === payload.taskId)
|
||||
if (!task) return
|
||||
task.segments = []
|
||||
})
|
||||
|
||||
const errorUnlisten = await listen<ErrorEvent>('task:error', ({ payload }) => {
|
||||
const task = this.tasks.find((item) => item.id === payload.taskId)
|
||||
if (!task) return
|
||||
@ -56,6 +68,16 @@ export const useTaskStore = defineStore('tasks', {
|
||||
task.error = payload.message
|
||||
})
|
||||
|
||||
const logUnlisten = await listen<LogEvent>('task:log', ({ payload }) => {
|
||||
if (!this.logsByTaskId[payload.taskId]) {
|
||||
this.logsByTaskId[payload.taskId] = []
|
||||
}
|
||||
this.logsByTaskId[payload.taskId].push(payload.message)
|
||||
if (this.logsByTaskId[payload.taskId].length > 300) {
|
||||
this.logsByTaskId[payload.taskId] = this.logsByTaskId[payload.taskId].slice(-300)
|
||||
}
|
||||
})
|
||||
|
||||
const doneUnlisten = await listen<SubtitleTask>('task:done', ({ payload }) => {
|
||||
const index = this.tasks.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) {
|
||||
@ -65,13 +87,21 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
})
|
||||
|
||||
this.unlisteners = [progressUnlisten, segmentUnlisten, errorUnlisten, doneUnlisten]
|
||||
this.unlisteners = [
|
||||
progressUnlisten,
|
||||
segmentUnlisten,
|
||||
resetSegmentsUnlisten,
|
||||
errorUnlisten,
|
||||
logUnlisten,
|
||||
doneUnlisten,
|
||||
]
|
||||
this.ready = true
|
||||
},
|
||||
|
||||
async startTask(payload: StartTaskPayload) {
|
||||
const task = await invoke<SubtitleTask>('start_subtitle_task', { payload })
|
||||
this.tasks.unshift(task)
|
||||
this.logsByTaskId[task.id] = []
|
||||
this.selectedTaskId = task.id
|
||||
},
|
||||
|
||||
|
||||
400
src/style.css
400
src/style.css
@ -3,12 +3,10 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color: #e2e8f0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(12, 74, 110, 0.45), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(194, 65, 12, 0.2), transparent 24%),
|
||||
linear-gradient(180deg, #020617 0%, #0f172a 100%);
|
||||
font-family: "IBM Plex Sans", "PingFang SC", sans-serif;
|
||||
color: #111827;
|
||||
background: #eef1f5;
|
||||
font-family: "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
html,
|
||||
@ -25,8 +23,390 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: 100%;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 14px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
|
||||
border: 1px solid #d9e0e8;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 14px 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbar-main,
|
||||
.toolbar-actions,
|
||||
.workspace-header,
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-title,
|
||||
.panel-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-title strong,
|
||||
.panel-title strong,
|
||||
.workspace-header strong {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.toolbar-title span,
|
||||
.panel-subtitle,
|
||||
.subtle,
|
||||
.feedback,
|
||||
.empty-state {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workspace-toolbar,
|
||||
.advanced-shell {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e6ebf1;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.advanced-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.advanced-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.field,
|
||||
.check {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.editor-input,
|
||||
.button,
|
||||
.task-item,
|
||||
.segment-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.editor-input {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #cfd8e3;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.editor-input:focus {
|
||||
outline: none;
|
||||
border-color: #94a3b8;
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d9e0e8;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.check input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.desktop-check {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
border-color: #cfd8e3;
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.button.small {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
min-height: 18px;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sidebar-panel,
|
||||
.workspace-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.workspace-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.list-stack,
|
||||
.segment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-stack {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.segment-list {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.task-item,
|
||||
.segment-item {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-item.active {
|
||||
border-color: #94a3b8;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 4px;
|
||||
margin-top: 6px;
|
||||
background: #dbe4ee;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
margin: 6px 0 0;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.export-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.source-text {
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.editor-input {
|
||||
min-height: 64px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.log-drawer {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #e6ebf1;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.log-toggle {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d9e0e8;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid #dfe6ee;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #dbe4ee;
|
||||
}
|
||||
|
||||
.log-line + .log-line {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.advanced-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user