diff --git a/__TEST__/caption.test.js b/__TEST__/caption.test.js index a691532..fa5c2c2 100644 --- a/__TEST__/caption.test.js +++ b/__TEST__/caption.test.js @@ -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"); +}); diff --git a/js/caption.js b/js/caption.js index 74fef10..ce3804c 100644 --- a/js/caption.js +++ b/js/caption.js @@ -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 } @@ -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 @@ -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); }