Skip to content
This repository was archived by the owner on Sep 25, 2023. It is now read-only.

Commit 2bd972f

Browse files
Add black frame analyzer
1 parent ce3e1a5 commit 2bd972f

File tree

5 files changed

+195
-34
lines changed

5 files changed

+195
-34
lines changed

ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs

+25-5
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,50 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
22

33
using System;
44
using System.Collections.Generic;
5+
using Microsoft.Extensions.Logging;
56
using Xunit;
67

78
public class TestBlackFrames
89
{
910
[FactSkipFFmpegTests]
1011
public void TestBlackFrameDetection()
1112
{
13+
var range = 1e-5;
14+
1215
var expected = new List<BlackFrame>();
1316
expected.AddRange(CreateFrameSequence(2, 3));
1417
expected.AddRange(CreateFrameSequence(5, 6));
1518
expected.AddRange(CreateFrameSequence(8, 9.96));
1619

17-
var actual = FFmpegWrapper.DetectBlackFrames(
18-
queueFile("rainbow.mp4"),
19-
new TimeRange(0, 10)
20-
);
20+
var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85);
2121

2222
for (var i = 0; i < expected.Count; i++)
2323
{
2424
var (e, a) = (expected[i], actual[i]);
2525
Assert.Equal(e.Percentage, a.Percentage);
26-
Assert.True(Math.Abs(e.Time - a.Time) <= 0.005);
26+
Assert.InRange(a.Time, e.Time - range, e.Time + range);
2727
}
2828
}
2929

30+
[FactSkipFFmpegTests]
31+
public void TestEndCreditDetection()
32+
{
33+
var analyzer = CreateBlackFrameAnalyzer();
34+
35+
var episode = queueFile("credits.mp4");
36+
episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
37+
38+
var result = analyzer.AnalyzeMediaFile(episode, AnalysisMode.Credits, 85);
39+
Assert.NotNull(result);
40+
Assert.Equal(300, result.IntroStart);
41+
}
42+
3043
private QueuedEpisode queueFile(string path)
3144
{
3245
return new()
3346
{
3447
EpisodeId = Guid.NewGuid(),
48+
Name = path,
3549
Path = "../../../video/" + path
3650
};
3751
}
@@ -47,4 +61,10 @@ private BlackFrame[] CreateFrameSequence(double start, double end)
4761

4862
return frames.ToArray();
4963
}
64+
65+
private BlackFrameAnalyzer CreateBlackFrameAnalyzer()
66+
{
67+
var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>();
68+
return new(logger);
69+
}
5070
}
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
namespace ConfusedPolarBear.Plugin.IntroSkipper;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Collections.ObjectModel;
6+
using System.Linq;
7+
using System.Threading;
8+
using Microsoft.Extensions.Logging;
9+
10+
/// <summary>
11+
/// Media file analyzer used to detect end credits that consist of text overlaid on a black background.
12+
/// Bisects the end of the video file to perform an efficient search.
13+
/// </summary>
14+
public class BlackFrameAnalyzer : IMediaFileAnalyzer
15+
{
16+
private readonly TimeSpan _maximumError = new(0, 0, 4);
17+
18+
private readonly ILogger<BlackFrameAnalyzer> _logger;
19+
20+
/// <summary>
21+
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
22+
/// </summary>
23+
/// <param name="logger">Logger.</param>
24+
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger)
25+
{
26+
_logger = logger;
27+
}
28+
29+
/// <inheritdoc />
30+
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
31+
ReadOnlyCollection<QueuedEpisode> analysisQueue,
32+
AnalysisMode mode,
33+
CancellationToken cancellationToken)
34+
{
35+
if (mode != AnalysisMode.Credits)
36+
{
37+
throw new NotImplementedException("mode must equal Credits");
38+
}
39+
40+
var creditTimes = new Dictionary<Guid, Intro>();
41+
42+
foreach (var episode in analysisQueue)
43+
{
44+
if (cancellationToken.IsCancellationRequested)
45+
{
46+
break;
47+
}
48+
49+
var intro = AnalyzeMediaFile(
50+
episode,
51+
mode,
52+
Plugin.Instance!.Configuration.BlackFrameMinimumPercentage);
53+
54+
if (intro is null)
55+
{
56+
continue;
57+
}
58+
59+
creditTimes[episode.EpisodeId] = intro;
60+
}
61+
62+
Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
63+
64+
return analysisQueue
65+
.Where(x => !creditTimes.ContainsKey(x.EpisodeId))
66+
.ToList()
67+
.AsReadOnly();
68+
}
69+
70+
/// <summary>
71+
/// Analyzes an individual media file. Only public because of unit tests.
72+
/// </summary>
73+
/// <param name="episode">Media file to analyze.</param>
74+
/// <param name="mode">Analysis mode.</param>
75+
/// <param name="minimum">Percentage of the frame that must be black.</param>
76+
/// <returns>Credits timestamp.</returns>
77+
public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum)
78+
{
79+
// Start by analyzing the last four minutes of the file.
80+
var start = TimeSpan.FromMinutes(4);
81+
var end = TimeSpan.Zero;
82+
var firstFrameTime = 0.0;
83+
84+
// Continue bisecting the end of the file until the range that contains the first black
85+
// frame is smaller than the maximum permitted error.
86+
while (start - end > _maximumError)
87+
{
88+
// Analyze the middle two seconds from the current bisected range
89+
var midpoint = (start + end) / 2;
90+
var scanTime = episode.Duration - midpoint.TotalSeconds;
91+
var tr = new TimeRange(scanTime, scanTime + 2);
92+
93+
_logger.LogTrace(
94+
"{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
95+
episode.Name,
96+
episode.Duration,
97+
start,
98+
end,
99+
tr.Start,
100+
tr.End);
101+
102+
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
103+
_logger.LogTrace("{Episode}, black frames: {Count}", episode.Name, frames.Length);
104+
105+
if (frames.Length == 0)
106+
{
107+
// Since no black frames were found, slide the range closer to the end
108+
start = midpoint;
109+
}
110+
else
111+
{
112+
// Some black frames were found, slide the range closer to the start
113+
end = midpoint;
114+
firstFrameTime = frames[0].Time + scanTime;
115+
}
116+
}
117+
118+
if (firstFrameTime > 0)
119+
{
120+
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
121+
}
122+
123+
return null;
124+
}
125+
}

ConfusedPolarBear.Plugin.IntroSkipper/FFmpegWrapper.cs

+15-7
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,12 @@ public static TimeRange[] DetectSilence(QueuedEpisode episode, int limit)
222222
/// </summary>
223223
/// <param name="episode">Media file to analyze.</param>
224224
/// <param name="range">Time range to search.</param>
225-
/// <returns>Array of frames that are at least 50% black.</returns>
226-
public static BlackFrame[] DetectBlackFrames(QueuedEpisode episode, TimeRange range)
225+
/// <param name="minimum">Percentage of the frame that must be black.</param>
226+
/// <returns>Array of frames that are mostly black.</returns>
227+
public static BlackFrame[] DetectBlackFrames(
228+
QueuedEpisode episode,
229+
TimeRange range,
230+
int minimum)
227231
{
228232
// Seek to the start of the time range and find frames that are at least 50% black.
229233
var args = string.Format(
@@ -233,10 +237,10 @@ public static BlackFrame[] DetectBlackFrames(QueuedEpisode episode, TimeRange ra
233237
episode.Path,
234238
range.End - range.Start);
235239

236-
// Cache the results to GUID-blackframes-v1-START-END.
240+
// Cache the results to GUID-blackframes-START-END-v1.
237241
var cacheKey = string.Format(
238242
CultureInfo.InvariantCulture,
239-
"{0}-blackframes-v1-{1}-{2}",
243+
"{0}-blackframes-{1}-{2}-v1",
240244
episode.EpisodeId.ToString("N"),
241245
range.Start,
242246
range.End);
@@ -263,10 +267,14 @@ public static BlackFrame[] DetectBlackFrames(QueuedEpisode episode, TimeRange ra
263267
matches[1].Value.Split(':')[1]
264268
);
265269

266-
blackFrames.Add(new(
270+
var bf = new BlackFrame(
267271
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
268-
Convert.ToDouble(strTime, CultureInfo.InvariantCulture)
269-
));
272+
Convert.ToDouble(strTime, CultureInfo.InvariantCulture));
273+
274+
if (bf.Percentage > minimum)
275+
{
276+
blackFrames.Add(bf);
277+
}
270278
}
271279

272280
return blackFrames.ToArray();

ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/DetectCreditsTask.cs

+30-22
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,8 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat
122122
return;
123123
}
124124

125-
// Increment totalProcessed by the number of episodes in this season that were actually analyzed
126-
// (instead of just using the number of episodes in the current season).
127-
var analyzed = AnalyzeSeason(episodes, cancellationToken);
128-
Interlocked.Add(ref totalProcessed, analyzed);
125+
AnalyzeSeason(episodes, cancellationToken);
126+
Interlocked.Add(ref totalProcessed, episodes.Count);
129127
}
130128
catch (FingerprintException ex)
131129
{
@@ -151,39 +149,49 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat
151149
}
152150

153151
/// <summary>
154-
/// Fingerprints all episodes in the provided season and stores the timestamps of all introductions.
152+
/// Analyzes all episodes in the season for end credits.
155153
/// </summary>
156154
/// <param name="episodes">Episodes in this season.</param>
157155
/// <param name="cancellationToken">Cancellation token provided by the scheduled task.</param>
158-
/// <returns>Number of episodes from the provided season that were analyzed.</returns>
159-
private int AnalyzeSeason(
156+
private void AnalyzeSeason(
160157
ReadOnlyCollection<QueuedEpisode> episodes,
161158
CancellationToken cancellationToken)
162159
{
163-
// Skip seasons with an insufficient number of episodes.
164-
if (episodes.Count <= 1)
160+
// Only analyze specials (season 0) if the user has opted in.
161+
if (episodes[0].SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
165162
{
166-
return episodes.Count;
163+
return;
167164
}
168165

169-
// Only analyze specials (season 0) if the user has opted in.
170-
var first = episodes[0];
171-
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
166+
// Analyze with Chromaprint first and fall back to the black frame detector
167+
var analyzers = new IMediaFileAnalyzer[]
168+
{
169+
// TODO: FIXME: new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()),
170+
new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>())
171+
};
172+
173+
// Use each analyzer to find credits in all media files, removing successfully analyzed files
174+
// from the queue.
175+
var remaining = new ReadOnlyCollection<QueuedEpisode>(episodes);
176+
foreach (var analyzer in analyzers)
172177
{
173-
return 0;
178+
remaining = AnalyzeFiles(remaining, analyzer, cancellationToken);
174179
}
180+
}
175181

182+
private ReadOnlyCollection<QueuedEpisode> AnalyzeFiles(
183+
ReadOnlyCollection<QueuedEpisode> episodes,
184+
IMediaFileAnalyzer analyzer,
185+
CancellationToken cancellationToken)
186+
{
176187
_logger.LogInformation(
177-
"Analyzing {Count} episodes from {Name} season {Season}",
188+
"Analyzing {Count} episodes from {Name} season {Season} with {Analyzer}",
178189
episodes.Count,
179-
first.SeriesName,
180-
first.SeasonNumber);
181-
182-
// Analyze the season with Chromaprint
183-
var chromaprint = new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>());
184-
chromaprint.AnalyzeMediaFiles(episodes, AnalysisMode.Credits, cancellationToken);
190+
episodes[0].SeriesName,
191+
episodes[0].SeasonNumber,
192+
analyzer.GetType().Name);
185193

186-
return episodes.Count;
194+
return analyzer.AnalyzeMediaFiles(episodes, AnalysisMode.Credits, cancellationToken);
187195
}
188196

189197
/// <summary>

0 commit comments

Comments
 (0)