2026-09-22 06:54:06 -04:00
|
|
|
use crate::models::{CaptionStyle, Clip, CutMode};
|
|
|
|
|
use crate::services::dependency_manager::ffmpeg_bin;
|
|
|
|
|
use std::io::{BufRead, BufReader, Read as _};
|
2026-09-21 11:20:32 -04:00
|
|
|
use std::path::Path;
|
2026-09-22 06:54:06 -04:00
|
|
|
use std::process::{Command, Stdio};
|
|
|
|
|
|
|
|
|
|
/// Strip YouTube auto-generated VTT karaoke tags and positioning metadata
|
|
|
|
|
/// that confuse ffmpeg's VTT parser / mov_text conversion.
|
|
|
|
|
/// Returns the path to a cleaned temp VTT file.
|
|
|
|
|
pub fn sanitize_vtt_for_ffmpeg(caption_path: &str, temp_dir: &str) -> Result<String, String> {
|
|
|
|
|
let content = std::fs::read_to_string(caption_path)
|
|
|
|
|
.map_err(|e| format!("Failed to read VTT file '{}': {e}", caption_path))?;
|
|
|
|
|
|
|
|
|
|
std::fs::create_dir_all(temp_dir)
|
|
|
|
|
.map_err(|e| format!("Failed to create temp dir for sanitized VTT: {e}"))?;
|
|
|
|
|
|
|
|
|
|
let out_path = Path::new(temp_dir).join("sanitized.vtt");
|
|
|
|
|
let mut output = String::with_capacity(content.len());
|
|
|
|
|
|
|
|
|
|
for line in content.lines() {
|
|
|
|
|
if line.contains("-->") {
|
|
|
|
|
// Strip positioning metadata (align:start position:0% etc.) from timestamp lines
|
|
|
|
|
if let Some(arrow_end) = line.find("-->") {
|
|
|
|
|
let after_arrow = &line[arrow_end + 3..];
|
|
|
|
|
// Find end of the second timestamp (digits, colons, dots/commas)
|
|
|
|
|
let ts_end = after_arrow
|
|
|
|
|
.find(|c: char| !c.is_ascii_digit() && c != ':' && c != '.' && c != ',' && c != ' ')
|
|
|
|
|
.unwrap_or(after_arrow.len());
|
|
|
|
|
let cleaned = format!("{}{}", &line[..arrow_end + 3], &after_arrow[..ts_end].trim_end());
|
|
|
|
|
output.push_str(&cleaned);
|
|
|
|
|
} else {
|
|
|
|
|
output.push_str(line);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Strip inline tags: <c>, </c>, and inline timestamps like <00:00:00.599>
|
|
|
|
|
let cleaned = strip_vtt_tags(line);
|
|
|
|
|
output.push_str(&cleaned);
|
|
|
|
|
}
|
|
|
|
|
output.push('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::fs::write(&out_path, &output)
|
|
|
|
|
.map_err(|e| format!("Failed to write sanitized VTT: {e}"))?;
|
|
|
|
|
|
|
|
|
|
Ok(out_path.to_string_lossy().to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Sanitize a VTT file AND trim it to a clip's time range, shifting timestamps
|
|
|
|
|
/// to start from 0. Used for mux mode where the video uses input seeking
|
|
|
|
|
/// (output PTS starts at ~0) so subtitle timestamps must also start from 0.
|
|
|
|
|
pub fn trim_and_sanitize_vtt(
|
|
|
|
|
caption_path: &str,
|
|
|
|
|
temp_dir: &str,
|
|
|
|
|
start_time: f64,
|
|
|
|
|
end_time: f64,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
let content = std::fs::read_to_string(caption_path)
|
|
|
|
|
.map_err(|e| format!("Failed to read VTT file '{}': {e}", caption_path))?;
|
|
|
|
|
|
|
|
|
|
std::fs::create_dir_all(temp_dir)
|
|
|
|
|
.map_err(|e| format!("Failed to create temp dir for trimmed VTT: {e}"))?;
|
|
|
|
|
|
|
|
|
|
let out_path = Path::new(temp_dir).join("trimmed.vtt");
|
|
|
|
|
let mut output = String::from("WEBVTT\n\n");
|
|
|
|
|
|
|
|
|
|
// Split into blocks on blank lines, process each cue
|
|
|
|
|
let blocks = content.replace("\r\n", "\n");
|
|
|
|
|
let blocks: Vec<&str> = blocks.split("\n\n").collect();
|
|
|
|
|
|
|
|
|
|
for block in &blocks {
|
|
|
|
|
let lines: Vec<&str> = block.trim().lines().collect();
|
|
|
|
|
// Find the timestamp line
|
|
|
|
|
let ts_idx = lines.iter().position(|l| l.contains("-->"));
|
|
|
|
|
let ts_idx = match ts_idx {
|
|
|
|
|
Some(i) => i,
|
|
|
|
|
None => continue,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let ts_line = lines[ts_idx];
|
|
|
|
|
// Parse the two timestamps from the line
|
|
|
|
|
let (cue_start, cue_end) = match parse_vtt_timestamp_line(ts_line) {
|
|
|
|
|
Some(pair) => pair,
|
|
|
|
|
None => continue,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Skip cues outside the clip range
|
|
|
|
|
if cue_end <= start_time || cue_start >= end_time {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clamp and shift to start from 0
|
|
|
|
|
let shifted_start = (cue_start - start_time).max(0.0);
|
|
|
|
|
let shifted_end = (cue_end - start_time).min(end_time - start_time);
|
|
|
|
|
|
|
|
|
|
if shifted_end <= shifted_start {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write the cue with shifted timestamps and sanitized text
|
|
|
|
|
output.push_str(&format_vtt_timestamp(shifted_start));
|
|
|
|
|
output.push_str(" --> ");
|
|
|
|
|
output.push_str(&format_vtt_timestamp(shifted_end));
|
|
|
|
|
output.push('\n');
|
|
|
|
|
|
|
|
|
|
// Collect text lines (everything after the timestamp line), sanitize tags
|
|
|
|
|
for &line in &lines[ts_idx + 1..] {
|
|
|
|
|
let cleaned = strip_vtt_tags(line);
|
|
|
|
|
let cleaned = cleaned.trim();
|
|
|
|
|
if !cleaned.is_empty() {
|
|
|
|
|
output.push_str(cleaned);
|
|
|
|
|
output.push('\n');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
output.push('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::fs::write(&out_path, &output)
|
|
|
|
|
.map_err(|e| format!("Failed to write trimmed VTT: {e}"))?;
|
|
|
|
|
|
|
|
|
|
Ok(out_path.to_string_lossy().to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse a VTT timestamp line like "00:01:23.456 --> 00:02:34.567 align:start"
|
|
|
|
|
/// Returns (start_seconds, end_seconds) or None if parsing fails.
|
|
|
|
|
fn parse_vtt_timestamp_line(line: &str) -> Option<(f64, f64)> {
|
|
|
|
|
let arrow_pos = line.find("-->")?;
|
|
|
|
|
let before = line[..arrow_pos].trim();
|
|
|
|
|
let after_arrow = &line[arrow_pos + 3..];
|
|
|
|
|
// The end timestamp ends at the first non-timestamp character
|
|
|
|
|
let end_ts_str = after_arrow
|
|
|
|
|
.trim_start()
|
|
|
|
|
.split(|c: char| !c.is_ascii_digit() && c != ':' && c != '.' && c != ',')
|
|
|
|
|
.next()?;
|
|
|
|
|
|
|
|
|
|
Some((parse_vtt_ts(before)?, parse_vtt_ts(end_ts_str)?))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse a single VTT timestamp "HH:MM:SS.mmm" or "MM:SS.mmm" into seconds.
|
|
|
|
|
fn parse_vtt_ts(ts: &str) -> Option<f64> {
|
|
|
|
|
let normalized = ts.replace(',', ".");
|
|
|
|
|
let parts: Vec<&str> = normalized.split(':').collect();
|
|
|
|
|
match parts.len() {
|
|
|
|
|
3 => {
|
|
|
|
|
let h: f64 = parts[0].parse().ok()?;
|
|
|
|
|
let m: f64 = parts[1].parse().ok()?;
|
|
|
|
|
let s: f64 = parts[2].parse().ok()?;
|
|
|
|
|
Some(h * 3600.0 + m * 60.0 + s)
|
|
|
|
|
}
|
|
|
|
|
2 => {
|
|
|
|
|
let m: f64 = parts[0].parse().ok()?;
|
|
|
|
|
let s: f64 = parts[1].parse().ok()?;
|
|
|
|
|
Some(m * 60.0 + s)
|
|
|
|
|
}
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Format seconds as a VTT timestamp "HH:MM:SS.mmm".
|
|
|
|
|
fn format_vtt_timestamp(secs: f64) -> String {
|
|
|
|
|
let total_ms = (secs * 1000.0).round() as u64;
|
|
|
|
|
let ms = total_ms % 1000;
|
|
|
|
|
let total_s = total_ms / 1000;
|
|
|
|
|
let s = total_s % 60;
|
|
|
|
|
let total_m = total_s / 60;
|
|
|
|
|
let m = total_m % 60;
|
|
|
|
|
let h = total_m / 60;
|
|
|
|
|
format!("{:02}:{:02}:{:02}.{:03}", h, m, s, ms)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn strip_vtt_tags(line: &str) -> String {
|
|
|
|
|
let mut result = String::with_capacity(line.len());
|
|
|
|
|
let mut in_tag = false;
|
|
|
|
|
let mut chars = line.chars().peekable();
|
|
|
|
|
|
|
|
|
|
while let Some(c) = chars.next() {
|
|
|
|
|
if c == '<' {
|
|
|
|
|
in_tag = true;
|
|
|
|
|
} else if c == '>' {
|
|
|
|
|
in_tag = false;
|
|
|
|
|
} else if !in_tag {
|
|
|
|
|
result.push(c);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert a hex color "#RRGGBB" to ASS color format "&H00BBGGRR".
|
|
|
|
|
/// ASS uses BGR byte order with an alpha prefix byte (00 = fully opaque).
|
|
|
|
|
fn hex_to_ass_color(hex: &str) -> String {
|
|
|
|
|
let hex = hex.trim_start_matches('#');
|
|
|
|
|
if hex.len() >= 6 {
|
|
|
|
|
let r = &hex[0..2];
|
|
|
|
|
let g = &hex[2..4];
|
|
|
|
|
let b = &hex[4..6];
|
|
|
|
|
format!("&H00{}{}{}", b.to_uppercase(), g.to_uppercase(), r.to_uppercase())
|
|
|
|
|
} else {
|
|
|
|
|
"&H00FFFFFF".to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert a background opacity (0.0=transparent, 1.0=opaque) to ASS BackColour.
|
|
|
|
|
/// ASS alpha: 00=opaque, FF=transparent (inverted from CSS).
|
|
|
|
|
fn opacity_to_ass_back_colour(opacity: f64) -> String {
|
|
|
|
|
let alpha = ((1.0 - opacity.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
|
|
|
|
format!("&H{:02X}000000", alpha)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build an ASS force_style string from user caption settings.
|
|
|
|
|
fn caption_style_to_force_style(style: &CaptionStyle) -> String {
|
|
|
|
|
let font_size = (style.font_size as f64 * 2.0).round() as u32;
|
|
|
|
|
let primary_colour = hex_to_ass_color(&style.text_color);
|
|
|
|
|
let back_colour = opacity_to_ass_back_colour(style.background_opacity);
|
|
|
|
|
let (outline, shadow) = if style.text_outline {
|
|
|
|
|
("2", "1")
|
|
|
|
|
} else {
|
|
|
|
|
("0", "0")
|
|
|
|
|
};
|
|
|
|
|
// ASS Alignment: 2 = bottom-center, 8 = top-center
|
|
|
|
|
let alignment = if style.position == "top" { "8" } else { "2" };
|
|
|
|
|
let margin_v = if style.position == "top" { "40" } else { "40" };
|
|
|
|
|
|
|
|
|
|
format!(
|
|
|
|
|
"Fontsize={},PrimaryColour={},BackColour={},Outline={},Shadow={},Alignment={},MarginV={},BorderStyle=4",
|
|
|
|
|
font_size, primary_colour, back_colour, outline, shadow, alignment, margin_v
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert a hex color "#RRGGBB" to ASS color with a specific alpha.
|
|
|
|
|
/// Returns "&H<AA><BB><GG><RR>" format.
|
|
|
|
|
fn hex_to_ass_color_with_alpha(hex: &str, alpha: u8) -> String {
|
|
|
|
|
let hex = hex.trim_start_matches('#');
|
|
|
|
|
if hex.len() >= 6 {
|
|
|
|
|
let r = &hex[0..2];
|
|
|
|
|
let g = &hex[2..4];
|
|
|
|
|
let b = &hex[4..6];
|
|
|
|
|
format!("&H{:02X}{}{}{}", alpha, b.to_uppercase(), g.to_uppercase(), r.to_uppercase())
|
|
|
|
|
} else {
|
|
|
|
|
format!("&H{:02X}FFFFFF", alpha)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Format seconds as an ASS timestamp "H:MM:SS.cc" (centiseconds, not milliseconds).
|
|
|
|
|
fn format_ass_timestamp(secs: f64) -> String {
|
|
|
|
|
let total_cs = (secs * 100.0).round() as u64;
|
|
|
|
|
let cs = total_cs % 100;
|
|
|
|
|
let total_s = total_cs / 100;
|
|
|
|
|
let s = total_s % 60;
|
|
|
|
|
let total_m = total_s / 60;
|
|
|
|
|
let m = total_m % 60;
|
|
|
|
|
let h = total_m / 60;
|
|
|
|
|
format!("{}:{:02}:{:02}.{:02}", h, m, s, cs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse word-level timing data from YouTube-style VTT cue text.
|
|
|
|
|
/// Returns a Vec of (word_text, start_time_seconds) pairs.
|
|
|
|
|
///
|
|
|
|
|
/// YouTube format: `the<00:00:00.599><c> gym</c><00:00:00.840><c> I</c>`
|
|
|
|
|
fn parse_vtt_word_timings(raw_text: &str, cue_start: f64) -> Vec<(String, f64)> {
|
|
|
|
|
if !raw_text.contains("<c>") {
|
|
|
|
|
return Vec::new();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut segments: Vec<(String, f64)> = Vec::new();
|
|
|
|
|
|
|
|
|
|
// Find the first timestamp tag position
|
|
|
|
|
let first_ts_pos = find_timestamp_tag_pos(raw_text);
|
|
|
|
|
|
|
|
|
|
// Extract leading text (before any timestamp tag)
|
|
|
|
|
if let Some(pos) = first_ts_pos {
|
|
|
|
|
if pos > 0 {
|
|
|
|
|
let leading = strip_vtt_tags(&raw_text[..pos]).trim().to_string();
|
|
|
|
|
if !leading.is_empty() {
|
|
|
|
|
segments.push((leading, cue_start));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse <timestamp><c> word</c> pairs
|
|
|
|
|
let mut search_from = 0;
|
|
|
|
|
while search_from < raw_text.len() {
|
|
|
|
|
// Find next timestamp tag like <00:00:05.320>
|
|
|
|
|
if let Some((ts_val, ts_end)) = find_next_timestamp(raw_text, search_from) {
|
|
|
|
|
// After the timestamp, expect <c>...</c>
|
|
|
|
|
let after_ts = &raw_text[ts_end..];
|
|
|
|
|
if let Some(c_start) = after_ts.find("<c>") {
|
|
|
|
|
let content_start = c_start + 3;
|
|
|
|
|
if let Some(c_end) = after_ts[content_start..].find("</c>") {
|
|
|
|
|
let word = after_ts[content_start..content_start + c_end].trim().to_string();
|
|
|
|
|
if !word.is_empty() {
|
|
|
|
|
if let Some(time) = parse_vtt_ts(&ts_val) {
|
|
|
|
|
segments.push((word, time));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
search_from = ts_end + content_start + c_end + 4; // past </c>
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
search_from = ts_end;
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
segments
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Find the byte position of the first `<HH:MM:SS.mmm>` tag in text.
|
|
|
|
|
fn find_timestamp_tag_pos(text: &str) -> Option<usize> {
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
let bytes = text.as_bytes();
|
|
|
|
|
while i < bytes.len() {
|
|
|
|
|
if bytes[i] == b'<' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
|
|
|
|
|
// Check if this looks like a timestamp tag
|
|
|
|
|
if let Some(end) = text[i..].find('>') {
|
|
|
|
|
let inner = &text[i + 1..i + end];
|
|
|
|
|
if inner.contains(':') && inner.contains('.') {
|
|
|
|
|
return Some(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Find the next `<HH:MM:SS.mmm>` timestamp tag starting from `from`.
|
|
|
|
|
/// Returns (timestamp_string, byte_position_after_closing_bracket).
|
|
|
|
|
fn find_next_timestamp(text: &str, from: usize) -> Option<(String, usize)> {
|
|
|
|
|
let slice = &text[from..];
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
let bytes = slice.as_bytes();
|
|
|
|
|
while i < bytes.len() {
|
|
|
|
|
if bytes[i] == b'<' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
|
|
|
|
|
if let Some(end_rel) = slice[i..].find('>') {
|
|
|
|
|
let inner = &slice[i + 1..i + end_rel];
|
|
|
|
|
if inner.contains(':') && (inner.contains('.') || inner.contains(',')) {
|
|
|
|
|
return Some((inner.to_string(), from + i + end_rel + 1));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A single spoken line extracted from a YouTube VTT cue.
|
|
|
|
|
/// Used to build the rolling two-line subtitle display.
|
|
|
|
|
struct SpokenLine {
|
|
|
|
|
plain_text: String,
|
|
|
|
|
raw_text: String,
|
|
|
|
|
start_time: f64,
|
|
|
|
|
end_time: f64,
|
|
|
|
|
has_karaoke: bool,
|
|
|
|
|
is_non_speech: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract a flat sequence of spoken lines from YouTube auto-generated VTT content.
|
|
|
|
|
/// Skips zero-duration transition cues and extracts only the active (karaoke) line
|
|
|
|
|
/// from two-line cues, ignoring the context line (which is reconstructed from the
|
|
|
|
|
/// previous SpokenLine during ASS generation).
|
|
|
|
|
fn extract_spoken_lines(content: &str) -> Vec<SpokenLine> {
|
|
|
|
|
let normalized = content.replace("\r\n", "\n");
|
|
|
|
|
let blocks: Vec<&str> = normalized.split("\n\n").collect();
|
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
|
|
|
|
|
|
for block in &blocks {
|
|
|
|
|
let block_lines: Vec<&str> = block.trim().lines().collect();
|
|
|
|
|
let ts_idx = block_lines.iter().position(|l| l.contains("-->"));
|
|
|
|
|
let ts_idx = match ts_idx {
|
|
|
|
|
Some(i) => i,
|
|
|
|
|
None => continue,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let ts_line = block_lines[ts_idx];
|
|
|
|
|
let (cue_start, cue_end) = match parse_vtt_timestamp_line(ts_line) {
|
|
|
|
|
Some(pair) => pair,
|
|
|
|
|
None => continue,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Skip zero-duration transition cues (YouTube uses e.g. 00:02.629 --> 00:02.639)
|
|
|
|
|
if (cue_end - cue_start) < 0.05 {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let text_lines: Vec<&str> = block_lines[ts_idx + 1..].iter().copied().collect();
|
|
|
|
|
if text_lines.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find the karaoke line (contains <c> tags)
|
|
|
|
|
let karaoke_line = text_lines.iter().find(|l| l.contains("<c>"));
|
|
|
|
|
|
|
|
|
|
if let Some(raw) = karaoke_line {
|
|
|
|
|
let plain = strip_vtt_tags(raw).trim().to_string();
|
|
|
|
|
if !plain.is_empty() {
|
|
|
|
|
lines.push(SpokenLine {
|
|
|
|
|
plain_text: plain,
|
|
|
|
|
raw_text: raw.to_string(),
|
|
|
|
|
start_time: cue_start,
|
|
|
|
|
end_time: cue_end,
|
|
|
|
|
has_karaoke: true,
|
|
|
|
|
is_non_speech: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// No karaoke — could be plain text or non-speech like [Music]
|
|
|
|
|
let all_text: Vec<String> = text_lines.iter()
|
|
|
|
|
.map(|l| strip_vtt_tags(l).trim().to_string())
|
|
|
|
|
.filter(|l| !l.is_empty())
|
|
|
|
|
.collect();
|
|
|
|
|
let joined = all_text.join(" ");
|
|
|
|
|
if joined.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let is_non_speech = joined.starts_with('[') && joined.ends_with(']');
|
|
|
|
|
lines.push(SpokenLine {
|
|
|
|
|
plain_text: joined.clone(),
|
|
|
|
|
raw_text: joined,
|
|
|
|
|
start_time: cue_start,
|
|
|
|
|
end_time: cue_end,
|
|
|
|
|
has_karaoke: false,
|
|
|
|
|
is_non_speech,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lines
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build a karaoke text string with \k tags from a raw VTT line's word timings.
|
|
|
|
|
fn build_karaoke_text(raw: &str, cue_start: f64, cue_end: f64) -> String {
|
|
|
|
|
let words = parse_vtt_word_timings(raw, cue_start);
|
|
|
|
|
if words.len() >= 2 {
|
|
|
|
|
let mut parts = Vec::new();
|
|
|
|
|
for (i, (word, start)) in words.iter().enumerate() {
|
|
|
|
|
let next_start = if i + 1 < words.len() {
|
|
|
|
|
words[i + 1].1
|
|
|
|
|
} else {
|
|
|
|
|
cue_end
|
|
|
|
|
};
|
|
|
|
|
let duration_cs = ((next_start - start) * 100.0).round().max(1.0) as u64;
|
|
|
|
|
parts.push(format!("{{\\k{}}}{}", duration_cs, word));
|
|
|
|
|
}
|
|
|
|
|
parts.join(" ")
|
|
|
|
|
} else {
|
|
|
|
|
strip_vtt_tags(raw)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Group spoken lines into 2-line pages for the teleprompter display.
|
|
|
|
|
/// If the gap between two consecutive lines exceeds `gap_threshold` seconds,
|
|
|
|
|
/// the pair is split into separate single-line pages.
|
|
|
|
|
/// Non-speech lines (e.g., [Music]) always get their own page.
|
|
|
|
|
fn group_into_pages(spoken_lines: &[SpokenLine], gap_threshold: f64) -> Vec<Vec<usize>> {
|
|
|
|
|
let mut pages: Vec<Vec<usize>> = Vec::new();
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
|
|
|
|
|
while i < spoken_lines.len() {
|
|
|
|
|
let line = &spoken_lines[i];
|
|
|
|
|
|
|
|
|
|
if line.is_non_speech {
|
|
|
|
|
pages.push(vec![i]);
|
|
|
|
|
i += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if i + 1 < spoken_lines.len() {
|
|
|
|
|
let next = &spoken_lines[i + 1];
|
|
|
|
|
let gap = next.start_time - line.end_time;
|
|
|
|
|
|
|
|
|
|
if gap <= gap_threshold && !next.is_non_speech {
|
|
|
|
|
pages.push(vec![i, i + 1]);
|
|
|
|
|
i += 2;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pages.push(vec![i]);
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pages
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build karaoke text for a 1-or-2-line page, stitching word timings
|
|
|
|
|
/// across lines with \N as the visual line break. The \k durations flow
|
|
|
|
|
/// continuously so karaoke highlighting progresses top-to-bottom.
|
|
|
|
|
fn build_page_karaoke_text(lines: &[&SpokenLine]) -> String {
|
|
|
|
|
struct WordEntry {
|
|
|
|
|
text: String,
|
|
|
|
|
start: f64,
|
|
|
|
|
is_line_break_before: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut entries: Vec<WordEntry> = Vec::new();
|
|
|
|
|
|
|
|
|
|
for (line_idx, line) in lines.iter().enumerate() {
|
|
|
|
|
let is_new_line = line_idx > 0;
|
|
|
|
|
|
|
|
|
|
if line.has_karaoke {
|
|
|
|
|
let words = parse_vtt_word_timings(&line.raw_text, line.start_time);
|
|
|
|
|
if words.len() >= 2 {
|
|
|
|
|
for (w_idx, (word, start)) in words.iter().enumerate() {
|
|
|
|
|
entries.push(WordEntry {
|
|
|
|
|
text: word.clone(),
|
|
|
|
|
start: *start,
|
|
|
|
|
is_line_break_before: is_new_line && w_idx == 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
entries.push(WordEntry {
|
|
|
|
|
text: strip_vtt_tags(&line.raw_text),
|
|
|
|
|
start: line.start_time,
|
|
|
|
|
is_line_break_before: is_new_line,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
entries.push(WordEntry {
|
|
|
|
|
text: line.plain_text.clone(),
|
|
|
|
|
start: line.start_time,
|
|
|
|
|
is_line_break_before: is_new_line,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if entries.is_empty() {
|
|
|
|
|
return String::new();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let page_end = lines.last().unwrap().end_time;
|
|
|
|
|
|
|
|
|
|
let mut parts: Vec<String> = Vec::new();
|
|
|
|
|
for (i, entry) in entries.iter().enumerate() {
|
|
|
|
|
let next_start = if i + 1 < entries.len() {
|
|
|
|
|
entries[i + 1].start
|
|
|
|
|
} else {
|
|
|
|
|
page_end
|
|
|
|
|
};
|
|
|
|
|
let duration_cs = ((next_start - entry.start) * 100.0).round().max(1.0) as u64;
|
|
|
|
|
|
|
|
|
|
let prefix = if entry.is_line_break_before {
|
|
|
|
|
"\\N"
|
|
|
|
|
} else if i > 0 {
|
|
|
|
|
" "
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
};
|
|
|
|
|
parts.push(format!("{}{{\\k{}}}{}", prefix, duration_cs, entry.text));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts.join("")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
/// Convert a word-timed VTT file to an ASS file with paged teleprompter display.
|
|
|
|
|
/// Lines are grouped into 2-line pages. Karaoke \k tags flow continuously from
|
|
|
|
|
/// line 1 through line 2 within each page. Pages cross-fade with \fad transitions.
|
2026-09-22 06:54:06 -04:00
|
|
|
pub fn vtt_to_ass_with_karaoke(
|
|
|
|
|
caption_path: &str,
|
|
|
|
|
temp_dir: &str,
|
|
|
|
|
style: Option<&CaptionStyle>,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
let content = std::fs::read_to_string(caption_path)
|
|
|
|
|
.map_err(|e| format!("Failed to read VTT file '{}': {e}", caption_path))?;
|
|
|
|
|
|
|
|
|
|
std::fs::create_dir_all(temp_dir)
|
|
|
|
|
.map_err(|e| format!("Failed to create temp dir for ASS: {e}"))?;
|
|
|
|
|
|
|
|
|
|
let out_path = Path::new(temp_dir).join("karaoke.ass");
|
|
|
|
|
|
|
|
|
|
let font_size = style.map_or(45, |s| (s.font_size as f64 * 2.5).round() as u32);
|
|
|
|
|
let primary_colour = style.map_or("&H00FFFFFF".to_string(), |s| hex_to_ass_color(&s.text_color));
|
|
|
|
|
let secondary_colour = "&H73CCCCCC".to_string();
|
|
|
|
|
let outline_colour = "&H00000000".to_string();
|
|
|
|
|
let back_colour = style.map_or("&H80000000".to_string(), |s| opacity_to_ass_back_colour(s.background_opacity));
|
|
|
|
|
let text_outline = style.map_or(true, |s| s.text_outline);
|
|
|
|
|
let (border_style, outline_val, shadow_val) = if text_outline {
|
|
|
|
|
(4, 2, 0)
|
|
|
|
|
} else {
|
|
|
|
|
(3, 0, 0)
|
|
|
|
|
};
|
|
|
|
|
let margin_v: i32 = 40;
|
|
|
|
|
|
|
|
|
|
let mut output = String::new();
|
|
|
|
|
|
|
|
|
|
output.push_str("[Script Info]\n");
|
|
|
|
|
output.push_str("ScriptType: v4.00+\n");
|
|
|
|
|
output.push_str("PlayResX: 1920\n");
|
|
|
|
|
output.push_str("PlayResY: 1080\n");
|
|
|
|
|
output.push_str("WrapStyle: 0\n");
|
|
|
|
|
output.push_str("ScaledBorderAndShadow: yes\n");
|
|
|
|
|
output.push('\n');
|
|
|
|
|
|
|
|
|
|
output.push_str("[V4+ Styles]\n");
|
|
|
|
|
output.push_str("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n");
|
|
|
|
|
output.push_str(&format!(
|
|
|
|
|
"Style: Default,Arial,{},{},{},{},{},0,0,0,0,100,100,0,0,{},{},{},2,20,20,{},1\n",
|
|
|
|
|
font_size, primary_colour, secondary_colour, outline_colour, back_colour,
|
|
|
|
|
border_style, outline_val, shadow_val, margin_v
|
|
|
|
|
));
|
|
|
|
|
output.push('\n');
|
|
|
|
|
|
|
|
|
|
output.push_str("[Events]\n");
|
|
|
|
|
output.push_str("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n");
|
|
|
|
|
|
|
|
|
|
let spoken_lines = extract_spoken_lines(&content);
|
|
|
|
|
let gap_threshold = 2.0;
|
2026-09-22 06:54:21 -04:00
|
|
|
let crossfade_ms = 300;
|
|
|
|
|
let crossfade_s = crossfade_ms as f64 / 1000.0;
|
2026-09-22 06:54:06 -04:00
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
let pages = group_into_pages(&spoken_lines, gap_threshold);
|
|
|
|
|
let page_count = pages.len();
|
|
|
|
|
|
|
|
|
|
let cx = 960;
|
|
|
|
|
let y_bottom = 1080 - margin_v;
|
|
|
|
|
|
|
|
|
|
for (page_idx, page_indices) in pages.iter().enumerate() {
|
|
|
|
|
let page_lines: Vec<&SpokenLine> = page_indices.iter().map(|&i| &spoken_lines[i]).collect();
|
|
|
|
|
let first_line = page_lines[0];
|
|
|
|
|
let last_line = *page_lines.last().unwrap();
|
|
|
|
|
|
|
|
|
|
if page_lines.len() == 1 && first_line.is_non_speech {
|
2026-09-22 06:54:06 -04:00
|
|
|
output.push_str(&format!(
|
|
|
|
|
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})}}{}\n",
|
2026-09-22 06:54:21 -04:00
|
|
|
format_ass_timestamp(first_line.start_time),
|
|
|
|
|
format_ass_timestamp(first_line.end_time),
|
|
|
|
|
cx, y_bottom,
|
|
|
|
|
first_line.plain_text
|
2026-09-22 06:54:06 -04:00
|
|
|
));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
let natural_start = first_line.start_time;
|
|
|
|
|
let natural_end = last_line.end_time;
|
2026-09-22 06:54:06 -04:00
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
let is_first = page_idx == 0;
|
|
|
|
|
let is_last = page_idx == page_count - 1;
|
2026-09-22 06:54:06 -04:00
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
let display_start = if is_first {
|
|
|
|
|
natural_start
|
2026-09-22 06:54:06 -04:00
|
|
|
} else {
|
2026-09-22 06:54:21 -04:00
|
|
|
(natural_start - crossfade_s).max(0.0)
|
2026-09-22 06:54:06 -04:00
|
|
|
};
|
2026-09-22 06:54:21 -04:00
|
|
|
let display_end = if is_last {
|
|
|
|
|
natural_end
|
2026-09-22 06:54:06 -04:00
|
|
|
} else {
|
2026-09-22 06:54:21 -04:00
|
|
|
natural_end + crossfade_s
|
2026-09-22 06:54:06 -04:00
|
|
|
};
|
|
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
let fade_in = if is_first { 0 } else { crossfade_ms };
|
|
|
|
|
let fade_out = if is_last { 0 } else { crossfade_ms };
|
2026-09-22 06:54:06 -04:00
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
let karaoke_text = build_page_karaoke_text(&page_lines);
|
2026-09-22 06:54:06 -04:00
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
output.push_str(&format!(
|
|
|
|
|
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})\\fad({},{})}}{}\n",
|
|
|
|
|
format_ass_timestamp(display_start),
|
|
|
|
|
format_ass_timestamp(display_end),
|
|
|
|
|
cx, y_bottom,
|
|
|
|
|
fade_in, fade_out,
|
|
|
|
|
karaoke_text
|
|
|
|
|
));
|
2026-09-22 06:54:06 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::fs::write(&out_path, &output)
|
|
|
|
|
.map_err(|e| format!("Failed to write ASS file: {e}"))?;
|
|
|
|
|
|
|
|
|
|
Ok(out_path.to_string_lossy().to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse `out_time_ms=<microseconds>` from ffmpeg's `-progress` output.
|
|
|
|
|
/// Returns the time in seconds.
|
|
|
|
|
fn parse_progress_time_us(line: &str) -> Option<f64> {
|
|
|
|
|
let val = line.strip_prefix("out_time_ms=")?;
|
|
|
|
|
let us: i64 = val.trim().parse().ok()?;
|
|
|
|
|
if us < 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(us as f64 / 1_000_000.0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run an ffmpeg command with real-time progress reporting.
|
|
|
|
|
/// Uses ffmpeg's `-progress pipe:1 -nostats` flag to get machine-readable
|
|
|
|
|
/// `\n`-delimited progress on stdout (avoids the `\r`-only stderr issue).
|
|
|
|
|
/// Calls `on_progress(percent)` with values 0.0..=1.0 as encoding proceeds.
|
|
|
|
|
fn run_ffmpeg_with_progress(
|
|
|
|
|
args: &[String],
|
|
|
|
|
duration: f64,
|
|
|
|
|
on_progress: &dyn Fn(f64),
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
// Inject -progress pipe:1 -nostats before the output file (last arg)
|
|
|
|
|
let mut full_args = args.to_vec();
|
|
|
|
|
if let Some(output_pos) = full_args.len().checked_sub(1) {
|
|
|
|
|
full_args.insert(output_pos, "-nostats".to_string());
|
|
|
|
|
full_args.insert(output_pos, "pipe:1".to_string());
|
|
|
|
|
full_args.insert(output_pos, "-progress".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut child = Command::new(ffmpeg_bin())
|
|
|
|
|
.args(&full_args)
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped())
|
|
|
|
|
.spawn()
|
|
|
|
|
.map_err(|e| format!("Failed to run ffmpeg: {e}"))?;
|
|
|
|
|
|
|
|
|
|
// Read progress from stdout (-progress pipe:1 outputs \n-delimited key=value)
|
|
|
|
|
let stderr_handle = child.stderr.take().map(|stderr| {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let mut buf = String::new();
|
|
|
|
|
let mut reader = BufReader::new(stderr);
|
|
|
|
|
let _ = reader.read_to_string(&mut buf);
|
|
|
|
|
buf
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if let Some(stdout) = child.stdout.take() {
|
|
|
|
|
let reader = BufReader::new(stdout);
|
|
|
|
|
for line in reader.lines().map_while(Result::ok) {
|
|
|
|
|
if let Some(time_secs) = parse_progress_time_us(&line) {
|
|
|
|
|
let percent = if duration > 0.0 {
|
|
|
|
|
(time_secs / duration).clamp(0.0, 1.0)
|
|
|
|
|
} else {
|
|
|
|
|
0.0
|
|
|
|
|
};
|
|
|
|
|
on_progress(percent);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let status = child.wait().map_err(|e| format!("ffmpeg wait failed: {e}"))?;
|
|
|
|
|
|
|
|
|
|
if !status.success() {
|
|
|
|
|
let stderr_text = stderr_handle
|
|
|
|
|
.and_then(|h| h.join().ok())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
return Err(format!("ffmpeg export failed: {}", stderr_text));
|
|
|
|
|
}
|
|
|
|
|
on_progress(1.0);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-09-21 11:20:32 -04:00
|
|
|
|
|
|
|
|
pub fn build_ffmpeg_args(
|
|
|
|
|
clip: &Clip,
|
|
|
|
|
source: &str,
|
|
|
|
|
output: &str,
|
|
|
|
|
cut_mode: &CutMode,
|
|
|
|
|
) -> Vec<String> {
|
|
|
|
|
let mut args = vec![
|
|
|
|
|
"-y".to_string(),
|
|
|
|
|
"-ss".to_string(),
|
|
|
|
|
format!("{}", clip.start_time),
|
|
|
|
|
"-to".to_string(),
|
|
|
|
|
format!("{}", clip.end_time),
|
|
|
|
|
"-i".to_string(),
|
|
|
|
|
source.to_string(),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
match cut_mode {
|
|
|
|
|
CutMode::Lossless => {
|
|
|
|
|
args.extend(["-c".to_string(), "copy".to_string()]);
|
|
|
|
|
}
|
|
|
|
|
CutMode::Precise { format: _ } => {
|
2026-09-21 11:25:28 -04:00
|
|
|
let ext = Path::new(output)
|
|
|
|
|
.extension()
|
|
|
|
|
.map(|e| e.to_string_lossy().to_ascii_lowercase())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let (video_codec, audio_codec) = match ext.as_str() {
|
|
|
|
|
"webm" => ("libvpx-vp9", "libopus"),
|
|
|
|
|
"mp4" | "mkv" => ("libx264", "aac"),
|
|
|
|
|
_ => ("libx264", "aac"),
|
|
|
|
|
};
|
2026-09-21 11:20:32 -04:00
|
|
|
args.extend([
|
|
|
|
|
"-c:v".to_string(),
|
2026-09-21 11:25:28 -04:00
|
|
|
video_codec.to_string(),
|
2026-09-21 11:20:32 -04:00
|
|
|
"-c:a".to_string(),
|
2026-09-21 11:25:28 -04:00
|
|
|
audio_codec.to_string(),
|
2026-09-21 11:20:32 -04:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
args.push(output.to_string());
|
|
|
|
|
args
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 06:54:06 -04:00
|
|
|
/// Build ffmpeg args for exporting a clip with subtitles muxed as a track.
|
|
|
|
|
/// Works with both lossless and precise cut modes.
|
|
|
|
|
/// `caption_path` must be a pre-trimmed VTT (timestamps shifted to start from 0)
|
|
|
|
|
/// produced by `trim_and_sanitize_vtt()`.
|
|
|
|
|
pub fn build_ffmpeg_args_mux_subs(
|
|
|
|
|
clip: &Clip,
|
|
|
|
|
source: &str,
|
|
|
|
|
output: &str,
|
|
|
|
|
cut_mode: &CutMode,
|
|
|
|
|
caption_path: &str,
|
|
|
|
|
) -> Vec<String> {
|
|
|
|
|
let ext = Path::new(output)
|
|
|
|
|
.extension()
|
|
|
|
|
.map(|e| e.to_string_lossy().to_ascii_lowercase())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let sub_codec = match ext.as_str() {
|
|
|
|
|
"mp4" | "m4v" => "mov_text",
|
|
|
|
|
"mkv" => "srt",
|
|
|
|
|
_ => "mov_text",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let duration = clip.end_time - clip.start_time;
|
|
|
|
|
|
|
|
|
|
// Use INPUT seeking on the video (-ss/-t BEFORE -i) for speed (especially
|
|
|
|
|
// lossless). The pre-trimmed VTT already has timestamps starting from 0
|
|
|
|
|
// to match the video output PTS.
|
|
|
|
|
let mut args = vec![
|
|
|
|
|
"-y".to_string(),
|
|
|
|
|
"-ss".to_string(),
|
|
|
|
|
format!("{}", clip.start_time),
|
|
|
|
|
"-t".to_string(),
|
|
|
|
|
format!("{}", duration),
|
|
|
|
|
"-i".to_string(),
|
|
|
|
|
source.to_string(),
|
|
|
|
|
"-i".to_string(),
|
|
|
|
|
caption_path.to_string(),
|
|
|
|
|
"-map".to_string(),
|
|
|
|
|
"0:v".to_string(),
|
|
|
|
|
"-map".to_string(),
|
|
|
|
|
"0:a".to_string(),
|
|
|
|
|
"-map".to_string(),
|
|
|
|
|
"1:s".to_string(),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
match cut_mode {
|
|
|
|
|
CutMode::Lossless => {
|
|
|
|
|
args.extend([
|
|
|
|
|
"-c:v".to_string(),
|
|
|
|
|
"copy".to_string(),
|
|
|
|
|
"-c:a".to_string(),
|
|
|
|
|
"copy".to_string(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
CutMode::Precise { format: _ } => {
|
|
|
|
|
let (video_codec, audio_codec) = match ext.as_str() {
|
|
|
|
|
"webm" => ("libvpx-vp9", "libopus"),
|
|
|
|
|
"mp4" | "mkv" => ("libx264", "aac"),
|
|
|
|
|
_ => ("libx264", "aac"),
|
|
|
|
|
};
|
|
|
|
|
args.extend([
|
|
|
|
|
"-c:v".to_string(),
|
|
|
|
|
video_codec.to_string(),
|
|
|
|
|
"-c:a".to_string(),
|
|
|
|
|
audio_codec.to_string(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
args.extend(["-c:s".to_string(), sub_codec.to_string()]);
|
|
|
|
|
args.push(output.to_string());
|
|
|
|
|
args
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build ffmpeg args for exporting a clip with subtitles burned into the video.
|
|
|
|
|
/// Always re-encodes. If the user selected lossless, we override to H.264/AAC.
|
|
|
|
|
/// `caption_path` should be an ASS file (from `vtt_to_ass_with_karaoke()`) with
|
|
|
|
|
/// style and karaoke tags baked in. Uses the `ass=` filter (not `subtitles=`)
|
|
|
|
|
/// to preserve `\kf` karaoke timing through libass.
|
|
|
|
|
pub fn build_ffmpeg_args_burnin_subs(
|
|
|
|
|
clip: &Clip,
|
|
|
|
|
source: &str,
|
|
|
|
|
output: &str,
|
|
|
|
|
cut_mode: &CutMode,
|
|
|
|
|
caption_path: &str,
|
|
|
|
|
_caption_style: Option<&CaptionStyle>,
|
|
|
|
|
) -> Vec<String> {
|
|
|
|
|
let ext = Path::new(output)
|
|
|
|
|
.extension()
|
|
|
|
|
.map(|e| e.to_string_lossy().to_ascii_lowercase())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let (video_codec, audio_codec) = match cut_mode {
|
|
|
|
|
CutMode::Precise { format: _ } => match ext.as_str() {
|
|
|
|
|
"webm" => ("libvpx-vp9", "libopus"),
|
|
|
|
|
"mp4" | "mkv" => ("libx264", "aac"),
|
|
|
|
|
_ => ("libx264", "aac"),
|
|
|
|
|
},
|
|
|
|
|
// Lossless override — burn-in requires re-encoding
|
|
|
|
|
CutMode::Lossless => ("libx264", "aac"),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Escape the path for the ffmpeg ass filter (colons and backslashes)
|
|
|
|
|
let escaped_path = caption_path
|
|
|
|
|
.replace('\\', "\\\\")
|
|
|
|
|
.replace(':', "\\:")
|
|
|
|
|
.replace("'", "\\'");
|
|
|
|
|
|
|
|
|
|
let duration = clip.end_time - clip.start_time;
|
|
|
|
|
|
|
|
|
|
// Use the `ass=` filter, NOT `subtitles=`. The `subtitles` filter routes
|
|
|
|
|
// through libavformat and can flatten karaoke \kf tags. The `ass` filter
|
|
|
|
|
// passes the file directly to libass, preserving all override tags.
|
|
|
|
|
// Style is baked into the ASS [V4+ Styles] header, no force_style needed.
|
|
|
|
|
let vf = format!("ass={}", escaped_path);
|
|
|
|
|
|
|
|
|
|
// Use output seeking (-ss/-t AFTER -i) so the ass filter reads
|
|
|
|
|
// the original timestamps and they match the output time range.
|
|
|
|
|
vec![
|
|
|
|
|
"-y".to_string(),
|
|
|
|
|
"-i".to_string(),
|
|
|
|
|
source.to_string(),
|
|
|
|
|
"-ss".to_string(),
|
|
|
|
|
format!("{}", clip.start_time),
|
|
|
|
|
"-t".to_string(),
|
|
|
|
|
format!("{}", duration),
|
|
|
|
|
"-vf".to_string(),
|
|
|
|
|
vf,
|
|
|
|
|
"-c:v".to_string(),
|
|
|
|
|
video_codec.to_string(),
|
|
|
|
|
"-c:a".to_string(),
|
|
|
|
|
audio_codec.to_string(),
|
|
|
|
|
output.to_string(),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:25:28 -04:00
|
|
|
pub fn expand_tilde_path(path: &str) -> String {
|
|
|
|
|
if path == "~" {
|
|
|
|
|
return dirs::home_dir()
|
|
|
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
|
|
|
.unwrap_or_else(|| path.to_string());
|
|
|
|
|
}
|
|
|
|
|
if let Some(rest) = path.strip_prefix("~/") {
|
|
|
|
|
if let Some(home) = dirs::home_dir() {
|
|
|
|
|
return home.join(rest).to_string_lossy().to_string();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
path.to_string()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:20:32 -04:00
|
|
|
pub fn generate_output_path(
|
|
|
|
|
output_dir: &str,
|
|
|
|
|
video_title: &str,
|
|
|
|
|
clip_label: &str,
|
|
|
|
|
extension: &str,
|
|
|
|
|
) -> String {
|
|
|
|
|
let sanitized_title = sanitize_filename(video_title);
|
|
|
|
|
let sanitized_label = sanitize_filename(clip_label);
|
|
|
|
|
let base = format!(
|
|
|
|
|
"{}/{} - {}.{}",
|
|
|
|
|
output_dir, sanitized_title, sanitized_label, extension
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if !Path::new(&base).exists() {
|
|
|
|
|
return base;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i in 2..100 {
|
|
|
|
|
let candidate = format!(
|
|
|
|
|
"{}/{} - {} ({}).{}",
|
|
|
|
|
output_dir, sanitized_title, sanitized_label, i, extension
|
|
|
|
|
);
|
|
|
|
|
if !Path::new(&candidate).exists() {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
base
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sanitize_filename(name: &str) -> String {
|
|
|
|
|
name.chars()
|
|
|
|
|
.map(|c| {
|
|
|
|
|
if c == '/'
|
|
|
|
|
|| c == '\\'
|
|
|
|
|
|| c == ':'
|
|
|
|
|
|| c == '"'
|
|
|
|
|
|| c == '|'
|
|
|
|
|
|| c == '?'
|
|
|
|
|
|| c == '*'
|
|
|
|
|
|| c == '<'
|
|
|
|
|
|| c == '>'
|
|
|
|
|
{
|
|
|
|
|
'_'
|
|
|
|
|
} else {
|
|
|
|
|
c
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn get_extension(source: &str, cut_mode: &CutMode) -> String {
|
|
|
|
|
match cut_mode {
|
|
|
|
|
CutMode::Lossless => Path::new(source)
|
|
|
|
|
.extension()
|
|
|
|
|
.map(|e| e.to_string_lossy().to_string())
|
|
|
|
|
.unwrap_or_else(|| "mp4".to_string()),
|
|
|
|
|
CutMode::Precise { format } => format.clone(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn export_single_clip(
|
|
|
|
|
clip: &Clip,
|
|
|
|
|
source: &str,
|
|
|
|
|
output: &str,
|
|
|
|
|
cut_mode: &CutMode,
|
2026-09-22 06:54:06 -04:00
|
|
|
on_progress: &dyn Fn(f64),
|
2026-09-21 11:20:32 -04:00
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let args = build_ffmpeg_args(clip, source, output, cut_mode);
|
2026-09-22 06:54:06 -04:00
|
|
|
let duration = clip.end_time - clip.start_time;
|
|
|
|
|
run_ffmpeg_with_progress(&args, duration, on_progress)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn export_single_clip_with_subs(
|
|
|
|
|
clip: &Clip,
|
|
|
|
|
source: &str,
|
|
|
|
|
output: &str,
|
|
|
|
|
cut_mode: &CutMode,
|
|
|
|
|
caption_path: &str,
|
|
|
|
|
burn_in: bool,
|
|
|
|
|
caption_style: Option<&CaptionStyle>,
|
|
|
|
|
on_progress: &dyn Fn(f64),
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let temp_dir = std::env::temp_dir()
|
|
|
|
|
.join("video-clipper-subs-sanitize")
|
|
|
|
|
.to_string_lossy()
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let args = if burn_in {
|
|
|
|
|
// Convert VTT to ASS with karaoke \kf tags and style baked into the header
|
|
|
|
|
let ass_path = vtt_to_ass_with_karaoke(caption_path, &temp_dir, caption_style)?;
|
|
|
|
|
// Style is in the ASS header, so pass None for caption_style
|
|
|
|
|
build_ffmpeg_args_burnin_subs(clip, source, output, cut_mode, &ass_path, None)
|
|
|
|
|
} else {
|
|
|
|
|
let trimmed = trim_and_sanitize_vtt(caption_path, &temp_dir, clip.start_time, clip.end_time)?;
|
|
|
|
|
build_ffmpeg_args_mux_subs(clip, source, output, cut_mode, &trimmed)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
eprintln!(
|
|
|
|
|
"[video-clipper:export] with subs (burn_in={}) — ffmpeg {}",
|
|
|
|
|
burn_in,
|
|
|
|
|
args.join(" ")
|
|
|
|
|
);
|
|
|
|
|
let duration = clip.end_time - clip.start_time;
|
|
|
|
|
run_ffmpeg_with_progress(&args, duration, on_progress)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn export_merged(
|
|
|
|
|
clips: &[Clip],
|
|
|
|
|
source: &str,
|
|
|
|
|
output: &str,
|
|
|
|
|
cut_mode: &CutMode,
|
|
|
|
|
temp_dir: &str,
|
|
|
|
|
on_progress: &dyn Fn(usize, f64),
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let mut temp_files = Vec::new();
|
|
|
|
|
let ext = get_extension(source, cut_mode);
|
|
|
|
|
let total = clips.len();
|
|
|
|
|
|
|
|
|
|
for (i, clip) in clips.iter().enumerate() {
|
|
|
|
|
let temp_path = format!("{}/merge_part_{}.{}", temp_dir, i, ext);
|
|
|
|
|
let clip_idx = i;
|
|
|
|
|
export_single_clip(clip, source, &temp_path, cut_mode, &|pct| {
|
|
|
|
|
on_progress(clip_idx, pct);
|
|
|
|
|
})?;
|
|
|
|
|
temp_files.push(temp_path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Concat step — no granular progress, signal start
|
|
|
|
|
on_progress(total, 0.0);
|
|
|
|
|
|
|
|
|
|
let concat_path = format!("{}/concat_list.txt", temp_dir);
|
|
|
|
|
let concat_content: String = temp_files
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| format!("file '{}'", p))
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
std::fs::write(&concat_path, concat_content)
|
|
|
|
|
.map_err(|e| format!("Failed to write concat file: {e}"))?;
|
|
|
|
|
|
|
|
|
|
let result = Command::new(ffmpeg_bin())
|
|
|
|
|
.args([
|
|
|
|
|
"-y",
|
|
|
|
|
"-f",
|
|
|
|
|
"concat",
|
|
|
|
|
"-safe",
|
|
|
|
|
"0",
|
|
|
|
|
"-i",
|
|
|
|
|
&concat_path,
|
|
|
|
|
"-c",
|
|
|
|
|
"copy",
|
|
|
|
|
output,
|
|
|
|
|
])
|
2026-09-21 11:20:32 -04:00
|
|
|
.output()
|
2026-09-22 06:54:06 -04:00
|
|
|
.map_err(|e| format!("Failed to run ffmpeg concat: {e}"))?;
|
2026-09-21 11:20:32 -04:00
|
|
|
|
2026-09-22 06:54:06 -04:00
|
|
|
if !result.status.success() {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&result.stderr);
|
|
|
|
|
return Err(format!("ffmpeg merge failed: {stderr}"));
|
2026-09-21 11:20:32 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-22 06:54:06 -04:00
|
|
|
for f in &temp_files {
|
|
|
|
|
let _ = std::fs::remove_file(f);
|
|
|
|
|
}
|
|
|
|
|
let _ = std::fs::remove_file(&concat_path);
|
|
|
|
|
|
|
|
|
|
on_progress(total, 1.0);
|
2026-09-21 11:20:32 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 06:54:06 -04:00
|
|
|
/// Merged export with subtitle support. Each segment is exported with subs,
|
|
|
|
|
/// then the segments are concatenated.
|
|
|
|
|
pub fn export_merged_with_subs(
|
2026-09-21 11:20:32 -04:00
|
|
|
clips: &[Clip],
|
|
|
|
|
source: &str,
|
|
|
|
|
output: &str,
|
|
|
|
|
cut_mode: &CutMode,
|
|
|
|
|
temp_dir: &str,
|
2026-09-22 06:54:06 -04:00
|
|
|
caption_path: &str,
|
|
|
|
|
burn_in: bool,
|
|
|
|
|
caption_style: Option<&CaptionStyle>,
|
|
|
|
|
on_progress: &dyn Fn(usize, f64),
|
2026-09-21 11:20:32 -04:00
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let mut temp_files = Vec::new();
|
2026-09-22 06:54:06 -04:00
|
|
|
let ext = if burn_in {
|
|
|
|
|
match cut_mode {
|
|
|
|
|
CutMode::Precise { format } => format.clone(),
|
|
|
|
|
CutMode::Lossless => "mp4".to_string(),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
get_extension(source, cut_mode)
|
|
|
|
|
};
|
|
|
|
|
let total = clips.len();
|
2026-09-21 11:20:32 -04:00
|
|
|
|
|
|
|
|
for (i, clip) in clips.iter().enumerate() {
|
|
|
|
|
let temp_path = format!("{}/merge_part_{}.{}", temp_dir, i, ext);
|
2026-09-22 06:54:06 -04:00
|
|
|
let clip_idx = i;
|
|
|
|
|
export_single_clip_with_subs(
|
|
|
|
|
clip, source, &temp_path, cut_mode, caption_path, burn_in, caption_style,
|
|
|
|
|
&|pct| { on_progress(clip_idx, pct); },
|
|
|
|
|
)?;
|
2026-09-21 11:20:32 -04:00
|
|
|
temp_files.push(temp_path);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 06:54:06 -04:00
|
|
|
on_progress(total, 0.0);
|
|
|
|
|
|
2026-09-21 11:20:32 -04:00
|
|
|
let concat_path = format!("{}/concat_list.txt", temp_dir);
|
|
|
|
|
let concat_content: String = temp_files
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| format!("file '{}'", p))
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
std::fs::write(&concat_path, concat_content)
|
|
|
|
|
.map_err(|e| format!("Failed to write concat file: {e}"))?;
|
|
|
|
|
|
2026-09-22 06:54:06 -04:00
|
|
|
let result = Command::new(ffmpeg_bin())
|
2026-09-21 11:20:32 -04:00
|
|
|
.args([
|
|
|
|
|
"-y",
|
|
|
|
|
"-f",
|
|
|
|
|
"concat",
|
|
|
|
|
"-safe",
|
|
|
|
|
"0",
|
|
|
|
|
"-i",
|
|
|
|
|
&concat_path,
|
|
|
|
|
"-c",
|
|
|
|
|
"copy",
|
|
|
|
|
output,
|
|
|
|
|
])
|
|
|
|
|
.output()
|
|
|
|
|
.map_err(|e| format!("Failed to run ffmpeg concat: {e}"))?;
|
|
|
|
|
|
|
|
|
|
if !result.status.success() {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&result.stderr);
|
|
|
|
|
return Err(format!("ffmpeg merge failed: {stderr}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for f in &temp_files {
|
|
|
|
|
let _ = std::fs::remove_file(f);
|
|
|
|
|
}
|
|
|
|
|
let _ = std::fs::remove_file(&concat_path);
|
|
|
|
|
|
2026-09-22 06:54:06 -04:00
|
|
|
on_progress(total, 1.0);
|
2026-09-21 11:20:32 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_ffmpeg_args_lossless() {
|
|
|
|
|
let clip = Clip {
|
|
|
|
|
id: "1".to_string(),
|
|
|
|
|
start_time: 10.5,
|
|
|
|
|
end_time: 20.0,
|
|
|
|
|
label: "Clip 1".to_string(),
|
|
|
|
|
color: "#000".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let args = build_ffmpeg_args(&clip, "input.mp4", "output.mp4", &CutMode::Lossless);
|
|
|
|
|
assert!(args.contains(&"-c".to_string()));
|
|
|
|
|
assert!(args.contains(&"copy".to_string()));
|
|
|
|
|
assert!(args.contains(&"10.5".to_string()));
|
|
|
|
|
assert!(args.contains(&"20".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_ffmpeg_args_precise() {
|
|
|
|
|
let clip = Clip {
|
|
|
|
|
id: "1".to_string(),
|
|
|
|
|
start_time: 5.0,
|
|
|
|
|
end_time: 15.0,
|
|
|
|
|
label: "Clip 1".to_string(),
|
|
|
|
|
color: "#000".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let mode = CutMode::Precise {
|
|
|
|
|
format: "mp4".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let args = build_ffmpeg_args(&clip, "input.mp4", "output.mp4", &mode);
|
|
|
|
|
assert!(args.contains(&"-c:v".to_string()));
|
|
|
|
|
assert!(args.contains(&"libx264".to_string()));
|
2026-09-21 11:25:28 -04:00
|
|
|
assert!(args.contains(&"aac".to_string()));
|
|
|
|
|
|
|
|
|
|
let webm_args = build_ffmpeg_args(&clip, "input.mp4", "output.webm", &mode);
|
|
|
|
|
assert!(webm_args.contains(&"libvpx-vp9".to_string()));
|
|
|
|
|
assert!(webm_args.contains(&"libopus".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 06:54:06 -04:00
|
|
|
#[test]
|
|
|
|
|
fn test_build_ffmpeg_args_mux_subs_lossless() {
|
|
|
|
|
let clip = Clip {
|
|
|
|
|
id: "1".to_string(),
|
|
|
|
|
start_time: 10.0,
|
|
|
|
|
end_time: 20.0,
|
|
|
|
|
label: "Clip 1".to_string(),
|
|
|
|
|
color: "#000".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let args = build_ffmpeg_args_mux_subs(
|
|
|
|
|
&clip,
|
|
|
|
|
"input.mp4",
|
|
|
|
|
"output.mp4",
|
|
|
|
|
&CutMode::Lossless,
|
|
|
|
|
"/tmp/subs.vtt",
|
|
|
|
|
);
|
|
|
|
|
assert!(args.contains(&"-map".to_string()));
|
|
|
|
|
assert!(args.contains(&"1:s".to_string()));
|
|
|
|
|
assert!(args.contains(&"mov_text".to_string()));
|
|
|
|
|
assert!(args.contains(&"copy".to_string()));
|
|
|
|
|
// Input seeking: -ss must come BEFORE -i
|
|
|
|
|
let ss_pos = args.iter().position(|a| a == "-ss").unwrap();
|
|
|
|
|
let i_pos = args.iter().position(|a| a == "-i").unwrap();
|
|
|
|
|
assert!(ss_pos < i_pos, "-ss must come before -i for input seeking");
|
|
|
|
|
// Uses -t duration, not -to
|
|
|
|
|
assert!(args.contains(&"-t".to_string()));
|
|
|
|
|
assert!(!args.contains(&"-to".to_string()));
|
|
|
|
|
assert!(args.contains(&"10".to_string())); // duration = 20 - 10 = 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_ffmpeg_args_mux_subs_precise() {
|
|
|
|
|
let clip = Clip {
|
|
|
|
|
id: "1".to_string(),
|
|
|
|
|
start_time: 5.0,
|
|
|
|
|
end_time: 15.0,
|
|
|
|
|
label: "Clip 1".to_string(),
|
|
|
|
|
color: "#000".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let mode = CutMode::Precise {
|
|
|
|
|
format: "mp4".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let args = build_ffmpeg_args_mux_subs(
|
|
|
|
|
&clip,
|
|
|
|
|
"input.mp4",
|
|
|
|
|
"output.mp4",
|
|
|
|
|
&mode,
|
|
|
|
|
"/tmp/subs.vtt",
|
|
|
|
|
);
|
|
|
|
|
assert!(args.contains(&"-map".to_string()));
|
|
|
|
|
assert!(args.contains(&"1:s".to_string()));
|
|
|
|
|
assert!(args.contains(&"libx264".to_string()));
|
|
|
|
|
assert!(args.contains(&"-t".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_ffmpeg_args_burnin_subs_precise() {
|
|
|
|
|
let clip = Clip {
|
|
|
|
|
id: "1".to_string(),
|
|
|
|
|
start_time: 5.0,
|
|
|
|
|
end_time: 15.0,
|
|
|
|
|
label: "Clip 1".to_string(),
|
|
|
|
|
color: "#000".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let mode = CutMode::Precise {
|
|
|
|
|
format: "mp4".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let args = build_ffmpeg_args_burnin_subs(
|
|
|
|
|
&clip,
|
|
|
|
|
"input.mp4",
|
|
|
|
|
"output.mp4",
|
|
|
|
|
&mode,
|
|
|
|
|
"/tmp/subs.vtt",
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
// Uses the ass= filter (not subtitles=) for karaoke preservation
|
|
|
|
|
let vf_arg = args.iter().find(|a| a.starts_with("ass=")).unwrap();
|
|
|
|
|
assert!(vf_arg.contains("subs.vtt"));
|
|
|
|
|
assert!(args.contains(&"libx264".to_string()));
|
|
|
|
|
// Output seeking: -ss must come AFTER -i
|
|
|
|
|
let i_pos = args.iter().position(|a| a == "-i").unwrap();
|
|
|
|
|
let ss_pos = args.iter().position(|a| a == "-ss").unwrap();
|
|
|
|
|
assert!(ss_pos > i_pos, "-ss must come after -i for output seeking");
|
|
|
|
|
// Uses -t duration, not -to
|
|
|
|
|
assert!(args.contains(&"-t".to_string()));
|
|
|
|
|
assert!(!args.contains(&"-to".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_ffmpeg_args_burnin_subs_lossless_override() {
|
|
|
|
|
let clip = Clip {
|
|
|
|
|
id: "1".to_string(),
|
|
|
|
|
start_time: 0.0,
|
|
|
|
|
end_time: 10.0,
|
|
|
|
|
label: "Clip 1".to_string(),
|
|
|
|
|
color: "#000".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let args = build_ffmpeg_args_burnin_subs(
|
|
|
|
|
&clip,
|
|
|
|
|
"input.mp4",
|
|
|
|
|
"output.mp4",
|
|
|
|
|
&CutMode::Lossless,
|
|
|
|
|
"/tmp/subs.vtt",
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
assert!(args.contains(&"libx264".to_string()));
|
|
|
|
|
assert!(args.contains(&"aac".to_string()));
|
|
|
|
|
let vf_arg = args.iter().find(|a| a.starts_with("ass=")).unwrap();
|
|
|
|
|
assert!(vf_arg.contains("subs.vtt"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_sanitize_vtt_strips_tags() {
|
|
|
|
|
let result = strip_vtt_tags("the<00:00:00.599><c> gym</c><00:00:00.840><c> I</c>");
|
|
|
|
|
assert_eq!(result, "the gym I");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_parse_vtt_timestamp_line() {
|
|
|
|
|
let result = parse_vtt_timestamp_line("00:01:23.456 --> 00:02:34.567 align:start position:0%");
|
|
|
|
|
assert!(result.is_some());
|
|
|
|
|
let (start, end) = result.unwrap();
|
|
|
|
|
assert!((start - 83.456).abs() < 0.001);
|
|
|
|
|
assert!((end - 154.567).abs() < 0.001);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_format_vtt_timestamp() {
|
|
|
|
|
assert_eq!(format_vtt_timestamp(0.0), "00:00:00.000");
|
|
|
|
|
assert_eq!(format_vtt_timestamp(83.456), "00:01:23.456");
|
|
|
|
|
assert_eq!(format_vtt_timestamp(3661.5), "01:01:01.500");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_trim_and_sanitize_vtt() {
|
|
|
|
|
let temp = std::env::temp_dir().join("test-trim-vtt");
|
|
|
|
|
let _ = std::fs::create_dir_all(&temp);
|
|
|
|
|
|
|
|
|
|
let vtt_content = "\
|
|
|
|
|
WEBVTT
|
|
|
|
|
|
|
|
|
|
00:00:10.000 --> 00:00:15.000 align:start position:0%
|
|
|
|
|
the<00:00:10.500><c> first</c><c> cue</c>
|
|
|
|
|
|
|
|
|
|
00:00:20.000 --> 00:00:25.000
|
|
|
|
|
second cue
|
|
|
|
|
|
|
|
|
|
00:00:30.000 --> 00:00:35.000
|
|
|
|
|
third cue
|
|
|
|
|
";
|
|
|
|
|
let input_path = temp.join("input.vtt");
|
|
|
|
|
std::fs::write(&input_path, vtt_content).unwrap();
|
|
|
|
|
|
|
|
|
|
// Trim to 18s-28s — should include the second cue only
|
|
|
|
|
let result = trim_and_sanitize_vtt(
|
|
|
|
|
input_path.to_str().unwrap(),
|
|
|
|
|
temp.to_str().unwrap(),
|
|
|
|
|
18.0,
|
|
|
|
|
28.0,
|
|
|
|
|
);
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let trimmed = std::fs::read_to_string(result.unwrap()).unwrap();
|
|
|
|
|
// Second cue shifted: 20-18=2s start, 25-18=7s end
|
|
|
|
|
assert!(trimmed.contains("00:00:02.000 --> 00:00:07.000"));
|
|
|
|
|
// First and third cues should NOT be present
|
|
|
|
|
assert!(!trimmed.contains("first"));
|
|
|
|
|
assert!(!trimmed.contains("third"));
|
|
|
|
|
assert!(trimmed.contains("second cue"));
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&temp);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 11:25:28 -04:00
|
|
|
#[test]
|
|
|
|
|
fn test_expand_tilde_path() {
|
|
|
|
|
let expanded = expand_tilde_path("~/Videos");
|
|
|
|
|
assert!(!expanded.starts_with("~/"));
|
|
|
|
|
assert!(expanded.ends_with("Videos") || expanded.contains("Videos"));
|
2026-09-21 11:20:32 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_generate_output_path_no_collision() {
|
|
|
|
|
let path = generate_output_path("/tmp", "My Video", "Clip 1", "mp4");
|
|
|
|
|
assert!(path.contains("My Video"));
|
|
|
|
|
assert!(path.contains("Clip 1"));
|
|
|
|
|
assert!(path.ends_with(".mp4"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_sanitize_filename() {
|
|
|
|
|
assert_eq!(sanitize_filename("a/b:c"), "a_b_c");
|
|
|
|
|
assert_eq!(sanitize_filename("normal"), "normal");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_get_extension_lossless() {
|
|
|
|
|
assert_eq!(get_extension("video.webm", &CutMode::Lossless), "webm");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_get_extension_precise() {
|
|
|
|
|
let mode = CutMode::Precise {
|
|
|
|
|
format: "mkv".to_string(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(get_extension("video.webm", &mode), "mkv");
|
|
|
|
|
}
|
2026-09-22 06:54:06 -04:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_page_karaoke_single_line_with_karaoke() {
|
|
|
|
|
let line = SpokenLine {
|
|
|
|
|
plain_text: "hello world".to_string(),
|
|
|
|
|
raw_text: "hello<00:00:01.000><c> world</c>".to_string(),
|
|
|
|
|
start_time: 0.5,
|
|
|
|
|
end_time: 1.5,
|
|
|
|
|
has_karaoke: true,
|
|
|
|
|
is_non_speech: false,
|
|
|
|
|
};
|
|
|
|
|
let result = build_page_karaoke_text(&[&line]);
|
|
|
|
|
assert!(result.contains("{\\k50}hello"));
|
|
|
|
|
assert!(result.contains("{\\k50}world"));
|
|
|
|
|
assert!(!result.contains("\\N"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_page_karaoke_two_lines_stitched() {
|
|
|
|
|
let line1 = SpokenLine {
|
|
|
|
|
plain_text: "hello world".to_string(),
|
|
|
|
|
raw_text: "hello<00:00:01.000><c> world</c>".to_string(),
|
|
|
|
|
start_time: 0.5,
|
|
|
|
|
end_time: 1.5,
|
|
|
|
|
has_karaoke: true,
|
|
|
|
|
is_non_speech: false,
|
|
|
|
|
};
|
|
|
|
|
let line2 = SpokenLine {
|
|
|
|
|
plain_text: "foo bar".to_string(),
|
|
|
|
|
raw_text: "foo<00:00:02.500><c> bar</c>".to_string(),
|
|
|
|
|
start_time: 2.0,
|
|
|
|
|
end_time: 3.0,
|
|
|
|
|
has_karaoke: true,
|
|
|
|
|
is_non_speech: false,
|
|
|
|
|
};
|
|
|
|
|
let result = build_page_karaoke_text(&[&line1, &line2]);
|
|
|
|
|
assert!(result.contains("\\N"));
|
|
|
|
|
assert!(result.contains("{\\k100}world"));
|
|
|
|
|
assert!(result.contains("{\\k50}foo"));
|
|
|
|
|
assert!(result.contains("{\\k50}bar"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_build_page_karaoke_no_karaoke_tags() {
|
|
|
|
|
let line = SpokenLine {
|
|
|
|
|
plain_text: "just plain text".to_string(),
|
|
|
|
|
raw_text: "just plain text".to_string(),
|
|
|
|
|
start_time: 1.0,
|
|
|
|
|
end_time: 3.0,
|
|
|
|
|
has_karaoke: false,
|
|
|
|
|
is_non_speech: false,
|
|
|
|
|
};
|
|
|
|
|
let result = build_page_karaoke_text(&[&line]);
|
|
|
|
|
assert!(result.contains("{\\k200}just plain text"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_group_into_pages_even() {
|
|
|
|
|
let lines = vec![
|
|
|
|
|
SpokenLine { plain_text: "a".into(), raw_text: "a".into(), start_time: 0.0, end_time: 1.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
SpokenLine { plain_text: "b".into(), raw_text: "b".into(), start_time: 1.0, end_time: 2.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
SpokenLine { plain_text: "c".into(), raw_text: "c".into(), start_time: 2.0, end_time: 3.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
SpokenLine { plain_text: "d".into(), raw_text: "d".into(), start_time: 3.0, end_time: 4.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
];
|
|
|
|
|
let pages = group_into_pages(&lines, 2.0);
|
|
|
|
|
assert_eq!(pages.len(), 2);
|
|
|
|
|
assert_eq!(pages[0], vec![0, 1]);
|
|
|
|
|
assert_eq!(pages[1], vec![2, 3]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_group_into_pages_gap_splits() {
|
|
|
|
|
let lines = vec![
|
|
|
|
|
SpokenLine { plain_text: "a".into(), raw_text: "a".into(), start_time: 0.0, end_time: 1.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
SpokenLine { plain_text: "b".into(), raw_text: "b".into(), start_time: 5.0, end_time: 6.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
];
|
|
|
|
|
let pages = group_into_pages(&lines, 2.0);
|
|
|
|
|
assert_eq!(pages.len(), 2);
|
|
|
|
|
assert_eq!(pages[0], vec![0]);
|
|
|
|
|
assert_eq!(pages[1], vec![1]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_group_into_pages_odd_count() {
|
|
|
|
|
let lines = vec![
|
|
|
|
|
SpokenLine { plain_text: "a".into(), raw_text: "a".into(), start_time: 0.0, end_time: 1.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
SpokenLine { plain_text: "b".into(), raw_text: "b".into(), start_time: 1.0, end_time: 2.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
SpokenLine { plain_text: "c".into(), raw_text: "c".into(), start_time: 2.0, end_time: 3.0, has_karaoke: false, is_non_speech: false },
|
|
|
|
|
];
|
|
|
|
|
let pages = group_into_pages(&lines, 2.0);
|
|
|
|
|
assert_eq!(pages.len(), 2);
|
|
|
|
|
assert_eq!(pages[0], vec![0, 1]);
|
|
|
|
|
assert_eq!(pages[1], vec![2]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 06:54:21 -04:00
|
|
|
#[test]
|
|
|
|
|
fn test_vtt_to_ass_paged_output() {
|
|
|
|
|
let vtt_content = "\
|
|
|
|
|
WEBVTT
|
|
|
|
|
|
|
|
|
|
00:00:00.500 --> 00:00:01.500
|
|
|
|
|
hello<00:00:01.000><c> world</c>
|
|
|
|
|
|
|
|
|
|
00:00:02.000 --> 00:00:03.000
|
|
|
|
|
foo<00:00:02.500><c> bar</c>
|
|
|
|
|
|
|
|
|
|
00:00:03.000 --> 00:00:04.000
|
|
|
|
|
baz<00:00:03.500><c> qux</c>
|
|
|
|
|
|
|
|
|
|
00:00:04.000 --> 00:00:05.000
|
|
|
|
|
last<00:00:04.500><c> line</c>
|
|
|
|
|
";
|
|
|
|
|
let temp = std::env::temp_dir().join("test-paged-ass");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&temp);
|
|
|
|
|
std::fs::create_dir_all(&temp).unwrap();
|
|
|
|
|
|
|
|
|
|
let vtt_path = temp.join("test.vtt");
|
|
|
|
|
std::fs::write(&vtt_path, vtt_content).unwrap();
|
|
|
|
|
|
|
|
|
|
let result = vtt_to_ass_with_karaoke(
|
|
|
|
|
vtt_path.to_str().unwrap(),
|
|
|
|
|
temp.to_str().unwrap(),
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
|
|
|
|
|
let ass_path = result.unwrap();
|
|
|
|
|
let ass_content = std::fs::read_to_string(&ass_path).unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(ass_content.contains("[Script Info]"));
|
|
|
|
|
assert!(ass_content.contains("PlayResX: 1920"));
|
|
|
|
|
assert!(ass_content.contains("[Events]"));
|
|
|
|
|
assert!(ass_content.contains("\\an2\\pos("));
|
|
|
|
|
assert!(!ass_content.contains("\\move("));
|
|
|
|
|
assert!(ass_content.contains("\\N"));
|
|
|
|
|
assert!(ass_content.contains("\\fad("));
|
|
|
|
|
assert!(ass_content.contains("\\k"));
|
|
|
|
|
|
|
|
|
|
let dialogue_count = ass_content.matches("Dialogue:").count();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
dialogue_count, 2,
|
|
|
|
|
"Expected 2 pages (4 lines / 2). Got {dialogue_count}.\nASS:\n{ass_content}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&temp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_vtt_to_ass_paged_gap_splits_page() {
|
|
|
|
|
let vtt_content = "\
|
|
|
|
|
WEBVTT
|
|
|
|
|
|
|
|
|
|
00:00:00.500 --> 00:00:01.500
|
|
|
|
|
hello<00:00:01.000><c> world</c>
|
|
|
|
|
|
|
|
|
|
00:00:05.000 --> 00:00:06.000
|
|
|
|
|
far<00:00:05.500><c> away</c>
|
|
|
|
|
";
|
|
|
|
|
let temp = std::env::temp_dir().join("test-paged-gap-ass");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&temp);
|
|
|
|
|
std::fs::create_dir_all(&temp).unwrap();
|
|
|
|
|
|
|
|
|
|
let vtt_path = temp.join("test.vtt");
|
|
|
|
|
std::fs::write(&vtt_path, vtt_content).unwrap();
|
|
|
|
|
|
|
|
|
|
let result = vtt_to_ass_with_karaoke(
|
|
|
|
|
vtt_path.to_str().unwrap(),
|
|
|
|
|
temp.to_str().unwrap(),
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
|
|
|
|
|
let ass_content = std::fs::read_to_string(result.unwrap()).unwrap();
|
|
|
|
|
|
|
|
|
|
let dialogue_count = ass_content.matches("Dialogue:").count();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
dialogue_count, 2,
|
|
|
|
|
"Gap >2s should split into separate pages. Got {dialogue_count}.\nASS:\n{ass_content}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for line in ass_content.lines() {
|
|
|
|
|
if line.starts_with("Dialogue:") {
|
|
|
|
|
assert!(
|
|
|
|
|
!line.contains("\\N"),
|
|
|
|
|
"Single-line page should not contain \\N: {line}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&temp);
|
|
|
|
|
}
|
2026-09-21 11:20:32 -04:00
|
|
|
}
|