diff --git a/src/Test/Logic/DisplayableParagraphHelperTest.cs b/src/Test/Logic/DisplayableParagraphHelperTest.cs
new file mode 100644
index 0000000000..3a86dee8ab
--- /dev/null
+++ b/src/Test/Logic/DisplayableParagraphHelperTest.cs
@@ -0,0 +1,137 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Nikse.SubtitleEdit.Core.Common;
+using Nikse.SubtitleEdit.Logic;
+using System.Collections.Generic;
+
+namespace Test.Logic
+{
+ [TestClass]
+ public class DisplayableParagraphHelperTest
+ {
+
+ ///
+ /// Tests that the longest paragraph is selected when neither has any overlap.
+ ///
+ [TestMethod]
+ public void GetLongestParagraphTest()
+ {
+ var paragraphs = new List()
+ {
+ new Paragraph("Longer", TimeCode.ParseToMilliseconds("00:00:10,500"), TimeCode.ParseToMilliseconds("00:00:15,000")),
+ new Paragraph("Shorter", TimeCode.ParseToMilliseconds("00:00:20,000"), TimeCode.ParseToMilliseconds("00:00:21,000"))
+ };
+ DisplayableParagraphHelper helper = new DisplayableParagraphHelper(TimeCode.ParseToMilliseconds("00:00:00,000"), TimeCode.ParseToMilliseconds("00:00:30,000"), 1000);
+ AddAllParagraphs(helper, paragraphs);
+
+ List selectedParagraphs = helper.GetParagraphs(1);
+ Assert.AreEqual(1, selectedParagraphs.Count);
+
+ Assert.AreEqual("Longer", selectedParagraphs[0].Text);
+ }
+
+ ///
+ /// Tests that the paragraph without overlap is chosen when the alternative is completely overlapped by a longer paragraph.
+ ///
+ [TestMethod]
+ public void GetLeastOverlappingParagraphTest()
+ {
+ var paragraphs = new List()
+ {
+ new Paragraph("Outer", TimeCode.ParseToMilliseconds("00:00:5,000"), TimeCode.ParseToMilliseconds("00:00:15,000")),
+ new Paragraph("Inner", TimeCode.ParseToMilliseconds("00:00:10,000"), TimeCode.ParseToMilliseconds("00:00:11,000")),
+ new Paragraph("Second", TimeCode.ParseToMilliseconds("00:00:20,000"), TimeCode.ParseToMilliseconds("00:00:21,000")),
+ };
+ DisplayableParagraphHelper helper = new DisplayableParagraphHelper(TimeCode.ParseToMilliseconds("00:00:00,000"), TimeCode.ParseToMilliseconds("00:00:30,000"), 1000);
+ AddAllParagraphs(helper, paragraphs);
+
+ List selectedParagraphs = helper.GetParagraphs(2);
+ Assert.AreEqual(2, selectedParagraphs.Count);
+
+ Assert.AreEqual("Outer", selectedParagraphs[0].Text);
+ Assert.AreEqual("Second", selectedParagraphs[1].Text);
+ }
+
+ ///
+ /// Tests that a paragraph that partially overlaps another paragraph is chosen when the alternative completely overlaps another paragraph.
+ ///
+ [TestMethod]
+ public void GetPartiallyOverlappingTest()
+ {
+ var paragraphs = new List()
+ {
+ new Paragraph("Outer", TimeCode.ParseToMilliseconds("00:00:5,000"), TimeCode.ParseToMilliseconds("00:00:15,000")),
+ new Paragraph("Inner", TimeCode.ParseToMilliseconds("00:00:07,000"), TimeCode.ParseToMilliseconds("00:00:10,000")),
+ new Paragraph("Partial", TimeCode.ParseToMilliseconds("00:00:14,000"), TimeCode.ParseToMilliseconds("00:00:16,000")),
+ };
+ DisplayableParagraphHelper helper = new DisplayableParagraphHelper(TimeCode.ParseToMilliseconds("00:00:00,000"), TimeCode.ParseToMilliseconds("00:00:30,000"), 1000);
+ AddAllParagraphs(helper, paragraphs);
+
+ List selectedParagraphs = helper.GetParagraphs(2);
+ Assert.AreEqual(2, selectedParagraphs.Count);
+
+ Assert.AreEqual("Outer", selectedParagraphs[0].Text);
+ Assert.AreEqual("Partial", selectedParagraphs[1].Text);
+ }
+
+ ///
+ /// Tests that consecutive paragraphs can be chosen (starting and ending at the same time).
+ ///
+ [TestMethod]
+ public void GetConsecutiveParagraphsTest()
+ {
+ List paragraphs = CreateConsecutiveParagraphs(1);
+ DisplayableParagraphHelper helper = new DisplayableParagraphHelper(TimeCode.ParseToMilliseconds("00:00:00,000"), TimeCode.ParseToMilliseconds("00:00:30,000"), 1000);
+ AddAllParagraphs(helper, paragraphs);
+
+ List selectedParagraphs = helper.GetParagraphs(3);
+
+ Assert.AreEqual(3, selectedParagraphs.Count);
+ Assert.AreEqual("P1 L1", selectedParagraphs[0].Text);
+ Assert.AreEqual("P2 L1", selectedParagraphs[1].Text);
+ Assert.AreEqual("P3 L1", selectedParagraphs[2].Text);
+ }
+
+ ///
+ /// Tests that only a single layer of paragraphs will be chosen when all paragraphs overlap in a layer 3 deep.
+ ///
+ [TestMethod]
+ public void GetSingleOverlapLayerTest()
+ {
+ var paragraphs = new List();
+ paragraphs.AddRange(CreateConsecutiveParagraphs(1));
+ paragraphs.AddRange(CreateConsecutiveParagraphs(2));
+ paragraphs.AddRange(CreateConsecutiveParagraphs(3));
+ DisplayableParagraphHelper helper = new DisplayableParagraphHelper(TimeCode.ParseToMilliseconds("00:00:00,000"), TimeCode.ParseToMilliseconds("00:00:30,000"), 1000);
+ AddAllParagraphs(helper, paragraphs);
+
+ List selectedParagraphs = helper.GetParagraphs(4);
+
+ Assert.AreEqual(4, selectedParagraphs.Count);
+ Assert.IsTrue(selectedParagraphs[0].Text.StartsWith("P1"));
+ Assert.IsTrue(selectedParagraphs[1].Text.StartsWith("P2"));
+ Assert.IsTrue(selectedParagraphs[2].Text.StartsWith("P3"));
+ Assert.IsTrue(selectedParagraphs[3].Text.StartsWith("P4"));
+ }
+
+ private List CreateConsecutiveParagraphs(int layerNumber)
+ {
+ var paragraphs = new List()
+ {
+ new Paragraph($"P1 L{layerNumber}", TimeCode.ParseToMilliseconds("00:00:2,500"), TimeCode.ParseToMilliseconds("00:00:3,000")),
+ new Paragraph($"P2 L{layerNumber}", TimeCode.ParseToMilliseconds("00:00:3,000"), TimeCode.ParseToMilliseconds("00:00:3,500")),
+ new Paragraph($"P3 L{layerNumber}", TimeCode.ParseToMilliseconds("00:00:3,500"), TimeCode.ParseToMilliseconds("00:00:4,000")),
+ new Paragraph($"P4 L{layerNumber}", TimeCode.ParseToMilliseconds("00:00:4,000"), TimeCode.ParseToMilliseconds("00:00:4,500")),
+ };
+ return paragraphs;
+ }
+
+ private void AddAllParagraphs(DisplayableParagraphHelper helper, List paragraphs)
+ {
+ foreach (var paragraph in paragraphs)
+ {
+ helper.Add(paragraph);
+ }
+ }
+
+ }
+}
diff --git a/src/Test/Test.csproj b/src/Test/Test.csproj
index 1244cc478f..1899915d5c 100644
--- a/src/Test/Test.csproj
+++ b/src/Test/Test.csproj
@@ -73,6 +73,7 @@
+
diff --git a/src/ui/Controls/AudioVisualizer.cs b/src/ui/Controls/AudioVisualizer.cs
index 6bc735280f..d3f116170e 100644
--- a/src/ui/Controls/AudioVisualizer.cs
+++ b/src/ui/Controls/AudioVisualizer.cs
@@ -11,6 +11,7 @@
using System.Threading.Tasks;
using System.Windows.Forms;
using Nikse.SubtitleEdit.Core.Forms;
+using System.Diagnostics;
namespace Nikse.SubtitleEdit.Controls
{
@@ -428,10 +429,12 @@ private void LoadParagraphs(Subtitle subtitle, int primarySelectedIndex, ListVie
return;
}
- const double additionalSeconds = 15.0; // Helps when scrolling
- var startThresholdMilliseconds = (_startPositionSeconds - additionalSeconds) * TimeCode.BaseUnit;
- var endThresholdMilliseconds = (EndPositionSeconds + additionalSeconds) * TimeCode.BaseUnit;
- var displayableParagraphs = new List();
+ double startVisibleMilliseconds = _startPositionSeconds * TimeCode.BaseUnit;
+ double endVisibleMilliseconds = EndPositionSeconds * TimeCode.BaseUnit;
+
+ DisplayableParagraphHelper paragraphHelper = new DisplayableParagraphHelper(
+ startVisibleMilliseconds, endVisibleMilliseconds, 15 * TimeCode.BaseUnit);
+
for (var i = 0; i < subtitle.Paragraphs.Count; i++)
{
var p = subtitle.Paragraphs[i];
@@ -441,30 +444,11 @@ private void LoadParagraphs(Subtitle subtitle, int primarySelectedIndex, ListVie
continue;
}
- _subtitle.Paragraphs.Add(p);
- if (p.EndTime.TotalMilliseconds >= startThresholdMilliseconds && p.StartTime.TotalMilliseconds <= endThresholdMilliseconds)
- {
- displayableParagraphs.Add(p);
- if (displayableParagraphs.Count > 99)
- {
- break;
- }
- }
+ paragraphHelper.Add(p);
}
+ List selectedParagraphs = paragraphHelper.GetParagraphs(100);
+ _displayableParagraphs.AddRange(selectedParagraphs);
- displayableParagraphs = displayableParagraphs.OrderBy(p => p.StartTime.TotalMilliseconds).ToList();
- var lastStartTime = -1d;
- foreach (var p in displayableParagraphs)
- {
- if (displayableParagraphs.Count > 30 &&
- (p.DurationTotalMilliseconds < 0.01 || p.StartTime.TotalMilliseconds - lastStartTime < 90))
- {
- continue;
- }
-
- _displayableParagraphs.Add(p);
- lastStartTime = p.StartTime.TotalMilliseconds;
- }
var primaryParagraph = subtitle.GetParagraphOrDefault(primarySelectedIndex);
if (primaryParagraph != null && !primaryParagraph.StartTime.IsMaxTime)
diff --git a/src/ui/Logic/DisplayableParagraphHelper.cs b/src/ui/Logic/DisplayableParagraphHelper.cs
new file mode 100644
index 0000000000..c224c4e0a7
--- /dev/null
+++ b/src/ui/Logic/DisplayableParagraphHelper.cs
@@ -0,0 +1,681 @@
+using Nikse.SubtitleEdit.Core.Common;
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+
+namespace Nikse.SubtitleEdit.Logic
+{
+ /**
+ *
+ *
+ * A class that helps determine which paragraphs should be displayed when there may be too many to
+ * efficiently render on the timeline at the same time.
+ *
+ *
+ * It assumes that:
+ *
+ * - It is good to select paragraphs slightly outside the visible area so that there is something to see while scrolling,
+ * but the timeline will be stationary most of the time, so it is better to select paragraphs that are currently visible.
+ * - It is more useful to have paragraphs that cover a large area of the timeline rather than a small area with 10 paragraphs
+ * layered on top of each other.
+ * - There are situtations where paragraphs may overlap, but it is useful to see most or all of them
+ * (such as dialogue shown at the same time as a paragraph shown next to text in the video).
+ * - More predictable behavior is better - pruning a large paragraph is more noticeable than pruning a small one, and the
+ * visible paragraphs should stay constant as much as possible while scrolling.
+ *
+ *
+ *
+ * Therefore, this class aims to maximize the amount of coverage of the timeline.
+ * Non-overlapping paragraphs are preferred first to prevent a stack of overlapping paragraphs with a large blank space.
+ * Paragraphs outside the visible area are only choosen once enough visible paragraphs have been chosen, to prevent a blank timeline.
+ * This class may select paragraphs that are very close together if all other preferred
+ * paragraphs have been chosen already.
+ *
+ *
+ */
+ public class DisplayableParagraphHelper
+ {
+ ///
+ /// The percentage of the visible timeline that must be covered by paragraphs before a paragraph outside the visible area may be chosen.
+ ///
+ /// Note that this is cumulative: two paragraphs stacked on top of each other count exactly the same as the same two paragraphs with no overlap.
+ ///
+ ///
+ private const double VisibleSelectionRequirement = 0.5;
+
+ ///
+ /// How many partitions to divide processed paragraphs into. This helps reduce the number of paragraphs processed each time
+ /// the cache needs to be updated. The number is somewhat arbitrary, with similar results obtained with both 100 and 1000
+ /// partitions (though performance was better with a larger partition count).
+ ///
+ private const int NumberOfPartitions = 500;
+
+ ///
+ /// Paragraphs that may be chosen when requested later.
+ ///
+ private readonly List _paragraphs = new List();
+
+ ///
+ /// The beginning of the invisible area that paragraphs may be chosen from to improve scrolling.
+ ///
+ private readonly double _startThresholdMilliseconds;
+ ///
+ /// The end of the invisible area of the timeline to consider.
+ ///
+ private readonly double _endThresholdMilliseconds;
+
+ ///
+ /// The beginning of the visible area of the timeline.
+ ///
+ private readonly double _startVisibleMilliseconds;
+ ///
+ /// The end of the visible area of the timeline.
+ ///
+ private readonly double _endVisibleMilliseconds;
+
+ ///
+ /// Creates a new displayable paragraph helper that will choose paragraphs between the start and end time, with some additional padding on either side.
+ ///
+ /// The start of the visible area of the timeline in milliseconds.
+ /// The end of the visible area of the timeline in milliseconds.
+ /// Additional time outside of the visible area to include, to improve rendering while scrolling.
+ public DisplayableParagraphHelper(double startMilliseconds, double endMilliseconds, double additionalMilliseconds)
+ {
+ _startThresholdMilliseconds = startMilliseconds - additionalMilliseconds;
+ _endThresholdMilliseconds = endMilliseconds + additionalMilliseconds;
+
+ _startVisibleMilliseconds = startMilliseconds;
+ _endVisibleMilliseconds = endMilliseconds;
+ }
+
+ ///
+ /// Adds a paragraph to the pool of available paragraphs the helper will choose from.
+ ///
+ ///
+ public void Add(Paragraph p)
+ {
+ if (IsInThreshold(p))
+ {
+ _paragraphs.Add(p);
+ }
+ }
+
+ ///
+ /// Gets up to a maximum number of paragraphs from the displayable paragraph helper. Paragraphs retrieved will provide
+ /// the best overall coverage of the range provided in the constructor, avoiding heavily layered areas until the rest
+ /// of the timeline is well covered.
+ ///
+ ///
+ ///
+ public List GetParagraphs(int limit)
+ {
+ if (limit >= _paragraphs.Count)
+ {
+ return _paragraphs;
+ }
+
+ // Ensure that longer paragraphs are preferred.
+ _paragraphs.Sort(new ParagraphComparer());
+
+ // Remember the average amount of paragraph overlap for the duration of each paragraph.
+ var coverageCache = new Dictionary();
+
+ // Improve efficiency of updating cache as the cache grows.
+ var cachedParagraphPartitions = new TimelineMap(
+ _startThresholdMilliseconds, _endThresholdMilliseconds, NumberOfPartitions);
+
+ // Remember how many layers of paragraphs exist at the start and end time of each processed paragraph.
+ // This needs to be a regular list because we need the BinarySearch method that returns the
+ // proper index for new items and we need to be able to iterate over subsequent entries.
+ // A SortedList doesn't allow this, and a dictionary is less efficient to iterate over.
+ var records = new List(limit * 2);
+
+ double totalParagraphCoverage = CalculateAverageParagraphCoverage();
+ var currentVisibleCoverage = 0d;
+ var lowestParagraphOverlap = 0d;
+
+ var result = new List();
+ while (result.Count < limit && _paragraphs.Count > 0)
+ {
+ int bestParagraphIndex = GetBestParagraphIndex(
+ _paragraphs, totalParagraphCoverage, currentVisibleCoverage, lowestParagraphOverlap, records,
+ coverageCache, cachedParagraphPartitions);
+
+ if (bestParagraphIndex == -1)
+ {
+ // This shouldn't happen, but choose the first paragraph just in case.
+ bestParagraphIndex = 0;
+ }
+ Paragraph selection = _paragraphs[bestParagraphIndex];
+ _paragraphs.RemoveAt(bestParagraphIndex);
+ lowestParagraphOverlap = coverageCache[selection];
+
+ result.Add(selection);
+ UpdateTimestampLayers(records, selection);
+ // Update running total of coverage when the paragraph is visible.
+ if (IsVisible(selection))
+ {
+ double coveragePercent = CalculateVisiblePercentOfTimeline(selection);
+ currentVisibleCoverage += coveragePercent;
+ }
+
+ // Update cache if this isn't the last loop.
+ if (result.Count < limit)
+ {
+ UpdateCacheForParagraph(selection, coverageCache, cachedParagraphPartitions);
+ }
+ }
+
+ return result;
+ }
+
+
+ ///
+ /// Determines whether the paragraph is in the visible area.
+ ///
+ ///
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsVisible(Paragraph p)
+ {
+ return IsInRange(p, _startVisibleMilliseconds, _endVisibleMilliseconds);
+ }
+
+ ///
+ /// Determines whether the paragraph is visible in the area just outside the visible area.
+ /// Note that a paragraph that passes this test may also be in the visible area, so
+ /// !IsInThreshold(p)
is not necessarily the same as IsVisible(p)
.
+ ///
+ ///
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsInThreshold(Paragraph p)
+ {
+ return IsInRange(p, _startThresholdMilliseconds, _endThresholdMilliseconds);
+ }
+
+ ///
+ /// Determines whether two paragraphs overlap.
+ ///
+ ///
+ ///
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool ParagraphsOverlap(Paragraph p1, Paragraph p2)
+ {
+ return IsInRange(p1, p2.StartTime.TotalMilliseconds, p2.EndTime.TotalMilliseconds);
+ }
+
+ ///
+ /// Determines whether any portion of a paragraph is within the start and end range.
+ ///
+ ///
+ /// Start time of range, in milliseconds.
+ /// End time of range, in milliseconds.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsInRange(Paragraph p, double startMilliseconds, double endMilliseconds)
+ {
+ return p.StartTime.TotalMilliseconds <= endMilliseconds && p.EndTime.TotalMilliseconds >= startMilliseconds;
+ }
+
+ ///
+ /// Calculates the average number of layers of paragraphs at any time in the visible portion of the timeline.
+ ///
+ /// For example, two paragraphs covering the left half of the timeline count the same as a single paragraph
+ /// covering the whole timeline. This has the benefit of being easier to calculate, and allows
+ /// coverage percentage to be built up over time (when selecting paragraphs).
+ ///
+ ///
+ private double CalculateAverageParagraphCoverage()
+ {
+ double average = 0;
+ foreach (Paragraph p in _paragraphs)
+ {
+ if (IsVisible(p) && p.DurationTotalMilliseconds > 0)
+ {
+ average += CalculateVisiblePercentOfTimeline(p);
+ }
+ }
+ return average;
+ }
+
+ ///
+ /// Calculates the percent of the visible timeline that a paragraph is visible.
+ /// If the paragraph is partially invisible, parts outside the visible area are not considered.
+ ///
+ ///
+ ///
+ private double CalculateVisiblePercentOfTimeline(Paragraph p)
+ {
+ double startClamped = Math.Max(p.StartTime.TotalMilliseconds, _startVisibleMilliseconds);
+ double endClamped = Math.Min(p.EndTime.TotalMilliseconds, _endVisibleMilliseconds);
+ return (endClamped - startClamped) / (_endVisibleMilliseconds - _startVisibleMilliseconds);
+ }
+
+ ///
+ /// Calculates the amount of paragraph coverage in the given range.
+ ///
+ /// Amount of coverage is defined as the number of layers of paragraphs times the duration of the overlap, divided by
+ /// the duration of the paragraph.
+ /// This is computed piecewise - a time range that contains a single paragraph for 1 millisecond and no paragraphs
+ /// for the other 1000 milliseconds will be very close to 0.
+ /// A time range half covered with one paragraph and half covered with two paragraphs will have a coverage of 1.5.
+ ///
+ ///
+ ///
+ /// An ordered list indicating how many paragraphs are visible at each location on the timeline.
+ /// Start of range, in milliseconds
+ /// End of range, in milliseconds
+ /// The average amount of paragraph coverage in the given range. This is a floating point number greater than or equal to 0.
+ private double CalculateCoverageInRange(List currentCoverage, double startMillis, double endMillis)
+ {
+
+ if (currentCoverage.Count == 0)
+ {
+ // There are no coverage records, so by default the answer is 0.
+ // Prevents array out-of-bounds exceptions as well.
+ return 0;
+ }
+
+ double previousTimestamp;
+ double previousNumberOfParagraphs;
+
+ // The sum of number of paragraphs times the duration of the overlap.
+ double weightedCoverage = 0;
+
+ var startRecord = new TimestampLayerEntry(startMillis);
+ int startIndex = currentCoverage.BinarySearch(startRecord, new TimestampEntryComparer());
+ if (startIndex < 0)
+ {
+ // Start of range has no record, need to build the information from the record we would have found.
+ startIndex = ~startIndex;
+ if (startIndex > 0)
+ {
+ if (startIndex >= currentCoverage.Count)
+ {
+ // The start index comes after all paragraphs have ended, so there can't be any coverage.
+ return 0;
+ }
+ // Any start record that would have existed at startIndex would have the same number of paragraphs
+ // as the previous record.
+ previousNumberOfParagraphs = currentCoverage[startIndex - 1].NumberOfParagraphs;
+ if (endMillis <= currentCoverage[startIndex].TimestampMillis)
+ {
+ // The start and end both happen before the same record. Average coverage over the entire range is trivial.
+ return previousNumberOfParagraphs;
+ }
+ weightedCoverage = previousNumberOfParagraphs * (currentCoverage[startIndex].TimestampMillis - startMillis);
+ previousNumberOfParagraphs = currentCoverage[startIndex].NumberOfParagraphs;
+ }
+ else
+ {
+ // startIndex is 0.
+ // The start range is before the first record - there cannot be any paragraph coverage yet.
+ previousNumberOfParagraphs = 0;
+ }
+ // We are guaranteed to have at least one item in the array because of the checks above.
+ previousTimestamp = currentCoverage[startIndex].TimestampMillis;
+ }
+ else
+ {
+ // The start timestamp matches an existing record, so there is no leading coverage to calculate.
+ if (startIndex >= currentCoverage.Count)
+ {
+ // We can't combine with the above check because building the previous record data is
+ // very different depending on the value of startIndex.
+ return 0;
+ }
+ TimestampLayerEntry previousRecord = currentCoverage[startIndex];
+ previousTimestamp = previousRecord.TimestampMillis;
+ previousNumberOfParagraphs = previousRecord.NumberOfParagraphs;
+ }
+
+
+ // Add weighted overlap for segments in the middle of the range.
+ if (startIndex < currentCoverage.Count - 1)
+ {
+ int currentIndex = startIndex + 1;
+ while (currentIndex < currentCoverage.Count && currentCoverage[currentIndex].TimestampMillis <= endMillis)
+ {
+ TimestampLayerEntry currentRecord = currentCoverage[currentIndex];
+ weightedCoverage += previousNumberOfParagraphs * (currentRecord.TimestampMillis - previousTimestamp);
+
+ previousTimestamp = currentRecord.TimestampMillis;
+ previousNumberOfParagraphs = currentRecord.NumberOfParagraphs;
+ currentIndex++;
+ }
+ }
+
+ if (previousTimestamp != endMillis)
+ {
+ // There was no record exactly matching the end range, so there was a little bit left over.
+ weightedCoverage += previousNumberOfParagraphs * (endMillis - previousTimestamp);
+ }
+
+ return weightedCoverage / (endMillis - startMillis);
+ }
+
+ ///
+ /// Chooses the best paragraph from the list of candidate paragraphs.
+ ///
+ /// The total coverage of the visible timeline, in order to prefer visible paragraphs
+ /// until the timeline has enough coverage.
+ /// The current coverage of the visible timeline. If this is high enough,
+ /// invisible paragraphs may be chosen.
+ /// The minimum amount of coverage in the timeline, so that this can exit early if such a minimum is found.
+ ///
+ ///
+ ///
+ ///
+ private int GetBestParagraphIndex(List candidates, double totalVisibleCoverage, double currentVisibleCoverage,
+ double coverageThreshold, List timestampLayerCounts, Dictionary coverageCache, TimelineMap partitions)
+ {
+ var currentMinimumCoverage = double.MaxValue;
+ var bestParagraphIndex = -1;
+
+ for (var i = 0; i < candidates.Count; i++)
+ {
+ Paragraph p = candidates[i];
+ bool paragraphVisible = IsVisible(p);
+ // Only consider visible paragraphs until a minimum portion of the visible area is covered.
+ if (currentVisibleCoverage > totalVisibleCoverage * VisibleSelectionRequirement || paragraphVisible)
+ {
+ if (!coverageCache.TryGetValue(p, out double existingCoverage))
+ {
+ existingCoverage = CalculateCoverageInRange(timestampLayerCounts, p.StartTime.TotalMilliseconds, p.EndTime.TotalMilliseconds);
+ coverageCache.Add(p, existingCoverage);
+ partitions.Add(p);
+ }
+ // A better paragraph has fewer paragraphs already in that location on the timeline.
+ if (existingCoverage < currentMinimumCoverage)
+ {
+ currentMinimumCoverage = existingCoverage;
+ bestParagraphIndex = i;
+ if (existingCoverage <= coverageThreshold)
+ {
+ return bestParagraphIndex;
+ }
+ }
+ }
+ }
+
+ return bestParagraphIndex;
+ }
+
+ ///
+ /// Given a paragraph that has just been selected, find cached coverage values for paragraphs that overlap with the
+ /// new paragraph and adjust the value.
+ ///
+ /// This is an O(1) operation per cache value, versus an O(n log n) operation to calculate from scratch.
+ /// As the cache fills up, the number of values to update increases, making the scan for overlapping paragraphs slower.
+ /// This is optimized by checking only those paragraphs from the same partition as the newly added paragraph. This
+ /// is a much smaller set than the set of all cached paragraphs.
+ ///
+ ///
+ ///
+ ///
+ ///
+ private void UpdateCacheForParagraph(Paragraph p, Dictionary coverageCache, TimelineMap partitions)
+ {
+ HashSet partitionedParagraphs = partitions.GetParagraphsNearParagraph(p);
+ // We don't want to update the cache entry for the selected paragraph on this iteration or any further iterations.
+ partitionedParagraphs.Remove(p);
+ coverageCache.Remove(p);
+
+ foreach (Paragraph key in partitionedParagraphs)
+ {
+ // The partition may contain paragraphs that have been selected and evicted from the cache,
+ // so this isn't guaranteed to exist.
+ if (ParagraphsOverlap(key, p) && coverageCache.TryGetValue(key, out double coverage))
+ {
+ double overlapMillis = CalculateOverlapLength(key, p);
+ coverageCache[key] = coverage + overlapMillis / key.DurationTotalMilliseconds;
+ }
+ }
+ }
+
+ private double CalculateOverlapLength(Paragraph p1, Paragraph p2)
+ {
+ double overlapStart = Math.Max(p1.StartTime.TotalMilliseconds, p2.StartTime.TotalMilliseconds);
+ double overlapEnd = Math.Min(p1.EndTime.TotalMilliseconds, p2.EndTime.TotalMilliseconds);
+ return overlapEnd - overlapStart;
+ }
+
+ ///
+ /// Updates the timestamp layer list, adding new entries for the paragraph in the correct location.
+ ///
+ ///
+ ///
+ private void UpdateTimestampLayers(List records, Paragraph addedParagraph)
+ {
+ int startIndex = CreateAndGetRecordIndex(records, addedParagraph.StartTime.TotalMilliseconds);
+ int endIndex = CreateAndGetRecordIndex(records, addedParagraph.EndTime.TotalMilliseconds);
+ for (var i = startIndex; i < endIndex; i++)
+ {
+ records[i].NumberOfParagraphs++;
+ }
+ }
+
+ ///
+ /// Creates a new TimestampLayerEntry at the given time if required and inserts into the layer entry list.
+ /// If a new entry was created, updates the number of paragraphs property to match the previous item.
+ ///
+ ///
+ ///
+ /// The index of the TimestampLayerEntry that corresponds to this timestamp, either an existing
+ /// entry or a newly created one.
+ private int CreateAndGetRecordIndex(List records, double timestamp)
+ {
+ TimestampLayerEntry newRecord = new TimestampLayerEntry(timestamp);
+
+ int recordIndex = records.BinarySearch(newRecord, new TimestampEntryComparer());
+ if (recordIndex < 0)
+ {
+ recordIndex = ~recordIndex;
+
+ if (recordIndex > 0)
+ {
+ // Carry over the overlap from the previous item to keep layers correct.
+ newRecord.NumberOfParagraphs = records[recordIndex - 1].NumberOfParagraphs;
+ }
+
+ records.Insert(recordIndex, newRecord);
+ }
+
+ return recordIndex;
+ }
+
+ ///
+ /// A class that stores the number of paragraph layers at an instant in time.
+ ///
+ private class TimestampLayerEntry
+ {
+ public double TimestampMillis { get; }
+ public int NumberOfParagraphs { get; set; }
+
+ public TimestampLayerEntry(double milliseconds)
+ {
+ TimestampMillis = milliseconds;
+ }
+
+ public override string ToString()
+ {
+ return $"Record - {TimestampMillis} millis / {NumberOfParagraphs} paragraphs";
+ }
+
+ }
+
+ ///
+ /// An IComparer that compares TimestampLayerEntry objects solely based on the timestamp.
+ ///
+ private class TimestampEntryComparer : IComparer
+ {
+ public int Compare(TimestampLayerEntry x, TimestampLayerEntry y)
+ {
+ return x.TimestampMillis.CompareTo(y.TimestampMillis);
+ }
+ }
+
+ ///
+ /// A comparer for paragraphs, prioritizing those that are:
+ ///
+ /// - longer
+ /// - Have a smaller (earlier) start time.
+ ///
+ ///
+ private class ParagraphComparer : IComparer
+ {
+ public int Compare(Paragraph x, Paragraph y)
+ {
+ // Calculation is (y.duration - x.duration) so that if x.duration is larger, the difference is < 0 and x comes first.
+ double lengthComparison = y.DurationTotalMilliseconds - x.DurationTotalMilliseconds;
+ if (lengthComparison > 0)
+ {
+ return 1;
+ }
+ else if (lengthComparison < 0)
+ {
+ return -1;
+ }
+
+ // Calculation is (x.start - y.start) so that if x comes first, difference is < 0.
+ return Math.Sign(x.StartTime.TotalMilliseconds - y.StartTime.TotalMilliseconds);
+ }
+ }
+
+ ///
+ /// A class that provides efficient access to a set of paragraphs near another another paragraph
+ /// by slicing up the timeline into equally sized partitions and grouping paragraphs into those partitions.
+ ///
+ /// This is helpful when scanning for collisions between a large number of paragraphs, limiting
+ /// the search to those that are nearby on the timeline.
+ ///
+ ///
+ private class TimelineMap
+ {
+
+ private readonly double _startMillis;
+ private readonly double _endMillis;
+ private readonly int _partitionCount;
+
+ private readonly double _partitionWidth;
+ private readonly HashSet[] _partitions;
+
+ public TimelineMap(double startMillis, double endMillis, int partitionCount)
+ {
+ _startMillis = startMillis;
+ _endMillis = endMillis;
+ _partitionCount = partitionCount;
+
+ _partitionWidth = (_endMillis - _startMillis) / _partitionCount;
+ _partitions = new HashSet[_partitionCount];
+ }
+
+ ///
+ /// Adds a paragraph to the set.
+ ///
+ ///
+ public void Add(Paragraph p)
+ {
+ PartitionRange insertRange = GetPartitionRange(p);
+ for (var i = insertRange.StartIndex; i <= insertRange.EndIndex; i++)
+ {
+ if (_partitions[i] == null)
+ {
+ _partitions[i] = new HashSet();
+ }
+ _partitions[i].Add(p);
+ }
+ }
+
+ ///
+ /// Gets the set of all paragraphs that are near the given paragraph.
+ ///
+ /// The paragraphs returned are not guaranteed to overlap the paragraph,
+ /// they instead occupy at least one slice of the timeline that the paragraph does.
+ ///
+ ///
+ ///
+ ///
+ public HashSet GetParagraphsNearParagraph(Paragraph p)
+ {
+ PartitionRange range = GetPartitionRange(p);
+ HashSet result = new HashSet();
+ for (var i = range.StartIndex; i <= range.EndIndex; i++)
+ {
+ HashSet partition = _partitions[i];
+ if (partition != null)
+ {
+ result.UnionWith(partition);
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// Gets a range of partiction indices that a paragraph occupies.
+ ///
+ ///
+ ///
+ private PartitionRange GetPartitionRange(Paragraph p)
+ {
+ int startPartition = GetPartitionNumber(p.StartTime.TotalMilliseconds, false);
+ int endPartition = GetPartitionNumber(p.EndTime.TotalMilliseconds, true);
+ return new PartitionRange(startPartition, endPartition);
+ }
+
+ ///
+ /// Calculates which partition a particular timestamp falls into, rounding up or down to
+ /// assign the correct index to the start or end of a time range.
+ ///
+ /// The index returned is guaranteed to be within the partition array.
+ ///
+ ///
+ /// The timestamp, in milliseconds.
+ ///
+ ///
+ private int GetPartitionNumber(double timestampMillis, bool roundUp)
+ {
+ double partitionNumberFraction = (timestampMillis - _startMillis) / _partitionWidth;
+ int partitionNumber;
+ if (roundUp)
+ {
+ partitionNumber = (int)Math.Ceiling(partitionNumberFraction);
+ }
+ else
+ {
+ partitionNumber = (int)Math.Floor(partitionNumberFraction);
+ }
+
+ if (partitionNumber < 0)
+ {
+ partitionNumber = 0;
+ }
+ else if (partitionNumber >= _partitionCount)
+ {
+ partitionNumber = _partitionCount - 1;
+ }
+
+ return partitionNumber;
+ }
+
+ ///
+ /// A range of partition indices that can be indexed in the partition array.
+ ///
+ private class PartitionRange
+ {
+ public PartitionRange(int startIndex, int endIndex)
+ {
+ StartIndex = startIndex;
+ EndIndex = endIndex;
+ }
+ public int StartIndex { get; }
+ public int EndIndex { get; }
+ }
+
+ }
+
+
+ }
+}
diff --git a/src/ui/SubtitleEdit.csproj b/src/ui/SubtitleEdit.csproj
index 5d06f7866f..07634a1675 100644
--- a/src/ui/SubtitleEdit.csproj
+++ b/src/ui/SubtitleEdit.csproj
@@ -1510,6 +1510,7 @@
+