Add Shorts wizard and v4 video-style pipeline

Turn the Shorts tab into a step-by-step wizard and port
amigadb-short-fx-v4.sh: Video background style, auto/manual crop,
source fade-in, and blur/opacity/margin settings.
This commit is contained in:
2026-08-15 17:59:18 +02:00
parent 58e11ba4e3
commit 8a734d58d9
10 changed files with 1911 additions and 145 deletions
+170 -10
View File
@@ -1,17 +1,25 @@
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using AmiReel.Models;
namespace AmiReel.Services;
/// <summary>
/// Renders a vertical (1080x1920, 50 FPS) YouTube Short from an existing video, with a chosen
/// visual style and title-card text overlays. A C# port of amigadb-short.sh so end users don't
/// need bash/WSL — the FFmpeg filtergraphs below are copied verbatim from that script.
/// visual style and title-card text overlays. A C# port of amigadb-short-fx-v4.sh so end users
/// don't need bash/WSL — the FFmpeg filtergraphs below are copied from that script.
/// </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);
private readonly ProcessRunner _runner = new();
public async Task RenderAsync(ShortSettings settings, IProgress<RenderProgress> progress, Action<string> log, CancellationToken token)
@@ -45,9 +53,11 @@ public sealed class ShortsPipeline
double infoStart = Math.Max(0, duration - 7);
double ctaStart = Math.Max(0, duration - 3.5);
double hookEnd = Math.Min(2.7, duration);
double fadeIn = Math.Clamp(settings.SourceFadeInSeconds, 0, duration);
string baseFilter = await ResolveBaseFilterAsync(tools, settings, log, token);
string filterComplex = BuildFilterComplex(
GetBaseFilter(settings.Style),
ApplySourceFade(baseFilter, fadeIn),
fontEsc: EscapeFfmpegPath(settings.FontFile),
hookEsc: EscapeFfmpegPath(hookFile),
titleEsc: EscapeFfmpegPath(titleFile),
@@ -58,6 +68,8 @@ public sealed class ShortsPipeline
List<string> args = ["-hide_banner", "-y", "-ss", settings.Start, "-t", F(duration), "-i", settings.InputFile];
if (settings.Style == ShortStyle.Brand)
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
if (settings.Style == ShortStyle.Video)
args.AddRange(["-stream_loop", "-1", "-i", settings.BackgroundVideo]);
args.AddRange([
"-filter_complex", filterComplex,
@@ -73,6 +85,7 @@ public sealed class ShortsPipeline
"-b:a", "320k",
"-ar", "48000",
"-movflags", "+faststart",
"-t", F(duration),
"-shortest",
settings.OutputPath,
]);
@@ -121,6 +134,18 @@ public sealed class ShortsPipeline
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage)))
throw new FileNotFoundException("The brand style requires a background image.");
if (s.Style == ShortStyle.Video && (string.IsNullOrWhiteSpace(s.BackgroundVideo) || !File.Exists(s.BackgroundVideo)))
throw new FileNotFoundException("The video style requires a background video.");
if (s.VideoBlur < 0)
throw new ArgumentException("Background video blur must be zero or greater.");
if (s.VideoOpacity is < 0 or > 1)
throw new ArgumentException("Background video opacity must be between 0.0 and 1.0.");
if (s.CropLeft < 0 || s.CropRight < 0 || s.CropTop < 0 || s.CropBottom < 0)
throw new ArgumentException("Manual crop values must be non-negative.");
if (s.SourceMarginX < 0 || s.SourceMarginX >= 540)
throw new ArgumentException("Source side margin must be between 0 and 539 pixels.");
if (s.SourceFadeInSeconds < 0)
throw new ArgumentException("Source fade-in must be zero or greater.");
}
internal static string BuildMeta(ShortSettings s)
@@ -163,6 +188,137 @@ public sealed class ShortsPipeline
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
internal static string ApplySourceFade(string baseFilter, double fadeInSeconds)
{
if (fadeInSeconds <= 0)
return baseFilter;
return $"[0:v]format=rgba,fade=t=in:st=0:d={F(fadeInSeconds)}:alpha=1[srcfade];" +
baseFilter.Replace("[0:v]", "[srcfade]", StringComparison.Ordinal);
}
internal static int SourceTargetWidth(int sourceMarginX) => 1080 - (sourceMarginX * 2);
internal static string BuildSourceScaleFilter(int sourceMarginX) =>
$"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}";
internal static string BuildVideoBaseFilter(string cropFilter, string scaleFilter, double blur, double opacity)
{
string blurFilter = blur > 0 ? $",gblur=sigma={F(blur)}" : "";
return
"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];" +
"[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;
}
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;
}
private async Task<string> ResolveBaseFilterAsync(
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
{
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)
{
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)
{
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.");
}
}
else
{
log("Source crop: centered 4:3");
}
return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity);
}
private async Task<string?> DetectHorizontalCropAsync(
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",
"-",
];
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))
return null;
log($"Source crop: w={cropWidth}, x={cropX} (auto left/right black crop)");
return $"crop={cropWidth}:ih:{cropX}:0";
}
internal static string GetBaseFilter(ShortStyle style) => style switch
{
ShortStyle.Brand => """
@@ -182,13 +338,14 @@ public sealed class ShortsPipeline
""",
ShortStyle.Mirror => """
[0:v]split=3[fg_src][top_src][bottom_src];
[top_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[top];
[bottom_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[bottom];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=white@0.70,setsar=1[fg];
[top]pad=1080:1920:0:0:color=0x050616[canvas];
[canvas][bottom]overlay=x=0:y=1300[tmp];
[tmp][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
[0:v]split=4[canvas_src][fg_src][top_src][bottom_src];
[canvas_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,drawbox=x=0:y=0:w=1080:h=1920:color=0x010203:t=fill[canvas];
[fg_src]scale=940:760:force_original_aspect_ratio=decrease,setsar=1[fg];
[top_src]scale=1400:470:flags=lanczos,vflip,eq=brightness=-0.48:contrast=0.92:saturation=0.68,gblur=sigma=7,setsar=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*0.62*(Y/H)'[top];
[bottom_src]scale=1400:470:flags=lanczos,vflip,eq=brightness=-0.50:contrast=0.90:saturation=0.64,gblur=sigma=7,setsar=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*0.58*(1-Y/H)'[bottom];
[canvas][top]overlay=x=(W-w)/2:y=250:format=auto[tmp1];
[tmp1][bottom]overlay=x=(W-w)/2:y=1435:format=auto[tmp2];
[tmp2][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
""",
ShortStyle.Crop => """
@@ -381,6 +538,9 @@ public sealed class ShortsPipeline
[tmp][fg]overlay=x=(W-w)/2:y=480:format=auto[base]
""",
ShortStyle.Video => BuildVideoBaseFilter(
DefaultSourceCropFilter, BuildSourceScaleFilter(40), blur: 0, opacity: 1),
_ => throw new ArgumentOutOfRangeException(nameof(style), style, "Unknown short style."),
};
}