Show opening/closing 9:16 stills from the same filtergraph, and let users drag a keep-region on a source frame to fill the crop insets.
709 lines
37 KiB
C#
709 lines
37 KiB
C#
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-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)
|
|
{
|
|
Validate(settings);
|
|
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
|
|
|
|
if (settings.Style == ShortStyle.Spectrum)
|
|
{
|
|
MediaProbe probe = new(_runner, log);
|
|
bool hasAudio = await probe.HasAudioAsync(tools, settings.InputFile, token);
|
|
if (!hasAudio)
|
|
throw new InvalidOperationException("The spectrum style requires an audio stream in the source video.");
|
|
}
|
|
|
|
string? outputDirectory = Path.GetDirectoryName(Path.GetFullPath(settings.OutputPath));
|
|
if (!string.IsNullOrEmpty(outputDirectory))
|
|
Directory.CreateDirectory(outputDirectory);
|
|
|
|
string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", "shorts", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(workDir);
|
|
|
|
try
|
|
{
|
|
string hookFile = WriteTextFile(workDir, "hook.txt", settings.Hook);
|
|
string titleFile = WriteTextFile(workDir, "title.txt", settings.Title);
|
|
string metaFile = WriteTextFile(workDir, "meta.txt", BuildMeta(settings));
|
|
string websiteFile = WriteTextFile(workDir, "website.txt", settings.Website);
|
|
|
|
double duration = settings.DurationSeconds;
|
|
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(
|
|
ApplySourceFade(baseFilter, fadeIn),
|
|
fontEsc: EscapeFfmpegPath(settings.FontFile),
|
|
hookEsc: EscapeFfmpegPath(hookFile),
|
|
titleEsc: EscapeFfmpegPath(titleFile),
|
|
metaEsc: EscapeFfmpegPath(metaFile),
|
|
websiteEsc: EscapeFfmpegPath(websiteFile),
|
|
hookEnd, infoStart, ctaStart);
|
|
|
|
List<string> args = BuildInputArguments(settings, duration);
|
|
|
|
args.AddRange([
|
|
"-filter_complex", filterComplex,
|
|
"-map", "[vout]",
|
|
"-map", "0:a?",
|
|
"-c:v", "libx264",
|
|
"-preset", settings.Preset,
|
|
"-crf", settings.Crf.ToString(CultureInfo.InvariantCulture),
|
|
"-profile:v", "high",
|
|
"-level:v", "4.2",
|
|
"-pix_fmt", "yuv420p",
|
|
"-c:a", "aac",
|
|
"-b:a", "320k",
|
|
"-ar", "48000",
|
|
"-movflags", "+faststart",
|
|
"-t", F(duration),
|
|
"-shortest",
|
|
settings.OutputPath,
|
|
]);
|
|
|
|
progress.Report(new(2, "Rendering short", $"Applying the '{settings.Style}' style"));
|
|
TimeSpan totalSpan = TimeSpan.FromSeconds(duration);
|
|
await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, outputHandler: output =>
|
|
{
|
|
if (output.Time is not { } time) return;
|
|
double percent = Math.Min(98, 2 + time.TotalSeconds / duration * 96);
|
|
progress.Report(new(percent, "Rendering short", $"{time:hh\\:mm\\:ss} / {totalSpan:hh\\:mm\\:ss}"));
|
|
});
|
|
|
|
progress.Report(new(100, "Complete", settings.OutputPath));
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(workDir, true); } catch { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders two 9:16 stills with the same filtergraph as the final Short: opening hook and closing title card.
|
|
/// </summary>
|
|
public async Task<ShortPreviewFrames> RenderPreviewAsync(
|
|
ShortSettings settings, string outputDirectory, Action<string> log, CancellationToken token)
|
|
{
|
|
ValidatePreview(settings);
|
|
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
|
|
Directory.CreateDirectory(outputDirectory);
|
|
|
|
string workDir = Path.Combine(outputDirectory, "meta");
|
|
Directory.CreateDirectory(workDir);
|
|
|
|
string hookFile = WriteTextFile(workDir, "hook.txt", string.IsNullOrWhiteSpace(settings.Hook) ? "THIS RAN ON AN AMIGA" : settings.Hook);
|
|
string titleFile = WriteTextFile(workDir, "title.txt", string.IsNullOrWhiteSpace(settings.Title) ? "TITLE" : settings.Title);
|
|
string metaFile = WriteTextFile(workDir, "meta.txt", BuildMeta(settings));
|
|
string websiteFile = WriteTextFile(workDir, "website.txt", string.IsNullOrWhiteSpace(settings.Website) ? "AMIGADB.NET" : settings.Website);
|
|
|
|
log("Resolving preview look...");
|
|
string baseFilter = await ResolveBaseFilterAsync(tools, settings, log, token);
|
|
bool includeText = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
|
|
|
|
string openingPath = Path.Combine(outputDirectory, "opening.png");
|
|
string closingPath = Path.Combine(outputDirectory, "closing.png");
|
|
await RenderPreviewStillAsync(
|
|
tools, settings, baseFilter, includeText,
|
|
hookFile, titleFile, metaFile, websiteFile,
|
|
ShortPreviewShot.Opening, openingPath, log, token);
|
|
await RenderPreviewStillAsync(
|
|
tools, settings, baseFilter, includeText,
|
|
hookFile, titleFile, metaFile, websiteFile,
|
|
ShortPreviewShot.Closing, closingPath, log, token);
|
|
|
|
return new ShortPreviewFrames(openingPath, closingPath);
|
|
}
|
|
|
|
/// <summary>Writes one source frame (no style filters) so the crop editor can show the raw clip.</summary>
|
|
public async Task ExtractSourceFrameAsync(
|
|
string inputFile,
|
|
string start,
|
|
string ffmpegPath,
|
|
string ffprobePath,
|
|
string outputPath,
|
|
Action<string> log,
|
|
CancellationToken token)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(inputFile) || !File.Exists(inputFile))
|
|
throw new FileNotFoundException("Select a valid source video for the Short.");
|
|
|
|
string? directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
|
if (!string.IsNullOrEmpty(directory))
|
|
Directory.CreateDirectory(directory);
|
|
|
|
ToolPaths tools = await ToolExtractor.ResolveAsync(ffmpegPath, ffprobePath, token);
|
|
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token);
|
|
}
|
|
|
|
internal static List<string> BuildExtractFrameArguments(string inputFile, string start, string outputPath) =>
|
|
[
|
|
"-hide_banner",
|
|
"-y",
|
|
"-ss",
|
|
string.IsNullOrWhiteSpace(start) ? "00:00:00" : start,
|
|
"-i",
|
|
inputFile,
|
|
"-frames:v",
|
|
"1",
|
|
"-update",
|
|
"1",
|
|
outputPath,
|
|
];
|
|
|
|
/// <summary>Looks for a common bold system font to pre-fill the Shorts font field with.</summary>
|
|
public static string? FindDefaultFont()
|
|
{
|
|
string fontsDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
|
|
foreach (string name in new[] { "segoeuib.ttf", "seguisb.ttf", "arialbd.ttf", "calibrib.ttf" })
|
|
{
|
|
string candidate = Path.Combine(fontsDir, name);
|
|
if (File.Exists(candidate)) return candidate;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
internal static void Validate(ShortSettings s)
|
|
{
|
|
ValidatePreview(s);
|
|
if (string.IsNullOrWhiteSpace(s.Title))
|
|
throw new ArgumentException("Enter a title.");
|
|
if (string.IsNullOrWhiteSpace(s.OutputPath))
|
|
throw new ArgumentException("Select an output file.");
|
|
if (string.IsNullOrWhiteSpace(s.FontFile) || !File.Exists(s.FontFile))
|
|
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
|
|
}
|
|
|
|
internal static void ValidatePreview(ShortSettings s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
|
|
throw new FileNotFoundException("Select a valid source video for the Short.");
|
|
if (s.DurationSeconds <= 0)
|
|
throw new ArgumentException("Duration must be greater than zero.");
|
|
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)
|
|
{
|
|
List<string> parts = [];
|
|
if (!string.IsNullOrWhiteSpace(s.Group)) parts.Add(s.Group.Trim());
|
|
if (!string.IsNullOrWhiteSpace(s.Year)) parts.Add(s.Year.Trim());
|
|
if (!string.IsNullOrWhiteSpace(s.Type)) parts.Add(s.Type.Trim());
|
|
return string.Join(" · ", parts);
|
|
}
|
|
|
|
internal static string EscapeFfmpegPath(string value) =>
|
|
value.Replace("\\", "\\\\").Replace(":", "\\:").Replace("'", "\\'");
|
|
|
|
private static string WriteTextFile(string directory, string fileName, string content)
|
|
{
|
|
string path = Path.Combine(directory, fileName);
|
|
File.WriteAllText(path, content + "\n", new UTF8Encoding(false));
|
|
return path;
|
|
}
|
|
|
|
internal static string BuildFilterComplex(
|
|
string baseFilter, string fontEsc, string hookEsc, string titleEsc, string metaEsc, string websiteEsc,
|
|
double hookEnd, double infoStart, double ctaStart, bool includeTextOverlays = true)
|
|
{
|
|
if (!includeTextOverlays)
|
|
return baseFilter + ";[base]fps=50,format=yuv420p[vout]";
|
|
|
|
string hookEndStr = F(hookEnd);
|
|
string infoStartStr = F(infoStart);
|
|
string ctaStartStr = F(ctaStart);
|
|
|
|
return baseFilter + ";" +
|
|
"[base]" +
|
|
$"drawbox=x=0:y=0:w=1080:h=245:[email protected]:t=fill:enable='between(t,0,{hookEndStr})'," +
|
|
$"drawtext=fontfile='{fontEsc}':textfile='{hookEsc}':fontcolor=white:fontsize=70:line_spacing=10:x=(w-text_w)/2:y=70:fix_bounds=true:[email protected]:shadowx=4:shadowy=4:enable='between(t,0,{hookEndStr})'," +
|
|
$"drawbox=x=0:y=1490:w=1080:h=430:[email protected]:t=fill:enable='gte(t,{infoStartStr})'," +
|
|
$"drawtext=fontfile='{fontEsc}':textfile='{titleEsc}':fontcolor=white:fontsize=62:line_spacing=8:x=(w-text_w)/2:y=1560:fix_bounds=true:[email protected]:shadowx=4:shadowy=4:enable='gte(t,{infoStartStr})'," +
|
|
$"drawtext=fontfile='{fontEsc}':textfile='{metaEsc}':[email protected]:fontsize=42:x=(w-text_w)/2:y=1655:fix_bounds=true:[email protected]:shadowx=3:shadowy=3:enable='gte(t,{infoStartStr})'," +
|
|
$"drawtext=fontfile='{fontEsc}':textfile='{websiteEsc}':fontcolor=0x55c8ff:fontsize=52:x=(w-text_w)/2:y=1780:fix_bounds=true:[email protected]:shadowx=4:shadowy=4:enable='gte(t,{ctaStartStr})'," +
|
|
"fps=50,format=yuv420p[vout]";
|
|
}
|
|
|
|
internal static List<string> BuildInputArguments(ShortSettings settings, double clipDuration, string? seek = null)
|
|
{
|
|
List<string> args =
|
|
[
|
|
"-hide_banner",
|
|
"-y",
|
|
"-ss",
|
|
seek ?? settings.Start,
|
|
"-t",
|
|
F(clipDuration),
|
|
"-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]);
|
|
return args;
|
|
}
|
|
|
|
internal static (double HookEnd, double InfoStart, double CtaStart) PreviewOverlayWindows(ShortPreviewShot shot, double duration)
|
|
{
|
|
if (shot == ShortPreviewShot.Opening)
|
|
return (Math.Max(duration, 1), duration + 1, duration + 1);
|
|
|
|
return (-1, 0, 0);
|
|
}
|
|
|
|
internal static double PreviewSeekSeconds(ShortPreviewShot shot, double duration)
|
|
{
|
|
if (shot == ShortPreviewShot.Opening)
|
|
return Math.Min(1.2, Math.Max(0, duration * 0.2));
|
|
|
|
return Math.Max(0, duration - 0.4);
|
|
}
|
|
|
|
internal static string AddTimestampOffset(string start, double seconds)
|
|
{
|
|
if (!TimeSpan.TryParse(start, CultureInfo.InvariantCulture, out TimeSpan parsed))
|
|
parsed = TimeSpan.Zero;
|
|
if (seconds < 0)
|
|
seconds = 0;
|
|
return (parsed + TimeSpan.FromSeconds(seconds)).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
|
|
|
|
private async Task RenderPreviewStillAsync(
|
|
ToolPaths tools,
|
|
ShortSettings settings,
|
|
string baseFilter,
|
|
bool includeText,
|
|
string hookFile,
|
|
string titleFile,
|
|
string metaFile,
|
|
string websiteFile,
|
|
ShortPreviewShot shot,
|
|
string outputPath,
|
|
Action<string> log,
|
|
CancellationToken token)
|
|
{
|
|
(double hookEnd, double infoStart, double ctaStart) = PreviewOverlayWindows(shot, settings.DurationSeconds);
|
|
string filterComplex = BuildFilterComplex(
|
|
baseFilter,
|
|
fontEsc: includeText ? EscapeFfmpegPath(settings.FontFile) : "",
|
|
hookEsc: EscapeFfmpegPath(hookFile),
|
|
titleEsc: EscapeFfmpegPath(titleFile),
|
|
metaEsc: EscapeFfmpegPath(metaFile),
|
|
websiteEsc: EscapeFfmpegPath(websiteFile),
|
|
hookEnd,
|
|
infoStart,
|
|
ctaStart,
|
|
includeTextOverlays: includeText);
|
|
|
|
string seek = AddTimestampOffset(settings.Start, PreviewSeekSeconds(shot, settings.DurationSeconds));
|
|
List<string> args = BuildInputArguments(settings, 0.12, seek);
|
|
args.AddRange([
|
|
"-filter_complex", filterComplex,
|
|
"-map", "[vout]",
|
|
"-frames:v", "1",
|
|
"-update", "1",
|
|
outputPath,
|
|
]);
|
|
|
|
log($"Preview {shot.ToString().ToLowerInvariant()} frame...");
|
|
await _runner.RunAsync(tools.Ffmpeg, args, log, null, token);
|
|
}
|
|
|
|
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 => """
|
|
[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1[bg];
|
|
[0:v]scale=920:700:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x07111f,setsar=1,eq=contrast=1.02:saturation=1.02[fg];
|
|
[bg]drawbox=x=65:y=505:w=950:h=730:color=black@0.22:t=fill[bg2];
|
|
[bg2][fg]overlay=x=(W-w)/2:y=520:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Pixel => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=270:480:force_original_aspect_ratio=increase,crop=270:480,scale=1080:1920:flags=neighbor,eq=brightness=-0.36:saturation=1.18,gblur=sigma=2,setsar=1[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x55c8ff,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
|
drawbox=x=0:y='300+20*sin(t*1.70)':w=1080:h=8:color=0x55c8ff@0.72:t=fill,
|
|
drawbox=x=0:y='1610+18*sin(t*1.35)':w=1080:h=8:color=0xff7020@0.72:t=fill[base]
|
|
""",
|
|
|
|
ShortStyle.Mirror => """
|
|
[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 => """
|
|
[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,eq=brightness=-0.04:saturation=1.05[base]
|
|
""",
|
|
|
|
ShortStyle.Workbench => """
|
|
[0:v]split=2[canvas_src][screen_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=0x090c20:t=fill,
|
|
drawbox=x=55:y=410:w=970:h=930:color=0xc4c8d0:t=fill,
|
|
drawbox=x=70:y=425:w=940:h=900:color=0x1b4f9c:t=fill,
|
|
drawbox=x=70:y=425:w=940:h=74:color=0xf28c28:t=fill,
|
|
drawbox=x=88:y=445:w=34:h=34:color=0x111111:t=fill[window];
|
|
[screen_src]scale=960:760:force_original_aspect_ratio=decrease,pad=iw+10:ih+10:5:5:color=0xffffff,setsar=1[screen];
|
|
[window][screen]overlay=x=(W-w)/2:y=525:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Blur => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=35,eq=brightness=-0.22:saturation=0.85,setsar=1[bg];
|
|
[fg_src]scale=1080:900:force_original_aspect_ratio=decrease,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Crt => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=28,eq=brightness=-0.45:saturation=0.55,setsar=1[bg];
|
|
[fg_src]scale=980:800:force_original_aspect_ratio=decrease,lenscorrection=k1=-0.060:k2=0.020,vignette=angle=PI/5,eq=contrast=1.08:saturation=1.10,setsar=1,
|
|
drawgrid=width=iw:height=4:thickness=1:color=black@0.18[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
|
drawbox=x=50:y='220+mod(t*340,1480)':w=980:h=4:color=white@0.08:t=fill[base]
|
|
""",
|
|
|
|
ShortStyle.Copper => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,eq=brightness=-0.50:saturation=0.80,gblur=sigma=18,setsar=1,
|
|
drawbox=x=0:y=0:w=1080:h=1920:color=0x061a5f@0.55:t=fill,
|
|
drawbox=x=0:y='220+20*sin(t*1.00)':w=1080:h=140:color=0x123fcb@0.28:t=fill,
|
|
drawbox=x=0:y='420+24*sin(t*1.20)':w=1080:h=100:color=0x2b8cff@0.22:t=fill,
|
|
drawbox=x=0:y='660+16*sin(t*1.50)':w=1080:h=120:color=0x00b8ff@0.18:t=fill,
|
|
drawbox=x=0:y='1110+22*sin(t*1.10)':w=1080:h=150:color=0xff7a1f@0.20:t=fill,
|
|
drawbox=x=0:y='1360+18*sin(t*1.30)':w=1080:h=110:color=0xffc120@0.18:t=fill,
|
|
drawbox=x=0:y='1660+15*sin(t*1.70)':w=1080:h=130:color=0x58d4ff@0.18:t=fill[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Stars => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.72:saturation=0.28,setsar=1,
|
|
drawbox=x=0:y=0:w=1080:h=1920:color=0x05081b@0.75:t=fill,
|
|
drawbox=x='mod(100+80*t,1080)':y='mod(80+210*t,1920)':w=3:h=3:color=white@0.95:t=fill,
|
|
drawbox=x='mod(880-55*t,1080)':y='mod(220+170*t,1920)':w=2:h=2:color=white@0.85:t=fill,
|
|
drawbox=x='mod(300+120*t,1080)':y='mod(520+140*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
|
|
drawbox=x='mod(980-75*t,1080)':y='mod(710+160*t,1920)':w=2:h=2:color=white@0.80:t=fill,
|
|
drawbox=x='mod(420+65*t,1080)':y='mod(1040+200*t,1920)':w=4:h=4:color=0xffd46b@0.85:t=fill,
|
|
drawbox=x='mod(750-90*t,1080)':y='mod(1280+110*t,1920)':w=2:h=2:color=white@0.88:t=fill,
|
|
drawbox=x='mod(210+75*t,1080)':y='mod(1440+180*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
|
|
drawbox=x='mod(1030-60*t,1080)':y='mod(1680+140*t,1920)':w=2:h=2:color=white@0.82:t=fill,
|
|
drawbox=x='mod(560+95*t,1080)':y='mod(260+260*t,1920)':w=2:h=2:color=white@0.92:t=fill,
|
|
drawbox=x='mod(60+110*t,1080)':y='mod(860+120*t,1920)':w=3:h=3:color=0xffd46b@0.82:t=fill[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=0x55c8ff,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Grid => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=24,eq=brightness=-0.62:saturation=0.36,setsar=1,
|
|
drawbox=x=0:y=0:w=1080:h=1920:color=0x090b17@0.72:t=fill,
|
|
drawgrid=width=90:height=90:thickness=2:color=0x39d2ff@0.18,
|
|
drawbox=x=0:y=0:w=1080:h=1120:color=0x050814@0.80:t=fill,
|
|
drawbox=x=0:y=1120:w=1080:h=4:color=0xff54d9@0.70:t=fill[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xff54d9,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Tiles => """
|
|
[0:v]split=5[fg_src][a][b][c][d];
|
|
[a]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[ta];
|
|
[b]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,hflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[tb];
|
|
[c]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,vflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[tc];
|
|
[d]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,hflip,vflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[td];
|
|
[ta][tb]hstack=inputs=2[top];
|
|
[tc][td]hstack=inputs=2[bottom];
|
|
[top][bottom]vstack=inputs=2[bg];
|
|
[fg_src]scale=980:800:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0xffffff,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Scan => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,scale=360:640:flags=neighbor,scale=1080:1920:flags=neighbor,eq=brightness=-0.44:saturation=1.05,gblur=sigma=2,setsar=1[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x55c8ff,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
|
drawbox=x=40:y='220+mod(t*380,1480)':w=1000:h=8:color=white@0.14:t=fill,
|
|
drawbox=x=0:y='280+20*sin(t*1.40)':w=1080:h=6:color=0x55c8ff@0.45:t=fill,
|
|
drawbox=x=0:y='1640+15*sin(t*1.20)':w=1080:h=6:color=0xff7020@0.45:t=fill[base]
|
|
""",
|
|
|
|
ShortStyle.Starfield => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=32,eq=brightness=-0.78:saturation=0.22,setsar=1,
|
|
drawbox=x=0:y=0:w=1080:h=1920:color=0x020511@0.82:t=fill,
|
|
drawbox=x='mod(120+75*t,1080)':y='mod(80+520*t,1920)':w=3:h=18:color=white@0.92:t=fill,
|
|
drawbox=x='mod(970-55*t,1080)':y='mod(250+430*t,1920)':w=2:h=14:color=0xa4dcff@0.85:t=fill,
|
|
drawbox=x='mod(370+95*t,1080)':y='mod(430+600*t,1920)':w=4:h=24:color=white@0.90:t=fill,
|
|
drawbox=x='mod(810-80*t,1080)':y='mod(640+470*t,1920)':w=2:h=17:color=0xffdc83@0.85:t=fill,
|
|
drawbox=x='mod(230+65*t,1080)':y='mod(900+540*t,1920)':w=3:h=20:color=white@0.88:t=fill,
|
|
drawbox=x='mod(1020-100*t,1080)':y='mod(1110+650*t,1920)':w=4:h=26:color=0x8fd5ff@0.90:t=fill,
|
|
drawbox=x='mod(560+85*t,1080)':y='mod(1320+490*t,1920)':w=2:h=16:color=white@0.86:t=fill,
|
|
drawbox=x='mod(70+110*t,1080)':y='mod(1510+590*t,1920)':w=3:h=22:color=0xffdc83@0.82:t=fill,
|
|
drawbox=x='mod(720-60*t,1080)':y='mod(1720+450*t,1920)':w=2:h=15:color=white@0.88:t=fill,
|
|
drawbox=x='mod(440+105*t,1080)':y='mod(180+700*t,1920)':w=3:h=28:color=0xa4dcff@0.88:t=fill,
|
|
drawbox=x='mod(900-72*t,1080)':y='mod(780+560*t,1920)':w=2:h=18:color=white@0.84:t=fill,
|
|
drawbox=x='mod(310+90*t,1080)':y='mod(1240+620*t,1920)':w=4:h=24:color=white@0.90:t=fill[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0x70cfff,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Plasma => """
|
|
[0:v]split=2[dim_src][fg_src];
|
|
nullsrc=size=1080x1920:rate=50,format=rgb24,
|
|
geq=r='128+105*sin(X/68+T*1.9)+22*sin(Y/91-T*1.1)':g='128+96*sin(Y/75+T*1.5)+26*sin((X+Y)/120+T*1.3)':b='128+110*sin((X-Y)/82-T*1.7)',
|
|
gblur=sigma=18,eq=brightness=-0.16:saturation=1.22[plasma];
|
|
[dim_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=26,eq=brightness=-0.70:saturation=0.35,setsar=1[dim];
|
|
[plasma][dim]blend=all_mode=overlay:all_opacity=0.35[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0xffffff,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Rasterbars => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.70:saturation=0.35,setsar=1,
|
|
drawbox=x=0:y=0:w=1080:h=1920:color=0x05061b@0.80:t=fill,
|
|
drawbox=x=0:y='250+90*sin(t*1.25)':w=1080:h=30:color=0xff3b7f@0.78:t=fill,
|
|
drawbox=x=0:y='300+90*sin(t*1.25)':w=1080:h=24:color=0xff7b32@0.72:t=fill,
|
|
drawbox=x=0:y='345+90*sin(t*1.25)':w=1080:h=18:color=0xffd12b@0.66:t=fill,
|
|
drawbox=x=0:y='1510+85*sin(t*1.10+1.2)':w=1080:h=18:color=0x55e6ff@0.68:t=fill,
|
|
drawbox=x=0:y='1550+85*sin(t*1.10+1.2)':w=1080:h=24:color=0x4b8cff@0.72:t=fill,
|
|
drawbox=x=0:y='1600+85*sin(t*1.10+1.2)':w=1080:h=30:color=0x8b55ff@0.76:t=fill[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Vhs => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=26,eq=brightness=-0.44:saturation=0.55,noise=alls=10:allf=t+u,setsar=1[bg];
|
|
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,chromashift=cbh=2:crh=-2,noise=alls=5:allf=t+u,eq=contrast=1.08:saturation=0.92,vignette=angle=PI/4.5,setsar=1,
|
|
drawgrid=width=iw:height=5:thickness=1:color=black@0.13[fg];
|
|
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
|
drawbox=x=0:y='300+mod(t*510,1320)':w=1080:h=10:color=white@0.08:t=fill,
|
|
drawbox=x=0:y='420+mod(t*320,1080)':w=1080:h=3:color=0x7bd7ff@0.10:t=fill[base]
|
|
""",
|
|
|
|
ShortStyle.Monitor => """
|
|
[0:v]split=2[bg_src][screen_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=34,eq=brightness=-0.68:saturation=0.32,setsar=1,
|
|
drawbox=x=0:y=0:w=1080:h=1920:color=0x11131a@0.78:t=fill,
|
|
drawbox=x=42:y=365:w=996:h=1000:color=0xaaa69a:t=fill,
|
|
drawbox=x=68:y=392:w=944:h=944:color=0x2a2926:t=fill,
|
|
drawbox=x=105:y=438:w=870:h=790:color=0x050505:t=fill,
|
|
drawbox=x=792:y=1260:w=44:h=18:color=0x6aff73:t=fill,
|
|
drawbox=x=852:y=1253:w=30:h=30:color=0x232323:t=fill,
|
|
drawbox=x=910:y=1253:w=30:h=30:color=0x232323:t=fill,
|
|
drawbox=x=380:y=1365:w=320:h=75:color=0x8f8b80:t=fill,
|
|
drawbox=x=290:y=1438:w=500:h=36:color=0x77736b:t=fill[monitor];
|
|
[screen_src]scale=840:750:force_original_aspect_ratio=decrease,lenscorrection=k1=-0.045:k2=0.012,vignette=angle=PI/5,eq=contrast=1.06:saturation=1.06,setsar=1[screen];
|
|
[monitor][screen]overlay=x=(W-w)/2:y=458:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Split => """
|
|
[0:v]split=4[bg_src][main_src][top_src][bottom_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=34,eq=brightness=-0.55:saturation=0.55,setsar=1[bg];
|
|
[top_src]scale=1080:520:force_original_aspect_ratio=increase,crop=1080:360:y='(ih-360)/2',eq=brightness=-0.28:saturation=0.88,gblur=sigma=2,setsar=1[top];
|
|
[bottom_src]scale=1080:520:force_original_aspect_ratio=increase,crop=1080:360:y='(ih-360)/2',hflip,eq=brightness=-0.32:saturation=0.80,gblur=sigma=2,setsar=1[bottom];
|
|
[main_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[main];
|
|
[bg][top]overlay=x=0:y=275[tmp1];
|
|
[tmp1][bottom]overlay=x=0:y=1285[tmp2];
|
|
[tmp2][main]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
|
""",
|
|
|
|
ShortStyle.Spectrum => """
|
|
[0:v]split=2[bg_src][fg_src];
|
|
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.62:saturation=0.40,setsar=1,
|
|
drawbox=x=0:y=0:w=1080:h=1920:color=0x040717@0.68:t=fill[bg];
|
|
[fg_src]scale=1000:780:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=0x55c8ff,setsar=1[fg];
|
|
[0:a]aformat=sample_fmts=fltp:channel_layouts=stereo,showspectrum=s=1000x250:mode=combined:color=rainbow:scale=sqrt:fscale=log:slide=scroll:win_func=hann:fps=50:opacity=0.85,format=rgba[spec];
|
|
[bg][spec]overlay=x=(W-w)/2:y=1360:format=auto[tmp];
|
|
[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."),
|
|
};
|
|
}
|