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:
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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}"))?
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
use crate::models::{CookieSource, DependencyStatus};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Cached resolved paths for ffmpeg and ffprobe binaries.
|
||||
/// Prefers ffmpeg-full (Homebrew keg) if available, otherwise falls back to PATH.
|
||||
static FFMPEG_PATH: OnceLock<String> = OnceLock::new();
|
||||
static FFPROBE_PATH: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Returns the best available ffmpeg binary path.
|
||||
/// Checks for Homebrew's ffmpeg-full keg first (has libass), then falls back to PATH ffmpeg.
|
||||
pub fn ffmpeg_bin() -> &'static str {
|
||||
FFMPEG_PATH.get_or_init(|| {
|
||||
let keg = PathBuf::from("/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg");
|
||||
if keg.exists() {
|
||||
keg.to_string_lossy().into_owned()
|
||||
} else {
|
||||
"ffmpeg".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the best available ffprobe binary path.
|
||||
pub fn ffprobe_bin() -> &'static str {
|
||||
FFPROBE_PATH.get_or_init(|| {
|
||||
let keg = PathBuf::from("/opt/homebrew/opt/ffmpeg-full/bin/ffprobe");
|
||||
if keg.exists() {
|
||||
keg.to_string_lossy().into_owned()
|
||||
} else {
|
||||
"ffprobe".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check_tool_exists(name: &str) -> Option<String> {
|
||||
let output = Command::new("which").arg(name).output().ok()?;
|
||||
@@ -80,6 +112,26 @@ pub fn get_all_dependency_statuses(cookie_source: &CookieSource) -> Vec<Dependen
|
||||
]
|
||||
}
|
||||
|
||||
/// Check whether ffmpeg's `subtitles` video filter is available.
|
||||
/// Requires ffmpeg to be compiled with libass support.
|
||||
pub fn check_subtitles_filter() -> bool {
|
||||
let output = Command::new(ffmpeg_bin())
|
||||
.arg("-filters")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
stdout.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
// Filter list lines look like: " T. subtitles V->V ..."
|
||||
trimmed.contains("subtitles") && trimmed.contains("V->V")
|
||||
})
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_install_args(dep_name: &str) -> Result<Vec<String>, String> {
|
||||
match dep_name {
|
||||
"ffmpeg" => Ok(vec![
|
||||
|
||||
@@ -16,9 +16,10 @@ pub fn parse_progress_line(line: &str) -> Option<f64> {
|
||||
pub fn start_download(
|
||||
url: &str,
|
||||
cookie_source: &CookieSource,
|
||||
temp_dir: &str,
|
||||
output_dir: &str,
|
||||
format_spec: &str,
|
||||
) -> Result<(Child, String), String> {
|
||||
let output_template = format!("{}/%(title)s.%(ext)s", temp_dir);
|
||||
let output_template = format!("{}/%(title)s.%(ext)s", output_dir);
|
||||
|
||||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.arg("-o")
|
||||
@@ -27,15 +28,17 @@ pub fn start_download(
|
||||
.arg("--no-part")
|
||||
.arg("-c")
|
||||
.arg("--remote-components")
|
||||
.arg("ejs:github");
|
||||
.arg("ejs:github")
|
||||
.arg("-f")
|
||||
.arg(format_spec);
|
||||
|
||||
for arg in cookie_source.to_ytdlp_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
cmd.arg(url)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::services::dependency_manager::ffprobe_bin;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn parse_ffprobe_output(output: &str) -> Vec<f64> {
|
||||
@@ -17,7 +18,7 @@ pub fn parse_ffprobe_output(output: &str) -> Vec<f64> {
|
||||
}
|
||||
|
||||
pub fn extract_keyframes(file_path: &str) -> Result<Vec<f64>, String> {
|
||||
let output = Command::new("ffprobe")
|
||||
let output = Command::new(ffprobe_bin())
|
||||
.args([
|
||||
"-select_streams",
|
||||
"v",
|
||||
|
||||
44
src-tauri/src/services/media_server.rs
Normal file
44
src-tauri/src/services/media_server.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::net::TcpListener;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
/// Bind a local HTTP file server to a random port and return the port + std listener.
|
||||
/// The caller is responsible for spawning the server on a Tokio runtime.
|
||||
///
|
||||
/// WKWebView plays audio correctly from http://localhost URLs with proper
|
||||
/// range-request support (provided by tower-http's ServeDir).
|
||||
pub fn bind_media_server() -> Result<(u16, TcpListener), String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("Failed to bind media server: {e}"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| format!("Failed to set non-blocking: {e}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local addr: {e}"))?
|
||||
.port();
|
||||
Ok((port, listener))
|
||||
}
|
||||
|
||||
/// Spawn the axum server on the Tauri async runtime.
|
||||
/// Must be called after a Tokio runtime is available.
|
||||
pub fn spawn_media_server(listener: TcpListener) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let tokio_listener = tokio::net::TcpListener::from_std(listener)
|
||||
.expect("Failed to convert std listener to tokio");
|
||||
|
||||
let service = ServeDir::new("/");
|
||||
let app = axum::Router::new()
|
||||
.fallback_service(service)
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
eprintln!(
|
||||
"[video-clipper:media-server] serving on {:?}",
|
||||
tokio_listener.local_addr()
|
||||
);
|
||||
|
||||
if let Err(e) = axum::serve(tokio_listener, app).await {
|
||||
eprintln!("[video-clipper:media-server] server error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@ pub mod clip_exporter;
|
||||
pub mod dependency_manager;
|
||||
pub mod download_manager;
|
||||
pub mod keyframe_index;
|
||||
pub mod media_server;
|
||||
pub mod subtitle_downloader;
|
||||
pub mod thumbnail_extractor;
|
||||
pub mod video_resolver;
|
||||
pub mod waveform_generator;
|
||||
|
||||
76
src-tauri/src/services/subtitle_downloader.rs
Normal file
76
src-tauri/src/services/subtitle_downloader.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::models::CookieSource;
|
||||
use std::process::Command;
|
||||
|
||||
/// Download English VTT subtitles for a URL.
|
||||
/// `is_auto` controls whether to use --write-subs (manual) or --write-auto-subs (auto-generated).
|
||||
/// Returns the path to the downloaded .vtt file, or an error.
|
||||
pub fn download_subtitles(
|
||||
url: &str,
|
||||
cookie_source: &CookieSource,
|
||||
output_dir: &str,
|
||||
is_auto: bool,
|
||||
) -> Result<String, String> {
|
||||
std::fs::create_dir_all(output_dir)
|
||||
.map_err(|e| format!("Failed to create subtitle dir: {e}"))?;
|
||||
|
||||
let output_template = format!("{}/%(title)s.%(ext)s", output_dir);
|
||||
|
||||
let mut cmd = Command::new("yt-dlp");
|
||||
cmd.arg("--skip-download")
|
||||
.arg("--remote-components")
|
||||
.arg("ejs:github");
|
||||
|
||||
if is_auto {
|
||||
cmd.arg("--write-auto-subs");
|
||||
} else {
|
||||
cmd.arg("--write-subs");
|
||||
}
|
||||
|
||||
cmd.arg("--sub-langs").arg("en.*,en");
|
||||
cmd.arg("--sub-format").arg("vtt");
|
||||
cmd.arg("--convert-subs").arg("vtt");
|
||||
cmd.arg("-o").arg(&output_template);
|
||||
|
||||
for arg in cookie_source.to_ytdlp_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
cmd.arg(url);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run yt-dlp for subtitles: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("yt-dlp subtitle download failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Find the downloaded .vtt file in the output directory
|
||||
find_vtt_file(output_dir)
|
||||
}
|
||||
|
||||
fn find_vtt_file(dir: &str) -> Result<String, String> {
|
||||
let entries = std::fs::read_dir(dir)
|
||||
.map_err(|e| format!("Failed to read subtitle dir: {e}"))?;
|
||||
|
||||
// Prefer .en.vtt, then any .vtt
|
||||
let mut vtt_files: Vec<String> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("vtt") {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
vtt_files.push(path_str);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer files with ".en." in the name
|
||||
if let Some(en_file) = vtt_files.iter().find(|f| f.contains(".en.")) {
|
||||
return Ok(en_file.clone());
|
||||
}
|
||||
|
||||
// Fall back to any VTT file
|
||||
vtt_files
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "No .vtt subtitle file found after download".to_string())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::models::ThumbnailSpritesheet;
|
||||
use crate::services::dependency_manager::ffmpeg_bin;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn compute_interval(duration: f64) -> f64 {
|
||||
@@ -27,7 +28,7 @@ pub fn extract_thumbnails(
|
||||
let frames_per_sheet = (columns * rows_per_sheet) as usize;
|
||||
|
||||
let frames_pattern = format!("{output_dir}/frame_%06d.jpg");
|
||||
let status = Command::new("ffmpeg")
|
||||
let status = Command::new(ffmpeg_bin())
|
||||
.args([
|
||||
"-i",
|
||||
file_path,
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
use crate::models::{CookieSource, VideoMetadata};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubtitleFormat {
|
||||
pub ext: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct YtDlpJson {
|
||||
pub title: String,
|
||||
pub duration: Option<f64>,
|
||||
pub fps: Option<f64>,
|
||||
pub thumbnail: Option<String>,
|
||||
/// Manually authored subtitles: language_code -> [formats]
|
||||
#[serde(default)]
|
||||
pub subtitles: HashMap<String, Vec<SubtitleFormat>>,
|
||||
/// Auto-generated subtitles: language_code -> [formats]
|
||||
#[serde(default)]
|
||||
pub automatic_captions: HashMap<String, Vec<SubtitleFormat>>,
|
||||
}
|
||||
|
||||
/// Check if subtitles have a VTT format for English (en, en-US, en-GB, en-orig).
|
||||
fn find_english_key(map: &HashMap<String, Vec<SubtitleFormat>>) -> Option<&str> {
|
||||
// Prefer exact "en", then any en-* variant
|
||||
for key in ["en", "en-US", "en-GB", "en-orig"] {
|
||||
if map.contains_key(key) {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
// Try any key starting with "en"
|
||||
for key in map.keys() {
|
||||
if key.starts_with("en") {
|
||||
return Some(key.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Determine caption availability: (has_captions, is_auto)
|
||||
pub fn detect_captions(meta: &YtDlpJson) -> (bool, bool) {
|
||||
// Priority 1: manual English subtitles
|
||||
if find_english_key(&meta.subtitles).is_some() {
|
||||
return (true, false);
|
||||
}
|
||||
// Priority 2: auto-generated English captions
|
||||
if find_english_key(&meta.automatic_captions).is_some() {
|
||||
return (true, true);
|
||||
}
|
||||
(false, false)
|
||||
}
|
||||
|
||||
pub fn parse_ytdlp_json(json_str: &str) -> Result<YtDlpJson, String> {
|
||||
@@ -41,6 +84,8 @@ pub fn resolve(url: &str, cookie_source: &CookieSource) -> Result<VideoMetadata,
|
||||
let json_str = String::from_utf8_lossy(&meta_output.stdout);
|
||||
let meta = parse_ytdlp_json(&json_str)?;
|
||||
|
||||
let (has_captions, captions_are_auto) = detect_captions(&meta);
|
||||
|
||||
let mut stream_cmd = Command::new("yt-dlp");
|
||||
stream_cmd
|
||||
.arg("-g")
|
||||
@@ -79,6 +124,8 @@ pub fn resolve(url: &str, cookie_source: &CookieSource) -> Result<VideoMetadata,
|
||||
fps: meta.fps.unwrap_or(30.0),
|
||||
thumbnail_url: meta.thumbnail,
|
||||
stream_url,
|
||||
has_captions,
|
||||
captions_are_auto,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,6 +154,8 @@ mod tests {
|
||||
assert!(result.duration.is_none());
|
||||
assert!(result.fps.is_none());
|
||||
assert!(result.thumbnail.is_none());
|
||||
assert!(result.subtitles.is_empty());
|
||||
assert!(result.automatic_captions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -114,4 +163,39 @@ mod tests {
|
||||
let result = parse_ytdlp_json("not json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_captions_manual() {
|
||||
let json = r#"{"title": "T", "subtitles": {"en": [{"ext": "vtt", "url": "http://x"}]}, "automatic_captions": {}}"#;
|
||||
let meta = parse_ytdlp_json(json).unwrap();
|
||||
let (has, is_auto) = detect_captions(&meta);
|
||||
assert!(has);
|
||||
assert!(!is_auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_captions_auto_only() {
|
||||
let json = r#"{"title": "T", "subtitles": {}, "automatic_captions": {"en": [{"ext": "vtt", "url": "http://x"}]}}"#;
|
||||
let meta = parse_ytdlp_json(json).unwrap();
|
||||
let (has, is_auto) = detect_captions(&meta);
|
||||
assert!(has);
|
||||
assert!(is_auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_captions_none() {
|
||||
let json = r#"{"title": "T"}"#;
|
||||
let meta = parse_ytdlp_json(json).unwrap();
|
||||
let (has, _) = detect_captions(&meta);
|
||||
assert!(!has);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_captions_en_variant() {
|
||||
let json = r#"{"title": "T", "subtitles": {"en-US": [{"ext": "vtt"}]}}"#;
|
||||
let meta = parse_ytdlp_json(json).unwrap();
|
||||
let (has, is_auto) = detect_captions(&meta);
|
||||
assert!(has);
|
||||
assert!(!is_auto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,110 @@
|
||||
use std::process::Command;
|
||||
use crate::models::WaveformTiers;
|
||||
use crate::services::dependency_manager::ffmpeg_bin;
|
||||
use std::io::{BufRead, BufReader, Read as _};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
pub fn extract_waveform(file_path: &str, sample_count: usize) -> Result<Vec<f64>, String> {
|
||||
let output = Command::new("ffmpeg")
|
||||
const TIER0_SIZE: usize = 2_000;
|
||||
const TIER1_SIZE: usize = 10_000;
|
||||
|
||||
/// Extract waveform peaks and build all zoom tiers in a single ffmpeg call.
|
||||
/// The `on_progress` callback receives values from 0.0 to 1.0.
|
||||
pub fn extract_waveform_tiers(
|
||||
file_path: &str,
|
||||
duration: f64,
|
||||
on_progress: impl Fn(f64) + Send + 'static,
|
||||
) -> Result<WaveformTiers, String> {
|
||||
// ~100 peaks/sec, capped at 200K, floored at 50K
|
||||
let raw_count = (duration * 100.0).round() as usize;
|
||||
let raw_count = raw_count.clamp(50_000, 200_000);
|
||||
|
||||
// Compute the actual output sample rate in Hz.
|
||||
// aresample=N sets rate to N Hz (NOT total count), so we must divide by duration.
|
||||
let sample_rate = ((raw_count as f64) / duration).ceil() as usize;
|
||||
let sample_rate = sample_rate.max(8);
|
||||
|
||||
eprintln!(
|
||||
"[waveform_generator] extracting ~{} peaks ({}Hz x {:.0}s) for '{}'",
|
||||
raw_count, sample_rate, duration, file_path
|
||||
);
|
||||
|
||||
let mut child = Command::new(ffmpeg_bin())
|
||||
.args([
|
||||
"-i",
|
||||
file_path,
|
||||
"-ac",
|
||||
"1",
|
||||
"-filter:a",
|
||||
&format!("aresample={sample_count}"),
|
||||
"-f",
|
||||
"f32le",
|
||||
"-i", file_path,
|
||||
"-ac", "1",
|
||||
"-ar", &sample_rate.to_string(),
|
||||
"-f", "f32le",
|
||||
"-vn",
|
||||
"-progress", "pipe:2",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to run ffmpeg: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("ffmpeg waveform extraction failed: {stderr}"));
|
||||
let stderr = child.stderr.take().ok_or("No stderr")?;
|
||||
let mut stdout = child.stdout.take().ok_or("No stdout")?;
|
||||
|
||||
// Parse progress from stderr in a background thread
|
||||
let duration_us = duration * 1_000_000.0;
|
||||
let progress_thread = std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if let Some(us_str) = line.strip_prefix("out_time_us=") {
|
||||
if let Ok(us) = us_str.parse::<f64>() {
|
||||
if duration_us > 0.0 {
|
||||
on_progress((us / duration_us).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Read all waveform data from stdout
|
||||
let mut waveform_data = Vec::new();
|
||||
stdout
|
||||
.read_to_end(&mut waveform_data)
|
||||
.map_err(|e| format!("Failed to read ffmpeg stdout: {e}"))?;
|
||||
|
||||
progress_thread.join().ok();
|
||||
|
||||
let status = child.wait().map_err(|e| format!("ffmpeg wait failed: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err("ffmpeg waveform extraction failed".to_string());
|
||||
}
|
||||
|
||||
let samples = parse_f32_samples(&output.stdout);
|
||||
let peaks = compute_peaks(&samples, sample_count);
|
||||
Ok(peaks)
|
||||
let samples = parse_f32_samples(&waveform_data);
|
||||
let tier2 = compute_peaks(&samples, raw_count);
|
||||
let tier1 = downsample(&tier2, TIER1_SIZE);
|
||||
let tier0 = downsample(&tier2, TIER0_SIZE);
|
||||
|
||||
eprintln!(
|
||||
"[waveform_generator] tiers: {} / {} / {} peaks",
|
||||
tier0.len(),
|
||||
tier1.len(),
|
||||
tier2.len()
|
||||
);
|
||||
|
||||
Ok(WaveformTiers { tier0, tier1, tier2 })
|
||||
}
|
||||
|
||||
/// Downsample a peaks array to a smaller size by taking the max of each chunk.
|
||||
fn downsample(peaks: &[f64], target_count: usize) -> Vec<f64> {
|
||||
if peaks.is_empty() || target_count == 0 {
|
||||
return vec![0.0; target_count];
|
||||
}
|
||||
if target_count >= peaks.len() {
|
||||
return peaks.to_vec();
|
||||
}
|
||||
|
||||
let chunk_size = (peaks.len() as f64 / target_count as f64).ceil() as usize;
|
||||
let chunk_size = chunk_size.max(1);
|
||||
|
||||
peaks
|
||||
.chunks(chunk_size)
|
||||
.map(|chunk| chunk.iter().cloned().fold(0.0_f64, f64::max))
|
||||
.take(target_count)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_f32_samples(data: &[u8]) -> Vec<f32> {
|
||||
@@ -53,7 +133,8 @@ fn compute_peaks(samples: &[f32], target_count: usize) -> Vec<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
peaks.resize(target_count, 0.0);
|
||||
// Do NOT resize/pad — the actual peak count from chunking is correct.
|
||||
// Padding with zeros causes the waveform to appear truncated.
|
||||
peaks
|
||||
}
|
||||
|
||||
@@ -87,4 +168,28 @@ mod tests {
|
||||
assert_eq!(samples.len(), 1);
|
||||
assert!((samples[0] - 0.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample() {
|
||||
let peaks = vec![0.1, 0.5, 0.3, 0.8, 0.2, 0.9];
|
||||
let down = downsample(&peaks, 3);
|
||||
assert_eq!(down.len(), 3);
|
||||
assert!((down[0] - 0.5).abs() < 0.001);
|
||||
assert!((down[1] - 0.8).abs() < 0.001);
|
||||
assert!((down[2] - 0.9).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_identity() {
|
||||
let peaks = vec![0.1, 0.5, 0.3];
|
||||
let down = downsample(&peaks, 5);
|
||||
assert_eq!(down, peaks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downsample_empty() {
|
||||
let down = downsample(&[], 3);
|
||||
assert_eq!(down.len(), 3);
|
||||
assert!(down.iter().all(|p| *p == 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user