mac打包

This commit is contained in:
kura 2026-03-19 11:54:44 +08:00
parent e0057c7060
commit 2a057e6917
14 changed files with 1055 additions and 193 deletions

3
.cargo/config.toml Normal file
View File

@ -0,0 +1,3 @@
[env]
MACOSX_DEPLOYMENT_TARGET = "10.15"
CMAKE_OSX_DEPLOYMENT_TARGET = "10.15"

68
src-tauri/Cargo.lock generated
View File

@ -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",
]

View File

@ -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"] }

View File

@ -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(())
}

View File

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

View File

@ -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(())
}

View File

@ -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)),
}
}

View File

@ -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"
}
}
}

View File

@ -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"
/>

View File

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

View File

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

View File

@ -68,3 +68,12 @@ export interface ErrorEvent {
taskId: string
message: string
}
export interface LogEvent {
taskId: string
message: string
}
export interface ResetSegmentsEvent {
taskId: string
}

View File

@ -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
},

View File

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