2024-10-25 14:31:50 -04:00
|
|
|
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
|
|
|
|
// SPDX-License-Identifier: GPL-3.0-only.
|
2024-10-25 14:15:12 -04:00
|
|
|
|
2022-11-02 17:06:21 -05:00
|
|
|
using System;
|
|
|
|
using System.Collections.Generic;
|
|
|
|
using System.Linq;
|
|
|
|
using System.Threading;
|
2024-11-02 18:17:22 +01:00
|
|
|
using System.Threading.Tasks;
|
2024-10-19 23:50:41 +02:00
|
|
|
using IntroSkipper.Configuration;
|
|
|
|
using IntroSkipper.Data;
|
2022-11-02 17:06:21 -05:00
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
2024-10-19 23:50:41 +02:00
|
|
|
namespace IntroSkipper.Analyzers;
|
2024-04-20 12:58:29 +02:00
|
|
|
|
2022-11-02 17:06:21 -05:00
|
|
|
/// <summary>
|
|
|
|
/// 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.
|
|
|
|
/// </summary>
|
2024-10-31 11:58:56 +01:00
|
|
|
public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFileAnalyzer
|
2022-11-02 17:06:21 -05:00
|
|
|
{
|
2024-10-31 11:58:56 +01:00
|
|
|
private static readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
2022-11-02 17:06:21 -05:00
|
|
|
private readonly TimeSpan _maximumError = new(0, 0, 4);
|
2024-10-31 11:58:56 +01:00
|
|
|
private readonly ILogger<BlackFrameAnalyzer> _logger = logger;
|
|
|
|
private readonly int _minimumCreditsDuration = _config.MinimumCreditsDuration;
|
|
|
|
private readonly int _maximumCreditsDuration = _config.MaximumCreditsDuration;
|
|
|
|
private readonly int _maximumMovieCreditsDuration = _config.MaximumMovieCreditsDuration;
|
|
|
|
private readonly int _blackFrameMinimumPercentage = _config.BlackFrameMinimumPercentage;
|
2022-11-02 17:06:21 -05:00
|
|
|
|
|
|
|
/// <inheritdoc />
|
2024-11-02 18:17:22 +01:00
|
|
|
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
|
2024-10-05 19:30:30 +02:00
|
|
|
IReadOnlyList<QueuedEpisode> analysisQueue,
|
2024-10-16 16:05:59 +02:00
|
|
|
AnalysisMode mode,
|
2022-11-02 17:06:21 -05:00
|
|
|
CancellationToken cancellationToken)
|
|
|
|
{
|
2024-10-16 16:05:59 +02:00
|
|
|
if (mode != AnalysisMode.Credits)
|
2022-11-02 17:06:21 -05:00
|
|
|
{
|
|
|
|
throw new NotImplementedException("mode must equal Credits");
|
|
|
|
}
|
|
|
|
|
2024-11-06 10:30:00 +01:00
|
|
|
var creditTimes = new List<Segment>();
|
2024-06-15 13:16:47 +02:00
|
|
|
|
2024-05-01 11:13:13 +02:00
|
|
|
bool isFirstEpisode = true;
|
|
|
|
|
2024-09-10 18:08:42 +02:00
|
|
|
double searchStart = _minimumCreditsDuration;
|
2024-05-01 11:13:13 +02:00
|
|
|
|
2024-09-10 18:08:42 +02:00
|
|
|
var searchDistance = 2 * _minimumCreditsDuration;
|
2024-05-01 11:13:13 +02:00
|
|
|
|
2024-11-06 10:30:00 +01:00
|
|
|
foreach (var episode in analysisQueue.Where(e => !e.GetAnalyzed(mode)))
|
2022-11-02 17:06:21 -05:00
|
|
|
{
|
|
|
|
if (cancellationToken.IsCancellationRequested)
|
|
|
|
{
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
2024-10-18 14:15:09 +02:00
|
|
|
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
|
|
|
|
|
|
|
|
var chapters = Plugin.Instance!.GetChapters(episode.EpisodeId);
|
|
|
|
var lastSuitableChapter = chapters.LastOrDefault(c =>
|
|
|
|
{
|
|
|
|
var start = TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds;
|
|
|
|
return start >= _minimumCreditsDuration && start <= creditDuration;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (lastSuitableChapter is not null)
|
|
|
|
{
|
|
|
|
searchStart = TimeSpan.FromTicks(lastSuitableChapter.StartPositionTicks).TotalSeconds;
|
|
|
|
isFirstEpisode = false;
|
|
|
|
}
|
|
|
|
|
2024-05-01 11:13:13 +02:00
|
|
|
if (isFirstEpisode)
|
|
|
|
{
|
|
|
|
var scanTime = episode.Duration - searchStart;
|
|
|
|
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
|
|
|
|
|
2024-09-10 18:08:42 +02:00
|
|
|
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
|
2024-05-01 11:13:13 +02:00
|
|
|
|
|
|
|
while (frames.Length > 0) // While black frames are found increase searchStart
|
|
|
|
{
|
|
|
|
searchStart += searchDistance;
|
|
|
|
|
|
|
|
scanTime = episode.Duration - searchStart;
|
|
|
|
tr = new TimeRange(scanTime - 0.5, scanTime);
|
|
|
|
|
2024-09-10 18:08:42 +02:00
|
|
|
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
|
2024-05-01 11:13:13 +02:00
|
|
|
|
2024-10-18 14:15:09 +02:00
|
|
|
if (searchStart > creditDuration)
|
2024-05-01 11:13:13 +02:00
|
|
|
{
|
2024-10-18 14:15:09 +02:00
|
|
|
searchStart = creditDuration;
|
2024-05-01 11:13:13 +02:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-09-10 18:08:42 +02:00
|
|
|
if (searchStart == _minimumCreditsDuration) // Skip if no black frames were found
|
2024-05-01 11:13:13 +02:00
|
|
|
{
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
isFirstEpisode = false;
|
|
|
|
}
|
|
|
|
|
2024-06-15 13:16:47 +02:00
|
|
|
var credit = AnalyzeMediaFile(
|
2022-11-02 17:06:21 -05:00
|
|
|
episode,
|
2024-05-01 11:13:13 +02:00
|
|
|
searchStart,
|
|
|
|
searchDistance,
|
2024-09-10 18:08:42 +02:00
|
|
|
_blackFrameMinimumPercentage);
|
2022-11-02 17:06:21 -05:00
|
|
|
|
2024-10-20 13:35:33 +02:00
|
|
|
if (credit is null || !credit.Valid)
|
2022-11-02 17:06:21 -05:00
|
|
|
{
|
2024-05-01 11:13:13 +02:00
|
|
|
// If no credits were found, reset the first-episode search logic for the next episode in the sequence.
|
2024-09-10 18:08:42 +02:00
|
|
|
searchStart = _minimumCreditsDuration;
|
2024-05-01 11:13:13 +02:00
|
|
|
isFirstEpisode = true;
|
2022-11-02 17:06:21 -05:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2024-09-12 08:37:47 +00:00
|
|
|
searchStart = episode.Duration - credit.Start + (0.5 * searchDistance);
|
2024-05-01 11:13:13 +02:00
|
|
|
|
2024-11-06 10:30:00 +01:00
|
|
|
creditTimes.Add(credit);
|
2024-11-02 18:17:22 +01:00
|
|
|
episode.SetAnalyzed(mode, true);
|
2022-11-02 17:06:21 -05:00
|
|
|
}
|
|
|
|
|
2024-08-31 19:32:37 +02:00
|
|
|
var analyzerHelper = new AnalyzerHelper(_logger);
|
2024-11-06 10:30:00 +01:00
|
|
|
var adjustedCreditTimes = analyzerHelper.AdjustIntroTimes(analysisQueue, creditTimes, mode);
|
2024-08-31 19:32:37 +02:00
|
|
|
|
2024-11-06 10:30:00 +01:00
|
|
|
await Plugin.Instance!.UpdateTimestamps(adjustedCreditTimes, mode).ConfigureAwait(false);
|
2022-11-02 17:06:21 -05:00
|
|
|
|
2024-11-06 10:30:00 +01:00
|
|
|
return analysisQueue;
|
2022-11-02 17:06:21 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
/// Analyzes an individual media file. Only public because of unit tests.
|
|
|
|
/// </summary>
|
|
|
|
/// <param name="episode">Media file to analyze.</param>
|
2024-05-01 11:13:13 +02:00
|
|
|
/// <param name="searchStart">Search Start Piont.</param>
|
|
|
|
/// <param name="searchDistance">Search Distance.</param>
|
2022-11-02 17:06:21 -05:00
|
|
|
/// <param name="minimum">Percentage of the frame that must be black.</param>
|
|
|
|
/// <returns>Credits timestamp.</returns>
|
2024-09-12 08:37:47 +00:00
|
|
|
public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum)
|
2022-11-02 17:06:21 -05:00
|
|
|
{
|
2022-11-26 02:28:40 -06:00
|
|
|
// Start by analyzing the last N minutes of the file.
|
2024-05-01 11:13:13 +02:00
|
|
|
var upperLimit = searchStart;
|
2024-09-10 18:08:42 +02:00
|
|
|
var lowerLimit = Math.Max(searchStart - searchDistance, _minimumCreditsDuration);
|
2024-05-01 11:13:13 +02:00
|
|
|
var start = TimeSpan.FromSeconds(upperLimit);
|
|
|
|
var end = TimeSpan.FromSeconds(lowerLimit);
|
2022-11-02 17:06:21 -05:00
|
|
|
var firstFrameTime = 0.0;
|
|
|
|
|
2024-10-18 14:15:09 +02:00
|
|
|
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
|
|
|
|
|
2022-11-02 17:06:21 -05:00
|
|
|
// 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);
|
2022-11-26 02:28:40 -06:00
|
|
|
_logger.LogTrace(
|
|
|
|
"{Episode} at {Start} has {Count} black frames",
|
|
|
|
episode.Name,
|
|
|
|
tr.Start,
|
|
|
|
frames.Length);
|
2022-11-02 17:06:21 -05:00
|
|
|
|
|
|
|
if (frames.Length == 0)
|
|
|
|
{
|
|
|
|
// Since no black frames were found, slide the range closer to the end
|
2024-05-10 14:05:59 +02:00
|
|
|
start = midpoint - TimeSpan.FromSeconds(2);
|
2024-05-01 11:13:13 +02:00
|
|
|
|
|
|
|
if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError)
|
|
|
|
{
|
2024-09-10 18:08:42 +02:00
|
|
|
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _minimumCreditsDuration);
|
2024-05-01 11:13:13 +02:00
|
|
|
|
|
|
|
// Reset end for a new search with the increased duration
|
|
|
|
end = TimeSpan.FromSeconds(lowerLimit);
|
|
|
|
}
|
2022-11-02 17:06:21 -05:00
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
// Some black frames were found, slide the range closer to the start
|
|
|
|
end = midpoint;
|
|
|
|
firstFrameTime = frames[0].Time + scanTime;
|
2024-05-01 11:13:13 +02:00
|
|
|
|
|
|
|
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
|
|
|
|
{
|
2024-10-18 14:15:09 +02:00
|
|
|
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), creditDuration);
|
2024-05-01 11:13:13 +02:00
|
|
|
|
|
|
|
// Reset start for a new search with the increased duration
|
|
|
|
start = TimeSpan.FromSeconds(upperLimit);
|
|
|
|
}
|
2022-11-02 17:06:21 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (firstFrameTime > 0)
|
|
|
|
{
|
|
|
|
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|