feat(captions): wire font, shadow, dimmed color, bg toggle into ASS generation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 10:33:23 -04:00
parent a6277f0aa5
commit 5b0ca72c4c

View File

@@ -547,17 +547,52 @@ pub fn vtt_to_ass_with_karaoke(
let out_path = Path::new(temp_dir).join("karaoke.ass"); 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); // Font
let font_name = style.map_or("Arial".to_string(), |s| s.font_family.clone());
let font_size = style.map_or(50, |s| (s.font_size as f64 * 2.75).round() as u32);
let bold_flag: i32 = if style.map_or(true, |s| s.bold) { -1 } else { 0 };
// Colors
let primary_colour = style.map_or("&H00FFFFFF".to_string(), |s| hex_to_ass_color(&s.text_color)); 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 = style.map_or("&H00000000".to_string(), |s| hex_to_ass_color(&s.outline_color));
let outline_colour = "&H00000000".to_string();
let back_colour = style.map_or("&H80000000".to_string(), |s| opacity_to_ass_back_colour(s.background_opacity)); // Dimmed color (SecondaryColour) — auto derives from text color, custom uses direct color
let text_outline = style.map_or(true, |s| s.text_outline); let secondary_colour = style.map_or("&H73CCCCCC".to_string(), |s| {
let (border_style, outline_val, shadow_val) = if text_outline { if s.dimmed_color_mode == "custom" {
(4, 2, 0) hex_to_ass_color(&s.dimmed_color)
} else { } else {
(3, 0, 0) let alpha = ((1.0 - s.dimmed_opacity.clamp(0.0, 1.0)) * 255.0).round() as u8;
hex_to_ass_color_with_alpha(&s.text_color, alpha)
}
});
// Background/Shadow mutual exclusivity
let bg_enabled = style.map_or(true, |s| s.background_enabled);
let shadow_enabled = style.map_or(false, |s| s.shadow_enabled);
let text_outline = style.map_or(true, |s| s.text_outline);
let (border_style, outline_val, shadow_val, back_colour) = if bg_enabled {
// Background mode: box + optional outline, no shadow
let bc = style.map_or("&H80000000".to_string(), |s| {
let alpha = ((1.0 - s.background_opacity.clamp(0.0, 1.0)) * 255.0).round() as u8;
hex_to_ass_color_with_alpha(&s.background_color, alpha)
});
let bs = if text_outline { 4 } else { 3 };
let ol = if text_outline { 2 } else { 0 };
(bs, ol, 0, bc)
} else if shadow_enabled {
// Shadow mode: outline + shadow, no box
let sc = style.map_or("&H00000000".to_string(), |s| hex_to_ass_color(&s.shadow_color));
let sd = style.map_or(2, |s| s.shadow_depth) as i32;
let ol = if text_outline { 2 } else { 0 };
(1, ol, sd, sc)
} else {
// Neither: just outline if enabled
let ol = if text_outline { 2 } else { 0 };
(1, ol, 0, "&HFF000000".to_string()) // fully transparent back
}; };
let word_highlight = style.map_or(true, |s| s.word_highlight);
let margin_v: i32 = 40; let margin_v: i32 = 40;
let mut output = String::new(); let mut output = String::new();
@@ -573,9 +608,9 @@ pub fn vtt_to_ass_with_karaoke(
output.push_str("[V4+ Styles]\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: 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!( output.push_str(&format!(
"Style: Default,Arial,{},{},{},{},{},0,0,0,0,100,100,0,0,{},{},{},2,20,20,{},1\n", "Style: Default,{},{},{},{},{},{},{},0,0,0,100,100,0,0,{},{},{},2,20,20,{},1\n",
font_size, primary_colour, secondary_colour, outline_colour, back_colour, font_name, font_size, primary_colour, secondary_colour, outline_colour, back_colour,
border_style, outline_val, shadow_val, margin_v bold_flag, border_style, outline_val, shadow_val, margin_v
)); ));
output.push('\n'); output.push('\n');
@@ -584,8 +619,9 @@ pub fn vtt_to_ass_with_karaoke(
let spoken_lines = extract_spoken_lines(&content); let spoken_lines = extract_spoken_lines(&content);
let gap_threshold = 2.0; let gap_threshold = 2.0;
let crossfade_ms = 300; let fade_ms = 100; // per-page fade in/out duration (sequential, no overlap)
let crossfade_s = crossfade_ms as f64 / 1000.0; let fade_s = fade_ms as f64 / 1000.0;
let hold_s = 100.0 / 1000.0; // hold fully visible after last word before fading out
let pages = group_into_pages(&spoken_lines, gap_threshold); let pages = group_into_pages(&spoken_lines, gap_threshold);
let page_count = pages.len(); let page_count = pages.len();
@@ -615,21 +651,22 @@ pub fn vtt_to_ass_with_karaoke(
let is_first = page_idx == 0; let is_first = page_idx == 0;
let is_last = page_idx == page_count - 1; let is_last = page_idx == page_count - 1;
let display_start = if is_first { // No overlap — each page fades in/out independently (sequential)
natural_start let display_start = natural_start;
} else {
(natural_start - crossfade_s).max(0.0)
};
let display_end = if is_last { let display_end = if is_last {
natural_end natural_end
} else { } else {
natural_end + crossfade_s natural_end + hold_s + fade_s
}; };
let fade_in = if is_first { 0 } else { crossfade_ms }; let fade_in = if is_first { 0 } else { fade_ms };
let fade_out = if is_last { 0 } else { crossfade_ms }; let fade_out = if is_last { 0 } else { fade_ms };
let karaoke_text = build_page_karaoke_text(&page_lines); let page_text = if word_highlight {
build_page_karaoke_text(&page_lines)
} else {
page_lines.iter().map(|l| l.plain_text.as_str()).collect::<Vec<_>>().join("\\N")
};
output.push_str(&format!( output.push_str(&format!(
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})\\fad({},{})}}{}\n", "Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})\\fad({},{})}}{}\n",
@@ -637,7 +674,7 @@ pub fn vtt_to_ass_with_karaoke(
format_ass_timestamp(display_end), format_ass_timestamp(display_end),
cx, y_bottom, cx, y_bottom,
fade_in, fade_out, fade_in, fade_out,
karaoke_text page_text
)); ));
} }