From 2bd972f3a37be8d8573070ab32426cfe6b9caad0 Mon Sep 17 00:00:00 2001 From: ConfusedPolarBear <33811686+ConfusedPolarBear@users.noreply.github.com> Date: Wed, 2 Nov 2022 17:06:21 -0500 Subject: [PATCH] Add black frame analyzer --- .../TestBlackFrames.cs | 30 ++++- .../video/credits.mp4 | Bin 0 -> 242246 bytes .../Analyzers/BlackFrameAnalyzer.cs | 125 ++++++++++++++++++ .../FFmpegWrapper.cs | 22 ++- .../ScheduledTasks/DetectCreditsTask.cs | 52 +++++--- 5 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs index 794ecd1..bdd34e5 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs @@ -2,6 +2,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; using System; using System.Collections.Generic; +using Microsoft.Extensions.Logging; using Xunit; public class TestBlackFrames @@ -9,29 +10,42 @@ public class TestBlackFrames [FactSkipFFmpegTests] public void TestBlackFrameDetection() { + var range = 1e-5; + var expected = new List(); expected.AddRange(CreateFrameSequence(2, 3)); expected.AddRange(CreateFrameSequence(5, 6)); expected.AddRange(CreateFrameSequence(8, 9.96)); - var actual = FFmpegWrapper.DetectBlackFrames( - queueFile("rainbow.mp4"), - new TimeRange(0, 10) - ); + var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85); for (var i = 0; i < expected.Count; i++) { var (e, a) = (expected[i], actual[i]); Assert.Equal(e.Percentage, a.Percentage); - Assert.True(Math.Abs(e.Time - a.Time) <= 0.005); + Assert.InRange(a.Time, e.Time - range, e.Time + range); } } + [FactSkipFFmpegTests] + public void TestEndCreditDetection() + { + var analyzer = CreateBlackFrameAnalyzer(); + + var episode = queueFile("credits.mp4"); + episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds; + + var result = analyzer.AnalyzeMediaFile(episode, AnalysisMode.Credits, 85); + Assert.NotNull(result); + Assert.Equal(300, result.IntroStart); + } + private QueuedEpisode queueFile(string path) { return new() { EpisodeId = Guid.NewGuid(), + Name = path, Path = "../../../video/" + path }; } @@ -47,4 +61,10 @@ private BlackFrame[] CreateFrameSequence(double start, double end) return frames.ToArray(); } + + private BlackFrameAnalyzer CreateBlackFrameAnalyzer() + { + var logger = new LoggerFactory().CreateLogger(); + return new(logger); + } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/credits.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..c8fa8d277fb92ab65b493ba893208238f6caeb97 GIT binary patch literal 242246 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 literal 0 HcmV?d00001 diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs new file mode 100644 index 0000000..1c28c5f --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs @@ -0,0 +1,125 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using Microsoft.Extensions.Logging; + +/// +/// Media file analyzer used to detect end credits that consist of text overlaid on a black background. +/// Bisects the end of the video file to perform an efficient search. +/// +public class BlackFrameAnalyzer : IMediaFileAnalyzer +{ + private readonly TimeSpan _maximumError = new(0, 0, 4); + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public BlackFrameAnalyzer(ILogger logger) + { + _logger = logger; + } + + /// + public ReadOnlyCollection AnalyzeMediaFiles( + ReadOnlyCollection analysisQueue, + AnalysisMode mode, + CancellationToken cancellationToken) + { + if (mode != AnalysisMode.Credits) + { + throw new NotImplementedException("mode must equal Credits"); + } + + var creditTimes = new Dictionary(); + + foreach (var episode in analysisQueue) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var intro = AnalyzeMediaFile( + episode, + mode, + Plugin.Instance!.Configuration.BlackFrameMinimumPercentage); + + if (intro is null) + { + continue; + } + + creditTimes[episode.EpisodeId] = intro; + } + + Plugin.Instance!.UpdateTimestamps(creditTimes, mode); + + return analysisQueue + .Where(x => !creditTimes.ContainsKey(x.EpisodeId)) + .ToList() + .AsReadOnly(); + } + + /// + /// Analyzes an individual media file. Only public because of unit tests. + /// + /// Media file to analyze. + /// Analysis mode. + /// Percentage of the frame that must be black. + /// Credits timestamp. + public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum) + { + // Start by analyzing the last four minutes of the file. + var start = TimeSpan.FromMinutes(4); + var end = TimeSpan.Zero; + var firstFrameTime = 0.0; + + // Continue bisecting the end of the file until the range that contains the first black + // frame is smaller than the maximum permitted error. + while (start - end > _maximumError) + { + // Analyze the middle two seconds from the current bisected range + var midpoint = (start + end) / 2; + var scanTime = episode.Duration - midpoint.TotalSeconds; + var tr = new TimeRange(scanTime, scanTime + 2); + + _logger.LogTrace( + "{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]", + episode.Name, + episode.Duration, + start, + end, + tr.Start, + tr.End); + + var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum); + _logger.LogTrace("{Episode}, black frames: {Count}", episode.Name, frames.Length); + + if (frames.Length == 0) + { + // Since no black frames were found, slide the range closer to the end + start = midpoint; + } + else + { + // Some black frames were found, slide the range closer to the start + end = midpoint; + firstFrameTime = frames[0].Time + scanTime; + } + } + + if (firstFrameTime > 0) + { + return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration)); + } + + return null; + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs index e8ba464..1cb2c4d 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs @@ -222,8 +222,12 @@ public static TimeRange[] DetectSilence(QueuedEpisode episode, int limit) /// /// Media file to analyze. /// Time range to search. - /// Array of frames that are at least 50% black. - public static BlackFrame[] DetectBlackFrames(QueuedEpisode episode, TimeRange range) + /// Percentage of the frame that must be black. + /// Array of frames that are mostly black. + public static BlackFrame[] DetectBlackFrames( + QueuedEpisode episode, + TimeRange range, + int minimum) { // Seek to the start of the time range and find frames that are at least 50% black. var args = string.Format( @@ -233,10 +237,10 @@ public static BlackFrame[] DetectBlackFrames(QueuedEpisode episode, TimeRange ra episode.Path, range.End - range.Start); - // Cache the results to GUID-blackframes-v1-START-END. + // Cache the results to GUID-blackframes-START-END-v1. var cacheKey = string.Format( CultureInfo.InvariantCulture, - "{0}-blackframes-v1-{1}-{2}", + "{0}-blackframes-{1}-{2}-v1", episode.EpisodeId.ToString("N"), range.Start, range.End); @@ -263,10 +267,14 @@ public static BlackFrame[] DetectBlackFrames(QueuedEpisode episode, TimeRange ra matches[1].Value.Split(':')[1] ); - blackFrames.Add(new( + var bf = new BlackFrame( Convert.ToInt32(strPercent, CultureInfo.InvariantCulture), - Convert.ToDouble(strTime, CultureInfo.InvariantCulture) - )); + Convert.ToDouble(strTime, CultureInfo.InvariantCulture)); + + if (bf.Percentage > minimum) + { + blackFrames.Add(bf); + } } return blackFrames.ToArray(); diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs index 224155f..d4da24e 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs @@ -122,10 +122,8 @@ public Task ExecuteAsync(IProgress progress, CancellationToken cancellat return; } - // Increment totalProcessed by the number of episodes in this season that were actually analyzed - // (instead of just using the number of episodes in the current season). - var analyzed = AnalyzeSeason(episodes, cancellationToken); - Interlocked.Add(ref totalProcessed, analyzed); + AnalyzeSeason(episodes, cancellationToken); + Interlocked.Add(ref totalProcessed, episodes.Count); } catch (FingerprintException ex) { @@ -151,39 +149,49 @@ public Task ExecuteAsync(IProgress progress, CancellationToken cancellat } /// - /// Fingerprints all episodes in the provided season and stores the timestamps of all introductions. + /// Analyzes all episodes in the season for end credits. /// /// Episodes in this season. /// Cancellation token provided by the scheduled task. - /// Number of episodes from the provided season that were analyzed. - private int AnalyzeSeason( + private void AnalyzeSeason( ReadOnlyCollection episodes, CancellationToken cancellationToken) { - // Skip seasons with an insufficient number of episodes. - if (episodes.Count <= 1) + // Only analyze specials (season 0) if the user has opted in. + if (episodes[0].SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero) { - return episodes.Count; + return; } - // Only analyze specials (season 0) if the user has opted in. - var first = episodes[0]; - if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero) + // Analyze with Chromaprint first and fall back to the black frame detector + var analyzers = new IMediaFileAnalyzer[] + { + // TODO: FIXME: new ChromaprintAnalyzer(_loggerFactory.CreateLogger()), + new BlackFrameAnalyzer(_loggerFactory.CreateLogger()) + }; + + // Use each analyzer to find credits in all media files, removing successfully analyzed files + // from the queue. + var remaining = new ReadOnlyCollection(episodes); + foreach (var analyzer in analyzers) { - return 0; + remaining = AnalyzeFiles(remaining, analyzer, cancellationToken); } + } + private ReadOnlyCollection AnalyzeFiles( + ReadOnlyCollection episodes, + IMediaFileAnalyzer analyzer, + CancellationToken cancellationToken) + { _logger.LogInformation( - "Analyzing {Count} episodes from {Name} season {Season}", + "Analyzing {Count} episodes from {Name} season {Season} with {Analyzer}", episodes.Count, - first.SeriesName, - first.SeasonNumber); - - // Analyze the season with Chromaprint - var chromaprint = new ChromaprintAnalyzer(_loggerFactory.CreateLogger()); - chromaprint.AnalyzeMediaFiles(episodes, AnalysisMode.Credits, cancellationToken); + episodes[0].SeriesName, + episodes[0].SeasonNumber, + analyzer.GetType().Name); - return episodes.Count; + return analyzer.AnalyzeMediaFiles(episodes, AnalysisMode.Credits, cancellationToken); } ///