diff --git a/src-tauri/src/services/clip_exporter.rs b/src-tauri/src/services/clip_exporter.rs
index 24b9c87..b88e5fc 100644
--- a/src-tauri/src/services/clip_exporter.rs
+++ b/src-tauri/src/services/clip_exporter.rs
@@ -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> world
+
+00:00:02.000 --> 00:00:03.000
+foo<00:00:02.500> bar
+
+00:00:03.000 --> 00:00:04.000
+baz<00:00:03.500> qux
+
+00:00:04.000 --> 00:00:05.000
+last<00:00:04.500> line
+";
+ 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> world
+
+00:00:05.000 --> 00:00:06.000
+far<00:00:05.500> away
+";
+ 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);
+ }
}