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:
+170
-10
@@ -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."),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user