Add video preview, source crop, find-videos, and render toasts
Bring the Video tab closer to Shorts with 4K preview stills and crop modes (none, auto, center, plus the visual editor). Settings store a default source folder so Find videos can collect output.avi sequences. Cap the FFmpeg log with auto-scroll, hide repeat spam, and show a Windows notification when a video or short finishes or fails.
This commit is contained in:
@@ -33,6 +33,7 @@ public static class FfmpegOutputFilter
|
||||
"other streams:",
|
||||
"global headers:",
|
||||
"muxing overhead:",
|
||||
"Last message repeated",
|
||||
];
|
||||
|
||||
public static bool IsBoilerplateLine(string line) =>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Windows.AppNotifications;
|
||||
using Microsoft.Windows.AppNotifications.Builder;
|
||||
|
||||
namespace AmiReel.Services;
|
||||
|
||||
/// <summary>Shows a Windows app notification when a Video or Shorts render finishes, fails, or is cancelled.</summary>
|
||||
public static class RenderNotifier
|
||||
{
|
||||
public static void Register()
|
||||
{
|
||||
try
|
||||
{
|
||||
AppNotificationManager.Default.NotificationInvoked -= OnNotificationInvoked;
|
||||
AppNotificationManager.Default.NotificationInvoked += OnNotificationInvoked;
|
||||
AppNotificationManager.Default.Register();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Notifications are unavailable when the process is elevated or has no identity.
|
||||
}
|
||||
}
|
||||
|
||||
public static void ShowCompleted(bool shorts) => Show(DescribeCompleted(shorts));
|
||||
|
||||
public static void ShowFailed(string summary) => Show(DescribeFailed(summary));
|
||||
|
||||
public static void ShowCancelled() => Show(DescribeCancelled());
|
||||
|
||||
internal static (string Title, string Body) DescribeCompleted(bool shorts) => shorts
|
||||
? ("Short complete", "The YouTube Short was rendered successfully.")
|
||||
: ("Render complete", "The AmiReel render completed successfully.");
|
||||
|
||||
internal static (string Title, string Body) DescribeFailed(string summary)
|
||||
{
|
||||
string body = string.IsNullOrWhiteSpace(summary)
|
||||
? "The render failed."
|
||||
: summary.ReplaceLineEndings(" ");
|
||||
if (body.Length > 180)
|
||||
body = body[..177] + "...";
|
||||
return ("Render failed", body);
|
||||
}
|
||||
|
||||
internal static (string Title, string Body) DescribeCancelled() =>
|
||||
("Render cancelled", "The render was cancelled.");
|
||||
|
||||
private static void Show((string Title, string Body) content)
|
||||
{
|
||||
try
|
||||
{
|
||||
AppNotification notification = new AppNotificationBuilder()
|
||||
.AddText(content.Title)
|
||||
.AddText(content.Body)
|
||||
.BuildNotification();
|
||||
AppNotificationManager.Default.Show(notification);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Show fails silently when notifications are unsupported.
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args)
|
||||
{
|
||||
App.MainWindowInstance?.DispatcherQueue.TryEnqueue(() => App.MainWindowInstance.Activate());
|
||||
}
|
||||
}
|
||||
+141
-5
@@ -43,8 +43,9 @@ public sealed class RenderPipeline
|
||||
double joinDuration = await ProbeCombinedDurationAsync(probe, tools, settings.InputFiles, token);
|
||||
|
||||
progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video"));
|
||||
string cropFilter = await ResolveSourceCropFilterAsync(tools, settings, log, token);
|
||||
List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
|
||||
"-vf", $"scale=2880:2160:flags=lanczos:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
|
||||
"-vf", BuildJoinVideoFilter(settings, cropFilter),
|
||||
"-r", settings.FramesPerSecond.ToString(CultureInfo.InvariantCulture)];
|
||||
joinArgs.AddRange(EncoderArguments(encoder));
|
||||
joinArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", main]);
|
||||
@@ -122,6 +123,64 @@ public sealed class RenderPipeline
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders two 16:9 stills with the same crop/scale/pad as the final video: a source frame and the end card.
|
||||
/// </summary>
|
||||
public async Task<VideoPreviewFrames> RenderPreviewAsync(
|
||||
RenderSettings settings, string outputDirectory, Action<string> log, CancellationToken token)
|
||||
{
|
||||
ValidatePreview(settings);
|
||||
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
string cropFilter = await ResolveSourceCropFilterAsync(tools, settings, log, token);
|
||||
string joinFilter = BuildJoinVideoFilter(settings, cropFilter);
|
||||
string openingPath = Path.Combine(outputDirectory, "opening.png");
|
||||
string closingPath = Path.Combine(outputDirectory, "closing.png");
|
||||
|
||||
MediaProbe probe = new(_runner, log);
|
||||
string previewInput = settings.InputFiles[0];
|
||||
double duration = 0;
|
||||
try
|
||||
{
|
||||
duration = await probe.ProbeDurationAsync(tools, previewInput, token);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
log($"WARNING: Could not probe source duration: {exception.Message}");
|
||||
}
|
||||
|
||||
double seek = PreviewSeekSeconds(settings.TrimStart, duration);
|
||||
log($"Preview opening frame at {F(seek)}s...");
|
||||
await _runner.RunAsync(
|
||||
tools.Ffmpeg,
|
||||
[
|
||||
"-hide_banner", "-y", "-ss", F(seek), "-i", previewInput,
|
||||
"-frames:v", "1", "-update", "1", "-vf", joinFilter, openingPath,
|
||||
],
|
||||
log,
|
||||
null,
|
||||
token);
|
||||
|
||||
string workDir = Path.Combine(outputDirectory, "meta");
|
||||
Directory.CreateDirectory(workDir);
|
||||
string endCardPath = await ResolveEndCardPathAsync(settings, workDir, token);
|
||||
log("Preview end-card frame...");
|
||||
await _runner.RunAsync(
|
||||
tools.Ffmpeg,
|
||||
[
|
||||
"-hide_banner", "-y", "-loop", "1", "-i", endCardPath,
|
||||
"-frames:v", "1", "-update", "1",
|
||||
"-vf", $"scale={settings.Width}:{settings.Height}:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
|
||||
closingPath,
|
||||
],
|
||||
log,
|
||||
null,
|
||||
token);
|
||||
|
||||
return new VideoPreviewFrames(openingPath, closingPath);
|
||||
}
|
||||
|
||||
private async Task<string> SelectEncoderAsync(ToolPaths tools, EncoderMode requested, Action<string> log, CancellationToken token)
|
||||
{
|
||||
if (requested == EncoderMode.CpuX264) return "x264";
|
||||
@@ -360,16 +419,93 @@ public sealed class RenderPipeline
|
||||
|
||||
internal static void Validate(RenderSettings s)
|
||||
{
|
||||
if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one video input file.");
|
||||
if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more input files no longer exist.");
|
||||
if (!string.IsNullOrWhiteSpace(s.EndCardPath) && !File.Exists(s.EndCardPath))
|
||||
throw new FileNotFoundException("End-card image was not found.");
|
||||
ValidatePreview(s);
|
||||
if (string.IsNullOrWhiteSpace(s.OutputDirectory)) throw new ArgumentException("Select an output directory.");
|
||||
if (string.IsNullOrWhiteSpace(s.OutputName) || s.OutputName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
throw new ArgumentException("Enter a valid output name.");
|
||||
if (s.ThumbnailInterval < 1) throw new ArgumentException("Thumbnail interval must be at least one second.");
|
||||
}
|
||||
|
||||
internal static void ValidatePreview(RenderSettings s)
|
||||
{
|
||||
if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one video input file.");
|
||||
if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more input files no longer exist.");
|
||||
if (!string.IsNullOrWhiteSpace(s.EndCardPath) && !File.Exists(s.EndCardPath))
|
||||
throw new FileNotFoundException("End-card image was not found.");
|
||||
if (s.CropLeft < 0 || s.CropRight < 0 || s.CropTop < 0 || s.CropBottom < 0)
|
||||
throw new ArgumentException("Manual crop values must be non-negative.");
|
||||
}
|
||||
|
||||
internal static string BuildJoinVideoFilter(RenderSettings settings, string cropFilter) =>
|
||||
SourceCropFilters.Prefix(
|
||||
cropFilter,
|
||||
$"scale=2880:2160:flags=lanczos:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p");
|
||||
|
||||
internal static string BuildSourceCropFilter(RenderSettings settings, string? autoDetected) =>
|
||||
SourceCropFilters.Resolve(
|
||||
settings.SourceCropMode,
|
||||
settings.CropLeft,
|
||||
settings.CropRight,
|
||||
settings.CropTop,
|
||||
settings.CropBottom,
|
||||
autoDetected);
|
||||
|
||||
internal static double PreviewSeekSeconds(double trimStart, double durationSeconds)
|
||||
{
|
||||
double start = Math.Max(0, trimStart);
|
||||
if (durationSeconds <= start)
|
||||
return start;
|
||||
|
||||
double remaining = durationSeconds - start;
|
||||
return start + Math.Min(1.2, Math.Max(0, remaining * 0.2));
|
||||
}
|
||||
|
||||
internal static string FormatTimestamp(double seconds) =>
|
||||
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
|
||||
|
||||
private async Task<string> ResolveSourceCropFilterAsync(
|
||||
ToolPaths tools, RenderSettings settings, Action<string> log, CancellationToken token)
|
||||
{
|
||||
string? detected = null;
|
||||
if (!SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom)
|
||||
&& settings.SourceCropMode == ShortSourceCropMode.Auto)
|
||||
{
|
||||
detected = await DetectHorizontalCropAsync(tools, settings, log, token);
|
||||
if (detected is null)
|
||||
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
|
||||
}
|
||||
else if (SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom))
|
||||
{
|
||||
log($"Source crop: manual L={settings.CropLeft} R={settings.CropRight} T={settings.CropTop} B={settings.CropBottom}");
|
||||
}
|
||||
else if (settings.SourceCropMode == ShortSourceCropMode.None)
|
||||
{
|
||||
log("Source crop: none");
|
||||
}
|
||||
else
|
||||
{
|
||||
log("Source crop: centered 4:3");
|
||||
}
|
||||
|
||||
return BuildSourceCropFilter(settings, detected);
|
||||
}
|
||||
|
||||
private async Task<string?> DetectHorizontalCropAsync(
|
||||
ToolPaths tools, RenderSettings settings, Action<string> log, CancellationToken token)
|
||||
{
|
||||
string input = settings.InputFiles[0];
|
||||
List<string> args = SourceCropFilters.BuildHorizontalCropDetectArguments(
|
||||
input, FormatTimestamp(settings.TrimStart), 4);
|
||||
|
||||
string output = await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, allowFailure: true);
|
||||
string? filter = SourceCropFilters.TryBuildAutoHorizontalFilter(output);
|
||||
if (filter is null)
|
||||
return null;
|
||||
|
||||
log($"Source crop: {filter} (auto left/right black crop)");
|
||||
return filter;
|
||||
}
|
||||
|
||||
private static string EscapeConcat(string path) => path.Replace("'", "'\\''");
|
||||
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
+34
-79
@@ -1,7 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AmiReel.Models;
|
||||
|
||||
namespace AmiReel.Services;
|
||||
@@ -13,12 +12,7 @@ namespace AmiReel.Services;
|
||||
/// </summary>
|
||||
public sealed class ShortsPipeline
|
||||
{
|
||||
internal const string DefaultSourceCropFilter =
|
||||
"crop='min(iw,ih*4/3)':'min(ih,iw*3/4)':'(iw-ow)/2':'(ih-oh)/2'";
|
||||
|
||||
private static readonly Regex CropSpecRegex = new(
|
||||
@"crop=(\d+):(\d+):(\d+):(\d+)",
|
||||
RegexOptions.CultureInvariant);
|
||||
internal const string DefaultSourceCropFilter = SourceCropFilters.CenterFourByThreeFilter;
|
||||
|
||||
private readonly ProcessRunner _runner = new();
|
||||
|
||||
@@ -421,7 +415,7 @@ public sealed class ShortsPipeline
|
||||
$"scale={SourceTargetWidth(sourceMarginX)}:-2:flags=lanczos";
|
||||
|
||||
internal static string BuildManualCropFilter(int left, int right, int top, int bottom) =>
|
||||
$"crop=iw-{left}-{right}:ih-{top}-{bottom}:{left}:{top}";
|
||||
SourceCropFilters.BuildManualCropFilter(left, right, top, bottom);
|
||||
|
||||
internal static string BuildVideoBaseFilter(string cropFilter, string scaleFilter, double blur, double opacity)
|
||||
{
|
||||
@@ -430,49 +424,18 @@ public sealed class ShortsPipeline
|
||||
"color=c=black:s=1080x1920:r=50[black];" +
|
||||
$"[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1{blurFilter},format=rgba,colorchannelmixer=aa={F(opacity)}[bgvideo];" +
|
||||
"[black][bgvideo]overlay=x=0:y=0:format=auto[bg];" +
|
||||
$"[0:v]{cropFilter},{scaleFilter},setsar=1[fg];" +
|
||||
$"[0:v]{SourceCropFilters.Prefix(cropFilter, scaleFilter)},setsar=1[fg];" +
|
||||
"[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]";
|
||||
}
|
||||
|
||||
internal static string? SelectMostCommonCropSpec(string ffmpegOutput)
|
||||
{
|
||||
Dictionary<string, int> counts = new(StringComparer.Ordinal);
|
||||
foreach (Match match in CropSpecRegex.Matches(ffmpegOutput))
|
||||
{
|
||||
string spec = match.Value;
|
||||
counts[spec] = counts.GetValueOrDefault(spec) + 1;
|
||||
}
|
||||
internal static string? SelectMostCommonCropSpec(string ffmpegOutput) =>
|
||||
SourceCropFilters.SelectMostCommonCropSpec(ffmpegOutput);
|
||||
|
||||
return counts.Count == 0
|
||||
? null
|
||||
: counts.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal).First().Key;
|
||||
}
|
||||
internal static bool TryParseCropSpec(string spec, out int width, out int height, out int x, out int y) =>
|
||||
SourceCropFilters.TryParseCropSpec(spec, out width, out height, out x, out y);
|
||||
|
||||
internal static bool TryParseCropSpec(string spec, out int width, out int height, out int x, out int y)
|
||||
{
|
||||
Match match = CropSpecRegex.Match(spec);
|
||||
if (!match.Success)
|
||||
{
|
||||
width = height = x = y = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
height = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
x = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
|
||||
y = int.Parse(match.Groups[4].Value, CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryBuildAutoHorizontalCrop(int detectedWidth, int detectedX, out int width, out int x)
|
||||
{
|
||||
int inset = (int)(detectedWidth * 0.005 / 2) * 2;
|
||||
width = ((detectedWidth - (2 * inset)) / 2) * 2;
|
||||
x = ((detectedX + inset) / 2) * 2;
|
||||
if (width < 16)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
internal static bool TryBuildAutoHorizontalCrop(int detectedWidth, int detectedX, out int width, out int x) =>
|
||||
SourceCropFilters.TryBuildAutoHorizontalCrop(detectedWidth, detectedX, out width, out x);
|
||||
|
||||
private async Task<string> ResolveBaseFilterAsync(
|
||||
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
|
||||
@@ -480,33 +443,36 @@ public sealed class ShortsPipeline
|
||||
if (settings.Style != ShortStyle.Video)
|
||||
return GetBaseFilter(settings.Style);
|
||||
|
||||
string cropFilter = DefaultSourceCropFilter;
|
||||
string scaleFilter = BuildSourceScaleFilter(settings.SourceMarginX);
|
||||
bool hasManualCrop = settings.CropLeft > 0 || settings.CropRight > 0
|
||||
|| settings.CropTop > 0 || settings.CropBottom > 0;
|
||||
|
||||
if (hasManualCrop)
|
||||
string? detected = null;
|
||||
if (!SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom)
|
||||
&& settings.SourceCropMode == ShortSourceCropMode.Auto)
|
||||
{
|
||||
detected = await DetectHorizontalCropAsync(tools, settings, log, token);
|
||||
if (detected is null)
|
||||
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
|
||||
}
|
||||
else if (SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom))
|
||||
{
|
||||
cropFilter = BuildManualCropFilter(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom);
|
||||
log($"Source crop: manual L={settings.CropLeft} R={settings.CropRight} T={settings.CropTop} B={settings.CropBottom}");
|
||||
}
|
||||
else if (settings.SourceCropMode == ShortSourceCropMode.Auto)
|
||||
else if (settings.SourceCropMode == ShortSourceCropMode.None)
|
||||
{
|
||||
string? detected = await DetectHorizontalCropAsync(tools, settings, log, token);
|
||||
if (detected is not null)
|
||||
{
|
||||
cropFilter = detected;
|
||||
}
|
||||
else
|
||||
{
|
||||
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
|
||||
}
|
||||
log("Source crop: none");
|
||||
}
|
||||
else
|
||||
{
|
||||
log("Source crop: centered 4:3");
|
||||
}
|
||||
|
||||
string cropFilter = SourceCropFilters.Resolve(
|
||||
settings.SourceCropMode,
|
||||
settings.CropLeft,
|
||||
settings.CropRight,
|
||||
settings.CropTop,
|
||||
settings.CropBottom,
|
||||
detected);
|
||||
|
||||
return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity);
|
||||
}
|
||||
|
||||
@@ -514,27 +480,16 @@ public sealed class ShortsPipeline
|
||||
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
|
||||
{
|
||||
double detectDuration = Math.Clamp(settings.DurationSeconds < 4 ? settings.DurationSeconds : 4, 0.5, 4);
|
||||
List<string> args =
|
||||
[
|
||||
"-hide_banner",
|
||||
"-ss", settings.Start,
|
||||
"-t", F(detectDuration),
|
||||
"-i", settings.InputFile,
|
||||
"-an",
|
||||
"-vf", "crop=iw:trunc(ih*0.70/2)*2:0:trunc(ih*0.15/2)*2,cropdetect=limit=28:round=2:reset=0",
|
||||
"-f", "null",
|
||||
"-",
|
||||
];
|
||||
List<string> args = SourceCropFilters.BuildHorizontalCropDetectArguments(
|
||||
settings.InputFile, settings.Start, detectDuration);
|
||||
|
||||
string output = await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, allowFailure: true);
|
||||
string? spec = SelectMostCommonCropSpec(output);
|
||||
if (spec is null || !TryParseCropSpec(spec, out int width, out _, out int x, out _))
|
||||
return null;
|
||||
if (!TryBuildAutoHorizontalCrop(width, x, out int cropWidth, out int cropX))
|
||||
string? filter = SourceCropFilters.TryBuildAutoHorizontalFilter(output);
|
||||
if (filter is null)
|
||||
return null;
|
||||
|
||||
log($"Source crop: w={cropWidth}, x={cropX} (auto left/right black crop)");
|
||||
return $"crop={cropWidth}:ih:{cropX}:0";
|
||||
log($"Source crop: {filter} (auto left/right black crop)");
|
||||
return filter;
|
||||
}
|
||||
|
||||
internal static string GetBaseFilter(ShortStyle style) => style switch
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using AmiReel.Models;
|
||||
|
||||
namespace AmiReel.Services;
|
||||
|
||||
/// <summary>Shared FFmpeg crop filters used by both Video and Shorts pipelines.</summary>
|
||||
internal static class SourceCropFilters
|
||||
{
|
||||
internal const string CenterFourByThreeFilter =
|
||||
"crop='min(iw,ih*4/3)':'min(ih,iw*3/4)':'(iw-ow)/2':'(ih-oh)/2'";
|
||||
|
||||
private static readonly Regex CropSpecRegex = new(
|
||||
@"crop=(\d+):(\d+):(\d+):(\d+)",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
internal static string BuildManualCropFilter(int left, int right, int top, int bottom) =>
|
||||
$"crop=iw-{left}-{right}:ih-{top}-{bottom}:{left}:{top}";
|
||||
|
||||
internal static bool HasManualCrop(int left, int right, int top, int bottom) =>
|
||||
left > 0 || right > 0 || top > 0 || bottom > 0;
|
||||
|
||||
internal static string Resolve(
|
||||
ShortSourceCropMode mode,
|
||||
int left,
|
||||
int right,
|
||||
int top,
|
||||
int bottom,
|
||||
string? autoDetected)
|
||||
{
|
||||
if (HasManualCrop(left, right, top, bottom))
|
||||
return BuildManualCropFilter(left, right, top, bottom);
|
||||
|
||||
return mode switch
|
||||
{
|
||||
ShortSourceCropMode.None => "",
|
||||
ShortSourceCropMode.Auto when !string.IsNullOrWhiteSpace(autoDetected) => autoDetected,
|
||||
ShortSourceCropMode.Auto => CenterFourByThreeFilter,
|
||||
_ => CenterFourByThreeFilter,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string Prefix(string cropFilter, string nextFilter) =>
|
||||
string.IsNullOrWhiteSpace(cropFilter) ? nextFilter : $"{cropFilter},{nextFilter}";
|
||||
|
||||
internal static List<string> BuildHorizontalCropDetectArguments(string inputFile, string start, double durationSeconds) =>
|
||||
[
|
||||
"-hide_banner",
|
||||
"-ss",
|
||||
string.IsNullOrWhiteSpace(start) ? "00:00:00" : start,
|
||||
"-t",
|
||||
durationSeconds.ToString("0.###", CultureInfo.InvariantCulture),
|
||||
"-i",
|
||||
inputFile,
|
||||
"-an",
|
||||
"-vf",
|
||||
"crop=iw:trunc(ih*0.70/2)*2:0:trunc(ih*0.15/2)*2,cropdetect=limit=28:round=2:reset=0",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
];
|
||||
|
||||
internal static string? SelectMostCommonCropSpec(string ffmpegOutput)
|
||||
{
|
||||
Dictionary<string, int> counts = new(StringComparer.Ordinal);
|
||||
foreach (Match match in CropSpecRegex.Matches(ffmpegOutput))
|
||||
{
|
||||
string spec = match.Value;
|
||||
counts[spec] = counts.GetValueOrDefault(spec) + 1;
|
||||
}
|
||||
|
||||
return counts.Count == 0
|
||||
? null
|
||||
: counts.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal).First().Key;
|
||||
}
|
||||
|
||||
internal static bool TryParseCropSpec(string spec, out int width, out int height, out int x, out int y)
|
||||
{
|
||||
Match match = CropSpecRegex.Match(spec);
|
||||
if (!match.Success)
|
||||
{
|
||||
width = height = x = y = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
height = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
x = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
|
||||
y = int.Parse(match.Groups[4].Value, CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryBuildAutoHorizontalCrop(int detectedWidth, int detectedX, out int width, out int x)
|
||||
{
|
||||
int inset = (int)(detectedWidth * 0.005 / 2) * 2;
|
||||
width = ((detectedWidth - (2 * inset)) / 2) * 2;
|
||||
x = ((detectedX + inset) / 2) * 2;
|
||||
if (width < 16)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static string? TryBuildAutoHorizontalFilter(string ffmpegOutput)
|
||||
{
|
||||
string? spec = SelectMostCommonCropSpec(ffmpegOutput);
|
||||
if (spec is null || !TryParseCropSpec(spec, out int width, out _, out int x, out _))
|
||||
return null;
|
||||
if (!TryBuildAutoHorizontalCrop(width, x, out int cropWidth, out int cropX))
|
||||
return null;
|
||||
|
||||
return $"crop={cropWidth}:ih:{cropX}:0";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace AmiReel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Locates sequential WinUAE-style recordings: output.avi, output_1.avi, output_2.avi, ...
|
||||
/// Stopping at the first missing name.
|
||||
/// </summary>
|
||||
public static class SourceRecordingFinder
|
||||
{
|
||||
public const string BaseFileName = "output";
|
||||
public const string FileExtension = ".avi";
|
||||
public const int MaximumFiles = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Returns existing sequential recordings in <paramref name="folder"/>, or an empty
|
||||
/// list when the folder is missing or <c>output.avi</c> is not present.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> FindSequentialOutputs(string? folder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
|
||||
return [];
|
||||
|
||||
string directory = Path.GetFullPath(folder);
|
||||
List<string> found = [];
|
||||
|
||||
string first = Path.Combine(directory, BaseFileName + FileExtension);
|
||||
if (!File.Exists(first))
|
||||
return found;
|
||||
|
||||
found.Add(first);
|
||||
|
||||
for (int index = 1; index < MaximumFiles; index++)
|
||||
{
|
||||
string next = Path.Combine(directory, $"{BaseFileName}_{index}{FileExtension}");
|
||||
if (!File.Exists(next))
|
||||
break;
|
||||
|
||||
found.Add(next);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user