craft-captions: an SCC pop-on load may run across the previous caption's erase

Ported from storytold/filmcraft#1837 (filmcraft#1791). A caption that closely
follows another popped on about a second late, because its load had to be one
unbroken run of free frames. Doubled codes stay adjacent; pauses are allowed
between them.
This commit is contained in:
jnohclee-rgb
2026-11-11 23:14:28 +00:00
committed by Dylan M. Taylor
parent 71fd9a791a
commit c55287c57a
2 changed files with 71 additions and 7 deletions
+25 -7
View File
@@ -446,14 +446,32 @@ pub fn schedule(doc: &Document) -> BTreeMap<i64, (u8, u8)> {
}
let load = load_pairs(&c.text);
let n = load.len() as i64;
// Doubled codes must stay adjacent, so the load is one contiguous run of free frames:
// the latest run ending by `s`, else the first run after `cursor` (delaying the caption).
let run_free = |occ: &BTreeMap<i64, (u8, u8)>, b: i64| (b..b + n).all(|f| !occ.contains_key(&f));
let b = (cursor..=s - n).rev().find(|&b| run_free(&occ, b)).unwrap_or_else(|| (cursor..).find(|&b| run_free(&occ, b)).unwrap_or(cursor));
for (k, p) in load.into_iter().enumerate() {
occ.insert(b + k as i64, p);
// Erasing displayed memory can interrupt loading non-displayed memory. Keep each
// doubled control/character code adjacent, but allow pauses between those groups.
let plan_load = |mut f: i64| {
let mut plan = Vec::with_capacity(load.len());
let mut k = 0;
while let Some(&p) = load.get(k) {
let doubled = (0x10..=0x1f).contains(&p.0) && load.get(k + 1) == Some(&p);
let width = if doubled { 2 } else { 1 };
while (f..f + width).any(|at| occ.contains_key(&at)) {
f += 1;
}
for offset in 0..width {
plan.push((f + offset, p));
}
f += width;
k += width as usize;
}
(plan, f)
};
// Prefer the latest load that finishes by the requested start; if it cannot fit,
// use the earliest available frames rather than waiting until the prior cue ends.
let (plan, end) = (cursor..=s - n).rev().map(plan_load).find(|(_, end)| *end <= s).unwrap_or_else(|| plan_load(cursor));
for (f, p) in plan {
occ.insert(f, p);
}
let mut d = s.max(b + n);
let mut d = s.max(end);
while occ.contains_key(&d) {
d += 1;
}
+46
View File
@@ -260,3 +260,49 @@ fn srt_to_vtt_to_scc_keeps_text_and_frames() {
assert_eq!(scc::frame_of(doc3.cues[0].start), 60);
assert_eq!(scc::frame_of(doc3.cues[1].end), 210);
}
#[test]
fn tight_pop_on_load_can_cross_the_previous_caption_end() {
let text = "A second caption that needs more time to load";
let doc = Document {
cues: vec![
Cue { start: scc::RATE.tick_of(30), end: scc::RATE.tick_of(60), text: "First".into(), ..Default::default() },
Cue { start: scc::RATE.tick_of(67), end: scc::RATE.tick_of(150), text: text.into(), ..Default::default() },
],
..Default::default()
};
for format in [Format::Scc, Format::Mcc] {
let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap();
assert_eq!(back.cues.len(), 2);
assert_eq!(scc::frame_of(back.cues[0].end), 60);
assert_eq!(scc::frame_of(back.cues[1].start), 67, "{format:?}");
assert_eq!(scc::frame_of(back.cues[1].end), 150);
}
}
#[test]
fn interrupted_load_keeps_extended_characters_and_repeated_basic_pairs() {
for text in ["A second caption that needs more time to load", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "ÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀ\nÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀÀ"]
{
let doc = Document {
cues: vec![
Cue { start: scc::RATE.tick_of(30), end: scc::RATE.tick_of(60), text: "First".into(), ..Default::default() },
Cue { start: scc::RATE.tick_of(67), end: scc::RATE.tick_of(180), text: text.into(), ..Default::default() },
],
..Default::default()
};
for format in [Format::Scc, Format::Mcc] {
let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap();
assert_eq!(back.cues.len(), 2);
assert_eq!(back.cues[1].text.replace(['\n', ' '], ""), text.replace(['\n', ' '], ""), "{format:?}");
assert_eq!(scc::frame_of(back.cues[0].end), 60);
assert_eq!(scc::frame_of(back.cues[1].end), 180);
}
let pairs = scc::schedule(&doc);
for (&frame, &pair) in &pairs {
if (0x10..=0x1f).contains(&pair.0) {
assert!(pairs.get(&(frame - 1)) == Some(&pair) || pairs.get(&(frame + 1)) == Some(&pair), "split doubled code at {frame}");
}
}
}
}