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:
2026-08-16 09:20:56 +02:00
parent e635b708a2
commit a590904e89
20 changed files with 1259 additions and 103 deletions
+113
View File
@@ -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";
}
}