Add Shorts wizard and v4 video-style pipeline

Turn the Shorts tab into a step-by-step wizard and port
amigadb-short-fx-v4.sh: Video background style, auto/manual crop,
source fade-in, and blur/opacity/margin settings.
This commit is contained in:
2026-08-15 17:59:18 +02:00
parent 58e11ba4e3
commit 8a734d58d9
10 changed files with 1911 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.");
}
}
}
@@ -186,6 +186,74 @@ public class ShortsPipelineTests
ShortsPipeline.Validate(settings); 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] [TestMethod]
public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag() public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag()
{ {
@@ -220,4 +288,105 @@ public class ShortsPipelineTests
StringAssert.Contains(result, "enable='gte(t,26.5)'"); StringAssert.Contains(result, "enable='gte(t,26.5)'");
StringAssert.Contains(result, "[vout]"); 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);
}
} }
+6
View File
@@ -181,6 +181,12 @@
<Setter Property="CornerRadius" Value="10" /> <Setter Property="CornerRadius" Value="10" />
</Style> </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"> <Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{ThemeResource AccentBrush}" /> <Setter Property="Background" Value="{ThemeResource AccentBrush}" />
<Setter Property="Foreground" Value="{ThemeResource PrimaryTextBrush}" /> <Setter Property="Foreground" Value="{ThemeResource PrimaryTextBrush}" />
+214 -118
View File
@@ -9,16 +9,24 @@
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}"> <Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </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"> <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="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> </StackPanel>
<Grid Grid.Row="1" ColumnSpacing="16" x:Name="VideoContentPanel"> <Grid Grid.Row="1" ColumnSpacing="16" x:Name="VideoContentPanel">
@@ -78,103 +86,189 @@
</Border> </Border>
</Grid> </Grid>
<Grid Grid.Row="1" RowSpacing="16" x:Name="ShortsContentPanel" Visibility="Collapsed"> <Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}" x:Name="ShortsContentPanel" Visibility="Collapsed">
<Grid.RowDefinitions> <StackPanel Spacing="14">
<RowDefinition Height="Auto" /> <StackPanel Spacing="4">
<RowDefinition Height="Auto" /> <TextBlock x:Name="ShortWizardTitleText" Text="Source clip" Style="{StaticResource SectionTitleStyle}" />
</Grid.RowDefinitions> <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>
<Grid ColumnSpacing="16"> <StackPanel x:Name="ShortStepClipPanel" Spacing="12">
<Grid.ColumnDefinitions> <TextBlock Text="Choose the Amiga clip and how much of it to use." Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<ColumnDefinition Width="*" /> <Grid ColumnSpacing="10" RowSpacing="10">
<ColumnDefinition Width="*" /> <Grid.ColumnDefinitions>
</Grid.ColumnDefinitions> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<Border Grid.Column="0" Padding="18" Style="{StaticResource CardBorderStyle}"> </Grid.ColumnDefinitions>
<StackPanel Spacing="12"> <Grid.RowDefinitions>
<TextBlock Text="Short source and clip" Style="{StaticResource SectionTitleStyle}" /> <RowDefinition Height="Auto" />
<Grid ColumnSpacing="10" RowSpacing="10"> <RowDefinition Height="Auto" />
<Grid.ColumnDefinitions> </Grid.RowDefinitions>
<ColumnDefinition Width="*" /> <TextBox x:Name="ShortInputBox" Grid.Row="0" Header="Source video" MinHeight="40"
<ColumnDefinition Width="Auto" /> AutomationProperties.Name="Source video"
</Grid.ColumnDefinitions> ToolTipService.ToolTip="Source video for this Short" />
<Grid.RowDefinitions> <Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseShortInput_Click"
<RowDefinition Height="Auto" /> VerticalAlignment="Bottom" MinWidth="120"
<RowDefinition Height="Auto" /> AutomationProperties.Name="Browse source video" />
</Grid.RowDefinitions> <TextBox x:Name="ShortOutputBox" Grid.Row="1" Header="Output file" MinHeight="40"
<TextBox x:Name="ShortInputBox" Grid.Row="0" MinHeight="40" ToolTipService.ToolTip="Source video for this Short" /> AutomationProperties.Name="Output file"
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseShortInput_Click" VerticalAlignment="Stretch" MinWidth="120" /> ToolTipService.ToolTip="Output .mp4 path. Leave blank to name it next to the source." />
<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"
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseShortOutput_Click" VerticalAlignment="Stretch" MinWidth="120" /> VerticalAlignment="Bottom" MinWidth="120"
</Grid> AutomationProperties.Name="Browse output file" />
<Grid ColumnSpacing="10"> </Grid>
<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>
</Grid>
<Button x:Name="PreviewShortSourceButton" Content="Preview source" Click="PreviewShortSource_Click" HorizontalAlignment="Left" />
</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 ColumnSpacing="10">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<ComboBox x:Name="ShortStyleBox" Grid.Column="0" Header="Style" SelectionChanged="ShortStyleBox_SelectionChanged" HorizontalAlignment="Stretch"> <TextBox x:Name="ShortStartBox" Grid.Column="0" Header="Start (HH:MM:SS)" Text="00:00:00"
<ComboBoxItem Content="Brand (title card image)" Tag="Brand" /> AutomationProperties.Name="Start time" />
<ComboBoxItem Content="Pixel" Tag="Pixel" /> <TextBox x:Name="ShortDurationBox" Grid.Column="1" Header="Duration (seconds)" Text="30"
<ComboBoxItem Content="Mirror" Tag="Mirror" /> AutomationProperties.Name="Duration in seconds" />
<ComboBoxItem Content="Crop" Tag="Crop" /> </Grid>
<ComboBoxItem Content="Workbench" Tag="Workbench" /> <Button x:Name="PreviewShortSourceButton" Content="Preview source" Click="PreviewShortSource_Click"
<ComboBoxItem Content="Blur" Tag="Blur" /> HorizontalAlignment="Left" AutomationProperties.Name="Preview source" />
<ComboBoxItem Content="CRT" Tag="Crt" /> </StackPanel>
<ComboBoxItem Content="Copper bars" Tag="Copper" />
<ComboBoxItem Content="Stars" Tag="Stars" /> <StackPanel x:Name="ShortStepEffectPanel" Spacing="12" Visibility="Collapsed">
<ComboBoxItem Content="Grid" Tag="Grid" /> <TextBlock Text="Pick a look. Extra settings appear on the next step only when that effect needs them."
<ComboBoxItem Content="Tiles" Tag="Tiles" /> Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<ComboBoxItem Content="Scanlines" Tag="Scan" /> <ComboBox x:Name="ShortStyleBox" Header="Effect" SelectionChanged="ShortStyleBox_SelectionChanged"
<ComboBoxItem Content="Starfield" Tag="Starfield" /> HorizontalAlignment="Stretch" AutomationProperties.Name="Visual effect">
<ComboBoxItem Content="Plasma" Tag="Plasma" /> <ComboBoxItem Content="Brand (title card image)" Tag="Brand" />
<ComboBoxItem Content="Rasterbars" Tag="Rasterbars" /> <ComboBoxItem Content="Pixel" Tag="Pixel" />
<ComboBoxItem Content="VHS" Tag="Vhs" /> <ComboBoxItem Content="Mirror" Tag="Mirror" />
<ComboBoxItem Content="Monitor" Tag="Monitor" /> <ComboBoxItem Content="Crop" Tag="Crop" />
<ComboBoxItem Content="Split" Tag="Split" /> <ComboBoxItem Content="Workbench" Tag="Workbench" />
<ComboBoxItem Content="Spectrum (needs audio)" Tag="Spectrum" /> <ComboBoxItem Content="Blur" Tag="Blur" />
<ComboBoxItem Content="CRT" Tag="Crt" />
<ComboBoxItem Content="Copper bars" Tag="Copper" />
<ComboBoxItem Content="Stars" Tag="Stars" />
<ComboBoxItem Content="Grid" Tag="Grid" />
<ComboBoxItem Content="Tiles" Tag="Tiles" />
<ComboBoxItem Content="Scanlines" Tag="Scan" />
<ComboBoxItem Content="Starfield" Tag="Starfield" />
<ComboBoxItem Content="Plasma" Tag="Plasma" />
<ComboBoxItem Content="Rasterbars" Tag="Rasterbars" />
<ComboBoxItem Content="VHS" Tag="Vhs" />
<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>
<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> </ComboBox>
<ComboBox x:Name="ShortPresetBox" Grid.Column="1" Header="x264 preset" HorizontalAlignment="Stretch"> <TextBlock Text="Manual crop overrides auto/center. Leave at 0 to use the mode above."
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>
</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="ultrafast" Tag="ultrafast" />
<ComboBoxItem Content="superfast" Tag="superfast" /> <ComboBoxItem Content="superfast" Tag="superfast" />
<ComboBoxItem Content="veryfast" Tag="veryfast" /> <ComboBoxItem Content="veryfast" Tag="veryfast" />
@@ -185,30 +279,19 @@
<ComboBoxItem Content="slower" Tag="slower" /> <ComboBoxItem Content="slower" Tag="slower" />
<ComboBoxItem Content="veryslow" Tag="veryslow" /> <ComboBoxItem Content="veryslow" Tag="veryslow" />
</ComboBox> </ComboBox>
<TextBox x:Name="ShortCrfBox" Grid.Column="1" Header="CRF" AutomationProperties.Name="CRF" />
</Grid> </Grid>
<Grid x:Name="ShortBackgroundRow" ColumnSpacing="10"> <TextBlock x:Name="ShortSummaryText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<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" />
</Grid>
</StackPanel> </StackPanel>
</Border> </StackPanel>
</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"> <StackPanel Spacing="12">
<TextBlock Text="Render progress" Style="{StaticResource SectionTitleStyle}" /> <TextBlock Text="Render progress" Style="{StaticResource SectionTitleStyle}" />
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
@@ -220,7 +303,12 @@
</StackPanel> </StackPanel>
</Border> </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"> <StackPanel Spacing="12">
<TextBlock Text="FFmpeg log" Style="{StaticResource SectionTitleStyle}" /> <TextBlock Text="FFmpeg log" Style="{StaticResource SectionTitleStyle}" />
<TextBox x:Name="LogBox" <TextBox x:Name="LogBox"
@@ -232,11 +320,14 @@
FontFamily="Consolas" FontFamily="Consolas"
FontSize="12" FontSize="12"
VerticalAlignment="Stretch" VerticalAlignment="Stretch"
Height="260" /> MinHeight="320" />
</StackPanel> </StackPanel>
</Border> </Border>
<Grid Grid.Row="4"> </Grid>
</ScrollViewer>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@@ -248,6 +339,11 @@
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10"> <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" <Button x:Name="OpenSettingsButton" Content="&#xE713;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets"
Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}" Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}"
ToolTipService.ToolTip="Settings" AutomationProperties.Name="Settings" /> ToolTipService.ToolTip="Settings" AutomationProperties.Name="Settings" />
+253 -15
View File
@@ -23,6 +23,10 @@ public sealed partial class MainPage : Page
private string? _lastRenderedFile; private string? _lastRenderedFile;
private bool _logFlushScheduled; private bool _logFlushScheduled;
private bool _isShortsTab; private bool _isShortsTab;
private bool _isRendering;
private bool _keepRenderOutput;
private List<ShortWizardStep> _wizardSteps = [.. ShortWizard.StepsFor(ShortStyle.Brand)];
private int _wizardIndex;
public MainPage() public MainPage()
{ {
@@ -48,12 +52,23 @@ public sealed partial class MainPage : Page
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite; ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
ShortCrfBox.Text = _loadedSettings.ShortsCrf; ShortCrfBox.Text = _loadedSettings.ShortsCrf;
ShortBackgroundBox.Text = _loadedSettings.ShortsBackgroundImage; 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) ShortFontBox.Text = string.IsNullOrWhiteSpace(_loadedSettings.ShortsFontFile)
? ShortsPipeline.FindDefaultFont() ?? "" ? ShortsPipeline.FindDefaultFont() ?? ""
: _loadedSettings.ShortsFontFile; : _loadedSettings.ShortsFontFile;
SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle); SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle);
SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset); SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset);
UpdateShortBackgroundVisibility(); SelectComboTag(ShortCropModeBox, _loadedSettings.ShortsSourceCropMode);
RefreshWizardSteps();
ShowWizardStep();
} }
private void VideoTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: false); private void VideoTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: false);
@@ -62,23 +77,135 @@ public sealed partial class MainPage : Page
private void SetActiveTab(bool shorts) private void SetActiveTab(bool shorts)
{ {
if (!_isRendering)
_keepRenderOutput = false;
_isShortsTab = shorts; _isShortsTab = shorts;
VideoContentPanel.Visibility = shorts ? Visibility.Collapsed : Visibility.Visible;
ShortsContentPanel.Visibility = shorts ? Visibility.Visible : Visibility.Collapsed;
Style primaryButtonStyle = (Style)Application.Current.Resources["PrimaryButtonStyle"]; Style primaryButtonStyle = (Style)Application.Current.Resources["PrimaryButtonStyle"];
VideoTabButton.Style = shorts ? null : primaryButtonStyle; Style tabButtonStyle = (Style)Application.Current.Resources["TabButtonStyle"];
ShortsTabButton.Style = shorts ? primaryButtonStyle : null; VideoTabButton.Style = shorts ? tabButtonStyle : primaryButtonStyle;
ShortsTabButton.Style = shorts ? primaryButtonStyle : tabButtonStyle;
RenderButton.Content = shorts ? "Render short" : "Start render"; RenderButton.Content = shorts ? "Render short" : "Start render";
UpdateWorkspaceVisibility();
} }
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateShortBackgroundVisibility(); private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
private void UpdateShortBackgroundVisibility()
{ {
bool isBrand = string.Equals(SelectedShortStyle(), nameof(ShortStyle.Brand), StringComparison.OrdinalIgnoreCase); if (!IsLoaded) return;
ShortBackgroundRow.Visibility = isBrand ? Visibility.Visible : Visibility.Collapsed; 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();
}
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) private async void AddInputs_Click(object sender, RoutedEventArgs e)
{ {
FileOpenPicker picker = CreateFileOpenPicker(); FileOpenPicker picker = CreateFileOpenPicker();
@@ -180,6 +307,7 @@ public sealed partial class MainPage : Page
{ {
try try
{ {
_keepRenderOutput = false;
SetRendering(true); SetRendering(true);
LogBox.Text = string.Empty; LogBox.Text = string.Empty;
_renderCancellation = new CancellationTokenSource(); _renderCancellation = new CancellationTokenSource();
@@ -191,10 +319,12 @@ public sealed partial class MainPage : Page
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
_keepRenderOutput = true;
UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled.")); UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled."));
} }
catch (Exception exception) catch (Exception exception)
{ {
_keepRenderOutput = true;
AppendLog("ERROR: " + exception); AppendLog("ERROR: " + exception);
StageText.Text = "Failed"; StageText.Text = "Failed";
await ShowMessageAsync("Render failed", UserFacingErrors.Summarize(exception)); await ShowMessageAsync("Render failed", UserFacingErrors.Summarize(exception));
@@ -308,14 +438,15 @@ public sealed partial class MainPage : Page
private ShortSettings ReadShortSettings() private ShortSettings ReadShortSettings()
{ {
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0) double duration = ParsePositiveDuration();
throw new ArgumentException("Enter a valid duration in seconds."); int crf = ParseCrf();
if (!int.TryParse(ShortCrfBox.Text, out int crf)) (double blur, double opacity, int marginX, ShortSourceCropMode cropMode, int left, int right, int top, int bottom)
throw new ArgumentException("CRF must be an integer."); = ReadVideoStyleOptions();
double fadeIn = ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
string input = ShortInputBox.Text.Trim(); string input = ShortInputBox.Text.Trim();
string output = ShortOutputBox.Text.Trim(); string output = ShortOutputBox.Text.Trim();
ShortStyle style = Enum.Parse<ShortStyle>(SelectedShortStyle()); ShortStyle style = SelectedShortStyleEnum();
if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(input)) if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(input))
{ {
string baseName = Path.GetFileNameWithoutExtension(input); string baseName = Path.GetFileNameWithoutExtension(input);
@@ -337,6 +468,16 @@ public sealed partial class MainPage : Page
DurationSeconds = duration, DurationSeconds = duration,
Style = style, Style = style,
BackgroundImage = ShortBackgroundBox.Text.Trim(), 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(), FontFile = ShortFontBox.Text.Trim(),
FfmpegPath = FfmpegPathBox.Text.Trim(), FfmpegPath = FfmpegPathBox.Text.Trim(),
FfprobePath = FfprobePathBox.Text.Trim(), FfprobePath = FfprobePathBox.Text.Trim(),
@@ -345,6 +486,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) private async void BrowseShortInput_Click(object sender, RoutedEventArgs e)
{ {
FileOpenPicker picker = CreateFileOpenPicker(); FileOpenPicker picker = CreateFileOpenPicker();
@@ -378,6 +572,17 @@ public sealed partial class MainPage : Page
ShortBackgroundBox.Text = file.Path; 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) private async void BrowseShortFont_Click(object sender, RoutedEventArgs e)
{ {
FileOpenPicker picker = CreateFileOpenPicker(); FileOpenPicker picker = CreateFileOpenPicker();
@@ -396,11 +601,25 @@ public sealed partial class MainPage : Page
private void SetRendering(bool rendering) private void SetRendering(bool rendering)
{ {
_isRendering = rendering;
RenderButton.IsEnabled = !rendering; RenderButton.IsEnabled = !rendering;
CancelButton.IsEnabled = rendering; CancelButton.IsEnabled = rendering;
OpenSettingsButton.IsEnabled = !rendering; OpenSettingsButton.IsEnabled = !rendering;
VideoTabButton.IsEnabled = !rendering; VideoTabButton.IsEnabled = !rendering;
ShortsTabButton.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) private void UpdateProgress(RenderProgress value)
@@ -496,6 +715,16 @@ public sealed partial class MainPage : Page
ShortsWebsite = ShortWebsiteBox.Text.Trim(), ShortsWebsite = ShortWebsiteBox.Text.Trim(),
ShortsFontFile = ShortFontBox.Text.Trim(), ShortsFontFile = ShortFontBox.Text.Trim(),
ShortsBackgroundImage = ShortBackgroundBox.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(), ShortsCrf = ShortCrfBox.Text.Trim(),
ShortsPreset = SelectedShortPreset(), ShortsPreset = SelectedShortPreset(),
}); });
@@ -507,6 +736,15 @@ public sealed partial class MainPage : Page
private string SelectedShortStyle() => (ShortStyleBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Brand"; 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 string SelectedShortPreset() => (ShortPresetBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "slow";
private void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme); 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 ShortsWebsite { get; init; } = "AMIGADB.NET";
public string ShortsFontFile { get; init; } = ""; public string ShortsFontFile { get; init; } = "";
public string ShortsBackgroundImage { 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 ShortsCrf { get; init; } = "16";
public string ShortsPreset { get; init; } = "slow"; public string ShortsPreset { get; init; } = "slow";
@@ -68,6 +78,16 @@ public sealed record AppSettings
ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET", ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET",
ShortsFontFile = ShortsFontFile ?? "", ShortsFontFile = ShortsFontFile ?? "",
ShortsBackgroundImage = ShortsBackgroundImage ?? "", 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, ShortsCrf = string.IsNullOrWhiteSpace(ShortsCrf) ? "16" : ShortsCrf,
ShortsPreset = string.IsNullOrWhiteSpace(ShortsPreset) ? "slow" : ShortsPreset, ShortsPreset = string.IsNullOrWhiteSpace(ShortsPreset) ? "slow" : ShortsPreset,
}; };
+20 -2
View File
@@ -1,6 +1,6 @@
namespace AmiReel.Models; 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 public enum ShortStyle
{ {
Brand, Brand,
@@ -22,9 +22,17 @@ public enum ShortStyle
Monitor, Monitor,
Split, Split,
Spectrum, 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>One YouTube Shorts render job's parameters (port of amigadb-short-fx-v4.sh).</summary>
public sealed record ShortSettings public sealed record ShortSettings
{ {
public required string InputFile { get; init; } public required string InputFile { get; init; }
@@ -39,6 +47,16 @@ public sealed record ShortSettings
public required double DurationSeconds { get; init; } public required double DurationSeconds { get; init; }
public required ShortStyle Style { get; init; } public required ShortStyle Style { get; init; }
public string BackgroundImage { 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 FontFile { get; init; }
public required string FfmpegPath { get; init; } public required string FfmpegPath { get; init; }
public required string FfprobePath { 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.",
_ => "",
};
}
+170 -10
View File
@@ -1,17 +1,25 @@
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using AmiReel.Models; using AmiReel.Models;
namespace AmiReel.Services; namespace AmiReel.Services;
/// <summary> /// <summary>
/// Renders a vertical (1080x1920, 50 FPS) YouTube Short from an existing video, with a chosen /// 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 /// visual style and title-card text overlays. A C# port of amigadb-short-fx-v4.sh so end users
/// need bash/WSL — the FFmpeg filtergraphs below are copied verbatim from that script. /// don't need bash/WSL — the FFmpeg filtergraphs below are copied from that script.
/// </summary> /// </summary>
public sealed class ShortsPipeline 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(); private readonly ProcessRunner _runner = new();
public async Task RenderAsync(ShortSettings settings, IProgress<RenderProgress> progress, Action<string> log, CancellationToken token) 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 infoStart = Math.Max(0, duration - 7);
double ctaStart = Math.Max(0, duration - 3.5); double ctaStart = Math.Max(0, duration - 3.5);
double hookEnd = Math.Min(2.7, duration); 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( string filterComplex = BuildFilterComplex(
GetBaseFilter(settings.Style), ApplySourceFade(baseFilter, fadeIn),
fontEsc: EscapeFfmpegPath(settings.FontFile), fontEsc: EscapeFfmpegPath(settings.FontFile),
hookEsc: EscapeFfmpegPath(hookFile), hookEsc: EscapeFfmpegPath(hookFile),
titleEsc: EscapeFfmpegPath(titleFile), titleEsc: EscapeFfmpegPath(titleFile),
@@ -58,6 +68,8 @@ public sealed class ShortsPipeline
List<string> args = ["-hide_banner", "-y", "-ss", settings.Start, "-t", F(duration), "-i", settings.InputFile]; List<string> args = ["-hide_banner", "-y", "-ss", settings.Start, "-t", F(duration), "-i", settings.InputFile];
if (settings.Style == ShortStyle.Brand) if (settings.Style == ShortStyle.Brand)
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]); args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
if (settings.Style == ShortStyle.Video)
args.AddRange(["-stream_loop", "-1", "-i", settings.BackgroundVideo]);
args.AddRange([ args.AddRange([
"-filter_complex", filterComplex, "-filter_complex", filterComplex,
@@ -73,6 +85,7 @@ public sealed class ShortsPipeline
"-b:a", "320k", "-b:a", "320k",
"-ar", "48000", "-ar", "48000",
"-movflags", "+faststart", "-movflags", "+faststart",
"-t", F(duration),
"-shortest", "-shortest",
settings.OutputPath, settings.OutputPath,
]); ]);
@@ -121,6 +134,18 @@ public sealed class ShortsPipeline
throw new FileNotFoundException("Select a bold TrueType/OpenType font file."); throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage))) if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage)))
throw new FileNotFoundException("The brand style requires a background image."); 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) internal static string BuildMeta(ShortSettings s)
@@ -163,6 +188,137 @@ public sealed class ShortsPipeline
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture); private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
internal static string ApplySourceFade(string baseFilter, double fadeInSeconds)
{
if (fadeInSeconds <= 0)
return baseFilter;
return $"[0:v]format=rgba,fade=t=in:st=0:d={F(fadeInSeconds)}:alpha=1[srcfade];" +
baseFilter.Replace("[0:v]", "[srcfade]", StringComparison.Ordinal);
}
internal static int SourceTargetWidth(int sourceMarginX) => 1080 - (sourceMarginX * 2);
internal static string BuildSourceScaleFilter(int sourceMarginX) =>
$"scale={SourceTargetWidth(sourceMarginX)}:-2:flags=lanczos";
internal static string BuildManualCropFilter(int left, int right, int top, int bottom) =>
$"crop=iw-{left}-{right}:ih-{top}-{bottom}:{left}:{top}";
internal static string BuildVideoBaseFilter(string cropFilter, string scaleFilter, double blur, double opacity)
{
string blurFilter = blur > 0 ? $",gblur=sigma={F(blur)}" : "";
return
"color=c=black:s=1080x1920:r=50[black];" +
$"[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1{blurFilter},format=rgba,colorchannelmixer=aa={F(opacity)}[bgvideo];" +
"[black][bgvideo]overlay=x=0:y=0:format=auto[bg];" +
$"[0:v]{cropFilter},{scaleFilter},setsar=1[fg];" +
"[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]";
}
internal static string? SelectMostCommonCropSpec(string ffmpegOutput)
{
Dictionary<string, int> counts = new(StringComparer.Ordinal);
foreach (Match match in CropSpecRegex.Matches(ffmpegOutput))
{
string spec = match.Value;
counts[spec] = counts.GetValueOrDefault(spec) + 1;
}
return counts.Count == 0
? null
: counts.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal).First().Key;
}
internal static bool TryParseCropSpec(string spec, out int width, out int height, out int x, out int y)
{
Match match = CropSpecRegex.Match(spec);
if (!match.Success)
{
width = height = x = y = 0;
return false;
}
width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
height = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
x = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
y = int.Parse(match.Groups[4].Value, CultureInfo.InvariantCulture);
return true;
}
internal static bool TryBuildAutoHorizontalCrop(int detectedWidth, int detectedX, out int width, out int x)
{
int inset = (int)(detectedWidth * 0.005 / 2) * 2;
width = ((detectedWidth - (2 * inset)) / 2) * 2;
x = ((detectedX + inset) / 2) * 2;
if (width < 16)
return false;
return true;
}
private async Task<string> ResolveBaseFilterAsync(
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
{
if (settings.Style != ShortStyle.Video)
return GetBaseFilter(settings.Style);
string cropFilter = DefaultSourceCropFilter;
string scaleFilter = BuildSourceScaleFilter(settings.SourceMarginX);
bool hasManualCrop = settings.CropLeft > 0 || settings.CropRight > 0
|| settings.CropTop > 0 || settings.CropBottom > 0;
if (hasManualCrop)
{
cropFilter = BuildManualCropFilter(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom);
log($"Source crop: manual L={settings.CropLeft} R={settings.CropRight} T={settings.CropTop} B={settings.CropBottom}");
}
else if (settings.SourceCropMode == ShortSourceCropMode.Auto)
{
string? detected = await DetectHorizontalCropAsync(tools, settings, log, token);
if (detected is not null)
{
cropFilter = detected;
}
else
{
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
}
}
else
{
log("Source crop: centered 4:3");
}
return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity);
}
private async Task<string?> DetectHorizontalCropAsync(
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
{
double detectDuration = Math.Clamp(settings.DurationSeconds < 4 ? settings.DurationSeconds : 4, 0.5, 4);
List<string> args =
[
"-hide_banner",
"-ss", settings.Start,
"-t", F(detectDuration),
"-i", settings.InputFile,
"-an",
"-vf", "crop=iw:trunc(ih*0.70/2)*2:0:trunc(ih*0.15/2)*2,cropdetect=limit=28:round=2:reset=0",
"-f", "null",
"-",
];
string output = await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, allowFailure: true);
string? spec = SelectMostCommonCropSpec(output);
if (spec is null || !TryParseCropSpec(spec, out int width, out _, out int x, out _))
return null;
if (!TryBuildAutoHorizontalCrop(width, x, out int cropWidth, out int cropX))
return null;
log($"Source crop: w={cropWidth}, x={cropX} (auto left/right black crop)");
return $"crop={cropWidth}:ih:{cropX}:0";
}
internal static string GetBaseFilter(ShortStyle style) => style switch internal static string GetBaseFilter(ShortStyle style) => style switch
{ {
ShortStyle.Brand => """ ShortStyle.Brand => """
@@ -182,13 +338,14 @@ public sealed class ShortsPipeline
""", """,
ShortStyle.Mirror => """ ShortStyle.Mirror => """
[0:v]split=3[fg_src][top_src][bottom_src]; [0:v]split=4[canvas_src][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]; [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];
[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=940:760:force_original_aspect_ratio=decrease,setsar=1[fg];
[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_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];
[top]pad=1080:1920:0:0:color=0x050616[canvas]; [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][bottom]overlay=x=0:y=1300[tmp]; [canvas][top]overlay=x=(W-w)/2:y=250:format=auto[tmp1];
[tmp][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base] [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 => """ ShortStyle.Crop => """
@@ -381,6 +538,9 @@ public sealed class ShortsPipeline
[tmp][fg]overlay=x=(W-w)/2:y=480:format=auto[base] [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."), _ => 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"