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:
@@ -552,15 +552,9 @@ fn build_page_karaoke_text(lines: &[&SpokenLine]) -> String {
|
|||||||
parts.join("")
|
parts.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Position parameters for the rolling subtitle layout.
|
/// Convert a word-timed VTT file to an ASS file with paged teleprompter display.
|
||||||
struct RollingLayout {
|
/// Lines are grouped into 2-line pages. Karaoke \k tags flow continuously from
|
||||||
cx: i32,
|
/// line 1 through line 2 within each page. Pages cross-fade with \fad transitions.
|
||||||
y_bottom: i32,
|
|
||||||
line_height: i32,
|
|
||||||
scroll_ms: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a word-timed VTT file to an ASS file with rolling two-line display.
|
|
||||||
pub fn vtt_to_ass_with_karaoke(
|
pub fn vtt_to_ass_with_karaoke(
|
||||||
caption_path: &str,
|
caption_path: &str,
|
||||||
temp_dir: &str,
|
temp_dir: &str,
|
||||||
@@ -587,14 +581,6 @@ pub fn vtt_to_ass_with_karaoke(
|
|||||||
};
|
};
|
||||||
let margin_v: i32 = 40;
|
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();
|
let mut output = String::new();
|
||||||
|
|
||||||
output.push_str("[Script Info]\n");
|
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 spoken_lines = extract_spoken_lines(&content);
|
||||||
let gap_threshold = 2.0;
|
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() {
|
let pages = group_into_pages(&spoken_lines, gap_threshold);
|
||||||
if line.is_non_speech {
|
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!(
|
output.push_str(&format!(
|
||||||
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})}}{}\n",
|
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})}}{}\n",
|
||||||
format_ass_timestamp(line.start_time),
|
format_ass_timestamp(first_line.start_time),
|
||||||
format_ass_timestamp(line.end_time),
|
format_ass_timestamp(first_line.end_time),
|
||||||
layout.cx, layout.y_bottom,
|
cx, y_bottom,
|
||||||
line.plain_text
|
first_line.plain_text
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let next_line = spoken_lines.get(i + 1);
|
let natural_start = first_line.start_time;
|
||||||
let next_next_line = spoken_lines.get(i + 2);
|
let natural_end = last_line.end_time;
|
||||||
|
|
||||||
let phase1_start = line.start_time;
|
let is_first = page_idx == 0;
|
||||||
let phase1_end = next_line
|
let is_last = page_idx == page_count - 1;
|
||||||
.filter(|nl| !nl.is_non_speech)
|
|
||||||
.map_or(line.end_time, |nl| nl.start_time);
|
|
||||||
|
|
||||||
let karaoke_text = if line.has_karaoke {
|
let display_start = if is_first {
|
||||||
build_karaoke_text(&line.raw_text, line.start_time, line.end_time)
|
natural_start
|
||||||
} else {
|
} 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 fade_in = if is_first { 0 } else { crossfade_ms };
|
||||||
let y1_to = layout.y_bottom - layout.line_height;
|
let fade_out = if is_last { 0 } else { crossfade_ms };
|
||||||
|
|
||||||
|
let karaoke_text = build_page_karaoke_text(&page_lines);
|
||||||
|
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\move({},{},{},{},0,{})}}{}\n",
|
"Dialogue: 0,{},{},Default,,0,0,0,,{{\\an2\\pos({},{})\\fad({},{})}}{}\n",
|
||||||
format_ass_timestamp(phase1_start),
|
format_ass_timestamp(display_start),
|
||||||
format_ass_timestamp(phase1_end),
|
format_ass_timestamp(display_end),
|
||||||
layout.cx, y1_from, layout.cx, y1_to,
|
cx, y_bottom,
|
||||||
layout.scroll_ms,
|
fade_in, fade_out,
|
||||||
karaoke_text
|
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)
|
std::fs::write(&out_path, &output)
|
||||||
@@ -1571,4 +1519,100 @@ third cue
|
|||||||
assert_eq!(pages[1], vec![2]);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user