filmcraft/crates/edit/src/transcript/tests.rs

298 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Deterministic tests of the transcript → sequence mapping and the text-based edits, on the
//! hand-made interview transcript (`tests/fixtures/interview.transcript.json`, 10 s, two speakers,
//! a 2 s pause after "show.", fillers "Um," and "uh", phrase "you know.").
use super::*;
use filmcraft_project::{Label, Project, SequenceSettings, TrackItem};
use filmcraft_time::FrameRate;
const R: FrameRate = FrameRate { num: 25, den: 1 };
const MEDIA: ItemId = ItemId(1);
fn s(x: f64) -> Tick {
Tick((x * TICKS_PER_SECOND as f64).round() as i64)
}
fn fixture() -> Transcript {
let mut t: Transcript = serde_json::from_str(include_str!("../../tests/fixtures/interview.transcript.json")).unwrap();
t.normalize();
t.check().unwrap();
t
}
fn transcripts() -> Transcripts {
let mut m = Transcripts::new();
m.insert(MEDIA, Arc::new(fixture()));
m
}
fn seq() -> Sequence {
let mut p = Project::new("t");
let id = p.new_sequence("s", SequenceSettings { frame_rate: R, ..Default::default() }, 1, 2, None);
p.sequence(id).unwrap().clone()
}
fn clip(id: u64, start: f64, dur: f64, src_in: f64) -> TrackItem {
TrackItem {
id: ClipId(id),
item: MEDIA,
name: "interview".into(),
label: Label::Iris,
start: s(start),
duration: s(dur),
source_in: s(src_in),
speed: 1.0,
reverse: false,
enabled: true,
link: None,
group: None,
effects: vec![],
markers: vec![],
gain_db: 0.0,
frame_hold: None,
scale_to_frame: false,
essential: None,
multicam: None,
time_interpolation: Default::default(),
hold_filters: false,
field_options: None,
source_channels: Vec::new(),
graphic: None,
}
}
fn texts(w: &[SeqWord]) -> Vec<&str> {
w.iter().map(|w| w.text.as_str()).collect()
}
fn media_dur(_: ItemId) -> Option<Tick> {
Some(s(10.0))
}
fn whole() -> Sequence {
let mut q = seq();
q.audio_tracks[0].items.push(clip(100, 0.0, 10.0, 0.0));
q
}
#[test]
fn words_map_through_the_clip() {
let mut q = seq();
// media 3.5 s – 6.5 s at timeline 2 s
q.audio_tracks[0].items.push(clip(100, 2.0, 3.0, 3.5));
let w = sequence_words(&q, &transcripts());
assert_eq!(texts(&w), ["Um,", "today", "we", "talk", "about", "rivers."]);
assert_eq!(w[0].start, s(2.2));
assert_eq!(w[0].end, s(2.5));
assert_eq!(w[5].end, s(4.4));
assert!(w.iter().all(|x| x.clip == ClipId(100) && x.item == MEDIA && x.track == 0));
assert_eq!(w[0].index, 4);
assert_eq!(w[0].speaker.as_deref(), Some("Speaker 1"));
}
#[test]
fn a_cut_word_is_kept_by_its_midpoint_and_clamped() {
let mut q = seq();
// cut inside "show." (1.20–1.70, midpoint 1.45): the clip starts at media 1.4
q.audio_tracks[0].items.push(clip(100, 0.0, 1.0, 1.4));
let w = sequence_words(&q, &transcripts());
assert_eq!(texts(&w), ["show."]);
assert_eq!(w[0].start, Tick::ZERO);
assert_eq!(w[0].end, s(0.3));
}
#[test]
fn speed_scales_word_times() {
let mut q = seq();
let mut c = clip(100, 0.0, 5.0, 0.0);
c.speed = 2.0;
q.audio_tracks[0].items.push(c);
let w = sequence_words(&q, &transcripts());
assert_eq!(w.len(), 20);
assert_eq!((w[3].start, w[3].end), (s(0.6), s(0.85)));
}
#[test]
fn duplicate_tracks_read_once_and_disabled_or_reversed_clips_are_silent() {
let mut q = whole();
q.audio_tracks[1].items.push(clip(101, 0.0, 10.0, 0.0));
let w = sequence_words(&q, &transcripts());
assert_eq!(w.len(), 20);
assert!(w.iter().all(|x| x.track == 0));
q.audio_tracks[0].items[0].enabled = false;
let w = sequence_words(&q, &transcripts());
assert_eq!(w.len(), 20);
assert!(w.iter().all(|x| x.track == 1));
q.audio_tracks[1].items[0].reverse = true;
assert!(sequence_words(&q, &transcripts()).is_empty());
}
#[test]
fn two_clips_of_the_same_media_repeat_words_in_edit_order() {
let mut q = seq();
// "rivers." twice: media 5.3–5.9 at 0 s and again at 1 s
q.audio_tracks[0].items.push(clip(100, 0.0, 1.0, 5.2));
q.audio_tracks[0].items.push(clip(101, 1.0, 1.0, 5.2));
let w = sequence_words(&q, &transcripts());
assert_eq!(texts(&w), ["rivers.", "rivers."]);
assert_eq!(w[1].start, s(1.1));
assert_eq!(w[1].clip, ClipId(101));
}
#[test]
fn search_word_at_and_paragraphs() {
let w = sequence_words(&whole(), &transcripts());
assert_eq!(search(&w, "rivers"), vec![9..10, 17..18]);
assert_eq!(search(&w, "YOU kn"), vec![18..20]);
assert_eq!(search(&w, "talk about"), vec![7..9]);
assert!(search(&w, " ").is_empty());
assert_eq!(word_at(&w, s(4.2)), Some(5));
assert_eq!(word_at(&w, s(2.5)), None); // in the pause
assert_eq!(word_at(&w, s(0.1)), None);
// pause ≥ 1.5 s after "show.", speaker change before "Thanks"
assert_eq!(paragraphs(&w, s(1.5)), vec![0..4, 4..10, 10..20]);
}
#[test]
fn word_ranges_snap_outward() {
let w = sequence_words(&whole(), &transcripts());
// "Welcome to the show." 0.50–1.70 → frames 12..43 (0.48–1.72)
let r = word_range(&w, 3, 0, R).unwrap();
assert_eq!((r.start, r.end()), (s(0.48), s(1.72)));
let m = media_word_range(&fixture(), 4, 9, R).unwrap();
assert_eq!((m.start, m.end()), (s(3.68), s(5.92)));
assert!(word_range(&w, 0, 99, R).is_none());
}
#[test]
fn extract_text_closes_the_gap() {
let mut q = whole();
let w = sequence_words(&q, &transcripts());
let r = word_range(&w, 0, 3, R).unwrap();
let mut next = 1000;
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, min_duration: R.frame_duration() };
let tracks: Vec<TrackId> = q.all_tracks().map(|t| t.id).collect();
crate::extract(&mut q, &tracks, r, &mut ctx);
q.check().unwrap();
let w2 = sequence_words(&q, &transcripts());
assert_eq!(w2.len(), 16);
assert_eq!(w2[0].text, "Um,");
assert_eq!(w2[0].start, s(3.70) - r.duration);
}
#[test]
fn pauses_are_found_and_removed_in_one_pass() {
let mut q = whole();
let w = sequence_words(&q, &transcripts());
// only the 2 s pause after "show." is ≥ 1 s; 0.1 s is kept on each side
let p = find_pauses(&w, s(1.0), s(0.1), R);
assert_eq!(p.len(), 1);
assert_eq!((p[0].start, p[0].end()), (s(1.80), s(3.60)));
// 0.4 s threshold also finds 5.90→6.40 and 7.60→8.00
let p2 = find_pauses(&w, s(0.4), s(0.1), R);
assert_eq!(p2.len(), 3);
let mut next = 1000;
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, min_duration: R.frame_duration() };
let removed = ripple_delete_ranges(&mut q, p2.clone(), &mut ctx);
q.check().unwrap();
assert_eq!(removed, p2.iter().map(|r| r.duration).fold(Tick::ZERO, |a, b| a + b));
let w2 = sequence_words(&q, &transcripts());
assert_eq!(texts(&w2), texts(&w));
// the long pause is now 0.2 s
assert_eq!(w2[4].start - w2[3].end, s(0.2));
assert_eq!(w2.last().unwrap().end, s(10.0) - removed);
assert!(find_pauses(&w2, s(0.4), s(0.1), R).is_empty());
}
#[test]
fn fillers_are_found_with_phrases_and_removed() {
let mut q = whole();
let w = sequence_words(&q, &transcripts());
let defaults: Vec<String> = DEFAULT_FILLERS.iter().map(|x| x.to_string()).collect();
let hits = find_fillers(&w, &defaults);
assert_eq!(hits, vec![4..5, 15..16]);
let mut more = defaults.clone();
more.push("You know".into());
more.push("you".into());
assert_eq!(find_fillers(&w, &more), vec![4..5, 15..16, 18..20]);
let ranges = filler_ranges(&w, &hits, R);
// "Um," 3.70–4.00 → nearest frames 3.68–4.00; "uh" 8.20–8.50 → 8.20–8.48
assert_eq!(ranges.iter().map(|r| (r.start, r.end())).collect::<Vec<_>>(), vec![(s(3.68), s(4.00)), (s(8.20), s(8.48))]);
let mut next = 1000;
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, min_duration: R.frame_duration() };
ripple_delete_ranges(&mut q, ranges, &mut ctx);
q.check().unwrap();
let w2 = sequence_words(&q, &transcripts());
assert_eq!(w2.len(), 18);
assert!(find_fillers(&w2, &defaults).is_empty());
assert_eq!(q.audio_tracks[0].items.len(), 3);
}
#[test]
fn merge_ranges_joins_overlaps() {
let r = |a: f64, b: f64| TimeRange::from_bounds(s(a), s(b));
let m = merge_ranges(vec![r(5.0, 6.0), r(1.0, 2.0), r(1.5, 3.0), r(3.0, 4.0)]);
assert_eq!(m, vec![r(1.0, 4.0), r(5.0, 6.0)]);
}
fn check_blocks(b: &[CaptionBlock], rules: &CaptionRules) {
for (i, x) in b.iter().enumerate() {
assert!(x.end > x.start, "{x:?}");
assert_eq!(R.snap(x.start), x.start);
assert_eq!(R.snap(x.end), x.end);
let lines: Vec<&str> = x.text.lines().collect();
assert!(lines.len() <= rules.lines, "{x:?}");
assert!(lines.iter().all(|l| l.chars().count() <= rules.max_chars), "{x:?}");
if i > 0 {
assert!(x.start >= b[i - 1].end + Tick(R.frame_duration().0 * rules.gap_frames), "{:?} overlaps {:?}", b[i - 1], x);
}
}
}
#[test]
fn captions_follow_line_length_speaker_and_pause_rules() {
let w = sequence_words(&whole(), &transcripts());
let rules = CaptionRules::default();
let b = caption_blocks(&w, &rules, R);
check_blocks(&b, &rules);
assert_eq!(
b.iter().map(|x| x.text.as_str()).collect::<Vec<_>>(),
["Welcome to the show.", "Um, today we talk about rivers.", "Thanks for having me. I uh love rivers,\nyou know."]
);
assert_eq!((b[0].start, b[0].end), (s(0.48), s(1.72)));
assert_eq!(b[2].speaker.as_deref(), Some("Speaker 2"));
assert_eq!(b[2].words, 10..20);
// single 20-character lines, two-frame gaps, 3 s minimum
let tight = CaptionRules { max_chars: 20, lines: 1, gap_frames: 2, min_duration: s(3.0), ..Default::default() };
let b = caption_blocks(&w, &tight, R);
check_blocks(&b, &tight);
assert!(b.len() >= 5);
// the first block is extended to 3 s (room before "Um,")
assert_eq!(b[0].end - b[0].start, s(3.0));
// every word is shown exactly once, in order
let all: Vec<usize> = b.iter().flat_map(|x| x.words.clone()).collect();
assert_eq!(all, (0..20).collect::<Vec<_>>());
}
#[test]
fn captions_never_overlap_with_dense_words() {
// 30 one-frame words back to back
let words: Vec<SeqWord> = (0..30)
.map(|i| SeqWord {
text: format!("w{i}."),
start: R.tick_of(i),
end: R.tick_of(i + 1),
clip: ClipId(1),
item: MEDIA,
index: i as usize,
track: 0,
speaker: if i % 3 == 0 { Some("A".into()) } else { Some("B".into()) },
confidence: 1.0,
})
.collect();
let rules = CaptionRules { gap_frames: 1, ..Default::default() };
let b = caption_blocks(&words, &rules, R);
check_blocks(&b, &rules);
}