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); + } }