Add Shorts look preview and visual crop editor

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.
This commit is contained in:
2026-08-15 21:08:36 +02:00
parent 8a734d58d9
commit 323324d713
9 changed files with 958 additions and 12 deletions
+172 -10
View File
@@ -65,11 +65,7 @@ public sealed class ShortsPipeline
websiteEsc: EscapeFfmpegPath(websiteFile),
hookEnd, infoStart, ctaStart);
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]);
List<string> args = BuildInputArguments(settings, duration);
args.AddRange([
"-filter_complex", filterComplex,
@@ -107,6 +103,78 @@ public sealed class ShortsPipeline
}
}
/// <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()
{
@@ -122,16 +190,21 @@ public sealed class ShortsPipeline
internal static void Validate(ShortSettings s)
{
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
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 (s.DurationSeconds <= 0)
throw new ArgumentException("Duration must be greater than zero.");
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)))
@@ -169,8 +242,11 @@ public sealed class ShortsPipeline
internal static string BuildFilterComplex(
string baseFilter, string fontEsc, string hookEsc, string titleEsc, string metaEsc, string websiteEsc,
double hookEnd, double infoStart, double ctaStart)
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);
@@ -186,8 +262,94 @@ public sealed class ShortsPipeline
"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)