// Copyright (C) 2024 Intro-Skipper contributors // SPDX-License-Identifier: GPL-3.0-only. using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using IntroSkipper.Configuration; using IntroSkipper.Data; using Microsoft.Extensions.Logging; namespace IntroSkipper.Analyzers; /// /// 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(ILogger logger) : IMediaFileAnalyzer { private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); private readonly TimeSpan _maximumError = new(0, 0, 4); private readonly ILogger _logger = logger; /// public async Task> AnalyzeMediaFiles( IReadOnlyList analysisQueue, AnalysisMode mode, CancellationToken cancellationToken) { if (mode != AnalysisMode.Credits) { throw new NotImplementedException("mode must equal Credits"); } var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList(); var searchStart = 0.0; foreach (var episode in episodesWithoutIntros) { if (cancellationToken.IsCancellationRequested) { break; } if (!AnalyzeChapters(episode, out var credit)) { if (searchStart < _config.MinimumCreditsDuration) { searchStart = FindSearchStart(episode); } credit = AnalyzeMediaFile( episode, searchStart, _config.BlackFrameMinimumPercentage); } if (credit is null || !credit.Valid) { continue; } episode.IsAnalyzed = true; await Plugin.Instance!.UpdateTimestampAsync(credit, mode).ConfigureAwait(false); searchStart = episode.Duration - credit.Start + _config.MinimumCreditsDuration; } return analysisQueue; } /// /// Analyzes an individual media file. Only public because of unit tests. /// /// Media file to analyze. /// Search Start Piont. /// Percentage of the frame that must be black. /// Credits timestamp. public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int minimum) { // Start by analyzing the last N minutes of the file. var searchDistance = 2 * _config.MinimumCreditsDuration; var upperLimit = searchStart; var lowerLimit = Math.Max(searchStart - searchDistance, _config.MinimumCreditsDuration); var start = TimeSpan.FromSeconds(upperLimit); var end = TimeSpan.FromSeconds(lowerLimit); 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} at {Start} has {Count} black frames", episode.Name, tr.Start, frames.Length); if (frames.Length == 0) { // Since no black frames were found, slide the range closer to the end start = midpoint - TimeSpan.FromSeconds(2); if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError) { lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _config.MinimumCreditsDuration); // Reset end for a new search with the increased duration end = TimeSpan.FromSeconds(lowerLimit); } } else { // Some black frames were found, slide the range closer to the start end = midpoint; firstFrameTime = frames[0].Time + scanTime; if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError) { upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), episode.Duration - episode.CreditsFingerprintStart); // Reset start for a new search with the increased duration start = TimeSpan.FromSeconds(upperLimit); } } } if (firstFrameTime > 0) { return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration)); } return null; } private bool AnalyzeChapters(QueuedEpisode episode, out Segment? segment) { // Get last chapter that falls within the valid credits duration range var suitableChapters = Plugin.Instance!.GetChapters(episode.EpisodeId) .Select(c => TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds) .Where(s => s >= episode.CreditsFingerprintStart && s <= episode.Duration - _config.MinimumCreditsDuration) .OrderByDescending(s => s).ToList(); // If suitable chapters found, use them to find the search start point foreach (var chapterStart in suitableChapters) { // Check for black frames at chapter start var startRange = new TimeRange(chapterStart, chapterStart + 1); var hasBlackFramesAtStart = FFmpegWrapper.DetectBlackFrames( episode, startRange, _config.BlackFrameMinimumPercentage).Length > 0; if (!hasBlackFramesAtStart) { break; } // Verify no black frames before chapter start var beforeRange = new TimeRange(chapterStart - 5, chapterStart - 4); var hasBlackFramesBefore = FFmpegWrapper.DetectBlackFrames( episode, beforeRange, _config.BlackFrameMinimumPercentage).Length > 0; if (!hasBlackFramesBefore) { segment = new(episode.EpisodeId, new TimeRange(chapterStart, episode.Duration)); return true; } } segment = null; return false; } private double FindSearchStart(QueuedEpisode episode) { var searchStart = 3 * _config.MinimumCreditsDuration; var scanTime = episode.Duration - searchStart; var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here. // Keep increasing search start time while black frames are found, to avoid false positives while (FFmpegWrapper.DetectBlackFrames(episode, tr, _config.BlackFrameMinimumPercentage).Length > 0) { // Increase by 2x minimum credits duration each iteration searchStart += 2 * _config.MinimumCreditsDuration; scanTime = episode.Duration - searchStart; tr = new TimeRange(scanTime - 0.5, scanTime); // Don't search past the required credits duration from the end if (searchStart > episode.Duration - episode.CreditsFingerprintStart) { searchStart = episode.Duration - episode.CreditsFingerprintStart; break; } } return searchStart; } }