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 @@ +