From e3bc43f083acc509ce3f6f38008545cea0858cab Mon Sep 17 00:00:00 2001 From: niksedk Date: Sun, 11 Oct 2026 18:16:34 +0200 Subject: [PATCH] Blu-ray sup: bridge gaps shorter than half a frame The typewriter effect left 1 ms between its steps (issue #15933). On the frame grid such a gap usually vanishes, but when it straddles the middle of a frame the end rounds down and the next start rounds up, so one frame shows nothing: the text flickers, and with a full frame image the video shows through. On the reporter's 206-step sample at 23.976 fps this left 4 empty frames. When the next subtitle starts less than half a frame after the pending ones end, and they would land on different frames, the pending subtitles that end last now stay up until the next one starts. Their display sets are written again with the new end. Co-Authored-By: Claude Opus 5.5 --- src/libse/BluRaySup/BluRaySupPicture.cs | 7 +-- .../Export/ExportHandlerBluRaySup.cs | 38 ++++++++++++++- .../ExportHandlerBluRaySupOverlapTests.cs | 47 +++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/libse/BluRaySup/BluRaySupPicture.cs b/src/libse/BluRaySup/BluRaySupPicture.cs index 735d63d694a..3d3a24381ca 100644 --- a/src/libse/BluRaySup/BluRaySupPicture.cs +++ b/src/libse/BluRaySup/BluRaySupPicture.cs @@ -1141,9 +1141,10 @@ private static byte[] CreateSupFrame(BluRaySupPicture pic, IList= 0; long pts; long endPts; diff --git a/src/libuilogic/Export/ExportHandlerBluRaySup.cs b/src/libuilogic/Export/ExportHandlerBluRaySup.cs index 9b68f039a97..b224cb8f97c 100644 --- a/src/libuilogic/Export/ExportHandlerBluRaySup.cs +++ b/src/libuilogic/Export/ExportHandlerBluRaySup.cs @@ -44,6 +44,12 @@ private sealed record PendingCue(byte[] Buffer, BluRaySupPicture Picture, SKColo private long _pendingStartPts; private long _pendingEndPts; + /// + /// The pending subtitles' times were changed after made their + /// display sets, so even a single one has to be written again. + /// + private bool _pendingRetimed; + public void WriteHeader(string fileOrFolderName, ImageParameter imageParameter) { _width = imageParameter.ScreenWidth; @@ -78,6 +84,7 @@ public void WriteParagraph(ImageParameter param) startPts < _pendingEndPts && endPts > _pendingStartPts; if (_pending.Count > 0 && !overlapsPending) { + BridgeGapShorterThanHalfAFrame(picture.StartTime, startPts, param.FramesPerSecond); FlushPending(); } @@ -114,7 +121,7 @@ private void FlushPending() try { - if (_pending.Count == 1) + if (_pending.Count == 1 && !_pendingRetimed) { // Nothing to compose - the display sets CreateParagraph made are the file. var buffer = _pending[0].Buffer; @@ -128,9 +135,38 @@ private void FlushPending() finally { _pending.Clear(); + _pendingRetimed = false; } } + /// + /// A gap shorter than half a frame is not a pause anyone means: the typewriter effect left + /// 1 ms between its steps (issue #15933). On the frame grid such a gap usually vanishes, but + /// when it straddles the middle of a frame the end rounds down and the next start rounds up, + /// and that frame shows nothing. The text flickers, and with a full frame image the video + /// shows through. The pending subtitles that end last are kept up until the next one starts. + /// + private void BridgeGapShorterThanHalfAFrame(long nextStartMs, long nextStartPts, double fps) + { + var gapMs = nextStartMs - _pendingEndMs; + if (fps <= 0 || gapMs <= 0 || gapMs >= 500.0 / fps || _pendingEndPts >= nextStartPts) + { + return; + } + + foreach (var cue in _pending) + { + if (cue.Picture.EndTime == _pendingEndMs) + { + cue.Picture.EndTime = nextStartMs; + } + } + + _pendingEndMs = nextStartMs; + _pendingEndPts = nextStartPts; + _pendingRetimed = true; + } + /// /// A subtitle as it sits in the frame, ready to be composed with others. /// diff --git a/tests/libuilogic/Export/ExportHandlerBluRaySupOverlapTests.cs b/tests/libuilogic/Export/ExportHandlerBluRaySupOverlapTests.cs index db72c6402b5..c9f6aa36936 100644 --- a/tests/libuilogic/Export/ExportHandlerBluRaySupOverlapTests.cs +++ b/tests/libuilogic/Export/ExportHandlerBluRaySupOverlapTests.cs @@ -247,6 +247,53 @@ public void LinesTouchingEndToStart_AreNotComposed() Assert.Equal(new[] { 1, 0, 1, 0 }, segments.Where(s => s.Type == Pcs).Select(ObjectCount)); } + /// + /// A gap shorter than half a frame that straddles the middle of a frame: the end rounds down + /// and the next start up, which left one frame with nothing on screen (issue #15933, the + /// typewriter effect's 1 ms gaps). The first line now stays up until the second one starts. + /// + [Fact] + public void GapShorterThanHalfAFrame_AcrossAFrame_LeavesNoEmptyFrame() + { + // At 25 fps 3.019 s rounds to the frame at 3.000 s and 3.021 s to the one at 3.040 s. + var segments = ReadSegments(Export( + Cue(0, 1, 3.019, ExportAlignment.BottomCenter, SKColors.White), + Cue(1, 3.021, 5, ExportAlignment.BottomCenter, SKColors.White))); + + var pcs = segments.Where(s => s.Type == Pcs).ToList(); + Assert.Equal(new long[] { 1000 * 90, 3040 * 90, 3040 * 90, 5000 * 90 }, pcs.Select(p => p.Pts)); + Assert.Equal(new[] { 1, 0, 1, 0 }, pcs.Select(ObjectCount)); + Assert.Single(segments.Where(s => s.Type == Ods && s.Pts == 1000 * 90)); + } + + [Fact] + public void OneMillisecondGap_At23976_LeavesNoEmptyFrame() + { + // Two typewriter steps from the issue #15933 sample, 55.367 s and 55.368 s. + var a = Cue(0, 54.000, 55.367, ExportAlignment.BottomCenter, SKColors.White); + var b = Cue(1, 55.368, 57.000, ExportAlignment.BottomCenter, SKColors.White); + a.FramesPerSecond = 23.976; + b.FramesPerSecond = 23.976; + + var segments = ReadSegments(Export(a, b)); + + var pcs = segments.Where(s => s.Type == Pcs).ToList(); + Assert.Equal(new[] { 1, 0, 1, 0 }, pcs.Select(ObjectCount)); + Assert.Equal(BluRaySupPicture.MillisecondsToPts(55368, 23.976), pcs[1].Pts); + Assert.Equal(pcs[2].Pts, pcs[1].Pts); + } + + [Fact] + public void GapOfHalfAFrameOrMore_IsKept() + { + var segments = ReadSegments(Export( + Cue(0, 1, 3.000, ExportAlignment.BottomCenter, SKColors.White), + Cue(1, 3.020, 5, ExportAlignment.BottomCenter, SKColors.White))); + + var pcs = segments.Where(s => s.Type == Pcs).ToList(); + Assert.Equal(new long[] { 1000 * 90, 3000 * 90, 3040 * 90, 5000 * 90 }, pcs.Select(p => p.Pts)); + } + [Fact] public void LinesOnTopOfEachOther_BecomeOneObject() {