chore: stage all pending work — caption styling, media server, processing modal, docs, summaries

Includes:
- Extended caption styling (font, shadow, dimmed color, bg toggle)
- Media server, subtitle downloader, VTT parser, processing modal
- Waveform tiers, thumbnail/timeline improvements, transport controls
- Hybrid download model, dependency management, clip export enhancements
- 21 chat summaries, 2 implementation plans, 2 design specs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 10:48:16 -04:00
parent fd05ae19b5
commit 8ad2f1c800
57 changed files with 4929 additions and 210 deletions

View File

@@ -12,6 +12,11 @@ pub fn check_dependencies(cookie_source: CookieSource) -> Vec<DependencyStatus>
dependency_manager::get_all_dependency_statuses(&cookie_source)
}
#[tauri::command]
pub fn check_subtitles_filter_available() -> bool {
dependency_manager::check_subtitles_filter()
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
pub enum InstallEvent {

View File

@@ -11,6 +11,12 @@ pub enum ExportEvent {
total: usize,
label: String,
},
ClipProgress {
current: usize,
total: usize,
label: String,
percent: f64,
},
Finished {
paths: Vec<String>,
},
@@ -27,6 +33,10 @@ pub async fn export_clips(
tokio::task::spawn_blocking(move || {
let output_directory = clip_exporter::expand_tilde_path(&config.output_directory);
let ext = clip_exporter::get_extension(&config.source_file_path, &config.cut_mode);
let use_subs = config.include_captions && config.caption_file_path.is_some();
let burn_in = config.burn_in_captions;
let caption_path = config.caption_file_path.as_deref().unwrap_or("");
let caption_style = config.caption_style.as_ref();
match config.export_scope {
ExportScope::Individual => {
@@ -34,10 +44,13 @@ pub async fn export_clips(
let mut output_paths = Vec::new();
for (i, clip) in config.clips.iter().enumerate() {
let clip_num = i + 1;
let label = clip.label.clone();
let _ = on_event.send(ExportEvent::Progress {
current: i + 1,
current: clip_num,
total,
label: clip.label.clone(),
label: label.clone(),
});
let output = clip_exporter::generate_output_path(
@@ -47,12 +60,35 @@ pub async fn export_clips(
&ext,
);
clip_exporter::export_single_clip(
clip,
&config.source_file_path,
&output,
&config.cut_mode,
)?;
let progress_cb = |pct: f64| {
let _ = on_event.send(ExportEvent::ClipProgress {
current: clip_num,
total,
label: label.clone(),
percent: pct,
});
};
if use_subs {
clip_exporter::export_single_clip_with_subs(
clip,
&config.source_file_path,
&output,
&config.cut_mode,
caption_path,
burn_in,
caption_style,
&progress_cb,
)?;
} else {
clip_exporter::export_single_clip(
clip,
&config.source_file_path,
&output,
&config.cut_mode,
&progress_cb,
)?;
}
output_paths.push(output);
}
@@ -63,6 +99,7 @@ pub async fn export_clips(
Ok(output_paths)
}
ExportScope::Merged => {
let total_clips = config.clips.len();
let _ = on_event.send(ExportEvent::Progress {
current: 1,
total: 1,
@@ -89,14 +126,45 @@ pub async fn export_clips(
.unwrap_or(std::cmp::Ordering::Equal)
});
let clip_labels: Vec<String> = sorted_clips.iter().map(|c| c.label.clone()).collect();
let temp_dir_str = temp_dir.to_string_lossy().to_string();
let merge_result = clip_exporter::export_merged(
&sorted_clips,
&config.source_file_path,
&output,
&config.cut_mode,
&temp_dir_str,
);
let merge_progress = |clip_idx: usize, pct: f64| {
let label = if clip_idx < clip_labels.len() {
format!("Encoding {}", clip_labels[clip_idx])
} else {
"Concatenating…".to_string()
};
let _ = on_event.send(ExportEvent::ClipProgress {
current: clip_idx + 1,
total: total_clips,
label,
percent: pct,
});
};
let merge_result = if use_subs {
clip_exporter::export_merged_with_subs(
&sorted_clips,
&config.source_file_path,
&output,
&config.cut_mode,
&temp_dir_str,
caption_path,
burn_in,
caption_style,
&merge_progress,
)
} else {
clip_exporter::export_merged(
&sorted_clips,
&config.source_file_path,
&output,
&config.cut_mode,
&temp_dir_str,
&merge_progress,
)
};
let _ = std::fs::remove_dir_all(&temp_dir);
merge_result?;

View File

@@ -1,5 +1,5 @@
use crate::models::{CookieSource, VideoMetadata};
use crate::services::{download_manager, video_resolver};
use crate::services::{download_manager, subtitle_downloader, video_resolver};
use serde::Serialize;
use std::io::{BufRead, BufReader};
use tauri::ipc::Channel;
@@ -23,24 +23,36 @@ pub enum DownloadEvent {
Error { message: String },
}
fn variant_dir(variant: &str) -> String {
std::env::temp_dir()
.join("video-clipper")
.join(variant)
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub async fn start_download(
url: String,
cookie_source: CookieSource,
format_spec: String,
variant: String,
on_event: Channel<DownloadEvent>,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
let temp_dir = std::env::temp_dir()
.join("video-clipper")
.to_string_lossy()
.to_string();
std::fs::create_dir_all(&temp_dir)
.map_err(|e| format!("Failed to create temp dir: {e}"))?;
let output_dir = variant_dir(&variant);
std::fs::create_dir_all(&output_dir)
.map_err(|e| format!("Failed to create dir: {e}"))?;
eprintln!(
"[video-clipper:download:{}] starting — format='{}' dir='{}'",
variant, format_spec, output_dir
);
let (mut child, _template) =
download_manager::start_download(&url, &cookie_source, &temp_dir)?;
download_manager::start_download(&url, &cookie_source, &output_dir, &format_spec)?;
let Some(stderr) = child.stderr.take() else {
let Some(stdout) = child.stdout.take() else {
let status = child
.wait()
.map_err(|e| format!("Download failed: {e}"))?;
@@ -51,7 +63,7 @@ pub async fn start_download(
return Ok(());
};
let reader = BufReader::new(stderr);
let reader = BufReader::new(stdout);
let mut last_file_path: Option<String> = None;
for line in reader.lines().map_while(Result::ok) {
@@ -59,9 +71,7 @@ pub async fn start_download(
let _ = on_event.send(DownloadEvent::Progress { percent: pct });
}
if line.contains("[download] Destination:")
|| line.contains("[Merger] Merging formats into")
{
if line.contains("[download] Destination:") {
if let Some(path) = line.split(": ").nth(1) {
let trimmed = path.trim().trim_matches('"').to_string();
let _ = on_event.send(DownloadEvent::FilePath {
@@ -69,6 +79,30 @@ pub async fn start_download(
});
last_file_path = Some(trimmed);
}
} else if line.contains("[Merger] Merging formats into") {
let trimmed = line
.trim_start_matches("[Merger] Merging formats into ")
.trim()
.trim_matches('"')
.to_string();
if !trimmed.is_empty() {
let _ = on_event.send(DownloadEvent::FilePath {
path: trimmed.clone(),
});
last_file_path = Some(trimmed);
}
} else if line.contains("has already been downloaded") {
if let Some(rest) = line.strip_prefix("[download] ") {
if let Some(path) = rest.strip_suffix(" has already been downloaded") {
let trimmed = path.trim().to_string();
if !trimmed.is_empty() {
let _ = on_event.send(DownloadEvent::FilePath {
path: trimmed.clone(),
});
last_file_path = Some(trimmed);
}
}
}
}
}
@@ -76,6 +110,12 @@ pub async fn start_download(
.wait()
.map_err(|e| format!("Download failed: {e}"))?;
let path = last_file_path.unwrap_or_default();
eprintln!(
"[video-clipper:download:{}] finished — success={}, path='{}'",
variant,
status.success(),
path
);
let _ = on_event.send(DownloadEvent::Finished {
success: status.success(),
path,
@@ -86,3 +126,67 @@ pub async fn start_download(
.await
.map_err(|e| format!("Task failed: {e}"))?
}
/// Check if a video with the given title exists in the variant's download dir.
#[tauri::command]
pub async fn check_cached_download(title: String, variant: String) -> Option<String> {
let dir = variant_dir(&variant);
let dir_path = std::path::Path::new(&dir);
if !dir_path.is_dir() {
return None;
}
let entries = std::fs::read_dir(dir_path).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if stem == title {
let path_str = path.to_string_lossy().to_string();
if let Ok(meta) = std::fs::metadata(&path_str) {
if meta.len() > 0 {
eprintln!(
"[video-clipper:cache:{}] found: '{}'",
variant, path_str
);
return Some(path_str);
}
}
}
}
}
}
None
}
/// Download English VTT subtitles for a video URL.
/// Returns the path to the .vtt file, or an error string.
#[tauri::command]
pub async fn download_subtitles(
url: String,
cookie_source: CookieSource,
is_auto: bool,
) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
let output_dir = std::env::temp_dir()
.join("video-clipper")
.join("subtitles")
.to_string_lossy()
.to_string();
eprintln!(
"[video-clipper:subtitles] downloading (auto={}) to '{}'",
is_auto, output_dir
);
let result =
subtitle_downloader::download_subtitles(&url, &cookie_source, &output_dir, is_auto);
match &result {
Ok(path) => eprintln!("[video-clipper:subtitles] done: '{}'", path),
Err(e) => eprintln!("[video-clipper:subtitles] FAILED: {}", e),
}
result
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}