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
+34 -79
View File
@@ -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