forked from ConfusedPolarBear/intro-skipper
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
26 changed files
with
1,430 additions
and
495 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
72 changes: 72 additions & 0 deletions
72
ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestBlackFrames.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using Microsoft.Extensions.Logging; | ||
using Xunit; | ||
|
||
public class TestBlackFrames | ||
{ | ||
[FactSkipFFmpegTests] | ||
public void TestBlackFrameDetection() | ||
{ | ||
var range = 1e-5; | ||
|
||
var expected = new List<BlackFrame>(); | ||
expected.AddRange(CreateFrameSequence(2, 3)); | ||
expected.AddRange(CreateFrameSequence(5, 6)); | ||
expected.AddRange(CreateFrameSequence(8, 9.96)); | ||
|
||
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.InRange(a.Time, e.Time - range, e.Time + range); | ||
} | ||
} | ||
|
||
[FactSkipFFmpegTests] | ||
public void TestEndCreditDetection() | ||
{ | ||
var range = 1; | ||
|
||
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.InRange(result.IntroStart, 300 - range, 300 + range); | ||
} | ||
|
||
private QueuedEpisode queueFile(string path) | ||
{ | ||
return new() | ||
{ | ||
EpisodeId = Guid.NewGuid(), | ||
Name = path, | ||
Path = "../../../video/" + path | ||
}; | ||
} | ||
|
||
private BlackFrame[] CreateFrameSequence(double start, double end) | ||
{ | ||
var frames = new List<BlackFrame>(); | ||
|
||
for (var i = start; i < end; i += 0.04) | ||
{ | ||
frames.Add(new(100, i)); | ||
} | ||
|
||
return frames.ToArray(); | ||
} | ||
|
||
private BlackFrameAnalyzer CreateBlackFrameAnalyzer() | ||
{ | ||
var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>(); | ||
return new(logger); | ||
} | ||
} |
83 changes: 83 additions & 0 deletions
83
ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestChapterAnalyzer.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.Collections.ObjectModel; | ||
using MediaBrowser.Model.Entities; | ||
using Microsoft.Extensions.Logging; | ||
using Xunit; | ||
|
||
public class TestChapterAnalyzer | ||
{ | ||
[Theory] | ||
[InlineData("Opening")] | ||
[InlineData("OP")] | ||
[InlineData("Intro")] | ||
[InlineData("Intro Start")] | ||
[InlineData("Introduction")] | ||
public void TestIntroductionExpression(string chapterName) | ||
{ | ||
var chapters = CreateChapters(chapterName, AnalysisMode.Introduction); | ||
var introChapter = FindChapter(chapters, AnalysisMode.Introduction); | ||
|
||
Assert.NotNull(introChapter); | ||
Assert.Equal(60, introChapter.IntroStart); | ||
Assert.Equal(90, introChapter.IntroEnd); | ||
} | ||
|
||
[Theory] | ||
[InlineData("End Credits")] | ||
[InlineData("Ending")] | ||
[InlineData("Credit start")] | ||
[InlineData("Closing Credits")] | ||
[InlineData("Credits")] | ||
public void TestEndCreditsExpression(string chapterName) | ||
{ | ||
var chapters = CreateChapters(chapterName, AnalysisMode.Credits); | ||
var creditsChapter = FindChapter(chapters, AnalysisMode.Credits); | ||
|
||
Assert.NotNull(creditsChapter); | ||
Assert.Equal(1890, creditsChapter.IntroStart); | ||
Assert.Equal(2000, creditsChapter.IntroEnd); | ||
} | ||
|
||
private Intro? FindChapter(Collection<ChapterInfo> chapters, AnalysisMode mode) | ||
{ | ||
var logger = new LoggerFactory().CreateLogger<ChapterAnalyzer>(); | ||
var analyzer = new ChapterAnalyzer(logger); | ||
|
||
var config = new Configuration.PluginConfiguration(); | ||
var expression = mode == AnalysisMode.Introduction ? | ||
config.ChapterAnalyzerIntroductionPattern : | ||
config.ChapterAnalyzerEndCreditsPattern; | ||
|
||
return analyzer.FindMatchingChapter(new() { Duration = 2000 }, chapters, expression, mode); | ||
} | ||
|
||
private Collection<ChapterInfo> CreateChapters(string name, AnalysisMode mode) | ||
{ | ||
var chapters = new[]{ | ||
CreateChapter("Cold Open", 0), | ||
CreateChapter(mode == AnalysisMode.Introduction ? name : "Introduction", 60), | ||
CreateChapter("Main Episode", 90), | ||
CreateChapter(mode == AnalysisMode.Credits ? name : "Credits", 1890) | ||
}; | ||
|
||
return new(new List<ChapterInfo>(chapters)); | ||
} | ||
|
||
/// <summary> | ||
/// Create a ChapterInfo object. | ||
/// </summary> | ||
/// <param name="name">Chapter name.</param> | ||
/// <param name="position">Chapter position (in seconds).</param> | ||
/// <returns>ChapterInfo.</returns> | ||
private ChapterInfo CreateChapter(string name, int position) | ||
{ | ||
return new() | ||
{ | ||
Name = name, | ||
StartPositionTicks = TimeSpan.FromSeconds(position).Ticks | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
Binary file not shown.
131 changes: 131 additions & 0 deletions
131
ConfusedPolarBear.Plugin.IntroSkipper/Analyzers/BlackFrameAnalyzer.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
namespace ConfusedPolarBear.Plugin.IntroSkipper; | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.Collections.ObjectModel; | ||
using System.Linq; | ||
using System.Threading; | ||
using Microsoft.Extensions.Logging; | ||
|
||
/// <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> | ||
public class BlackFrameAnalyzer : IMediaFileAnalyzer | ||
{ | ||
private readonly TimeSpan _maximumError = new(0, 0, 4); | ||
|
||
private readonly ILogger<BlackFrameAnalyzer> _logger; | ||
|
||
/// <summary> | ||
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class. | ||
/// </summary> | ||
/// <param name="logger">Logger.</param> | ||
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) | ||
{ | ||
_logger = logger; | ||
} | ||
|
||
/// <inheritdoc /> | ||
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles( | ||
ReadOnlyCollection<QueuedEpisode> analysisQueue, | ||
AnalysisMode mode, | ||
CancellationToken cancellationToken) | ||
{ | ||
if (mode != AnalysisMode.Credits) | ||
{ | ||
throw new NotImplementedException("mode must equal Credits"); | ||
} | ||
|
||
var creditTimes = new Dictionary<Guid, Intro>(); | ||
|
||
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(); | ||
} | ||
|
||
/// <summary> | ||
/// Analyzes an individual media file. Only public because of unit tests. | ||
/// </summary> | ||
/// <param name="episode">Media file to analyze.</param> | ||
/// <param name="mode">Analysis mode.</param> | ||
/// <param name="minimum">Percentage of the frame that must be black.</param> | ||
/// <returns>Credits timestamp.</returns> | ||
public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum) | ||
{ | ||
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration(); | ||
|
||
// Start by analyzing the last N minutes of the file. | ||
var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration); | ||
var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration); | ||
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; | ||
} | ||
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; | ||
} | ||
} |
Oops, something went wrong.