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