feat(subtitles): rewrite ASS generation to paged teleprompter model

Replace per-line 3-phase rolling animation with 2-line paged blocks.
Karaoke \k tags flow continuously across \N line breaks within each page.
Pages cross-fade with \fad transitions (300ms). No \move or \clip tags.
Reading flow is top-to-bottom within each page, matching teleprompter style.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 06:54:21 -04:00
parent acd8fb3ec4
commit 92117447d4

View File

@@ -552,15 +552,9 @@ fn build_page_karaoke_text(lines: &[&SpokenLine]) -> String {
parts.join("")
}
/// Position parameters for the rolling subtitle layout.
struct RollingLayout {
cx: i32,
y_bottom: i32,
line_height: i32,
scroll_ms: i32,
}
/// Convert a word-timed VTT file to an ASS file with rolling two-line display.
/// 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.
pub fn vtt_to_ass_with_karaoke(
caption_path: &str,
temp_dir: &str,
@@ -587,14 +581,6 @@ pub fn vtt_to_ass_with_karaoke(
};
let margin_v: i32 = 40;
let line_height = font_size as i32 + outline_val * 2 + 4;
let layout = RollingLayout {
cx: 960,
y_bottom: 1080 - margin_v,
line_height,
scroll_ms: 350,
};
let mut output = String::new();
output.push_str("[Script Info]\n");
@@ -619,99 +605,61 @@ 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;
for (i, line) in spoken_lines.iter().enumerate() {
if line.is_non_speech {
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 {
output.push_str(&format!(
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})}}{}\n",
format_ass_timestamp(line.start_time),
format_ass_timestamp(line.end_time),
layout.cx, layout.y_bottom,
line.plain_text
format_ass_timestamp(first_line.start_time),
format_ass_timestamp(first_line.end_time),
cx, y_bottom,
first_line.plain_text
));
continue;
}
let next_line = spoken_lines.get(i + 1);
let next_next_line = spoken_lines.get(i + 2);
let natural_start = first_line.start_time;
let natural_end = last_line.end_time;
let phase1_start = line.start_time;
let phase1_end = next_line
.filter(|nl| !nl.is_non_speech)
.map_or(line.end_time, |nl| nl.start_time);
let is_first = page_idx == 0;
let is_last = page_idx == page_count - 1;
let karaoke_text = if line.has_karaoke {
build_karaoke_text(&line.raw_text, line.start_time, line.end_time)
let display_start = if is_first {
natural_start
} else {
line.plain_text.clone()
(natural_start - crossfade_s).max(0.0)
};
let display_end = if is_last {
natural_end
} else {
natural_end + crossfade_s
};
let y1_from = layout.y_bottom;
let y1_to = layout.y_bottom - layout.line_height;
let fade_in = if is_first { 0 } else { crossfade_ms };
let fade_out = if is_last { 0 } else { crossfade_ms };
let karaoke_text = build_page_karaoke_text(&page_lines);
output.push_str(&format!(
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\move({},{},{},{},0,{})}}{}\n",
format_ass_timestamp(phase1_start),
format_ass_timestamp(phase1_end),
layout.cx, y1_from, layout.cx, y1_to,
layout.scroll_ms,
"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
));
let has_gap = next_line.map_or(true, |nl| nl.start_time - line.end_time > gap_threshold);
if has_gap {
let hold_end = line.end_time + 1.0;
let phase3_start = hold_end;
let phase3_end = phase3_start + 0.4;
let y_ctx = layout.y_bottom - layout.line_height;
output.push_str(&format!(
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})\\fad(0,400)}}{}\n",
format_ass_timestamp(phase3_start),
format_ass_timestamp(phase3_end),
layout.cx, y_ctx,
line.plain_text
));
continue;
}
let phase2_start = phase1_end;
let phase2_end = if let Some(nnl) = next_next_line.filter(|nnl| !nnl.is_non_speech) {
let nn_gap = nnl.start_time - next_line.unwrap().end_time;
if nn_gap > gap_threshold {
next_line.unwrap().end_time + 1.0
} else {
nnl.start_time
}
} else {
next_line.map_or(phase2_start + 2.0, |nl| nl.end_time)
};
if phase2_end > phase2_start {
let y2_from = layout.y_bottom - layout.line_height;
let y2_to = layout.y_bottom - 2 * layout.line_height;
output.push_str(&format!(
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\move({},{},{},{},0,{})}}{}\n",
format_ass_timestamp(phase2_start),
format_ass_timestamp(phase2_end),
layout.cx, y2_from, layout.cx, y2_to,
layout.scroll_ms,
line.plain_text
));
let phase3_start = phase2_end;
let phase3_end = phase3_start + 0.4;
let y3 = layout.y_bottom - 2 * layout.line_height;
output.push_str(&format!(
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})\\fad(0,400)}}{}\n",
format_ass_timestamp(phase3_start),
format_ass_timestamp(phase3_end),
layout.cx, y3,
line.plain_text
));
}
}
std::fs::write(&out_path, &output)
@@ -1571,4 +1519,100 @@ third cue
assert_eq!(pages[1], vec![2]);
}
#[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);
}
}