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:
+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
|
||||
|
||||
Reference in New Issue
Block a user