Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions __TEST__/caption.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,38 @@ test("cue timing data round-trips through the data property", () => {
expect(result.data[0].start).toBe("00:00:01.000");
expect(flat(result.data[0].text)).toBe("Offset start.");
});

test("a missing data-d next to a non-increasing data-m never goes negative (hyperaudio-lite-editor#411)", () => {
// "Alone." has no data-d and the NEXT word's data-m sits before its own
// (out-of-order times after edits; duplicate stamps on split tokens are the
// milder case). The derived duration went negative, producing a cue with
// stop < start — dropped by browsers, and formatSeconds wrapped the negative
// to a ~24h timestamp.
buildTranscript([
[2000, null, "Alone."],
[1500, 300, "Next."],
]);
const cues = parseVtt(caption().init("transcript", null).vtt);

expect(cues.length).toBeGreaterThanOrEqual(1);
for (const cue of cues) {
expect(cue.stop > cue.start).toBe(true); // lexicographic works for HH:MM:SS.mmm
expect(cue.stop.startsWith("23:59")).toBe(false);
}
});

test("an inverted or zero-length cue is repaired to a readable length, not skipped (hyperaudio-lite-editor#411)", () => {
// A single-word cue whose word has an explicit zero duration serialized as
// stop == start; the timing safeguard stepped over exactly these, so the
// zero-length cue shipped (and browsers dropped it from the track).
buildTranscript([
[0, 500, "One."],
[5000, 0, "Beep."],
]);
const cues = parseVtt(caption().init("transcript", null).vtt);

expect(cues).toHaveLength(2);
expect(cues[1].start).toBe("00:00:05.000");
// repaired: extended to the 1s minimum on-screen time
expect(cues[1].stop).toBe("00:00:06.000");
});
27 changes: 24 additions & 3 deletions js/caption.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ const caption = function () {
if (thisDuration > maxWordDuration) {
thisDuration = maxWordDuration;
}
if (thisDuration < 0) {
// the next word's data-m can sit at or before this word's
// (duplicate stamps on split tokens, or out-of-order times after
// edits) — a negative duration would invert the cue downstream
thisDuration = 0;
}
} else {
thisDuration = 5; // sensible default for the last word
}
Expand Down Expand Up @@ -376,9 +382,18 @@ const caption = function () {
function applyTimingSafeguards(caps) {
for (let c = 0; c < caps.length; c++) {
const start = timecodeToSeconds(caps[c].start);
const stop = timecodeToSeconds(caps[c].stop);
if (isNaN(start) || isNaN(stop) || stop <= start) {
continue; // skip malformed or zero-length cues
let stop = timecodeToSeconds(caps[c].stop);
if (isNaN(start) || isNaN(stop)) {
continue; // skip malformed cues
}

// An inverted or zero-length cue (stop <= start) — zero/duplicate word
// durations, or out-of-order times after edits — is REPAIRED rather
// than skipped: browsers drop such cues from a VTT track, and some SRT
// consumers reject the whole file. Treating its stop as the start lets
// the extension below give it a readable length.
if (stop <= start) {
stop = start;
}

// visible characters, counting a single space per line break
Expand All @@ -401,6 +416,12 @@ const caption = function () {
}
}

// never emit stop <= start, even when the next cue starts at or before
// this one (out-of-order cue starts) — a minimal cue beats a dropped one
if (desiredStop <= start) {
desiredStop = start + minCaptionGap;
}

if (desiredStop > stop) {
caps[c].stop = formatSeconds(desiredStop);
}
Expand Down
Loading