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 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 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)
let outline_colour = style.map_or("&H00000000".to_string(), |s| hex_to_ass_color(&s.outline_color));
// Dimmed color (SecondaryColour) — auto derives from text color, custom uses direct color
let secondary_colour = style.map_or("&H73CCCCCC".to_string(), |s| {
if s.dimmed_color_mode == "custom" {
hex_to_ass_color(&s.dimmed_color)
} 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 mut output = String::new();
@@ -573,9 +608,9 @@ pub fn vtt_to_ass_with_karaoke(
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
"Style: Default,{},{},{},{},{},{},{},0,0,0,100,100,0,0,{},{},{},2,20,20,{},1\n",
font_name, font_size, primary_colour, secondary_colour, outline_colour, back_colour,
bold_flag, border_style, outline_val, shadow_val, margin_v
));
output.push('\n');
@@ -584,8 +619,9 @@ pub fn vtt_to_ass_with_karaoke(
let spoken_lines = extract_spoken_lines(&content);
let gap_threshold = 2.0;
let crossfade_ms = 300;
let crossfade_s = crossfade_ms as f64 / 1000.0;
let fade_ms = 100; // per-page fade in/out duration (sequential, no overlap)
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 page_count = pages.len();
@@ -615,21 +651,22 @@ pub fn vtt_to_ass_with_karaoke(
let is_first = page_idx == 0;
let is_last = page_idx == page_count - 1;
let display_start = if is_first {
natural_start
} else {
(natural_start - crossfade_s).max(0.0)
};
// No overlap — each page fades in/out independently (sequential)
let display_start = natural_start;
let display_end = if is_last {
natural_end
} else {
natural_end + crossfade_s
natural_end + hold_s + fade_s
};
let fade_in = if is_first { 0 } else { crossfade_ms };
let fade_out = if is_last { 0 } else { crossfade_ms };
let fade_in = if is_first { 0 } else { fade_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!(
"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),
cx, y_bottom,
fade_in, fade_out,
karaoke_text
page_text
));
}