Compare commits

...
2 Commits
Author SHA1 Message Date
klevze 323324d713 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.
2026-08-15 21:08:36 +02:00
klevze 8a734d58d9 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.
2026-08-15 17:59:18 +02:00
14 changed files with 2857 additions and 145 deletions
+92
View File
@@ -0,0 +1,92 @@
using AmiReel.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Models;
[TestClass]
public class ShortWizardTests
{
[TestMethod]
public void StepsFor_BrandStyle_IncludesEffectSettingsPage()
{
// Act
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Brand);
// Assert
CollectionAssert.AreEqual(
new[]
{
ShortWizardStep.Clip,
ShortWizardStep.Effect,
ShortWizardStep.StyleOptions,
ShortWizardStep.Titles,
ShortWizardStep.Encode,
},
steps.ToArray());
}
[TestMethod]
public void StepsFor_VideoStyle_IncludesEffectSettingsPage()
{
// Act
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Video);
// Assert
Assert.IsTrue(steps.Contains(ShortWizardStep.StyleOptions));
Assert.AreEqual(5, steps.Count);
}
[TestMethod]
public void StepsFor_CropStyle_SkipsEffectSettingsPage()
{
// Act
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Crop);
// Assert
CollectionAssert.AreEqual(
new[]
{
ShortWizardStep.Clip,
ShortWizardStep.Effect,
ShortWizardStep.Titles,
ShortWizardStep.Encode,
},
steps.ToArray());
}
[TestMethod]
public void NeedsStyleOptions_OnlyBrandAndVideo_ReturnsTrue()
{
// Assert
Assert.IsTrue(ShortWizard.NeedsStyleOptions(ShortStyle.Brand));
Assert.IsTrue(ShortWizard.NeedsStyleOptions(ShortStyle.Video));
Assert.IsFalse(ShortWizard.NeedsStyleOptions(ShortStyle.Pixel));
Assert.IsFalse(ShortWizard.NeedsStyleOptions(ShortStyle.Spectrum));
}
[TestMethod]
public void Description_ForEveryStyle_IsNonEmpty()
{
foreach (ShortStyle style in Enum.GetValues<ShortStyle>())
{
// Act
string description = ShortWizard.Description(style);
// Assert
Assert.IsFalse(string.IsNullOrWhiteSpace(description), $"{style} is missing a description.");
}
}
[TestMethod]
public void Title_ForEveryStep_IsNonEmpty()
{
foreach (ShortWizardStep step in Enum.GetValues<ShortWizardStep>())
{
// Act
string title = ShortWizard.Title(step);
// Assert
Assert.IsFalse(string.IsNullOrWhiteSpace(title), $"{step} is missing a title.");
}
}
}
@@ -0,0 +1,100 @@
using AmiReel.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Models;
[TestClass]
public class SourceCropSelectionTests
{
[TestMethod]
public void FitUniform_WiderContainer_LetterboxesHorizontally()
{
// Act
(double offsetX, double offsetY, double width, double height) =
SourceCropSelection.FitUniform(400, 200, 100, 100);
// Assert
Assert.AreEqual(100, offsetX);
Assert.AreEqual(0, offsetY);
Assert.AreEqual(200, width);
Assert.AreEqual(200, height);
}
[TestMethod]
public void FitUniform_TallerContainer_LetterboxesVertically()
{
// Act
(double offsetX, double offsetY, double width, double height) =
SourceCropSelection.FitUniform(200, 400, 100, 100);
// Assert
Assert.AreEqual(0, offsetX);
Assert.AreEqual(100, offsetY);
Assert.AreEqual(200, width);
Assert.AreEqual(200, height);
}
[TestMethod]
public void ToInsets_FullFrameSelection_ReturnsEmptyInsets()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(640, 512, 640, 512, 0, 0, 640, 512);
// Assert
Assert.IsTrue(insets.IsEmpty);
}
[TestMethod]
public void ToInsets_CenteredSelection_ComputesAllFourEdges()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(100, 100, 100, 100, 10, 20, 70, 50);
// Assert
Assert.AreEqual(10, insets.Left);
Assert.AreEqual(20, insets.Right);
Assert.AreEqual(20, insets.Top);
Assert.AreEqual(30, insets.Bottom);
}
[TestMethod]
public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(100, 100, 100, 100, 80, 70, -70, -50);
// Assert
Assert.AreEqual(10, insets.Left);
Assert.AreEqual(20, insets.Right);
Assert.AreEqual(20, insets.Top);
Assert.AreEqual(30, insets.Bottom);
}
[TestMethod]
public void ToDisplayRect_RoundTripsKnownInsets()
{
// Arrange
PixelInsets insets = new(20, 20, 10, 10);
// Act
(double x, double y, double width, double height) =
SourceCropSelection.ToDisplayRect(100, 100, 200, 200, insets);
// Assert
Assert.AreEqual(40, x);
Assert.AreEqual(20, y);
Assert.AreEqual(120, width);
Assert.AreEqual(160, height);
}
[TestMethod]
public void ClampToDisplay_OutsideBounds_ClampsToEdges()
{
// Act
(double x, double y) = SourceCropSelection.ClampToDisplay(-8, 90, 80, 60);
// Assert
Assert.AreEqual(0, x);
Assert.AreEqual(60, y);
}
}
@@ -186,6 +186,74 @@ public class ShortsPipelineTests
ShortsPipeline.Validate(settings);
}
[TestMethod]
public void Validate_VideoStyleWithoutBackgroundVideo_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video);
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_VideoStyleWithBackgroundVideo_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
string background = Path.Combine(directory, "bg.mp4");
File.WriteAllText(background, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with { BackgroundVideo = background };
// Act & Assert (no exception)
ShortsPipeline.Validate(settings);
}
[TestMethod]
public void Validate_VideoOpacityAboveOne_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
string background = Path.Combine(directory, "bg.mp4");
File.WriteAllText(background, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with
{
BackgroundVideo = background,
VideoOpacity = 1.2,
};
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_SourceMarginTooWide_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font) with { SourceMarginX = 540 };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag()
{
@@ -220,4 +288,229 @@ public class ShortsPipelineTests
StringAssert.Contains(result, "enable='gte(t,26.5)'");
StringAssert.Contains(result, "[vout]");
}
[TestMethod]
public void GetBaseFilter_VideoStyle_UsesBackgroundVideoInput()
{
// Act
string filter = ShortsPipeline.GetBaseFilter(ShortStyle.Video);
// Assert
StringAssert.Contains(filter, "[1:v]");
StringAssert.Contains(filter, "colorchannelmixer=aa=1");
StringAssert.Contains(filter, "[base]");
Assert.IsFalse(filter.Contains("gblur=", StringComparison.Ordinal));
}
[TestMethod]
public void BuildVideoBaseFilter_WithBlur_InsertsGblur()
{
// Act
string filter = ShortsPipeline.BuildVideoBaseFilter(
"crop=iw:ih:0:0", "scale=1000:-2:flags=lanczos", blur: 8, opacity: 0.65);
// Assert
StringAssert.Contains(filter, "gblur=sigma=8");
StringAssert.Contains(filter, "colorchannelmixer=aa=0.65");
StringAssert.Contains(filter, "crop=iw:ih:0:0,scale=1000:-2:flags=lanczos");
}
[TestMethod]
public void ApplySourceFade_WhenFadeIsZero_ReturnsOriginalFilter()
{
// Arrange
const string baseFilter = "[0:v]split=2[bg][fg]";
// Act
string result = ShortsPipeline.ApplySourceFade(baseFilter, 0);
// Assert
Assert.AreEqual(baseFilter, result);
}
[TestMethod]
public void ApplySourceFade_WhenFadeIsPositive_RewritesSourceLabel()
{
// Act
string result = ShortsPipeline.ApplySourceFade("[0:v]split=2[bg][fg];[0:a]anull[a]", 1.5);
// Assert
StringAssert.StartsWith(result, "[0:v]format=rgba,fade=t=in:st=0:d=1.5:alpha=1[srcfade];");
StringAssert.Contains(result, "[srcfade]split=2[bg][fg]");
StringAssert.Contains(result, "[0:a]anull[a]");
Assert.IsFalse(result.Contains("[0:v]split", StringComparison.Ordinal));
}
[TestMethod]
public void SelectMostCommonCropSpec_PicksTheMostFrequentSpec()
{
// Arrange
const string output =
"crop=800:480:100:0\n" +
"crop=780:480:120:0\n" +
"crop=780:480:120:0\n" +
"crop=760:480:140:0";
// Act
string? spec = ShortsPipeline.SelectMostCommonCropSpec(output);
// Assert
Assert.AreEqual("crop=780:480:120:0", spec);
}
[TestMethod]
public void TryBuildAutoHorizontalCrop_AppliesEvenSafetyInset()
{
// Act
bool ok = ShortsPipeline.TryBuildAutoHorizontalCrop(800, 100, out int width, out int x);
// Assert
Assert.IsTrue(ok);
Assert.AreEqual(792, width);
Assert.AreEqual(104, x);
}
[TestMethod]
public void BuildManualCropFilter_UsesEdgeInsets()
{
// Act
string filter = ShortsPipeline.BuildManualCropFilter(120, 80, 10, 20);
// Assert
Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter);
}
[TestMethod]
public void SourceTargetWidth_SubtractsBothMargins()
{
// Act
int width = ShortsPipeline.SourceTargetWidth(40);
// Assert
Assert.AreEqual(1000, width);
}
[TestMethod]
public void ValidatePreview_WithMissingTitleAndOutput_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
ShortSettings settings = ValidSettings(input, Path.Combine(directory, "missing-font.ttf")) with
{
Title = "",
OutputPath = "",
};
// Act & Assert (no exception)
ShortsPipeline.ValidatePreview(settings);
}
[TestMethod]
public void ValidatePreview_WithMissingInput_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf");
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.ValidatePreview(settings));
}
[TestMethod]
public void PreviewOverlayWindows_Opening_HidesTitleCard()
{
// Act
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Opening, 30);
// Assert
Assert.AreEqual(30, hookEnd);
Assert.AreEqual(31, infoStart);
Assert.AreEqual(31, ctaStart);
}
[TestMethod]
public void PreviewOverlayWindows_Closing_ShowsTitlesImmediately()
{
// Act
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Closing, 30);
// Assert
Assert.AreEqual(-1, hookEnd);
Assert.AreEqual(0, infoStart);
Assert.AreEqual(0, ctaStart);
}
[TestMethod]
public void PreviewSeekSeconds_OpeningAndClosing_StayInsideDuration()
{
// Act
double opening = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Opening, 30);
double closing = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Closing, 30);
// Assert
Assert.AreEqual(1.2, opening);
Assert.AreEqual(29.6, closing);
}
[TestMethod]
public void AddTimestampOffset_AddsFractionalSeconds()
{
// Act
string result = ShortsPipeline.AddTimestampOffset("00:01:10", 1.2);
// Assert
Assert.AreEqual("00:01:11.200", result);
}
[TestMethod]
public void BuildInputArguments_VideoStyle_IncludesLoopedBackgroundVideo()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf", ShortStyle.Video) with
{
BackgroundVideo = @"D:\bg.mp4",
};
// Act
List<string> args = ShortsPipeline.BuildInputArguments(settings, 0.12, "00:00:01.200");
// Assert
CollectionAssert.Contains(args, "00:00:01.200");
CollectionAssert.Contains(args, "-stream_loop");
CollectionAssert.Contains(args, @"D:\bg.mp4");
CollectionAssert.Contains(args, "in.mp4");
}
[TestMethod]
public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng()
{
// Act
List<string> args = ShortsPipeline.BuildExtractFrameArguments(@"D:\clip.mp4", "00:00:05", @"C:\tmp\frame.png");
// Assert
CollectionAssert.Contains(args, "00:00:05");
CollectionAssert.Contains(args, @"D:\clip.mp4");
CollectionAssert.Contains(args, "-frames:v");
CollectionAssert.Contains(args, "1");
CollectionAssert.Contains(args, @"C:\tmp\frame.png");
}
[TestMethod]
public void BuildFilterComplex_WithoutTextOverlays_SkipsDrawtext()
{
// Act
string result = ShortsPipeline.BuildFilterComplex(
"[0:v]copy[base]",
fontEsc: "", hookEsc: "", titleEsc: "", metaEsc: "", websiteEsc: "",
hookEnd: 2.7, infoStart: 23, ctaStart: 26.5,
includeTextOverlays: false);
// Assert
StringAssert.Contains(result, "[0:v]copy[base]");
StringAssert.Contains(result, "[vout]");
Assert.IsFalse(result.Contains("drawtext", StringComparison.Ordinal));
Assert.IsFalse(result.Contains("drawbox", StringComparison.Ordinal));
}
}
+6
View File
@@ -181,6 +181,12 @@
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style x:Key="TabButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
<Setter Property="Padding" Value="16,9" />
<Setter Property="FontSize" Value="13" />
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{ThemeResource AccentBrush}" />
<Setter Property="Foreground" Value="{ThemeResource PrimaryTextBrush}" />
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="AmiReel.Controls.SourceCropEditor"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<StackPanel Spacing="10" MinWidth="640">
<TextBlock x:Name="StatusText"
Text="Drag on the frame to keep that region. The rest is cropped away."
Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" />
<ProgressBar x:Name="LoadProgress"
IsIndeterminate="True"
Height="6"
Visibility="Collapsed"
AutomationProperties.Name="Loading source frame" />
<Border Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid x:Name="CropHost"
Height="420"
Background="Transparent"
PointerPressed="CropHost_PointerPressed"
PointerMoved="CropHost_PointerMoved"
PointerReleased="CropHost_PointerReleased"
PointerCaptureLost="CropHost_PointerCaptureLost"
SizeChanged="CropHost_SizeChanged"
AutomationProperties.Name="Source crop frame"
AutomationProperties.AutomationId="SourceCropHost">
<Image x:Name="SourceImage"
Stretch="Uniform"
IsHitTestVisible="False"
AutomationProperties.Name="Source video frame" />
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
<Rectangle x:Name="SelectionRect"
Stroke="{ThemeResource AccentBrush}"
StrokeThickness="2"
Fill="#33007ACC"
Visibility="Collapsed" />
</Canvas>
</Grid>
</Border>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock x:Name="InsetsText"
Grid.Column="0"
Text="Crop L0 R0 T0 B0"
Style="{StaticResource MutedTextStyle}"
VerticalAlignment="Center" />
<Button x:Name="ClearCropButton"
Grid.Column="1"
Content="Clear crop"
Click="ClearCrop_Click"
AutomationProperties.Name="Clear crop"
AutomationProperties.AutomationId="ClearSourceCropButton" />
</Grid>
</StackPanel>
</UserControl>
+174
View File
@@ -0,0 +1,174 @@
using System.Runtime.InteropServices.WindowsRuntime;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Foundation;
namespace AmiReel.Controls;
public sealed partial class SourceCropEditor : UserControl
{
private int _pixelWidth;
private int _pixelHeight;
private bool _isDragging;
private Point _dragStart;
private PixelInsets _insets;
public SourceCropEditor()
{
InitializeComponent();
}
public PixelInsets Insets => _insets;
public async Task LoadVideoFrameAsync(
string inputFile, string start, string ffmpegPath, string ffprobePath, CancellationToken token)
{
LoadProgress.Visibility = Visibility.Visible;
StatusText.Text = "Loading a source frame...";
try
{
string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor");
Directory.CreateDirectory(directory);
string framePath = Path.Combine(directory, "source-frame.png");
await new ShortsPipeline().ExtractSourceFrameAsync(
inputFile, start, ffmpegPath, ffprobePath, framePath, _ => { }, token);
await using FileStream stream = File.OpenRead(framePath);
BitmapImage bitmap = new();
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
SourceImage.Source = bitmap;
_pixelWidth = bitmap.PixelWidth;
_pixelHeight = bitmap.PixelHeight;
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · drag on the frame to choose the kept region.";
RefreshSelectionVisual();
}
finally
{
LoadProgress.Visibility = Visibility.Collapsed;
}
}
public void SetInsets(PixelInsets insets)
{
_insets = insets;
RefreshSelectionVisual();
UpdateInsetsText();
}
private void CropHost_PointerPressed(object sender, PointerRoutedEventArgs e)
{
if (_pixelWidth <= 0 || !TryMapToDisplay(e, out double x, out double y, out _, out _))
return;
_isDragging = true;
_dragStart = new Point(x, y);
CropHost.CapturePointer(e.Pointer);
UpdateDraftSelection(x, y);
e.Handled = true;
}
private void CropHost_PointerMoved(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
return;
if (!TryMapToDisplay(e, out double x, out double y, out _, out _))
return;
UpdateDraftSelection(x, y);
e.Handled = true;
}
private void CropHost_PointerReleased(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
return;
_isDragging = false;
CropHost.ReleasePointerCapture(e.Pointer);
if (TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
CommitSelection(x, y, displayWidth, displayHeight);
e.Handled = true;
}
private void CropHost_PointerCaptureLost(object sender, PointerRoutedEventArgs e)
{
_isDragging = false;
}
private void CropHost_SizeChanged(object sender, SizeChangedEventArgs e) => RefreshSelectionVisual();
private void ClearCrop_Click(object sender, RoutedEventArgs e) => SetInsets(default);
private bool TryMapToDisplay(
PointerRoutedEventArgs e, out double x, out double y, out double displayWidth, out double displayHeight)
{
(double offsetX, double offsetY, displayWidth, displayHeight) = CurrentFit();
x = 0;
y = 0;
if (displayWidth <= 0 || displayHeight <= 0)
return false;
PointerPoint point = e.GetCurrentPoint(CropHost);
(x, y) = SourceCropSelection.ClampToDisplay(
point.Position.X - offsetX, point.Position.Y - offsetY, displayWidth, displayHeight);
return true;
}
private void UpdateDraftSelection(double x, double y)
{
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit();
if (displayWidth <= 0)
return;
_insets = SourceCropSelection.ToInsets(
_pixelWidth, _pixelHeight, displayWidth, displayHeight,
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
UpdateInsetsText();
}
private void CommitSelection(double x, double y, double displayWidth, double displayHeight)
{
_insets = SourceCropSelection.ToInsets(
_pixelWidth, _pixelHeight, displayWidth, displayHeight,
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
RefreshSelectionVisual();
UpdateInsetsText();
}
private void RefreshSelectionVisual()
{
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit();
if (displayWidth <= 0 || _pixelWidth <= 0)
{
SelectionRect.Visibility = Visibility.Collapsed;
return;
}
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
}
private void PlaceSelectionRect(double offsetX, double offsetY, double displayWidth, double displayHeight)
{
(double x, double y, double width, double height) = SourceCropSelection.ToDisplayRect(
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _insets);
Canvas.SetLeft(SelectionRect, offsetX + x);
Canvas.SetTop(SelectionRect, offsetY + y);
SelectionRect.Width = width;
SelectionRect.Height = height;
SelectionRect.Visibility = _insets.IsEmpty ? Visibility.Collapsed : Visibility.Visible;
}
private (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) CurrentFit() =>
SourceCropSelection.FitUniform(CropHost.ActualWidth, CropHost.ActualHeight, _pixelWidth, _pixelHeight);
private void UpdateInsetsText() =>
InsetsText.Text = _pixelWidth <= 0
? "Crop L0 R0 T0 B0"
: $"Crop L{_insets.Left} R{_insets.Right} T{_insets.Top} B{_insets.Bottom} · {_pixelWidth - _insets.Left - _insets.Right}×{_pixelHeight - _insets.Top - _insets.Bottom}";
}
+253 -77
View File
@@ -5,20 +5,29 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="using:AmiReel.Controls"
mc:Ignorable="d">
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid RowSpacing="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Button x:Name="VideoTabButton" Content="Video" Click="VideoTab_Click" Style="{StaticResource PrimaryButtonStyle}" MinWidth="120" />
<Button x:Name="ShortsTabButton" Content="Shorts" Click="ShortsTab_Click" MinWidth="120" />
<Button x:Name="ShortsTabButton" Content="Shorts" Click="ShortsTab_Click" Style="{StaticResource TabButtonStyle}" MinWidth="120" />
</StackPanel>
<Grid Grid.Row="1" ColumnSpacing="16" x:Name="VideoContentPanel">
@@ -78,21 +87,21 @@
</Border>
</Grid>
<Grid Grid.Row="1" RowSpacing="16" x:Name="ShortsContentPanel" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid ColumnSpacing="16">
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}" x:Name="ShortsContentPanel" Visibility="Collapsed">
<Grid ColumnSpacing="18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Spacing="14">
<StackPanel Spacing="4">
<TextBlock x:Name="ShortWizardTitleText" Text="Source clip" Style="{StaticResource SectionTitleStyle}" />
<TextBlock x:Name="ShortWizardStepText" Text="Step 1 of 4" Style="{StaticResource MutedTextStyle}" />
<ProgressBar x:Name="ShortWizardProgress" Minimum="0" Maximum="4" Value="1" Height="6" />
</StackPanel>
<Border Grid.Column="0" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Short source and clip" Style="{StaticResource SectionTitleStyle}" />
<StackPanel x:Name="ShortStepClipPanel" Spacing="12">
<TextBlock Text="Choose the Amiga clip and how much of it to use." Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<Grid ColumnSpacing="10" RowSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
@@ -102,58 +111,38 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox x:Name="ShortInputBox" Grid.Row="0" MinHeight="40" ToolTipService.ToolTip="Source video for this Short" />
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseShortInput_Click" VerticalAlignment="Stretch" MinWidth="120" />
<TextBox x:Name="ShortOutputBox" Grid.Row="1" MinHeight="40" ToolTipService.ToolTip="Output .mp4 path" />
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseShortOutput_Click" VerticalAlignment="Stretch" MinWidth="120" />
<TextBox x:Name="ShortInputBox" Grid.Row="0" Header="Source video" MinHeight="40"
AutomationProperties.Name="Source video"
ToolTipService.ToolTip="Source video for this Short" />
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseShortInput_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse source video" />
<TextBox x:Name="ShortOutputBox" Grid.Row="1" Header="Output file" MinHeight="40"
AutomationProperties.Name="Output file"
ToolTipService.ToolTip="Output .mp4 path. Leave blank to name it next to the source." />
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseShortOutput_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse output file" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Start (HH:MM:SS)" Style="{StaticResource FieldLabelStyle}" />
<TextBox x:Name="ShortStartBox" Text="00:00:00" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="Duration (seconds)" Style="{StaticResource FieldLabelStyle}" />
<TextBox x:Name="ShortDurationBox" Text="30" />
</StackPanel>
<TextBox x:Name="ShortStartBox" Grid.Column="0" Header="Start (HH:MM:SS)" Text="00:00:00"
AutomationProperties.Name="Start time" />
<TextBox x:Name="ShortDurationBox" Grid.Column="1" Header="Duration (seconds)" Text="30"
AutomationProperties.Name="Duration in seconds" />
</Grid>
<Button x:Name="PreviewShortSourceButton" Content="Preview source" Click="PreviewShortSource_Click" HorizontalAlignment="Left" />
<Button x:Name="PreviewShortSourceButton" Content="Preview source" Click="PreviewShortSource_Click"
HorizontalAlignment="Left" AutomationProperties.Name="Preview source" />
</StackPanel>
</Border>
<Border Grid.Column="1" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Title card" Style="{StaticResource SectionTitleStyle}" />
<TextBox x:Name="ShortTitleBox" Header="Title" ToolTipService.ToolTip="Production title, e.g. Ami-Back V1.04A" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortGroupBox" Grid.Column="0" Header="Group" />
<TextBox x:Name="ShortYearBox" Grid.Column="1" Header="Year" />
<TextBox x:Name="ShortTypeBox" Grid.Column="2" Header="Type" />
</Grid>
<TextBox x:Name="ShortHookBox" Header="Opening hook" />
<TextBox x:Name="ShortWebsiteBox" Header="Closing website text" />
</StackPanel>
</Border>
</Grid>
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Style and encoding" Style="{StaticResource SectionTitleStyle}" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ComboBox x:Name="ShortStyleBox" Grid.Column="0" Header="Style" SelectionChanged="ShortStyleBox_SelectionChanged" HorizontalAlignment="Stretch">
<StackPanel x:Name="ShortStepEffectPanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="Pick a look. Extra settings appear on the next step only when that effect needs them."
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<ComboBox x:Name="ShortStyleBox" Header="Effect" SelectionChanged="ShortStyleBox_SelectionChanged"
HorizontalAlignment="Stretch" AutomationProperties.Name="Visual effect">
<ComboBoxItem Content="Brand (title card image)" Tag="Brand" />
<ComboBoxItem Content="Pixel" Tag="Pixel" />
<ComboBoxItem Content="Mirror" Tag="Mirror" />
@@ -173,8 +162,125 @@
<ComboBoxItem Content="Monitor" Tag="Monitor" />
<ComboBoxItem Content="Split" Tag="Split" />
<ComboBoxItem Content="Spectrum (needs audio)" Tag="Spectrum" />
<ComboBoxItem Content="Video (background clip)" Tag="Video" />
</ComboBox>
<ComboBox x:Name="ShortPresetBox" Grid.Column="1" Header="x264 preset" HorizontalAlignment="Stretch">
<TextBlock x:Name="ShortStyleDescriptionText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<TextBox x:Name="ShortFadeInBox" Header="Source fade-in (seconds)" Text="1.5"
AutomationProperties.Name="Source fade-in seconds"
ToolTipService.ToolTip="Fade only the Amiga clip in. The background stays visible from the first frame. Use 0 to disable." />
</StackPanel>
<StackPanel x:Name="ShortStepStyleOptionsPanel" Spacing="12" Visibility="Collapsed">
<Grid x:Name="ShortBackgroundRow" ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortBackgroundBox" Grid.Column="0" Header="Background image"
AutomationProperties.Name="Background image" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortBackground_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse background image" />
</Grid>
<StackPanel x:Name="ShortVideoOptionsPanel" Spacing="12">
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortBackgroundVideoBox" Grid.Column="0" Header="Background video"
AutomationProperties.Name="Background video" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortBackgroundVideo_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse background video" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortVideoBlurBox" Grid.Column="0" Header="Background blur" Text="0"
AutomationProperties.Name="Background video blur" />
<TextBox x:Name="ShortVideoOpacityBox" Grid.Column="1" Header="Background opacity (0-1)" Text="1.0"
AutomationProperties.Name="Background video opacity" />
<TextBox x:Name="ShortSourceMarginBox" Grid.Column="2" Header="Side margin (px)" Text="40"
AutomationProperties.Name="Source side margin" />
</Grid>
<ComboBox x:Name="ShortCropModeBox" Header="Source crop" HorizontalAlignment="Stretch"
AutomationProperties.Name="Source crop mode">
<ComboBoxItem Content="Auto (remove left/right black borders)" Tag="Auto" />
<ComboBoxItem Content="Center 4:3" Tag="Center" />
</ComboBox>
<TextBlock Text="Manual crop overrides auto/center. Leave at 0 to use the mode above, or drag the kept region on a source frame."
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortCropLeftBox" Grid.Column="0" Header="Crop left" Text="0"
AutomationProperties.Name="Crop left pixels" />
<TextBox x:Name="ShortCropRightBox" Grid.Column="1" Header="Crop right" Text="0"
AutomationProperties.Name="Crop right pixels" />
<TextBox x:Name="ShortCropTopBox" Grid.Column="2" Header="Crop top" Text="0"
AutomationProperties.Name="Crop top pixels" />
<TextBox x:Name="ShortCropBottomBox" Grid.Column="3" Header="Crop bottom" Text="0"
AutomationProperties.Name="Crop bottom pixels" />
</Grid>
<Button x:Name="OpenCropEditorButton"
Content="Crop visually..."
Click="OpenCropEditor_Click"
HorizontalAlignment="Left"
AutomationProperties.Name="Open visual crop editor"
AutomationProperties.AutomationId="OpenCropEditorButton" />
</StackPanel>
</StackPanel>
<StackPanel x:Name="ShortStepTitlesPanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="These lines appear as the opening hook and the closing title card."
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<TextBox x:Name="ShortTitleBox" Header="Title"
AutomationProperties.Name="Title"
ToolTipService.ToolTip="Production title, e.g. Ami-Back V1.04A" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortGroupBox" Grid.Column="0" Header="Group" AutomationProperties.Name="Group" />
<TextBox x:Name="ShortYearBox" Grid.Column="1" Header="Year" AutomationProperties.Name="Year" />
<TextBox x:Name="ShortTypeBox" Grid.Column="2" Header="Type" AutomationProperties.Name="Type" />
</Grid>
<TextBox x:Name="ShortHookBox" Header="Opening hook" AutomationProperties.Name="Opening hook" />
<TextBox x:Name="ShortWebsiteBox" Header="Closing website text" AutomationProperties.Name="Closing website text" />
</StackPanel>
<StackPanel x:Name="ShortStepEncodePanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="Encoding quality and the font used for the title card."
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortFontBox" Grid.Column="0" Header="Bold font file (.ttf/.otf)"
AutomationProperties.Name="Bold font file" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortFont_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse font file" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="120" />
</Grid.ColumnDefinitions>
<ComboBox x:Name="ShortPresetBox" Grid.Column="0" Header="x264 preset"
HorizontalAlignment="Stretch" AutomationProperties.Name="x264 preset">
<ComboBoxItem Content="ultrafast" Tag="ultrafast" />
<ComboBoxItem Content="superfast" Tag="superfast" />
<ComboBoxItem Content="veryfast" Tag="veryfast" />
@@ -185,30 +291,69 @@
<ComboBoxItem Content="slower" Tag="slower" />
<ComboBoxItem Content="veryslow" Tag="veryslow" />
</ComboBox>
<TextBox x:Name="ShortCrfBox" Grid.Column="1" Header="CRF" AutomationProperties.Name="CRF" />
</Grid>
<Grid x:Name="ShortBackgroundRow" ColumnSpacing="10">
<TextBlock x:Name="ShortSummaryText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Width="308" Spacing="10">
<TextBlock Text="Preview" Style="{StaticResource SectionTitleStyle}" />
<TextBlock x:Name="ShortPreviewStatusText"
Text="Pick a source clip, then update the preview to see the 9:16 look."
Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" />
<Button x:Name="UpdateShortPreviewButton"
Content="Update preview"
Click="UpdateShortPreview_Click"
HorizontalAlignment="Stretch"
AutomationProperties.Name="Update short preview"
AutomationProperties.AutomationId="UpdateShortPreviewButton" />
<ProgressBar x:Name="ShortPreviewProgress"
IsIndeterminate="True"
Height="6"
Visibility="Collapsed"
AutomationProperties.Name="Preview progress" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortBackgroundBox" Grid.Column="0" Header="Background image (brand style)" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortBackground_Click" VerticalAlignment="Bottom" MinWidth="120" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="120" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortFontBox" Grid.Column="0" Header="Bold font file (.ttf/.otf)" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortFont_Click" VerticalAlignment="Bottom" MinWidth="120" />
<TextBox x:Name="ShortCrfBox" Grid.Column="2" Header="CRF" />
<StackPanel Spacing="6">
<TextBlock Text="Opening" Style="{StaticResource MutedTextStyle}" />
<Border Width="148" Height="263"
Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Image x:Name="ShortPreviewOpeningImage"
Stretch="Uniform"
AutomationProperties.Name="Opening hook preview" />
</Border>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="6">
<TextBlock Text="Closing" Style="{StaticResource MutedTextStyle}" />
<Border Width="148" Height="263"
Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Image x:Name="ShortPreviewClosingImage"
Stretch="Uniform"
AutomationProperties.Name="Closing title-card preview" />
</Border>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</Grid>
</Border>
<Border Grid.Row="2" Padding="18" Style="{StaticResource CardBorderStyle}">
<Border x:Name="RenderProgressPanel"
Grid.Row="2"
Padding="18"
Style="{StaticResource CardBorderStyle}"
Visibility="Collapsed"
AutomationProperties.Name="Render progress">
<StackPanel Spacing="12">
<TextBlock Text="Render progress" Style="{StaticResource SectionTitleStyle}" />
<StackPanel Orientation="Horizontal" Spacing="6">
@@ -220,7 +365,12 @@
</StackPanel>
</Border>
<Border Grid.Row="3" Padding="18" Style="{StaticResource CardBorderStyle}">
<Border x:Name="FfmpegLogPanel"
Grid.Row="3"
Padding="18"
Style="{StaticResource CardBorderStyle}"
Visibility="Collapsed"
AutomationProperties.Name="FFmpeg log">
<StackPanel Spacing="12">
<TextBlock Text="FFmpeg log" Style="{StaticResource SectionTitleStyle}" />
<TextBox x:Name="LogBox"
@@ -232,11 +382,14 @@
FontFamily="Consolas"
FontSize="12"
VerticalAlignment="Stretch"
Height="260" />
MinHeight="320" />
</StackPanel>
</Border>
<Grid Grid.Row="4">
</Grid>
</ScrollViewer>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -248,6 +401,11 @@
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button x:Name="ShortBackButton" Content="Previous" Click="ShortBack_Click" Visibility="Collapsed"
MinWidth="120" AutomationProperties.Name="Previous short step" />
<Button x:Name="ShortNextButton" Content="Next" Click="ShortNext_Click" Visibility="Collapsed"
MinWidth="120" Style="{StaticResource PrimaryButtonStyle}"
AutomationProperties.Name="Next short step" />
<Button x:Name="OpenSettingsButton" Content="&#xE713;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets"
Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}"
ToolTipService.ToolTip="Settings" AutomationProperties.Name="Settings" />
@@ -256,6 +414,24 @@
</StackPanel>
</Grid>
<ContentDialog x:Name="CropEditorDialog"
Title="Crop source"
PrimaryButtonText="Use this crop"
CloseButtonText="Cancel"
DefaultButton="Primary"
Background="{ThemeResource PanelBrush}"
Foreground="{ThemeResource TextBrush}"
BorderBrush="{ThemeResource HeroBorderBrush}"
BorderThickness="1.5"
CornerRadius="18"
PrimaryButtonStyle="{StaticResource DialogPrimaryButtonStyle}"
CloseButtonStyle="{StaticResource DialogCloseButtonStyle}">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMaxWidth">920</x:Double>
</ContentDialog.Resources>
<controls:SourceCropEditor x:Name="ShortCropEditor" />
</ContentDialog>
<ContentDialog x:Name="SettingsDialog"
Title="Settings"
PrimaryButtonText="Done"
+373 -15
View File
@@ -1,11 +1,13 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Text;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage;
using Windows.Storage.Pickers;
@@ -23,6 +25,12 @@ public sealed partial class MainPage : Page
private string? _lastRenderedFile;
private bool _logFlushScheduled;
private bool _isShortsTab;
private bool _isRendering;
private bool _keepRenderOutput;
private List<ShortWizardStep> _wizardSteps = [.. ShortWizard.StepsFor(ShortStyle.Brand)];
private int _wizardIndex;
private int _previewGeneration;
private string? _previewDirectory;
public MainPage()
{
@@ -48,12 +56,23 @@ public sealed partial class MainPage : Page
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
ShortCrfBox.Text = _loadedSettings.ShortsCrf;
ShortBackgroundBox.Text = _loadedSettings.ShortsBackgroundImage;
ShortBackgroundVideoBox.Text = _loadedSettings.ShortsBackgroundVideo;
ShortVideoBlurBox.Text = _loadedSettings.ShortsVideoBlur;
ShortVideoOpacityBox.Text = _loadedSettings.ShortsVideoOpacity;
ShortSourceMarginBox.Text = _loadedSettings.ShortsSourceMarginX;
ShortFadeInBox.Text = _loadedSettings.ShortsSourceFadeIn;
ShortCropLeftBox.Text = _loadedSettings.ShortsCropLeft;
ShortCropRightBox.Text = _loadedSettings.ShortsCropRight;
ShortCropTopBox.Text = _loadedSettings.ShortsCropTop;
ShortCropBottomBox.Text = _loadedSettings.ShortsCropBottom;
ShortFontBox.Text = string.IsNullOrWhiteSpace(_loadedSettings.ShortsFontFile)
? ShortsPipeline.FindDefaultFont() ?? ""
: _loadedSettings.ShortsFontFile;
SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle);
SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset);
UpdateShortBackgroundVisibility();
SelectComboTag(ShortCropModeBox, _loadedSettings.ShortsSourceCropMode);
RefreshWizardSteps();
ShowWizardStep();
}
private void VideoTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: false);
@@ -62,23 +81,137 @@ public sealed partial class MainPage : Page
private void SetActiveTab(bool shorts)
{
if (!_isRendering)
_keepRenderOutput = false;
_isShortsTab = shorts;
VideoContentPanel.Visibility = shorts ? Visibility.Collapsed : Visibility.Visible;
ShortsContentPanel.Visibility = shorts ? Visibility.Visible : Visibility.Collapsed;
Style primaryButtonStyle = (Style)Application.Current.Resources["PrimaryButtonStyle"];
VideoTabButton.Style = shorts ? null : primaryButtonStyle;
ShortsTabButton.Style = shorts ? primaryButtonStyle : null;
Style tabButtonStyle = (Style)Application.Current.Resources["TabButtonStyle"];
VideoTabButton.Style = shorts ? tabButtonStyle : primaryButtonStyle;
ShortsTabButton.Style = shorts ? primaryButtonStyle : tabButtonStyle;
RenderButton.Content = shorts ? "Render short" : "Start render";
UpdateWorkspaceVisibility();
}
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateShortBackgroundVisibility();
private void UpdateShortBackgroundVisibility()
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
bool isBrand = string.Equals(SelectedShortStyle(), nameof(ShortStyle.Brand), StringComparison.OrdinalIgnoreCase);
ShortBackgroundRow.Visibility = isBrand ? Visibility.Visible : Visibility.Collapsed;
if (!IsLoaded) return;
RefreshWizardSteps();
ShowWizardStep();
}
private void ShortBack_Click(object sender, RoutedEventArgs e)
{
if (_wizardIndex <= 0) return;
_keepRenderOutput = false;
_wizardIndex--;
ShowWizardStep();
}
private async void ShortNext_Click(object sender, RoutedEventArgs e)
{
try
{
ValidateCurrentWizardStep();
if (_wizardIndex >= _wizardSteps.Count - 1) return;
_keepRenderOutput = false;
_wizardIndex++;
ShowWizardStep();
}
catch (Exception exception)
{
await ShowMessageAsync("Check this step", exception.Message);
}
}
private void RefreshWizardSteps()
{
ShortWizardStep current = _wizardSteps[Math.Clamp(_wizardIndex, 0, _wizardSteps.Count - 1)];
_wizardSteps = [.. ShortWizard.StepsFor(SelectedShortStyleEnum())];
int index = _wizardSteps.IndexOf(current);
_wizardIndex = index >= 0 ? index : Math.Min(_wizardIndex, _wizardSteps.Count - 1);
}
private void ShowWizardStep()
{
ShortWizardStep step = _wizardSteps[Math.Clamp(_wizardIndex, 0, _wizardSteps.Count - 1)];
ShortWizardTitleText.Text = ShortWizard.Title(step);
ShortWizardStepText.Text = $"Step {_wizardIndex + 1} of {_wizardSteps.Count}";
ShortWizardProgress.Maximum = _wizardSteps.Count;
ShortWizardProgress.Value = _wizardIndex + 1;
ShortStepClipPanel.Visibility = VisibleIf(step == ShortWizardStep.Clip);
ShortStepEffectPanel.Visibility = VisibleIf(step == ShortWizardStep.Effect);
ShortStepStyleOptionsPanel.Visibility = VisibleIf(step == ShortWizardStep.StyleOptions);
ShortStepTitlesPanel.Visibility = VisibleIf(step == ShortWizardStep.Titles);
ShortStepEncodePanel.Visibility = VisibleIf(step == ShortWizardStep.Encode);
ShortStyle style = SelectedShortStyleEnum();
ShortStyleDescriptionText.Text = ShortWizard.Description(style);
ShortBackgroundRow.Visibility = VisibleIf(style == ShortStyle.Brand);
ShortVideoOptionsPanel.Visibility = VisibleIf(style == ShortStyle.Video);
if (step == ShortWizardStep.Encode)
ShortSummaryText.Text = BuildShortSummary();
UpdateWizardNavigation();
if (IsLoaded && step != ShortWizardStep.Clip)
_ = RefreshShortPreviewAsync(automatic: true);
}
private void UpdateWizardNavigation()
{
bool shorts = _isShortsTab && !_isRendering;
bool lastStep = _wizardIndex >= _wizardSteps.Count - 1;
ShortBackButton.Visibility = VisibleIf(shorts);
ShortNextButton.Visibility = VisibleIf(shorts && !lastStep);
ShortBackButton.IsEnabled = shorts && _wizardIndex > 0;
ShortNextButton.IsEnabled = shorts;
RenderButton.Visibility = VisibleIf(!_isRendering && (!shorts || lastStep));
}
private void ValidateCurrentWizardStep()
{
ShortWizardStep step = _wizardSteps[_wizardIndex];
switch (step)
{
case ShortWizardStep.Clip:
if (string.IsNullOrWhiteSpace(ShortInputBox.Text) || !File.Exists(ShortInputBox.Text.Trim()))
throw new FileNotFoundException("Select a valid source video for the Short.");
ParsePositiveDuration();
break;
case ShortWizardStep.Effect:
ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
break;
case ShortWizardStep.StyleOptions:
if (SelectedShortStyleEnum() == ShortStyle.Brand
&& (string.IsNullOrWhiteSpace(ShortBackgroundBox.Text) || !File.Exists(ShortBackgroundBox.Text.Trim())))
throw new FileNotFoundException("The brand style requires a background image.");
if (SelectedShortStyleEnum() == ShortStyle.Video)
ReadVideoStyleOptions();
break;
case ShortWizardStep.Titles:
if (string.IsNullOrWhiteSpace(ShortTitleBox.Text))
throw new ArgumentException("Enter a title.");
break;
case ShortWizardStep.Encode:
if (string.IsNullOrWhiteSpace(ShortFontBox.Text) || !File.Exists(ShortFontBox.Text.Trim()))
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
ParseCrf();
break;
}
}
private string BuildShortSummary()
{
string input = string.IsNullOrWhiteSpace(ShortInputBox.Text)
? "(no source yet)"
: Path.GetFileName(ShortInputBox.Text.Trim());
string title = string.IsNullOrWhiteSpace(ShortTitleBox.Text) ? "(no title yet)" : ShortTitleBox.Text.Trim();
return $"{SelectedShortStyleEnum()} · {input} · {ShortStartBox.Text.Trim()} + {ShortDurationBox.Text.Trim()}s · {title}";
}
private static Visibility VisibleIf(bool visible) => visible ? Visibility.Visible : Visibility.Collapsed;
private async void AddInputs_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
@@ -180,6 +313,7 @@ public sealed partial class MainPage : Page
{
try
{
_keepRenderOutput = false;
SetRendering(true);
LogBox.Text = string.Empty;
_renderCancellation = new CancellationTokenSource();
@@ -191,10 +325,12 @@ public sealed partial class MainPage : Page
}
catch (OperationCanceledException)
{
_keepRenderOutput = true;
UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled."));
}
catch (Exception exception)
{
_keepRenderOutput = true;
AppendLog("ERROR: " + exception);
StageText.Text = "Failed";
await ShowMessageAsync("Render failed", UserFacingErrors.Summarize(exception));
@@ -308,14 +444,15 @@ public sealed partial class MainPage : Page
private ShortSettings ReadShortSettings()
{
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
throw new ArgumentException("Enter a valid duration in seconds.");
if (!int.TryParse(ShortCrfBox.Text, out int crf))
throw new ArgumentException("CRF must be an integer.");
double duration = ParsePositiveDuration();
int crf = ParseCrf();
(double blur, double opacity, int marginX, ShortSourceCropMode cropMode, int left, int right, int top, int bottom)
= ReadVideoStyleOptions();
double fadeIn = ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
string input = ShortInputBox.Text.Trim();
string output = ShortOutputBox.Text.Trim();
ShortStyle style = Enum.Parse<ShortStyle>(SelectedShortStyle());
ShortStyle style = SelectedShortStyleEnum();
if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(input))
{
string baseName = Path.GetFileNameWithoutExtension(input);
@@ -337,6 +474,16 @@ public sealed partial class MainPage : Page
DurationSeconds = duration,
Style = style,
BackgroundImage = ShortBackgroundBox.Text.Trim(),
BackgroundVideo = ShortBackgroundVideoBox.Text.Trim(),
VideoBlur = blur,
VideoOpacity = opacity,
SourceCropMode = cropMode,
CropLeft = left,
CropRight = right,
CropTop = top,
CropBottom = bottom,
SourceMarginX = marginX,
SourceFadeInSeconds = fadeIn,
FontFile = ShortFontBox.Text.Trim(),
FfmpegPath = FfmpegPathBox.Text.Trim(),
FfprobePath = FfprobePathBox.Text.Trim(),
@@ -345,6 +492,59 @@ public sealed partial class MainPage : Page
};
}
private double ParsePositiveDuration()
{
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
throw new ArgumentException("Enter a valid duration in seconds.");
return duration;
}
private int ParseCrf()
{
if (!int.TryParse(ShortCrfBox.Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int crf))
throw new ArgumentException("CRF must be an integer.");
return crf;
}
private static double ParseNonNegativeNumber(string text, string name)
{
if (!double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0)
throw new ArgumentException($"Enter a valid non-negative value for {name}.");
return value;
}
private static int ParseNonNegativeInt(string text, string name)
{
if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) || value < 0)
throw new ArgumentException($"{name} must be a non-negative integer.");
return value;
}
private (double Blur, double Opacity, int MarginX, ShortSourceCropMode CropMode, int Left, int Right, int Top, int Bottom)
ReadVideoStyleOptions()
{
double blur = ParseNonNegativeNumber(ShortVideoBlurBox.Text, "background blur");
double opacity = ParseNonNegativeNumber(ShortVideoOpacityBox.Text, "background opacity");
if (opacity > 1)
throw new ArgumentException("Background opacity must be between 0.0 and 1.0.");
int marginX = ParseNonNegativeInt(ShortSourceMarginBox.Text, "Side margin");
if (marginX >= 540)
throw new ArgumentException("Source side margin must be less than 540 pixels.");
if (SelectedShortStyleEnum() == ShortStyle.Video
&& (string.IsNullOrWhiteSpace(ShortBackgroundVideoBox.Text) || !File.Exists(ShortBackgroundVideoBox.Text.Trim())))
throw new FileNotFoundException("The video style requires a background video.");
return (
blur,
opacity,
marginX,
SelectedShortCropMode(),
ParseNonNegativeInt(ShortCropLeftBox.Text, "Crop left"),
ParseNonNegativeInt(ShortCropRightBox.Text, "Crop right"),
ParseNonNegativeInt(ShortCropTopBox.Text, "Crop top"),
ParseNonNegativeInt(ShortCropBottomBox.Text, "Crop bottom"));
}
private async void BrowseShortInput_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
@@ -378,6 +578,17 @@ public sealed partial class MainPage : Page
ShortBackgroundBox.Text = file.Path;
}
private async void BrowseShortBackgroundVideo_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
foreach (string extension in SupportedVideoFormats.Extensions)
picker.FileTypeFilter.Add(extension);
picker.SuggestedStartLocation = PickerLocationId.VideosLibrary;
var file = await picker.PickSingleFileAsync();
if (file is not null)
ShortBackgroundVideoBox.Text = file.Path;
}
private async void BrowseShortFont_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
@@ -388,19 +599,147 @@ public sealed partial class MainPage : Page
ShortFontBox.Text = file.Path;
}
private async void OpenCropEditor_Click(object sender, RoutedEventArgs e)
{
string input = ShortInputBox.Text.Trim();
if (string.IsNullOrWhiteSpace(input) || !File.Exists(input))
{
await ShowMessageAsync("Crop source", "Select a valid source video first.");
return;
}
try
{
CropEditorDialog.XamlRoot = XamlRoot;
CropEditorDialog.RequestedTheme = ActualTheme;
await ShortCropEditor.LoadVideoFrameAsync(
input,
string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
CancellationToken.None);
ShortCropEditor.SetInsets(ReadCropInsets());
ContentDialogResult result = await CropEditorDialog.ShowAsync();
if (result != ContentDialogResult.Primary)
return;
PixelInsets insets = ShortCropEditor.Insets;
ShortCropLeftBox.Text = insets.Left.ToString(CultureInfo.InvariantCulture);
ShortCropRightBox.Text = insets.Right.ToString(CultureInfo.InvariantCulture);
ShortCropTopBox.Text = insets.Top.ToString(CultureInfo.InvariantCulture);
ShortCropBottomBox.Text = insets.Bottom.ToString(CultureInfo.InvariantCulture);
}
catch (Exception exception)
{
await ShowMessageAsync("Crop source", UserFacingErrors.Summarize(exception));
}
}
private PixelInsets ReadCropInsets()
{
static int ReadBox(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0
? value
: 0;
}
return new PixelInsets(
ReadBox(ShortCropLeftBox.Text),
ReadBox(ShortCropRightBox.Text),
ReadBox(ShortCropTopBox.Text),
ReadBox(ShortCropBottomBox.Text));
}
private async void PreviewShortSource_Click(object sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
await OpenPreviewAsync(ShortInputBox.Text.Trim());
}
private async void UpdateShortPreview_Click(object sender, RoutedEventArgs e) =>
await RefreshShortPreviewAsync(automatic: false);
private async Task RefreshShortPreviewAsync(bool automatic)
{
if (_isRendering)
return;
int generation = ++_previewGeneration;
ShortPreviewProgress.Visibility = Visibility.Visible;
UpdateShortPreviewButton.IsEnabled = false;
ShortPreviewStatusText.Text = "Rendering 9:16 preview frames...";
try
{
ShortSettings settings = ReadShortSettings();
ShortsPipeline.ValidatePreview(settings);
_previewDirectory ??= Path.Combine(Path.GetTempPath(), "AmiReel", "short-preview", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_previewDirectory);
ShortPreviewFrames frames = await new ShortsPipeline().RenderPreviewAsync(
settings, _previewDirectory, _ => { }, CancellationToken.None);
if (generation != _previewGeneration)
return;
ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath);
ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath);
bool hasFont = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
ShortPreviewStatusText.Text = hasFont
? $"{settings.Style} · opening hook and closing title card"
: "Look only — pick a font on the last step to include the title-card text.";
}
catch (Exception exception)
{
if (generation != _previewGeneration)
return;
string message = UserFacingErrors.Summarize(exception);
ShortPreviewStatusText.Text = message;
if (!automatic)
await ShowMessageAsync("Preview failed", message);
}
finally
{
if (generation == _previewGeneration)
{
ShortPreviewProgress.Visibility = Visibility.Collapsed;
UpdateShortPreviewButton.IsEnabled = true;
}
}
}
private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path)
{
await using FileStream stream = File.OpenRead(path);
BitmapImage bitmap = new() { DecodePixelWidth = 296 };
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
return bitmap;
}
private void SetRendering(bool rendering)
{
_isRendering = rendering;
RenderButton.IsEnabled = !rendering;
CancelButton.IsEnabled = rendering;
OpenSettingsButton.IsEnabled = !rendering;
VideoTabButton.IsEnabled = !rendering;
ShortsTabButton.IsEnabled = !rendering;
UpdateWorkspaceVisibility();
}
private void UpdateWorkspaceVisibility()
{
bool showSetup = !_isRendering;
bool showOutput = _isRendering || _keepRenderOutput;
VideoContentPanel.Visibility = VisibleIf(showSetup && !_isShortsTab);
ShortsContentPanel.Visibility = VisibleIf(showSetup && _isShortsTab);
RenderProgressPanel.Visibility = VisibleIf(showOutput);
FfmpegLogPanel.Visibility = VisibleIf(showOutput);
UpdateWizardNavigation();
}
private void UpdateProgress(RenderProgress value)
@@ -496,6 +835,16 @@ public sealed partial class MainPage : Page
ShortsWebsite = ShortWebsiteBox.Text.Trim(),
ShortsFontFile = ShortFontBox.Text.Trim(),
ShortsBackgroundImage = ShortBackgroundBox.Text.Trim(),
ShortsBackgroundVideo = ShortBackgroundVideoBox.Text.Trim(),
ShortsVideoBlur = ShortVideoBlurBox.Text.Trim(),
ShortsVideoOpacity = ShortVideoOpacityBox.Text.Trim(),
ShortsSourceCropMode = SelectedShortCropMode().ToString(),
ShortsCropLeft = ShortCropLeftBox.Text.Trim(),
ShortsCropRight = ShortCropRightBox.Text.Trim(),
ShortsCropTop = ShortCropTopBox.Text.Trim(),
ShortsCropBottom = ShortCropBottomBox.Text.Trim(),
ShortsSourceMarginX = ShortSourceMarginBox.Text.Trim(),
ShortsSourceFadeIn = ShortFadeInBox.Text.Trim(),
ShortsCrf = ShortCrfBox.Text.Trim(),
ShortsPreset = SelectedShortPreset(),
});
@@ -507,6 +856,15 @@ public sealed partial class MainPage : Page
private string SelectedShortStyle() => (ShortStyleBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Brand";
private ShortStyle SelectedShortStyleEnum() =>
Enum.TryParse(SelectedShortStyle(), ignoreCase: true, out ShortStyle style) ? style : ShortStyle.Brand;
private ShortSourceCropMode SelectedShortCropMode()
{
string tag = (ShortCropModeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto";
return Enum.TryParse(tag, ignoreCase: true, out ShortSourceCropMode mode) ? mode : ShortSourceCropMode.Auto;
}
private string SelectedShortPreset() => (ShortPresetBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "slow";
private void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme);
+20
View File
@@ -28,6 +28,16 @@ public sealed record AppSettings
public string ShortsWebsite { get; init; } = "AMIGADB.NET";
public string ShortsFontFile { get; init; } = "";
public string ShortsBackgroundImage { get; init; } = "";
public string ShortsBackgroundVideo { get; init; } = "";
public string ShortsVideoBlur { get; init; } = "0";
public string ShortsVideoOpacity { get; init; } = "1.0";
public string ShortsSourceCropMode { get; init; } = "Auto";
public string ShortsCropLeft { get; init; } = "0";
public string ShortsCropRight { get; init; } = "0";
public string ShortsCropTop { get; init; } = "0";
public string ShortsCropBottom { get; init; } = "0";
public string ShortsSourceMarginX { get; init; } = "40";
public string ShortsSourceFadeIn { get; init; } = "1.5";
public string ShortsCrf { get; init; } = "16";
public string ShortsPreset { get; init; } = "slow";
@@ -68,6 +78,16 @@ public sealed record AppSettings
ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET",
ShortsFontFile = ShortsFontFile ?? "",
ShortsBackgroundImage = ShortsBackgroundImage ?? "",
ShortsBackgroundVideo = ShortsBackgroundVideo ?? "",
ShortsVideoBlur = string.IsNullOrWhiteSpace(ShortsVideoBlur) ? "0" : ShortsVideoBlur,
ShortsVideoOpacity = string.IsNullOrWhiteSpace(ShortsVideoOpacity) ? "1.0" : ShortsVideoOpacity,
ShortsSourceCropMode = string.IsNullOrWhiteSpace(ShortsSourceCropMode) ? "Auto" : ShortsSourceCropMode,
ShortsCropLeft = string.IsNullOrWhiteSpace(ShortsCropLeft) ? "0" : ShortsCropLeft,
ShortsCropRight = string.IsNullOrWhiteSpace(ShortsCropRight) ? "0" : ShortsCropRight,
ShortsCropTop = string.IsNullOrWhiteSpace(ShortsCropTop) ? "0" : ShortsCropTop,
ShortsCropBottom = string.IsNullOrWhiteSpace(ShortsCropBottom) ? "0" : ShortsCropBottom,
ShortsSourceMarginX = string.IsNullOrWhiteSpace(ShortsSourceMarginX) ? "40" : ShortsSourceMarginX,
ShortsSourceFadeIn = string.IsNullOrWhiteSpace(ShortsSourceFadeIn) ? "1.5" : ShortsSourceFadeIn,
ShortsCrf = string.IsNullOrWhiteSpace(ShortsCrf) ? "16" : ShortsCrf,
ShortsPreset = string.IsNullOrWhiteSpace(ShortsPreset) ? "slow" : ShortsPreset,
};
+30 -2
View File
@@ -1,6 +1,6 @@
namespace AmiReel.Models;
/// <summary>Visual style applied to a YouTube Short. Mirrors the --style values of amigadb-short.sh.</summary>
/// <summary>Visual style applied to a YouTube Short. Mirrors the --style values of amigadb-short-fx-v4.sh.</summary>
public enum ShortStyle
{
Brand,
@@ -22,9 +22,27 @@ public enum ShortStyle
Monitor,
Split,
Spectrum,
Video,
}
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short.sh's arguments).</summary>
/// <summary>How the video style crops the Amiga source before overlaying it.</summary>
public enum ShortSourceCropMode
{
Auto,
Center,
}
/// <summary>Which moment of the Short a preview still represents.</summary>
public enum ShortPreviewShot
{
Opening,
Closing,
}
/// <summary>PNG stills of the opening hook and closing title card.</summary>
public sealed record ShortPreviewFrames(string OpeningPath, string ClosingPath);
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short-fx-v4.sh).</summary>
public sealed record ShortSettings
{
public required string InputFile { get; init; }
@@ -39,6 +57,16 @@ public sealed record ShortSettings
public required double DurationSeconds { get; init; }
public required ShortStyle Style { get; init; }
public string BackgroundImage { get; init; } = "";
public string BackgroundVideo { get; init; } = "";
public double VideoBlur { get; init; }
public double VideoOpacity { get; init; } = 1.0;
public ShortSourceCropMode SourceCropMode { get; init; } = ShortSourceCropMode.Auto;
public int CropLeft { get; init; }
public int CropRight { get; init; }
public int CropTop { get; init; }
public int CropBottom { get; init; }
public int SourceMarginX { get; init; } = 40;
public double SourceFadeInSeconds { get; init; } = 1.5;
public required string FontFile { get; init; }
public required string FfmpegPath { get; init; }
public required string FfprobePath { get; init; }
+63
View File
@@ -0,0 +1,63 @@
namespace AmiReel.Models;
/// <summary>One page of the Shorts create-wizard. Extra effect pages are inserted only when the style needs them.</summary>
public enum ShortWizardStep
{
Clip,
Effect,
StyleOptions,
Titles,
Encode,
}
/// <summary>Builds the Shorts wizard page list for a chosen visual style.</summary>
public static class ShortWizard
{
public static IReadOnlyList<ShortWizardStep> StepsFor(ShortStyle style)
{
List<ShortWizardStep> steps = [ShortWizardStep.Clip, ShortWizardStep.Effect];
if (NeedsStyleOptions(style))
steps.Add(ShortWizardStep.StyleOptions);
steps.Add(ShortWizardStep.Titles);
steps.Add(ShortWizardStep.Encode);
return steps;
}
public static bool NeedsStyleOptions(ShortStyle style) =>
style is ShortStyle.Brand or ShortStyle.Video;
public static string Title(ShortWizardStep step) => step switch
{
ShortWizardStep.Clip => "Source clip",
ShortWizardStep.Effect => "Visual effect",
ShortWizardStep.StyleOptions => "Effect settings",
ShortWizardStep.Titles => "Title card",
ShortWizardStep.Encode => "Encoding",
_ => "Short",
};
public static string Description(ShortStyle style) => style switch
{
ShortStyle.Brand => "Title-card background image with a framed source clip.",
ShortStyle.Pixel => "Pixelated backdrop with neon scan bars.",
ShortStyle.Mirror => "Reflected source strips above and below the clip.",
ShortStyle.Crop => "Full-frame vertical crop of the source.",
ShortStyle.Workbench => "Workbench-style window around the source.",
ShortStyle.Blur => "Blurred backdrop with a centered source.",
ShortStyle.Crt => "CRT curvature, scanlines, and a rolling highlight.",
ShortStyle.Copper => "Amiga copper bars behind the source.",
ShortStyle.Stars => "Twinkling star field behind a framed clip.",
ShortStyle.Grid => "Neon grid horizon behind the source.",
ShortStyle.Tiles => "Four-tile mirrored backdrop.",
ShortStyle.Scan => "Chunky scanlines and moving bars.",
ShortStyle.Starfield => "Fast-moving star streaks.",
ShortStyle.Plasma => "Demoscene plasma backdrop.",
ShortStyle.Rasterbars => "Classic raster bars above and below the clip.",
ShortStyle.Vhs => "Tape noise, chroma shift, and scanlines.",
ShortStyle.Monitor => "CRT monitor bezel around the source.",
ShortStyle.Split => "Split-band source strips around the main clip.",
ShortStyle.Spectrum => "Scrolling audio spectrum. The source video must have audio.",
ShortStyle.Video => "Looping background video with the Amiga clip overlaid.",
_ => "",
};
}
+111
View File
@@ -0,0 +1,111 @@
namespace AmiReel.Models;
/// <summary>Edge insets, in source pixels, removed by a manual crop.</summary>
public readonly record struct PixelInsets(int Left, int Right, int Top, int Bottom)
{
public bool IsEmpty => Left == 0 && Right == 0 && Top == 0 && Bottom == 0;
}
/// <summary>Maps a dragged display rectangle onto source-pixel crop insets.</summary>
public static class SourceCropSelection
{
public const int MinimumSize = 16;
public static (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) FitUniform(
double containerWidth, double containerHeight, int pixelWidth, int pixelHeight)
{
if (containerWidth <= 0 || containerHeight <= 0 || pixelWidth <= 0 || pixelHeight <= 0)
return (0, 0, 0, 0);
double containerAspect = containerWidth / containerHeight;
double imageAspect = pixelWidth / (double)pixelHeight;
double displayWidth;
double displayHeight;
if (containerAspect > imageAspect)
{
displayHeight = containerHeight;
displayWidth = containerHeight * imageAspect;
}
else
{
displayWidth = containerWidth;
displayHeight = containerWidth / imageAspect;
}
return ((containerWidth - displayWidth) / 2, (containerHeight - displayHeight) / 2, displayWidth, displayHeight);
}
public static PixelInsets ToInsets(
int pixelWidth,
int pixelHeight,
double displayWidth,
double displayHeight,
double x,
double y,
double width,
double height)
{
if (pixelWidth <= 0 || pixelHeight <= 0 || displayWidth <= 0 || displayHeight <= 0)
return default;
Normalize(ref x, ref y, ref width, ref height);
x = Math.Clamp(x, 0, displayWidth);
y = Math.Clamp(y, 0, displayHeight);
width = Math.Clamp(width, 0, displayWidth - x);
height = Math.Clamp(height, 0, displayHeight - y);
double scaleX = pixelWidth / displayWidth;
double scaleY = pixelHeight / displayHeight;
int left = ToEvenPixel(x * scaleX, pixelWidth - MinimumSize);
int top = ToEvenPixel(y * scaleY, pixelHeight - MinimumSize);
int right = ToEvenPixel((displayWidth - x - width) * scaleX, pixelWidth - MinimumSize - left);
int bottom = ToEvenPixel((displayHeight - y - height) * scaleY, pixelHeight - MinimumSize - top);
if (pixelWidth - left - right < MinimumSize)
right = Math.Max(0, pixelWidth - left - MinimumSize);
if (pixelHeight - top - bottom < MinimumSize)
bottom = Math.Max(0, pixelHeight - top - MinimumSize);
return new PixelInsets(left, right, top, bottom);
}
public static (double X, double Y, double Width, double Height) ToDisplayRect(
int pixelWidth, int pixelHeight, double displayWidth, double displayHeight, PixelInsets insets)
{
if (pixelWidth <= 0 || pixelHeight <= 0 || displayWidth <= 0 || displayHeight <= 0)
return (0, 0, Math.Max(0, displayWidth), Math.Max(0, displayHeight));
double scaleX = displayWidth / pixelWidth;
double scaleY = displayHeight / pixelHeight;
double x = insets.Left * scaleX;
double y = insets.Top * scaleY;
double width = Math.Max(1, (pixelWidth - insets.Left - insets.Right) * scaleX);
double height = Math.Max(1, (pixelHeight - insets.Top - insets.Bottom) * scaleY);
return (x, y, width, height);
}
public static (double X, double Y) ClampToDisplay(double x, double y, double displayWidth, double displayHeight) =>
(Math.Clamp(x, 0, Math.Max(0, displayWidth)), Math.Clamp(y, 0, Math.Max(0, displayHeight)));
private static void Normalize(ref double x, ref double y, ref double width, ref double height)
{
if (width < 0)
{
x += width;
width = -width;
}
if (height < 0)
{
y += height;
height = -height;
}
}
private static int ToEvenPixel(double value, int max)
{
int rounded = (int)Math.Round(value, MidpointRounding.AwayFromZero);
int clamped = Math.Clamp(rounded, 0, Math.Max(0, max));
return (clamped / 2) * 2;
}
}
+340 -18
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),
@@ -55,9 +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]);
List<string> args = BuildInputArguments(settings, duration);
args.AddRange([
"-filter_complex", filterComplex,
@@ -73,6 +81,7 @@ public sealed class ShortsPipeline
"-b:a", "320k",
"-ar", "48000",
"-movflags", "+faststart",
"-t", F(duration),
"-shortest",
settings.OutputPath,
]);
@@ -94,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()
{
@@ -109,18 +190,35 @@ 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)))
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)
@@ -144,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);
@@ -161,8 +262,225 @@ 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)
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 +500,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 +700,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."),
};
}
+904
View File
@@ -0,0 +1,904 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# AmigaDB YouTube Shorts generator
# Creates a 1080x1920, 50 FPS vertical MP4 from an existing Amiga video.
# V4 expanded style pack for 4:3 Amiga footage, with demoscene-inspired FX and optional image/video backgrounds.
SCRIPT_NAME="$(basename "$0")"
SCRIPT_VERSION="4.2"
INPUT=""
OUTPUT=""
START="00:00:00"
DURATION="30"
TITLE=""
GROUP=""
YEAR=""
TYPE=""
HOOK="THIS RAN ON AN AMIGA"
WEBSITE="AMIGADB.NET"
FONT_FILE=""
STYLE="brand"
BACKGROUND_IMAGE="amigadb_short.png"
BACKGROUND_VIDEO=""
VIDEO_BLUR="0"
VIDEO_OPACITY="1.0"
SOURCE_CROP_MODE="auto"
CROP_LEFT="0"
CROP_RIGHT="0"
CROP_TOP="0"
CROP_BOTTOM="0"
SOURCE_MARGIN_X="40"
SOURCE_FADE_IN="1.5"
CRF="16"
PRESET="slow"
usage() {
cat <<USAGE
Usage:
$SCRIPT_NAME --input VIDEO --title TITLE [options]
Required:
--input FILE Source video
--title TEXT Production title
Production metadata:
--group TEXT Group / creator
--year YEAR Release year
--type TEXT Demo, Intro, Cracktro, Music Disk, ...
--hook TEXT Opening hook (default: THIS RAN ON AN AMIGA)
--website TEXT Closing website text (default: AMIGADB.NET)
Clip settings:
--start TIME Start position, e.g. 00:01:23.500 (default: 00:00:00)
--duration SECONDS Output duration in seconds (default: 30)
--output FILE Output MP4 path
Visual style:
--style NAME brand, pixel, mirror, crop, workbench, blur,
crt, copper, stars, grid, tiles, scan,
starfield, plasma, rasterbars, vhs,
monitor, split, spectrum, video
default: brand
--background-image Background image for brand style
default: amigadb_short.png
--background-video Background video file for video style
--video-blur NUMBER Background video blur sigma (default: 0 = no blur)
--video-opacity NUM Background video opacity 0.0-1.0 (default: 1.0)
--source-crop MODE Source crop for video style: auto or center (default: auto)
auto detects/removes black left/right borders
--crop-left PX Manually crop PX pixels from source left edge (default: 0)
--crop-right PX Manually crop PX pixels from source right edge (default: 0)
--crop-top PX Manually crop PX pixels from source top edge (default: 0)
--crop-bottom PX Manually crop PX pixels from source bottom edge (default: 0)
Any manual crop value overrides --source-crop auto/center
--source-margin-x PX Left/right margin around source video in final 1080px frame
(default: 40; use 0 for edge-to-edge)
--source-fade-in SEC Source overlay fade-in at start, in seconds
(default: 1.5; use 0 to disable)
Encoding:
--font FILE Bold TrueType/OpenType font file
--crf NUMBER H.264 quality; lower is better (default: 16)
--preset NAME x264 preset (default: slow)
Other:
-h, --help Show this help
--version Show script version
Examples:
$SCRIPT_NAME \
--input "ami-back.mp4" \
--start "00:00:34" \
--duration 30 \
--title "Ami-Back V1.04A" \
--group "Pirates" \
--year "1991" \
--type "Cracktro" \
--hook "A 1991 AMIGA CRACKTRO" \
--style brand \
--background-image "amigadb_short.png" \
--output "ami-back-brand-short.mp4"
$SCRIPT_NAME \
--input "demo.mp4" \
--title "Demo Title" \
--group "Demo Group" \
--year "1992" \
--type "Demo" \
--style crt
$SCRIPT_NAME \
--input "demo.mp4" \
--title "Demo Title" \
--style video \
--background-video "backgrounds/neon-tunnel.mp4" \
--video-blur 8 \
--video-opacity 0.65 \
--crop-left 120 \
--crop-right 120 \
--crop-top 0 \
--crop-bottom 0 \
--source-margin-x 40 \
--output "demo-video-short.mp4"
USAGE
}
fail() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
find_default_font() {
local candidate=""
if command_exists fc-match; then
candidate="$(fc-match -f '%{file}\n' 'DejaVu Sans:style=Bold' 2>/dev/null | head -n 1 || true)"
if [[ -n "$candidate" && -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
fi
for candidate in \
/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf \
/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf \
/usr/share/fonts/opentype/noto/NotoSans-Bold.ttf; do
if [[ -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
while (($#)); do
case "$1" in
--input)
(($# >= 2)) || fail "Missing value after --input"
INPUT="$2"
shift 2
;;
--output)
(($# >= 2)) || fail "Missing value after --output"
OUTPUT="$2"
shift 2
;;
--start)
(($# >= 2)) || fail "Missing value after --start"
START="$2"
shift 2
;;
--duration)
(($# >= 2)) || fail "Missing value after --duration"
DURATION="$2"
shift 2
;;
--title)
(($# >= 2)) || fail "Missing value after --title"
TITLE="$2"
shift 2
;;
--group)
(($# >= 2)) || fail "Missing value after --group"
GROUP="$2"
shift 2
;;
--year)
(($# >= 2)) || fail "Missing value after --year"
YEAR="$2"
shift 2
;;
--type)
(($# >= 2)) || fail "Missing value after --type"
TYPE="$2"
shift 2
;;
--hook)
(($# >= 2)) || fail "Missing value after --hook"
HOOK="$2"
shift 2
;;
--website)
(($# >= 2)) || fail "Missing value after --website"
WEBSITE="$2"
shift 2
;;
--font)
(($# >= 2)) || fail "Missing value after --font"
FONT_FILE="$2"
shift 2
;;
--background-image|--background)
(($# >= 2)) || fail "Missing value after --background-image"
BACKGROUND_IMAGE="$2"
shift 2
;;
--background-video)
(($# >= 2)) || fail "Missing value after --background-video"
BACKGROUND_VIDEO="$2"
shift 2
;;
--video-blur|--background-video-blur)
(($# >= 2)) || fail "Missing value after --video-blur"
VIDEO_BLUR="$2"
shift 2
;;
--video-opacity|--background-video-opacity)
(($# >= 2)) || fail "Missing value after --video-opacity"
VIDEO_OPACITY="$2"
shift 2
;;
--source-crop)
(($# >= 2)) || fail "Missing value after --source-crop"
SOURCE_CROP_MODE="${2,,}"
shift 2
;;
--crop-left|--source-crop-left)
(($# >= 2)) || fail "Missing value after --crop-left"
CROP_LEFT="$2"
shift 2
;;
--crop-right|--source-crop-right)
(($# >= 2)) || fail "Missing value after --crop-right"
CROP_RIGHT="$2"
shift 2
;;
--crop-top|--source-crop-top)
(($# >= 2)) || fail "Missing value after --crop-top"
CROP_TOP="$2"
shift 2
;;
--crop-bottom|--source-crop-bottom)
(($# >= 2)) || fail "Missing value after --crop-bottom"
CROP_BOTTOM="$2"
shift 2
;;
--source-margin-x|--video-margin-x|--source-margin)
(($# >= 2)) || fail "Missing value after --source-margin-x"
SOURCE_MARGIN_X="$2"
shift 2
;;
--source-fade-in|--source-fade|--fade-in)
(($# >= 2)) || fail "Missing value after --source-fade-in"
SOURCE_FADE_IN="$2"
shift 2
;;
--style)
(($# >= 2)) || fail "Missing value after --style"
STYLE="${2,,}"
shift 2
;;
--crf)
(($# >= 2)) || fail "Missing value after --crf"
CRF="$2"
shift 2
;;
--preset)
(($# >= 2)) || fail "Missing value after --preset"
PRESET="$2"
shift 2
;;
--version)
printf "%s v%s\n" "$SCRIPT_NAME" "$SCRIPT_VERSION"
exit 0
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
command_exists ffmpeg || fail "ffmpeg is not installed or not in PATH"
[[ -n "$INPUT" ]] || fail "--input is required"
[[ -f "$INPUT" ]] || fail "Input file not found: $INPUT"
[[ -n "$TITLE" ]] || fail "--title is required"
[[ "$DURATION" =~ ^[0-9]+([.][0-9]+)?$ ]] || fail "--duration must be a positive number"
awk -v d="$DURATION" 'BEGIN { exit !(d > 0) }' || fail "--duration must be greater than zero"
[[ "$CRF" =~ ^[0-9]+$ ]] || fail "--crf must be an integer"
[[ "$VIDEO_BLUR" =~ ^[0-9]+([.][0-9]+)?$ ]] || fail "--video-blur must be zero or a positive number"
awk -v v="$VIDEO_BLUR" 'BEGIN { exit !(v >= 0) }' || fail "--video-blur must be zero or greater"
[[ "$VIDEO_OPACITY" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]] || fail "--video-opacity must be a number between 0.0 and 1.0"
awk -v v="$VIDEO_OPACITY" 'BEGIN { exit !(v >= 0 && v <= 1) }' || fail "--video-opacity must be between 0.0 and 1.0"
case "$SOURCE_CROP_MODE" in
auto|center) ;;
*) fail "--source-crop must be 'auto' or 'center'" ;;
esac
for crop_value in "$CROP_LEFT" "$CROP_RIGHT" "$CROP_TOP" "$CROP_BOTTOM"; do
[[ "$crop_value" =~ ^[0-9]+$ ]] || fail "Manual crop values must be non-negative integer pixel values"
done
[[ "$SOURCE_MARGIN_X" =~ ^[0-9]+$ ]] || fail "--source-margin-x must be a non-negative integer pixel value"
(( SOURCE_MARGIN_X < 540 )) || fail "--source-margin-x must be less than 540"
SOURCE_TARGET_WIDTH=$((1080 - (SOURCE_MARGIN_X * 2)))
[[ "$SOURCE_FADE_IN" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]] || fail "--source-fade-in must be zero or a positive number"
awk -v v="$SOURCE_FADE_IN" 'BEGIN { exit !(v >= 0) }' || fail "--source-fade-in must be zero or greater"
SOURCE_FADE_IN="$(awk -v v="$SOURCE_FADE_IN" -v d="$DURATION" 'BEGIN {
if (v > d) v = d;
printf "%.3f", v;
}')"
case "$STYLE" in
brand|pixel|mirror|crop|workbench|blur|crt|copper|stars|grid|tiles|scan|starfield|plasma|rasterbars|vhs|monitor|split|spectrum|video)
;;
*)
fail "Unknown style '$STYLE'. Use brand, pixel, mirror, crop, workbench, blur, crt, copper, stars, grid, tiles, scan, starfield, plasma, rasterbars, vhs, monitor, split, spectrum, or video"
;;
esac
if [[ -z "$FONT_FILE" ]]; then
FONT_FILE="$(find_default_font || true)"
fi
[[ -n "$FONT_FILE" && -f "$FONT_FILE" ]] || fail "No usable font found. Install fonts-dejavu-core or use --font FILE"
if [[ -z "$OUTPUT" ]]; then
base="$(basename "$INPUT")"
base="${base%.*}"
OUTPUT="${base}-${STYLE}-short.mp4"
fi
mkdir -p "$(dirname "$OUTPUT")"
TMP_DIR="$(mktemp -d -t amigadb-short.XXXXXX)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
printf '%s\n' "$HOOK" > "$TMP_DIR/hook.txt"
printf '%s\n' "$TITLE" > "$TMP_DIR/title.txt"
META_PARTS=()
[[ -n "$GROUP" ]] && META_PARTS+=("$GROUP")
[[ -n "$YEAR" ]] && META_PARTS+=("$YEAR")
[[ -n "$TYPE" ]] && META_PARTS+=("$TYPE")
META=""
if ((${#META_PARTS[@]})); then
for part in "${META_PARTS[@]}"; do
if [[ -n "$META" ]]; then
META+=" · "
fi
META+="$part"
done
fi
printf '%s\n' "$META" > "$TMP_DIR/meta.txt"
printf '%s\n' "$WEBSITE" > "$TMP_DIR/website.txt"
INFO_START="$(awk -v d="$DURATION" 'BEGIN { v=d-7; if (v<0) v=0; printf "%.3f", v }')"
CTA_START="$(awk -v d="$DURATION" 'BEGIN { v=d-3.5; if (v<0) v=0; printf "%.3f", v }')"
HOOK_END="$(awk -v d="$DURATION" 'BEGIN { v=2.7; if (d<v) v=d; printf "%.3f", v }')"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
resolve_path() {
local p="$1"
if [[ -f "$p" ]]; then
printf "%s\n" "$p"
return 0
fi
if [[ -f "$SCRIPT_DIR/$p" ]]; then
printf "%s\n" "$SCRIPT_DIR/$p"
return 0
fi
return 1
}
# Detect left/right black capture borders while deliberately ignoring the top
# and bottom 15% of the frame. This prevents full-width overscan/raster lines
# near the top/bottom from fooling cropdetect. Used by --style video.
detect_source_horizontal_crop() {
local detect_duration candidate crop_spec
detect_duration="$(awk -v d="$DURATION" 'BEGIN { v=(d<4?d:4); if (v<0.5) v=0.5; printf "%.3f", v }')"
# Inspect only the middle 70% vertically, then use cropdetect to locate the
# active horizontal picture. We only keep W and X; the source keeps its full
# height so this specifically removes left/right black borders.
candidate="$({
ffmpeg -hide_banner -ss "$START" -t "$detect_duration" -i "$INPUT" \
-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 - 2>&1 || true
} | grep -oE 'crop=[0-9]+:[0-9]+:[0-9]+:[0-9]+' \
| sort | uniq -c | sort -nr | head -n 1 | awk '{print $2}' || true)"
[[ "$candidate" =~ ^crop=([0-9]+):([0-9]+):([0-9]+):([0-9]+)$ ]] || return 1
local w="${BASH_REMATCH[1]}"
local x="${BASH_REMATCH[3]}"
# cropdetect can occasionally leave a few black pixels at each side. Trim a
# tiny extra safety margin (0.5% of detected width per side), still keeping
# even dimensions/offsets for H.264 compatibility.
crop_spec="$(awk -v w="$w" -v x="$x" 'BEGIN {
inset = int((w * 0.005) / 2) * 2;
nw = w - (2 * inset);
nx = x + inset;
nw = int(nw / 2) * 2;
nx = int(nx / 2) * 2;
if (nw < 16) exit 1;
printf "%d:%d", nw, nx;
}')" || return 1
[[ -n "$crop_spec" ]] || return 1
printf '%s\n' "$crop_spec"
}
if [[ "$STYLE" == "brand" ]]; then
BACKGROUND_IMAGE="$(resolve_path "$BACKGROUND_IMAGE" || true)"
[[ -n "$BACKGROUND_IMAGE" && -f "$BACKGROUND_IMAGE" ]] || fail "Brand style requires background image. Put amigadb_short.png next to the script or pass --background-image FILE"
fi
if [[ "$STYLE" == "video" ]]; then
[[ -n "$BACKGROUND_VIDEO" ]] || fail "Video style requires --background-video FILE"
BACKGROUND_VIDEO="$(resolve_path "$BACKGROUND_VIDEO" || true)"
[[ -n "$BACKGROUND_VIDEO" && -f "$BACKGROUND_VIDEO" ]] || fail "Background video not found. Pass a valid file with --background-video FILE"
fi
SOURCE_CROP_FILTER="crop='min(iw,ih*4/3)':'min(ih,iw*3/4)':'(iw-ow)/2':'(ih-oh)/2'"
SOURCE_SCALE_FILTER="scale=${SOURCE_TARGET_WIDTH}:-2:flags=lanczos"
SOURCE_CROP_SPEC="centered 4:3"
MANUAL_CROP=0
if (( CROP_LEFT > 0 || CROP_RIGHT > 0 || CROP_TOP > 0 || CROP_BOTTOM > 0 )); then
MANUAL_CROP=1
fi
if [[ "$STYLE" == "video" && "$MANUAL_CROP" -eq 1 ]]; then
# Manual values are pixels removed from each source edge. Manual crop takes
# precedence over automatic/center crop modes. Scale the remaining picture
# to the configured Short source width while preserving its aspect ratio.
SOURCE_CROP_FILTER="crop=iw-${CROP_LEFT}-${CROP_RIGHT}:ih-${CROP_TOP}-${CROP_BOTTOM}:${CROP_LEFT}:${CROP_TOP}"
SOURCE_SCALE_FILTER="scale=${SOURCE_TARGET_WIDTH}:-2:flags=lanczos"
SOURCE_CROP_SPEC="manual L=${CROP_LEFT} R=${CROP_RIGHT} T=${CROP_TOP} B=${CROP_BOTTOM}"
elif [[ "$STYLE" == "video" && "$SOURCE_CROP_MODE" == "auto" ]]; then
DETECTED_SOURCE_CROP="$(detect_source_horizontal_crop || true)"
if [[ -n "$DETECTED_SOURCE_CROP" ]]; then
DETECTED_W="${DETECTED_SOURCE_CROP%%:*}"
DETECTED_X="${DETECTED_SOURCE_CROP##*:}"
SOURCE_CROP_FILTER="crop=${DETECTED_W}:ih:${DETECTED_X}:0"
SOURCE_SCALE_FILTER="scale=${SOURCE_TARGET_WIDTH}:-2:flags=lanczos"
SOURCE_CROP_SPEC="w=${DETECTED_W}, x=${DETECTED_X} (auto left/right black crop)"
else
printf 'Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.\n' >&2
fi
fi
# Escape paths for FFmpeg filter option parsing.
ffmpeg_escape_path() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//:/\\:}"
value="${value//\'/\\\'}"
printf '%s' "$value"
}
FONT_ESC="$(ffmpeg_escape_path "$FONT_FILE")"
HOOK_ESC="$(ffmpeg_escape_path "$TMP_DIR/hook.txt")"
TITLE_ESC="$(ffmpeg_escape_path "$TMP_DIR/title.txt")"
META_ESC="$(ffmpeg_escape_path "$TMP_DIR/meta.txt")"
WEBSITE_ESC="$(ffmpeg_escape_path "$TMP_DIR/website.txt")"
BACKGROUND_ESC=""
if [[ "$STYLE" == "brand" ]]; then
BACKGROUND_ESC="$(ffmpeg_escape_path "$BACKGROUND_IMAGE")"
fi
HOOK_BOX_Y=0
HOOK_BOX_H=245
HOOK_TEXT_Y=70
HOOK_FONT_SIZE=70
INFO_BOX_Y=1490
INFO_BOX_H=430
TITLE_TEXT_Y=1560
TITLE_FONT_SIZE=62
META_TEXT_Y=1655
META_FONT_SIZE=42
WEBSITE_TEXT_Y=1780
WEBSITE_FONT_SIZE=52
if [[ "$STYLE" == "brand" ]]; then
HOOK_BOX_Y=250
HOOK_BOX_H=140
HOOK_TEXT_Y=285
HOOK_FONT_SIZE=60
INFO_BOX_Y=1310
INFO_BOX_H=270
TITLE_TEXT_Y=1360
TITLE_FONT_SIZE=56
META_TEXT_Y=1435
META_FONT_SIZE=38
WEBSITE_TEXT_Y=1510
WEBSITE_FONT_SIZE=48
fi
HAS_AUDIO=0
if command_exists ffprobe && ffprobe -v error -select_streams a:0 -show_entries stream=index -of csv=p=0 "$INPUT" | grep -q .; then
HAS_AUDIO=1
fi
if [[ "$STYLE" == "spectrum" && "$HAS_AUDIO" -ne 1 ]]; then
fail "The spectrum style requires an audio stream in the source video"
fi
case "$STYLE" in
brand)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]:t=fill[bg2];
[bg2][fg]overlay=x=(W-w)/2:y=520:format=auto[base]
FILTER
)
;;
pixel)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]:t=fill,
drawbox=x=0:y='1610+18*sin(t*1.35)':w=1080:h=8:[email protected]:t=fill[base]
FILTER
)
;;
mirror)
BASE_FILTER=$(cat <<'FILTER'
[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]
FILTER
)
;;
crop)
BASE_FILTER=$(cat <<'FILTER'
[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,eq=brightness=-0.04:saturation=1.05[base]
FILTER
)
;;
workbench)
BASE_FILTER=$(cat <<'FILTER'
[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]
FILTER
)
;;
blur)
BASE_FILTER=$(cat <<'FILTER'
[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]
FILTER
)
;;
crt)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected][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:[email protected]:t=fill[base]
FILTER
)
;;
copper)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]:t=fill,
drawbox=x=0:y='220+20*sin(t*1.00)':w=1080:h=140:[email protected]:t=fill,
drawbox=x=0:y='420+24*sin(t*1.20)':w=1080:h=100:[email protected]:t=fill,
drawbox=x=0:y='660+16*sin(t*1.50)':w=1080:h=120:[email protected]:t=fill,
drawbox=x=0:y='1110+22*sin(t*1.10)':w=1080:h=150:[email protected]:t=fill,
drawbox=x=0:y='1360+18*sin(t*1.30)':w=1080:h=110:[email protected]:t=fill,
drawbox=x=0:y='1660+15*sin(t*1.70)':w=1080:h=130:[email protected]: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]
FILTER
)
;;
stars)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]:t=fill,
drawbox=x='mod(100+80*t,1080)':y='mod(80+210*t,1920)':w=3:h=3:[email protected]:t=fill,
drawbox=x='mod(880-55*t,1080)':y='mod(220+170*t,1920)':w=2:h=2:[email protected]:t=fill,
drawbox=x='mod(300+120*t,1080)':y='mod(520+140*t,1920)':w=3:h=3:[email protected]:t=fill,
drawbox=x='mod(980-75*t,1080)':y='mod(710+160*t,1920)':w=2:h=2:[email protected]:t=fill,
drawbox=x='mod(420+65*t,1080)':y='mod(1040+200*t,1920)':w=4:h=4:[email protected]:t=fill,
drawbox=x='mod(750-90*t,1080)':y='mod(1280+110*t,1920)':w=2:h=2:[email protected]:t=fill,
drawbox=x='mod(210+75*t,1080)':y='mod(1440+180*t,1920)':w=3:h=3:[email protected]:t=fill,
drawbox=x='mod(1030-60*t,1080)':y='mod(1680+140*t,1920)':w=2:h=2:[email protected]:t=fill,
drawbox=x='mod(560+95*t,1080)':y='mod(260+260*t,1920)':w=2:h=2:[email protected]:t=fill,
drawbox=x='mod(60+110*t,1080)':y='mod(860+120*t,1920)':w=3:h=3:[email protected]: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]
FILTER
)
;;
grid)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]:t=fill,
drawgrid=width=90:height=90:thickness=2:[email protected],
drawbox=x=0:y=0:w=1080:h=1120:[email protected]:t=fill,
drawbox=x=0:y=1120:w=1080:h=4:[email protected]: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]
FILTER
)
;;
tiles)
BASE_FILTER=$(cat <<'FILTER'
[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]
FILTER
)
;;
scan)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]:t=fill,
drawbox=x=0:y='280+20*sin(t*1.40)':w=1080:h=6:[email protected]:t=fill,
drawbox=x=0:y='1640+15*sin(t*1.20)':w=1080:h=6:[email protected]:t=fill[base]
FILTER
)
;;
starfield)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]:t=fill,
drawbox=x='mod(120+75*t,1080)':y='mod(80+520*t,1920)':w=3:h=18:[email protected]:t=fill,
drawbox=x='mod(970-55*t,1080)':y='mod(250+430*t,1920)':w=2:h=14:[email protected]:t=fill,
drawbox=x='mod(370+95*t,1080)':y='mod(430+600*t,1920)':w=4:h=24:[email protected]:t=fill,
drawbox=x='mod(810-80*t,1080)':y='mod(640+470*t,1920)':w=2:h=17:[email protected]:t=fill,
drawbox=x='mod(230+65*t,1080)':y='mod(900+540*t,1920)':w=3:h=20:[email protected]:t=fill,
drawbox=x='mod(1020-100*t,1080)':y='mod(1110+650*t,1920)':w=4:h=26:[email protected]:t=fill,
drawbox=x='mod(560+85*t,1080)':y='mod(1320+490*t,1920)':w=2:h=16:[email protected]:t=fill,
drawbox=x='mod(70+110*t,1080)':y='mod(1510+590*t,1920)':w=3:h=22:[email protected]:t=fill,
drawbox=x='mod(720-60*t,1080)':y='mod(1720+450*t,1920)':w=2:h=15:[email protected]:t=fill,
drawbox=x='mod(440+105*t,1080)':y='mod(180+700*t,1920)':w=3:h=28:[email protected]:t=fill,
drawbox=x='mod(900-72*t,1080)':y='mod(780+560*t,1920)':w=2:h=18:[email protected]:t=fill,
drawbox=x='mod(310+90*t,1080)':y='mod(1240+620*t,1920)':w=4:h=24:[email protected]: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]
FILTER
)
;;
plasma)
BASE_FILTER=$(cat <<'FILTER'
[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]
FILTER
)
;;
rasterbars)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]:t=fill,
drawbox=x=0:y='250+90*sin(t*1.25)':w=1080:h=30:[email protected]:t=fill,
drawbox=x=0:y='300+90*sin(t*1.25)':w=1080:h=24:[email protected]:t=fill,
drawbox=x=0:y='345+90*sin(t*1.25)':w=1080:h=18:[email protected]:t=fill,
drawbox=x=0:y='1510+85*sin(t*1.10+1.2)':w=1080:h=18:[email protected]:t=fill,
drawbox=x=0:y='1550+85*sin(t*1.10+1.2)':w=1080:h=24:[email protected]:t=fill,
drawbox=x=0:y='1600+85*sin(t*1.10+1.2)':w=1080:h=30:[email protected]: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]
FILTER
)
;;
vhs)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected][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:[email protected]:t=fill,
drawbox=x=0:y='420+mod(t*320,1080)':w=1080:h=3:[email protected]:t=fill[base]
FILTER
)
;;
monitor)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]: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]
FILTER
)
;;
split)
BASE_FILTER=$(cat <<'FILTER'
[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]
FILTER
)
;;
spectrum)
BASE_FILTER=$(cat <<'FILTER'
[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:[email protected]: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]
FILTER
)
;;
video)
VIDEO_BLUR_FILTER=""
if awk -v v="$VIDEO_BLUR" 'BEGIN { exit !(v > 0) }'; then
VIDEO_BLUR_FILTER=",gblur=sigma=${VIDEO_BLUR}"
fi
BASE_FILTER=$(cat <<FILTER
color=c=black:s=1080x1920:r=50[black];
[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1${VIDEO_BLUR_FILTER},format=rgba,colorchannelmixer=aa=${VIDEO_OPACITY}[bgvideo];
[black][bgvideo]overlay=x=0:y=0:format=auto[bg];
[0:v]${SOURCE_CROP_FILTER},${SOURCE_SCALE_FILTER},setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
esac
SOURCE_FADE_PREFIX=""
if awk -v v="$SOURCE_FADE_IN" 'BEGIN { exit !(v > 0) }'; then
# Fade only the Amiga source stream. Background video/image stays fully
# visible from frame 0; the source overlay (and source-derived layers)
# ramp from transparent to opaque.
SOURCE_FADE_PREFIX="[0:v]format=rgba,fade=t=in:st=0:d=${SOURCE_FADE_IN}:alpha=1[srcfade];"
BASE_FILTER="${BASE_FILTER//\[0:v]/[srcfade]}"
fi
FILTER_COMPLEX=$(cat <<FILTER
${SOURCE_FADE_PREFIX}${BASE_FILTER};
[base]
drawbox=x=0:y=0:w=1080:h=245:color=[email protected]:t=fill:enable='between(t,0,${HOOK_END})',
drawtext=fontfile='${FONT_ESC}':textfile='${HOOK_ESC}':fontcolor=white:fontsize=70:line_spacing=10:x=(w-text_w)/2:y=70:fix_bounds=true:shadowcolor=[email protected]:shadowx=4:shadowy=4:enable='between(t,0,${HOOK_END})',
drawbox=x=0:y=1490:w=1080:h=430:color=[email protected]:t=fill:enable='gte(t,${INFO_START})',
drawtext=fontfile='${FONT_ESC}':textfile='${TITLE_ESC}':fontcolor=white:fontsize=62:line_spacing=8:x=(w-text_w)/2:y=1560:fix_bounds=true:shadowcolor=[email protected]:shadowx=4:shadowy=4:enable='gte(t,${INFO_START})',
drawtext=fontfile='${FONT_ESC}':textfile='${META_ESC}':fontcolor=[email protected]:fontsize=42:x=(w-text_w)/2:y=1655:fix_bounds=true:shadowcolor=[email protected]:shadowx=3:shadowy=3:enable='gte(t,${INFO_START})',
drawtext=fontfile='${FONT_ESC}':textfile='${WEBSITE_ESC}':fontcolor=0x55c8ff:fontsize=52:x=(w-text_w)/2:y=1780:fix_bounds=true:shadowcolor=[email protected]:shadowx=4:shadowy=4:enable='gte(t,${CTA_START})',
fps=50,format=yuv420p[vout]
FILTER
)
printf 'Creating AmigaDB YouTube Short...\n'
printf ' Input: %s\n' "$INPUT"
printf ' Segment: %s + %ss\n' "$START" "$DURATION"
printf ' Style: %s\n' "$STYLE"
printf ' Src fade: %ss\n' "$SOURCE_FADE_IN"
printf ' Output: %s\n' "$OUTPUT"
printf ' Font: %s\n' "$FONT_FILE"
if [[ "$STYLE" == "brand" ]]; then
printf ' Bg image: %s\n' "$BACKGROUND_IMAGE"
fi
if [[ "$STYLE" == "video" ]]; then
printf ' Bg video: %s\n' "$BACKGROUND_VIDEO"
printf ' Bg blur: %s\n' "$VIDEO_BLUR"
printf ' Bg alpha: %s\n' "$VIDEO_OPACITY"
printf ' Src crop: %s\n' "$SOURCE_CROP_SPEC"
printf ' Src margin:%s px each side (target width %s)\n' "$SOURCE_MARGIN_X" "$SOURCE_TARGET_WIDTH"
fi
FFMPEG_ARGS=(
-hide_banner -y
-ss "$START"
-t "$DURATION"
-i "$INPUT"
)
if [[ "$STYLE" == "brand" ]]; then
FFMPEG_ARGS+=( -loop 1 -i "$BACKGROUND_IMAGE" )
fi
if [[ "$STYLE" == "video" ]]; then
FFMPEG_ARGS+=( -stream_loop -1 -i "$BACKGROUND_VIDEO" )
fi
FFMPEG_ARGS+=(
-filter_complex "$FILTER_COMPLEX"
-map "[vout]"
-map 0:a?
-c:v libx264
-preset "$PRESET"
-crf "$CRF"
-profile:v high
-level:v 4.2
-pix_fmt yuv420p
-c:a aac
-b:a 320k
-ar 48000
-movflags +faststart
-t "$DURATION"
-shortest
"$OUTPUT"
)
ffmpeg "${FFMPEG_ARGS[@]}"
printf '\nDone: %s\n' "$OUTPUT"