Add blackframe API
This commit is contained in:
parent
af89e5f2b4
commit
8ee400f1f1
@ -0,0 +1,50 @@
|
|||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class TestBlackFrames
|
||||||
|
{
|
||||||
|
[FactSkipFFmpegTests]
|
||||||
|
public void TestBlackFrameDetection()
|
||||||
|
{
|
||||||
|
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 TimeRange(0, 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private QueuedEpisode queueFile(string path)
|
||||||
|
{
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
EpisodeId = Guid.NewGuid(),
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
BIN
ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4
Normal file
BIN
ConfusedPolarBear.Plugin.IntroSkipper.Tests/video/rainbow.mp4
Normal file
Binary file not shown.
@ -77,6 +77,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaximumEpisodeCreditsDuration { get; set; } = 4;
|
public int MaximumEpisodeCreditsDuration { get; set; } = 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
|
||||||
|
/// </summary>
|
||||||
|
public int BlackFrameMinimumPercentage { get; set; } = 85;
|
||||||
|
|
||||||
// ===== Playback settings =====
|
// ===== Playback settings =====
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
28
ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs
Normal file
28
ConfusedPolarBear.Plugin.IntroSkipper/Data/BlackFrame.cs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
namespace ConfusedPolarBear.Plugin.IntroSkipper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A frame of video that partially (or entirely) consists of black pixels.
|
||||||
|
/// </summary>
|
||||||
|
public class BlackFrame
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="percent">Percentage of the frame that is black.</param>
|
||||||
|
/// <param name="time">Time this frame appears at.</param>
|
||||||
|
public BlackFrame(int percent, double time)
|
||||||
|
{
|
||||||
|
Percentage = percent;
|
||||||
|
Time = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the percentage of the frame that is black.
|
||||||
|
/// </summary>
|
||||||
|
public int Percentage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the time (in seconds) this frame appeared at.
|
||||||
|
/// </summary>
|
||||||
|
public double Time { get; set; }
|
||||||
|
}
|
@ -16,16 +16,17 @@ public static class FFmpegWrapper
|
|||||||
{
|
{
|
||||||
private static readonly object InvertedIndexCacheLock = new();
|
private static readonly object InvertedIndexCacheLock = new();
|
||||||
|
|
||||||
// FFmpeg logs lines similar to the following:
|
|
||||||
// [silencedetect @ 0x000000000000] silence_start: 12.34
|
|
||||||
// [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
|
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly Regex SilenceDetectionExpression = new(
|
private static readonly Regex SilenceDetectionExpression = new(
|
||||||
"silence_(?<type>start|end): (?<time>[0-9\\.]+)");
|
"silence_(?<type>start|end): (?<time>[0-9\\.]+)");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used with FFmpeg's blackframe filter to extract the time and percentage of black pixels.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Regex BlackFrameRegex = new("(pblack|t):[0-9.]+");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the logger.
|
/// Gets or sets the logger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -190,7 +191,12 @@ public static class FFmpegWrapper
|
|||||||
var currentRange = new TimeRange();
|
var currentRange = new TimeRange();
|
||||||
var silenceRanges = new List<TimeRange>();
|
var silenceRanges = new List<TimeRange>();
|
||||||
|
|
||||||
// Each match will have a type (either "start" or "end") and a timecode (a double).
|
/* Each match will have a type (either "start" or "end") and a timecode (a double).
|
||||||
|
*
|
||||||
|
* Sample output:
|
||||||
|
* [silencedetect @ 0x000000000000] silence_start: 12.34
|
||||||
|
* [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
|
||||||
|
*/
|
||||||
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
|
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
|
||||||
foreach (Match match in SilenceDetectionExpression.Matches(raw))
|
foreach (Match match in SilenceDetectionExpression.Matches(raw))
|
||||||
{
|
{
|
||||||
@ -211,6 +217,61 @@ public static class FFmpegWrapper
|
|||||||
return silenceRanges.ToArray();
|
return silenceRanges.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the location of all black frames in a media file within a time range.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">Media file to analyze.</param>
|
||||||
|
/// <param name="range">Time range to search.</param>
|
||||||
|
/// <returns>Array of frames that are at least 50% black.</returns>
|
||||||
|
public static BlackFrame[] DetectBlackFrames(QueuedEpisode episode, TimeRange range)
|
||||||
|
{
|
||||||
|
// Seek to the start of the time range and find frames that are at least 50% black.
|
||||||
|
var args = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"-ss {0} -i \"{1}\" -to {2} -an -dn -sn -vf \"blackframe=amount=50\" -f null -",
|
||||||
|
range.Start,
|
||||||
|
episode.Path,
|
||||||
|
range.End - range.Start);
|
||||||
|
|
||||||
|
// Cache the results to GUID-blackframes-v1-START-END.
|
||||||
|
var cacheKey = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}-blackframes-v1-{1}-{2}",
|
||||||
|
episode.EpisodeId.ToString("N"),
|
||||||
|
range.Start,
|
||||||
|
range.End);
|
||||||
|
|
||||||
|
var blackFrames = new List<BlackFrame>();
|
||||||
|
|
||||||
|
/* Run the blackframe filter.
|
||||||
|
*
|
||||||
|
* Sample output:
|
||||||
|
* [Parsed_blackframe_0 @ 0x0000000] frame:1 pblack:99 pts:43 t:0.043000 type:B last_keyframe:0
|
||||||
|
* [Parsed_blackframe_0 @ 0x0000000] frame:2 pblack:99 pts:85 t:0.085000 type:B last_keyframe:0
|
||||||
|
*/
|
||||||
|
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
|
||||||
|
foreach (var line in raw.Split('\n'))
|
||||||
|
{
|
||||||
|
var matches = BlackFrameRegex.Matches(line);
|
||||||
|
if (matches.Count != 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (strPercent, strTime) = (
|
||||||
|
matches[0].Value.Split(':')[1],
|
||||||
|
matches[1].Value.Split(':')[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
blackFrames.Add(new(
|
||||||
|
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
|
||||||
|
Convert.ToDouble(strTime, CultureInfo.InvariantCulture)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return blackFrames.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets Chromaprint debugging logs.
|
/// Gets Chromaprint debugging logs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -296,10 +357,11 @@ public static class FFmpegWrapper
|
|||||||
{
|
{
|
||||||
var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg";
|
var ffmpegPath = Plugin.Instance?.FFmpegPath ?? "ffmpeg";
|
||||||
|
|
||||||
// The silencedetect filter outputs silence timestamps at the info log level.
|
// The silencedetect and blackframe filters output data at the info log level.
|
||||||
var logLevel = args.Contains("silencedetect", StringComparison.OrdinalIgnoreCase) ?
|
var useInfoLevel = args.Contains("silencedetect", StringComparison.OrdinalIgnoreCase) ||
|
||||||
"info" :
|
args.Contains("blackframe", StringComparison.OrdinalIgnoreCase);
|
||||||
"warning";
|
|
||||||
|
var logLevel = useInfoLevel ? "info" : "warning";
|
||||||
|
|
||||||
var cacheOutput =
|
var cacheOutput =
|
||||||
(Plugin.Instance?.Configuration.CacheFingerprints ?? false) &&
|
(Plugin.Instance?.Configuration.CacheFingerprints ?? false) &&
|
||||||
|
Loading…
x
Reference in New Issue
Block a user