Add YouTube Shorts tab (C# port of amigadb-short.sh)
New "Video | Shorts" tab strip on the main page. The existing Video tab and its render pipeline are unchanged; Shorts is a new, independent workflow sharing the same render-progress/log/output controls. - Services/ShortsPipeline.cs: C# port of amigadb-short.sh rather than shelling out to bash — end users on plain Windows don't have bash/WSL, and shipping this as a native part of the app keeps distribution self-contained. All 19 visual style filtergraphs (brand, pixel, mirror, crop, workbench, blur, crt, copper, stars, grid, tiles, scan, starfield, plasma, rasterbars, vhs, monitor, split, spectrum) are copied verbatim from the bash heredocs. Per explicit decision, kept parity with the script's actual behavior where the brand-style HOOK_BOX_Y/INFO_BOX_Y/font-size variables are computed but never actually used in the final filter (dead code in the original) rather than "fixing" it and changing rendered output. Reuses ToolExtractor/MediaProbe/ProcessRunner exactly as RenderPipeline does, so FFmpeg resolution and NVENC fallback behave identically. - Models/ShortSettings.cs: job parameters + ShortStyle enum. - AppSettings gains sticky Shorts defaults (style, hook, website, font, background image, CRF, preset) alongside the existing Video defaults. - MainPage: Video/Shorts tab buttons toggle two content panels; the background-image field auto-hides for non-brand styles; font field defaults to the first bold system font found (Segoe UI Bold, Segoe UI Semibold, Arial Bold, Calibri Bold); blank output path is auto-derived from the input filename and style, matching the script's default. - 13 new unit tests (escaping order, meta-line joining, validation, every style producing a non-empty filter, the drawtext trailer's timing gates). Full run is 72/72 passing. - Fixed a bug caught during manual testing: MainPage.Resources["PrimaryButtonStyle"] looked in the Page's own (empty) resource dictionary instead of Application.Current.Resources, throwing on every tab switch. Verified end-to-end: rendered a real Short from an existing video through the actual running app (not just unit tests) and inspected extracted frames — hook/title/meta/website text overlays appear and disappear at the correct timestamps with correct styling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
using AmiReel.Models;
|
||||
using AmiReel.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Services;
|
||||
|
||||
[TestClass]
|
||||
public class ShortsPipelineTests
|
||||
{
|
||||
private readonly List<string> _tempDirectories = [];
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
foreach (string directory in _tempDirectories)
|
||||
{
|
||||
try { Directory.Delete(directory, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateTempDirectory()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "AmiReelTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
_tempDirectories.Add(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
private static ShortSettings ValidSettings(string input, string font, ShortStyle style = ShortStyle.Crop, string background = "") => new()
|
||||
{
|
||||
InputFile = input,
|
||||
OutputPath = Path.Combine(Path.GetDirectoryName(input) ?? "", "out.mp4"),
|
||||
Title = "Test Short",
|
||||
Hook = "THIS RAN ON AN AMIGA",
|
||||
Website = "AMIGADB.NET",
|
||||
Start = "00:00:00",
|
||||
DurationSeconds = 30,
|
||||
Style = style,
|
||||
BackgroundImage = background,
|
||||
FontFile = font,
|
||||
FfmpegPath = "",
|
||||
FfprobePath = "",
|
||||
};
|
||||
|
||||
[TestMethod]
|
||||
public void EscapeFfmpegPath_WithBackslashesColonsAndQuotes_EscapesAllThree()
|
||||
{
|
||||
// Act
|
||||
string result = ShortsPipeline.EscapeFfmpegPath(@"C:\Fonts\font's.ttf");
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(@"C\:\\Fonts\\font\'s.ttf", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EscapeFfmpegPath_EscapesBackslashesBeforeIntroducingNewOnes()
|
||||
{
|
||||
// Act: if colon/quote escaping ran before backslash escaping, the backslashes
|
||||
// they introduce would themselves get doubled, which must not happen.
|
||||
string result = ShortsPipeline.EscapeFfmpegPath("C:\\a");
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("C\\:\\\\a", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildMeta_WithAllFieldsPopulated_JoinsWithMiddleDot()
|
||||
{
|
||||
// Arrange
|
||||
ShortSettings settings = ValidSettings("in.mp4", "font.ttf") with { Group = "Pirates", Year = "1991", Type = "Cracktro" };
|
||||
|
||||
// Act
|
||||
string meta = ShortsPipeline.BuildMeta(settings);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Pirates · 1991 · Cracktro", meta);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildMeta_WithSomeFieldsBlank_SkipsBlankFields()
|
||||
{
|
||||
// Arrange
|
||||
ShortSettings settings = ValidSettings("in.mp4", "font.ttf") with { Group = "Pirates", Year = "", Type = " " };
|
||||
|
||||
// Act
|
||||
string meta = ShortsPipeline.BuildMeta(settings);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Pirates", meta);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildMeta_WithAllFieldsBlank_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
ShortSettings settings = ValidSettings("in.mp4", "font.ttf");
|
||||
|
||||
// Act
|
||||
string meta = ShortsPipeline.BuildMeta(settings);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("", meta);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithMissingInputFile_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf");
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithBlankTitle_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 { Title = "" };
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithZeroDuration_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 { DurationSeconds = 0 };
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithMissingFontFile_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.mp4");
|
||||
File.WriteAllText(input, "data");
|
||||
ShortSettings settings = ValidSettings(input, Path.Combine(directory, "missing-font.ttf"));
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_BrandStyleWithoutBackgroundImage_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.Brand);
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_NonBrandStyleWithoutBackgroundImage_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");
|
||||
ShortSettings settings = ValidSettings(input, font, ShortStyle.Crop);
|
||||
|
||||
// Act & Assert (no exception)
|
||||
ShortsPipeline.Validate(settings);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag()
|
||||
{
|
||||
foreach (ShortStyle style in Enum.GetValues<ShortStyle>())
|
||||
{
|
||||
// Act
|
||||
string filter = ShortsPipeline.GetBaseFilter(style);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(filter), $"{style} produced an empty filter.");
|
||||
StringAssert.Contains(filter, "[base]", $"{style} filter does not label an output as [base].");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildFilterComplex_IncludesAllFourTextOverlaysAndTheirTimingGates()
|
||||
{
|
||||
// Act
|
||||
string result = ShortsPipeline.BuildFilterComplex(
|
||||
"[0:v]copy[base]",
|
||||
fontEsc: "C\\:\\\\font.ttf",
|
||||
hookEsc: "hook.txt", titleEsc: "title.txt", metaEsc: "meta.txt", websiteEsc: "website.txt",
|
||||
hookEnd: 2.7, infoStart: 23, ctaStart: 26.5);
|
||||
|
||||
// Assert
|
||||
StringAssert.Contains(result, "textfile='hook.txt'");
|
||||
StringAssert.Contains(result, "textfile='title.txt'");
|
||||
StringAssert.Contains(result, "textfile='meta.txt'");
|
||||
StringAssert.Contains(result, "textfile='website.txt'");
|
||||
StringAssert.Contains(result, "enable='between(t,0,2.7)'");
|
||||
StringAssert.Contains(result, "enable='gte(t,23)'");
|
||||
StringAssert.Contains(result, "enable='gte(t,26.5)'");
|
||||
StringAssert.Contains(result, "[vout]");
|
||||
}
|
||||
}
|
||||
+140
-4
@@ -9,13 +9,19 @@
|
||||
|
||||
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0" ColumnSpacing="16">
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="VideoTabButton" Content="Video" Click="VideoTab_Click" Style="{StaticResource PrimaryButtonStyle}" MinWidth="120" />
|
||||
<Button x:Name="ShortsTabButton" Content="Shorts" Click="ShortsTab_Click" MinWidth="120" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnSpacing="16" x:Name="VideoContentPanel">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -72,7 +78,137 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}">
|
||||
<Grid Grid.Row="1" RowSpacing="16" x:Name="ShortsContentPanel" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid ColumnSpacing="16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Padding="18" Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Short source and clip" Style="{StaticResource SectionTitleStyle}" />
|
||||
<Grid ColumnSpacing="10" RowSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBox x:Name="ShortInputBox" Grid.Row="0" MinHeight="40" ToolTipService.ToolTip="Source video for this Short" />
|
||||
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseShortInput_Click" VerticalAlignment="Stretch" MinWidth="120" />
|
||||
<TextBox x:Name="ShortOutputBox" Grid.Row="1" MinHeight="40" ToolTipService.ToolTip="Output .mp4 path" />
|
||||
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseShortOutput_Click" VerticalAlignment="Stretch" MinWidth="120" />
|
||||
</Grid>
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="Start (HH:MM:SS)" Style="{StaticResource FieldLabelStyle}" />
|
||||
<TextBox x:Name="ShortStartBox" Text="00:00:00" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="Duration (seconds)" Style="{StaticResource FieldLabelStyle}" />
|
||||
<TextBox x:Name="ShortDurationBox" Text="30" />
|
||||
</StackPanel>
|
||||
</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.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ComboBox x:Name="ShortStyleBox" Grid.Column="0" Header="Style" SelectionChanged="ShortStyleBox_SelectionChanged" HorizontalAlignment="Stretch">
|
||||
<ComboBoxItem Content="Brand (title card image)" Tag="Brand" />
|
||||
<ComboBoxItem Content="Pixel" Tag="Pixel" />
|
||||
<ComboBoxItem Content="Mirror" Tag="Mirror" />
|
||||
<ComboBoxItem Content="Crop" Tag="Crop" />
|
||||
<ComboBoxItem Content="Workbench" Tag="Workbench" />
|
||||
<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" />
|
||||
</ComboBox>
|
||||
<ComboBox x:Name="ShortPresetBox" Grid.Column="1" Header="x264 preset" HorizontalAlignment="Stretch">
|
||||
<ComboBoxItem Content="ultrafast" Tag="ultrafast" />
|
||||
<ComboBoxItem Content="superfast" Tag="superfast" />
|
||||
<ComboBoxItem Content="veryfast" Tag="veryfast" />
|
||||
<ComboBoxItem Content="faster" Tag="faster" />
|
||||
<ComboBoxItem Content="fast" Tag="fast" />
|
||||
<ComboBoxItem Content="medium" Tag="medium" />
|
||||
<ComboBoxItem Content="slow" Tag="slow" />
|
||||
<ComboBoxItem Content="slower" Tag="slower" />
|
||||
<ComboBoxItem Content="veryslow" Tag="veryslow" />
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
<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 (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>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="2" Padding="18" Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Render progress" Style="{StaticResource SectionTitleStyle}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -84,7 +220,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2" Padding="18" Style="{StaticResource CardBorderStyle}">
|
||||
<Border Grid.Row="3" Padding="18" Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="FFmpeg log" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBox x:Name="LogBox"
|
||||
@@ -100,7 +236,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3">
|
||||
<Grid Grid.Row="4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
|
||||
+181
-30
@@ -22,6 +22,7 @@ public sealed partial class MainPage : Page
|
||||
private CancellationTokenSource? _renderCancellation;
|
||||
private string? _lastRenderedFile;
|
||||
private bool _logFlushScheduled;
|
||||
private bool _isShortsTab;
|
||||
|
||||
public MainPage()
|
||||
{
|
||||
@@ -42,6 +43,40 @@ public sealed partial class MainPage : Page
|
||||
SelectTheme(_loadedSettings.Theme);
|
||||
ApplyTheme(_loadedSettings.Theme);
|
||||
MoveSourcesBox.IsChecked = _loadedSettings.ShouldMoveSourcesToOriginals;
|
||||
|
||||
ShortHookBox.Text = _loadedSettings.ShortsHook;
|
||||
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
|
||||
ShortCrfBox.Text = _loadedSettings.ShortsCrf;
|
||||
ShortBackgroundBox.Text = _loadedSettings.ShortsBackgroundImage;
|
||||
ShortFontBox.Text = string.IsNullOrWhiteSpace(_loadedSettings.ShortsFontFile)
|
||||
? ShortsPipeline.FindDefaultFont() ?? ""
|
||||
: _loadedSettings.ShortsFontFile;
|
||||
SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle);
|
||||
SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset);
|
||||
UpdateShortBackgroundVisibility();
|
||||
}
|
||||
|
||||
private void VideoTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: false);
|
||||
|
||||
private void ShortsTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: true);
|
||||
|
||||
private void SetActiveTab(bool shorts)
|
||||
{
|
||||
_isShortsTab = shorts;
|
||||
VideoContentPanel.Visibility = shorts ? Visibility.Collapsed : Visibility.Visible;
|
||||
ShortsContentPanel.Visibility = shorts ? Visibility.Visible : Visibility.Collapsed;
|
||||
Style primaryButtonStyle = (Style)Application.Current.Resources["PrimaryButtonStyle"];
|
||||
VideoTabButton.Style = shorts ? null : primaryButtonStyle;
|
||||
ShortsTabButton.Style = shorts ? primaryButtonStyle : null;
|
||||
RenderButton.Content = shorts ? "Render short" : "Start render";
|
||||
}
|
||||
|
||||
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateShortBackgroundVisibility();
|
||||
|
||||
private void UpdateShortBackgroundVisibility()
|
||||
{
|
||||
bool isBrand = string.Equals(SelectedShortStyle(), nameof(ShortStyle.Brand), StringComparison.OrdinalIgnoreCase);
|
||||
ShortBackgroundRow.Visibility = isBrand ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private async void AddInputs_Click(object sender, RoutedEventArgs e)
|
||||
@@ -145,18 +180,14 @@ public sealed partial class MainPage : Page
|
||||
{
|
||||
try
|
||||
{
|
||||
RenderSettings settings = ReadSettings();
|
||||
SaveSettings();
|
||||
SetRendering(true);
|
||||
LogBox.Text = string.Empty;
|
||||
_renderCancellation = new CancellationTokenSource();
|
||||
Progress<RenderProgress> progress = new(UpdateProgress);
|
||||
IReadOnlyList<string> resolvedInputs = await new RenderPipeline().RenderAsync(settings, progress, AppendLog, _renderCancellation.Token);
|
||||
ReplaceInputs(resolvedInputs);
|
||||
_lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
|
||||
OpenOutputButton.IsEnabled = true;
|
||||
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
|
||||
await ShowMessageAsync("Render complete", "The AmiReel render completed successfully.");
|
||||
|
||||
if (_isShortsTab)
|
||||
await RenderShortAsync(_renderCancellation.Token);
|
||||
else
|
||||
await RenderVideoAsync(_renderCancellation.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -176,6 +207,32 @@ public sealed partial class MainPage : Page
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenderVideoAsync(CancellationToken token)
|
||||
{
|
||||
RenderSettings settings = ReadSettings();
|
||||
SaveSettings();
|
||||
Progress<RenderProgress> progress = new(UpdateProgress);
|
||||
IReadOnlyList<string> resolvedInputs = await new RenderPipeline().RenderAsync(settings, progress, AppendLog, token);
|
||||
ReplaceInputs(resolvedInputs);
|
||||
_lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
|
||||
OpenOutputButton.IsEnabled = true;
|
||||
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
|
||||
await ShowMessageAsync("Render complete", "The AmiReel render completed successfully.");
|
||||
}
|
||||
|
||||
private async Task RenderShortAsync(CancellationToken token)
|
||||
{
|
||||
ShortSettings settings = ReadShortSettings();
|
||||
SaveSettings();
|
||||
Progress<RenderProgress> progress = new(UpdateProgress);
|
||||
await new ShortsPipeline().RenderAsync(settings, progress, AppendLog, token);
|
||||
_lastRenderedFile = settings.OutputPath;
|
||||
ShortOutputBox.Text = settings.OutputPath;
|
||||
OpenOutputButton.IsEnabled = true;
|
||||
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
|
||||
await ShowMessageAsync("Short complete", "The YouTube Short was rendered successfully.");
|
||||
}
|
||||
|
||||
private void Cancel_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CancelButton.IsEnabled = false;
|
||||
@@ -185,8 +242,11 @@ public sealed partial class MainPage : Page
|
||||
|
||||
private void OpenOutput_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Directory.Exists(OutputFolderBox.Text))
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", OutputFolderBox.Text) { UseShellExecute = true });
|
||||
string? directory = !string.IsNullOrWhiteSpace(_lastRenderedFile)
|
||||
? Path.GetDirectoryName(_lastRenderedFile)
|
||||
: OutputFolderBox.Text;
|
||||
if (!string.IsNullOrWhiteSpace(directory) && Directory.Exists(directory))
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", directory) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private async void PreviewSource_Click(object sender, RoutedEventArgs e)
|
||||
@@ -246,11 +306,101 @@ public sealed partial class MainPage : Page
|
||||
_inputs.Add(path);
|
||||
}
|
||||
|
||||
private ShortSettings ReadShortSettings()
|
||||
{
|
||||
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
|
||||
throw new ArgumentException("Enter a valid duration in seconds.");
|
||||
if (!int.TryParse(ShortCrfBox.Text, out int crf))
|
||||
throw new ArgumentException("CRF must be an integer.");
|
||||
|
||||
string input = ShortInputBox.Text.Trim();
|
||||
string output = ShortOutputBox.Text.Trim();
|
||||
ShortStyle style = Enum.Parse<ShortStyle>(SelectedShortStyle());
|
||||
if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
string baseName = Path.GetFileNameWithoutExtension(input);
|
||||
string? directory = Path.GetDirectoryName(input);
|
||||
output = Path.Combine(directory ?? "", $"{baseName}-{style.ToString().ToLowerInvariant()}-short.mp4");
|
||||
}
|
||||
|
||||
return new ShortSettings
|
||||
{
|
||||
InputFile = input,
|
||||
OutputPath = output,
|
||||
Title = ShortTitleBox.Text.Trim(),
|
||||
Group = ShortGroupBox.Text.Trim(),
|
||||
Year = ShortYearBox.Text.Trim(),
|
||||
Type = ShortTypeBox.Text.Trim(),
|
||||
Hook = ShortHookBox.Text.Trim(),
|
||||
Website = ShortWebsiteBox.Text.Trim(),
|
||||
Start = string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
|
||||
DurationSeconds = duration,
|
||||
Style = style,
|
||||
BackgroundImage = ShortBackgroundBox.Text.Trim(),
|
||||
FontFile = ShortFontBox.Text.Trim(),
|
||||
FfmpegPath = FfmpegPathBox.Text.Trim(),
|
||||
FfprobePath = FfprobePathBox.Text.Trim(),
|
||||
Crf = crf,
|
||||
Preset = SelectedShortPreset(),
|
||||
};
|
||||
}
|
||||
|
||||
private async void BrowseShortInput_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)
|
||||
ShortInputBox.Text = file.Path;
|
||||
}
|
||||
|
||||
private async void BrowseShortOutput_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FileSavePicker picker = new();
|
||||
picker.FileTypeChoices.Add("MP4 video", [".mp4"]);
|
||||
picker.SuggestedFileName = "short.mp4";
|
||||
InitializeWithWindow.Initialize(picker, App.MainWindowHandle);
|
||||
var file = await picker.PickSaveFileAsync();
|
||||
if (file is not null)
|
||||
ShortOutputBox.Text = file.Path;
|
||||
}
|
||||
|
||||
private async void BrowseShortBackground_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FileOpenPicker picker = CreateFileOpenPicker();
|
||||
picker.FileTypeFilter.Add(".png");
|
||||
picker.FileTypeFilter.Add(".jpg");
|
||||
picker.FileTypeFilter.Add(".jpeg");
|
||||
var file = await picker.PickSingleFileAsync();
|
||||
if (file is not null)
|
||||
ShortBackgroundBox.Text = file.Path;
|
||||
}
|
||||
|
||||
private async void BrowseShortFont_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FileOpenPicker picker = CreateFileOpenPicker();
|
||||
picker.FileTypeFilter.Add(".ttf");
|
||||
picker.FileTypeFilter.Add(".otf");
|
||||
var file = await picker.PickSingleFileAsync();
|
||||
if (file is not null)
|
||||
ShortFontBox.Text = file.Path;
|
||||
}
|
||||
|
||||
private async void PreviewShortSource_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
|
||||
await OpenPreviewAsync(ShortInputBox.Text.Trim());
|
||||
}
|
||||
|
||||
private void SetRendering(bool rendering)
|
||||
{
|
||||
RenderButton.IsEnabled = !rendering;
|
||||
CancelButton.IsEnabled = rendering;
|
||||
OpenSettingsButton.IsEnabled = !rendering;
|
||||
VideoTabButton.IsEnabled = !rendering;
|
||||
ShortsTabButton.IsEnabled = !rendering;
|
||||
}
|
||||
|
||||
private void UpdateProgress(RenderProgress value)
|
||||
@@ -341,6 +491,13 @@ public sealed partial class MainPage : Page
|
||||
EndCardHoldSeconds = HoldBox.Text.Trim(),
|
||||
ThumbnailInterval = IntervalBox.Text.Trim(),
|
||||
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
|
||||
ShortsStyle = SelectedShortStyle(),
|
||||
ShortsHook = ShortHookBox.Text.Trim(),
|
||||
ShortsWebsite = ShortWebsiteBox.Text.Trim(),
|
||||
ShortsFontFile = ShortFontBox.Text.Trim(),
|
||||
ShortsBackgroundImage = ShortBackgroundBox.Text.Trim(),
|
||||
ShortsCrf = ShortCrfBox.Text.Trim(),
|
||||
ShortsPreset = SelectedShortPreset(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -348,32 +505,26 @@ public sealed partial class MainPage : Page
|
||||
|
||||
private string SelectedEncoder() => (EncoderBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto";
|
||||
|
||||
private void SelectTheme(string theme)
|
||||
private string SelectedShortStyle() => (ShortStyleBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Brand";
|
||||
|
||||
private string SelectedShortPreset() => (ShortPresetBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "slow";
|
||||
|
||||
private void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme);
|
||||
|
||||
private void SelectEncoder(string encoder) => SelectComboTag(EncoderBox, encoder);
|
||||
|
||||
private static void SelectComboTag(ComboBox comboBox, string tag)
|
||||
{
|
||||
foreach (ComboBoxItem item in ThemeBox.Items)
|
||||
foreach (ComboBoxItem item in comboBox.Items.Cast<ComboBoxItem>())
|
||||
{
|
||||
if (string.Equals(item.Tag?.ToString(), theme, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(item.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ThemeBox.SelectedItem = item;
|
||||
comboBox.SelectedItem = item;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ThemeBox.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private void SelectEncoder(string encoder)
|
||||
{
|
||||
foreach (ComboBoxItem item in EncoderBox.Items)
|
||||
{
|
||||
if (string.Equals(item.Tag?.ToString(), encoder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
EncoderBox.SelectedItem = item;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
EncoderBox.SelectedIndex = 0;
|
||||
comboBox.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private void InputList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
|
||||
@@ -22,6 +22,15 @@ public sealed record AppSettings
|
||||
public required string ThumbnailInterval { get; init; }
|
||||
public bool? MoveSourcesToOriginals { get; init; }
|
||||
|
||||
// Shorts tab defaults (sticky across launches, same as the fields above).
|
||||
public string ShortsStyle { get; init; } = "Brand";
|
||||
public string ShortsHook { get; init; } = "THIS RAN ON AN AMIGA";
|
||||
public string ShortsWebsite { get; init; } = "AMIGADB.NET";
|
||||
public string ShortsFontFile { get; init; } = "";
|
||||
public string ShortsBackgroundImage { get; init; } = "";
|
||||
public string ShortsCrf { get; init; } = "16";
|
||||
public string ShortsPreset { get; init; } = "slow";
|
||||
|
||||
public bool ShouldMoveSourcesToOriginals => MoveSourcesToOriginals != false;
|
||||
|
||||
public static AppSettings Default(string outputFolder) => new()
|
||||
@@ -54,5 +63,12 @@ public sealed record AppSettings
|
||||
EndCardHoldSeconds = string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds,
|
||||
ThumbnailInterval = string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval,
|
||||
MoveSourcesToOriginals = MoveSourcesToOriginals ?? true,
|
||||
ShortsStyle = string.IsNullOrWhiteSpace(ShortsStyle) ? "Brand" : ShortsStyle,
|
||||
ShortsHook = ShortsHook ?? "THIS RAN ON AN AMIGA",
|
||||
ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET",
|
||||
ShortsFontFile = ShortsFontFile ?? "",
|
||||
ShortsBackgroundImage = ShortsBackgroundImage ?? "",
|
||||
ShortsCrf = string.IsNullOrWhiteSpace(ShortsCrf) ? "16" : ShortsCrf,
|
||||
ShortsPreset = string.IsNullOrWhiteSpace(ShortsPreset) ? "slow" : ShortsPreset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace AmiReel.Models;
|
||||
|
||||
/// <summary>Visual style applied to a YouTube Short. Mirrors the --style values of amigadb-short.sh.</summary>
|
||||
public enum ShortStyle
|
||||
{
|
||||
Brand,
|
||||
Pixel,
|
||||
Mirror,
|
||||
Crop,
|
||||
Workbench,
|
||||
Blur,
|
||||
Crt,
|
||||
Copper,
|
||||
Stars,
|
||||
Grid,
|
||||
Tiles,
|
||||
Scan,
|
||||
Starfield,
|
||||
Plasma,
|
||||
Rasterbars,
|
||||
Vhs,
|
||||
Monitor,
|
||||
Split,
|
||||
Spectrum,
|
||||
}
|
||||
|
||||
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short.sh's arguments).</summary>
|
||||
public sealed record ShortSettings
|
||||
{
|
||||
public required string InputFile { get; init; }
|
||||
public required string OutputPath { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string Group { get; init; } = "";
|
||||
public string Year { get; init; } = "";
|
||||
public string Type { get; init; } = "";
|
||||
public required string Hook { get; init; }
|
||||
public required string Website { get; init; }
|
||||
public required string Start { get; init; }
|
||||
public required double DurationSeconds { get; init; }
|
||||
public required ShortStyle Style { get; init; }
|
||||
public string BackgroundImage { get; init; } = "";
|
||||
public required string FontFile { get; init; }
|
||||
public required string FfmpegPath { get; init; }
|
||||
public required string FfprobePath { get; init; }
|
||||
public int Crf { get; init; } = 16;
|
||||
public string Preset { get; init; } = "slow";
|
||||
}
|
||||
@@ -4,22 +4,38 @@ AmiReel is a Windows desktop app for turning Amiga (or any) screen recordings in
|
||||
|
||||
- a 4K 50 FPS final video,
|
||||
- PNG/JPG thumbnails,
|
||||
- an animated WebP preview.
|
||||
- an animated WebP preview,
|
||||
- a vertical (1080×1920, 50 FPS) YouTube Short with a styled title card.
|
||||
|
||||
Built with **WinUI 3** on the Windows App SDK, driving FFmpeg under the hood.
|
||||
Built with **WinUI 3** on the Windows App SDK, driving FFmpeg under the hood. The app has two
|
||||
tabs — **Video** (the full-length render pipeline above) and **Shorts** (see below) — sharing
|
||||
the same render progress, log, and output controls.
|
||||
|
||||
## Features
|
||||
|
||||
### Video tab
|
||||
- Multiple ordered video inputs — `.avi`, `.mp4`, `.m4v`, `.mov`, `.mkv`, `.webm`, `.wmv`, `.flv`, `.mpg`, `.mpeg`, `.ts`, `.3gp`
|
||||
- Drag-and-drop or file-picker source selection
|
||||
- 3840×2160 output at 50 FPS
|
||||
- NVIDIA NVENC with automatic CPU `libx264` fallback
|
||||
- Configurable trim, fade, end-card hold, and thumbnail interval
|
||||
- Optional end-card image
|
||||
- Optional move of source recordings into `originals/` in the output folder
|
||||
|
||||
### Shorts tab
|
||||
- Turns one source video into a vertical YouTube Short with an opening hook, title/group/year/
|
||||
type card, and closing website call-to-action, all as timed text overlays
|
||||
- 19 visual styles ported from `amigadb-short.sh` — `brand` (title card image), `pixel`,
|
||||
`mirror`, `crop`, `workbench`, `blur`, `crt`, `copper`, `stars`, `grid`, `tiles`, `scan`,
|
||||
`starfield`, `plasma`, `rasterbars`, `vhs`, `monitor`, `split`, `spectrum` (needs audio)
|
||||
— see [`Services/ShortsPipeline.cs`](Services/ShortsPipeline.cs) for the exact FFmpeg filtergraphs
|
||||
- Configurable clip start/duration, CRF, and x264 preset
|
||||
- Style, font, background image, hook, and website text are sticky across launches
|
||||
|
||||
### Shared
|
||||
- NVIDIA NVENC with automatic CPU `libx264` fallback
|
||||
- Live render progress with frame counters and FFmpeg log output
|
||||
- Source and final-render preview (built-in or a custom player)
|
||||
- Dark and light themes
|
||||
- Optional move of source recordings into `originals/` in the output folder
|
||||
- Optional custom `ffmpeg.exe` / `ffprobe.exe` paths, with automatic detection from `PATH`
|
||||
- Self-contained single-file publish for easy distribution
|
||||
|
||||
@@ -37,11 +53,13 @@ app.manifest Win32 manifest (DPI awareness, OS compatibility)
|
||||
Assets/ Packaged app icons and tiles (all required sizes)
|
||||
branding/ Source artwork the Assets/ tiles are generated from
|
||||
Models/
|
||||
AppSettings.cs Persisted user settings
|
||||
RenderSettings.cs Render job parameters
|
||||
AppSettings.cs Persisted user settings (Video + Shorts defaults)
|
||||
RenderSettings.cs Video render job parameters
|
||||
ShortSettings.cs Shorts render job parameters, ShortStyle enum
|
||||
SupportedVideoFormats.cs Accepted input file extensions
|
||||
Services/
|
||||
RenderPipeline.cs Main render workflow (FFmpeg orchestration)
|
||||
RenderPipeline.cs Video render workflow (FFmpeg orchestration)
|
||||
ShortsPipeline.cs Shorts render workflow; the 19 style filtergraphs live here
|
||||
ProcessRunner.cs FFmpeg process execution and progress parsing
|
||||
FfmpegProgressParser.cs Parses time=/frame=/fps=/speed= from FFmpeg output lines
|
||||
FfmpegOutputFilter.cs Shared FFmpeg banner/boilerplate line filtering
|
||||
@@ -55,6 +73,7 @@ Properties/
|
||||
launchSettings.json
|
||||
PublishProfiles/ win-x64/x86/arm64 publish profiles
|
||||
AmiReel.Tests/ Unit tests (MSTest), mirrors Models/ and Services/
|
||||
amigadb-short.sh Original bash/FFmpeg script that ShortsPipeline.cs is a C# port of
|
||||
ThirdParty/ Embedded ffmpeg.exe / ffprobe.exe (not checked in, see below)
|
||||
publish-win-x64.ps1 Publish script
|
||||
```
|
||||
@@ -85,7 +104,8 @@ dotnet run --project .\AmiReel.csproj
|
||||
|
||||
Unit tests live in `AmiReel.Tests/` (MSTest), covering the pure logic in `Models/` and
|
||||
`Services/` — settings normalization, supported-format detection, FFmpeg output parsing/
|
||||
filtering, render-settings validation, and moving source files into `originals/`.
|
||||
filtering, render-settings validation, moving source files into `originals/`, and the Shorts
|
||||
filtergraph/escaping/validation logic.
|
||||
|
||||
```powershell
|
||||
dotnet test .\AmiReel.Tests\AmiReel.Tests.csproj
|
||||
@@ -151,8 +171,9 @@ User settings are stored in:
|
||||
```
|
||||
|
||||
This includes: output folder, end-card path, FFmpeg/preview-player paths, theme, encoder
|
||||
selection, timing settings, and whether source videos are moved into `originals/` after a
|
||||
successful render.
|
||||
selection, timing settings, whether source videos are moved into `originals/` after a
|
||||
successful render, and the Shorts tab's sticky defaults (style, font, background image, CRF,
|
||||
preset, hook, and website text).
|
||||
|
||||
## Notes for Distribution
|
||||
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using AmiReel.Models;
|
||||
|
||||
namespace AmiReel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a vertical (1080x1920, 50 FPS) YouTube Short from an existing video, with a chosen
|
||||
/// visual style and title-card text overlays. A C# port of amigadb-short.sh so end users don't
|
||||
/// need bash/WSL — the FFmpeg filtergraphs below are copied verbatim from that script.
|
||||
/// </summary>
|
||||
public sealed class ShortsPipeline
|
||||
{
|
||||
private readonly ProcessRunner _runner = new();
|
||||
|
||||
public async Task RenderAsync(ShortSettings settings, IProgress<RenderProgress> progress, Action<string> log, CancellationToken token)
|
||||
{
|
||||
Validate(settings);
|
||||
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
|
||||
|
||||
if (settings.Style == ShortStyle.Spectrum)
|
||||
{
|
||||
MediaProbe probe = new(_runner, log);
|
||||
bool hasAudio = await probe.HasAudioAsync(tools, settings.InputFile, token);
|
||||
if (!hasAudio)
|
||||
throw new InvalidOperationException("The spectrum style requires an audio stream in the source video.");
|
||||
}
|
||||
|
||||
string? outputDirectory = Path.GetDirectoryName(Path.GetFullPath(settings.OutputPath));
|
||||
if (!string.IsNullOrEmpty(outputDirectory))
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", "shorts", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
try
|
||||
{
|
||||
string hookFile = WriteTextFile(workDir, "hook.txt", settings.Hook);
|
||||
string titleFile = WriteTextFile(workDir, "title.txt", settings.Title);
|
||||
string metaFile = WriteTextFile(workDir, "meta.txt", BuildMeta(settings));
|
||||
string websiteFile = WriteTextFile(workDir, "website.txt", settings.Website);
|
||||
|
||||
double duration = settings.DurationSeconds;
|
||||
double infoStart = Math.Max(0, duration - 7);
|
||||
double ctaStart = Math.Max(0, duration - 3.5);
|
||||
double hookEnd = Math.Min(2.7, duration);
|
||||
|
||||
string filterComplex = BuildFilterComplex(
|
||||
GetBaseFilter(settings.Style),
|
||||
fontEsc: EscapeFfmpegPath(settings.FontFile),
|
||||
hookEsc: EscapeFfmpegPath(hookFile),
|
||||
titleEsc: EscapeFfmpegPath(titleFile),
|
||||
metaEsc: EscapeFfmpegPath(metaFile),
|
||||
websiteEsc: EscapeFfmpegPath(websiteFile),
|
||||
hookEnd, infoStart, ctaStart);
|
||||
|
||||
List<string> args = ["-hide_banner", "-y", "-ss", settings.Start, "-t", F(duration), "-i", settings.InputFile];
|
||||
if (settings.Style == ShortStyle.Brand)
|
||||
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
|
||||
|
||||
args.AddRange([
|
||||
"-filter_complex", filterComplex,
|
||||
"-map", "[vout]",
|
||||
"-map", "0:a?",
|
||||
"-c:v", "libx264",
|
||||
"-preset", settings.Preset,
|
||||
"-crf", settings.Crf.ToString(CultureInfo.InvariantCulture),
|
||||
"-profile:v", "high",
|
||||
"-level:v", "4.2",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "320k",
|
||||
"-ar", "48000",
|
||||
"-movflags", "+faststart",
|
||||
"-shortest",
|
||||
settings.OutputPath,
|
||||
]);
|
||||
|
||||
progress.Report(new(2, "Rendering short", $"Applying the '{settings.Style}' style"));
|
||||
TimeSpan totalSpan = TimeSpan.FromSeconds(duration);
|
||||
await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, outputHandler: output =>
|
||||
{
|
||||
if (output.Time is not { } time) return;
|
||||
double percent = Math.Min(98, 2 + time.TotalSeconds / duration * 96);
|
||||
progress.Report(new(percent, "Rendering short", $"{time:hh\\:mm\\:ss} / {totalSpan:hh\\:mm\\:ss}"));
|
||||
});
|
||||
|
||||
progress.Report(new(100, "Complete", settings.OutputPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(workDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Looks for a common bold system font to pre-fill the Shorts font field with.</summary>
|
||||
public static string? FindDefaultFont()
|
||||
{
|
||||
string fontsDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
|
||||
foreach (string name in new[] { "segoeuib.ttf", "seguisb.ttf", "arialbd.ttf", "calibrib.ttf" })
|
||||
{
|
||||
string candidate = Path.Combine(fontsDir, name);
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static void Validate(ShortSettings s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
|
||||
throw new FileNotFoundException("Select a valid source video for the Short.");
|
||||
if (string.IsNullOrWhiteSpace(s.Title))
|
||||
throw new ArgumentException("Enter a title.");
|
||||
if (string.IsNullOrWhiteSpace(s.OutputPath))
|
||||
throw new ArgumentException("Select an output file.");
|
||||
if (s.DurationSeconds <= 0)
|
||||
throw new ArgumentException("Duration must be greater than zero.");
|
||||
if (string.IsNullOrWhiteSpace(s.FontFile) || !File.Exists(s.FontFile))
|
||||
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
|
||||
if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage)))
|
||||
throw new FileNotFoundException("The brand style requires a background image.");
|
||||
}
|
||||
|
||||
internal static string BuildMeta(ShortSettings s)
|
||||
{
|
||||
List<string> parts = [];
|
||||
if (!string.IsNullOrWhiteSpace(s.Group)) parts.Add(s.Group.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(s.Year)) parts.Add(s.Year.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(s.Type)) parts.Add(s.Type.Trim());
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
internal static string EscapeFfmpegPath(string value) =>
|
||||
value.Replace("\\", "\\\\").Replace(":", "\\:").Replace("'", "\\'");
|
||||
|
||||
private static string WriteTextFile(string directory, string fileName, string content)
|
||||
{
|
||||
string path = Path.Combine(directory, fileName);
|
||||
File.WriteAllText(path, content + "\n", new UTF8Encoding(false));
|
||||
return path;
|
||||
}
|
||||
|
||||
internal static string BuildFilterComplex(
|
||||
string baseFilter, string fontEsc, string hookEsc, string titleEsc, string metaEsc, string websiteEsc,
|
||||
double hookEnd, double infoStart, double ctaStart)
|
||||
{
|
||||
string hookEndStr = F(hookEnd);
|
||||
string infoStartStr = F(infoStart);
|
||||
string ctaStartStr = F(ctaStart);
|
||||
|
||||
return baseFilter + ";" +
|
||||
"[base]" +
|
||||
$"drawbox=x=0:y=0:w=1080:h=245:color=black@0.50:t=fill:enable='between(t,0,{hookEndStr})'," +
|
||||
$"drawtext=fontfile='{fontEsc}':textfile='{hookEsc}':fontcolor=white:fontsize=70:line_spacing=10:x=(w-text_w)/2:y=70:fix_bounds=true:shadowcolor=black@0.75:shadowx=4:shadowy=4:enable='between(t,0,{hookEndStr})'," +
|
||||
$"drawbox=x=0:y=1490:w=1080:h=430:color=black@0.62:t=fill:enable='gte(t,{infoStartStr})'," +
|
||||
$"drawtext=fontfile='{fontEsc}':textfile='{titleEsc}':fontcolor=white:fontsize=62:line_spacing=8:x=(w-text_w)/2:y=1560:fix_bounds=true:shadowcolor=black@0.80:shadowx=4:shadowy=4:enable='gte(t,{infoStartStr})'," +
|
||||
$"drawtext=fontfile='{fontEsc}':textfile='{metaEsc}':fontcolor=white@0.92:fontsize=42:x=(w-text_w)/2:y=1655:fix_bounds=true:shadowcolor=black@0.80:shadowx=3:shadowy=3:enable='gte(t,{infoStartStr})'," +
|
||||
$"drawtext=fontfile='{fontEsc}':textfile='{websiteEsc}':fontcolor=0x55c8ff:fontsize=52:x=(w-text_w)/2:y=1780:fix_bounds=true:shadowcolor=black@0.85:shadowx=4:shadowy=4:enable='gte(t,{ctaStartStr})'," +
|
||||
"fps=50,format=yuv420p[vout]";
|
||||
}
|
||||
|
||||
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
internal static string GetBaseFilter(ShortStyle style) => style switch
|
||||
{
|
||||
ShortStyle.Brand => """
|
||||
[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1[bg];
|
||||
[0:v]scale=920:700:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x07111f,setsar=1,eq=contrast=1.02:saturation=1.02[fg];
|
||||
[bg]drawbox=x=65:y=505:w=950:h=730:color=black@0.22:t=fill[bg2];
|
||||
[bg2][fg]overlay=x=(W-w)/2:y=520:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Pixel => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=270:480:force_original_aspect_ratio=increase,crop=270:480,scale=1080:1920:flags=neighbor,eq=brightness=-0.36:saturation=1.18,gblur=sigma=2,setsar=1[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x55c8ff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
||||
drawbox=x=0:y='300+20*sin(t*1.70)':w=1080:h=8:color=0x55c8ff@0.72:t=fill,
|
||||
drawbox=x=0:y='1610+18*sin(t*1.35)':w=1080:h=8:color=0xff7020@0.72:t=fill[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Mirror => """
|
||||
[0:v]split=3[fg_src][top_src][bottom_src];
|
||||
[top_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[top];
|
||||
[bottom_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[bottom];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=white@0.70,setsar=1[fg];
|
||||
[top]pad=1080:1920:0:0:color=0x050616[canvas];
|
||||
[canvas][bottom]overlay=x=0:y=1300[tmp];
|
||||
[tmp][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Crop => """
|
||||
[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,eq=brightness=-0.04:saturation=1.05[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Workbench => """
|
||||
[0:v]split=2[canvas_src][screen_src];
|
||||
[canvas_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,
|
||||
drawbox=x=0:y=0:w=1080:h=1920:color=0x090c20:t=fill,
|
||||
drawbox=x=55:y=410:w=970:h=930:color=0xc4c8d0:t=fill,
|
||||
drawbox=x=70:y=425:w=940:h=900:color=0x1b4f9c:t=fill,
|
||||
drawbox=x=70:y=425:w=940:h=74:color=0xf28c28:t=fill,
|
||||
drawbox=x=88:y=445:w=34:h=34:color=0x111111:t=fill[window];
|
||||
[screen_src]scale=960:760:force_original_aspect_ratio=decrease,pad=iw+10:ih+10:5:5:color=0xffffff,setsar=1[screen];
|
||||
[window][screen]overlay=x=(W-w)/2:y=525:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Blur => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=35,eq=brightness=-0.22:saturation=0.85,setsar=1[bg];
|
||||
[fg_src]scale=1080:900:force_original_aspect_ratio=decrease,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Crt => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=28,eq=brightness=-0.45:saturation=0.55,setsar=1[bg];
|
||||
[fg_src]scale=980:800:force_original_aspect_ratio=decrease,lenscorrection=k1=-0.060:k2=0.020,vignette=angle=PI/5,eq=contrast=1.08:saturation=1.10,setsar=1,
|
||||
drawgrid=width=iw:height=4:thickness=1:color=black@0.18[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
||||
drawbox=x=50:y='220+mod(t*340,1480)':w=980:h=4:color=white@0.08:t=fill[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Copper => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,eq=brightness=-0.50:saturation=0.80,gblur=sigma=18,setsar=1,
|
||||
drawbox=x=0:y=0:w=1080:h=1920:color=0x061a5f@0.55:t=fill,
|
||||
drawbox=x=0:y='220+20*sin(t*1.00)':w=1080:h=140:color=0x123fcb@0.28:t=fill,
|
||||
drawbox=x=0:y='420+24*sin(t*1.20)':w=1080:h=100:color=0x2b8cff@0.22:t=fill,
|
||||
drawbox=x=0:y='660+16*sin(t*1.50)':w=1080:h=120:color=0x00b8ff@0.18:t=fill,
|
||||
drawbox=x=0:y='1110+22*sin(t*1.10)':w=1080:h=150:color=0xff7a1f@0.20:t=fill,
|
||||
drawbox=x=0:y='1360+18*sin(t*1.30)':w=1080:h=110:color=0xffc120@0.18:t=fill,
|
||||
drawbox=x=0:y='1660+15*sin(t*1.70)':w=1080:h=130:color=0x58d4ff@0.18:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Stars => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.72:saturation=0.28,setsar=1,
|
||||
drawbox=x=0:y=0:w=1080:h=1920:color=0x05081b@0.75:t=fill,
|
||||
drawbox=x='mod(100+80*t,1080)':y='mod(80+210*t,1920)':w=3:h=3:color=white@0.95:t=fill,
|
||||
drawbox=x='mod(880-55*t,1080)':y='mod(220+170*t,1920)':w=2:h=2:color=white@0.85:t=fill,
|
||||
drawbox=x='mod(300+120*t,1080)':y='mod(520+140*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
|
||||
drawbox=x='mod(980-75*t,1080)':y='mod(710+160*t,1920)':w=2:h=2:color=white@0.80:t=fill,
|
||||
drawbox=x='mod(420+65*t,1080)':y='mod(1040+200*t,1920)':w=4:h=4:color=0xffd46b@0.85:t=fill,
|
||||
drawbox=x='mod(750-90*t,1080)':y='mod(1280+110*t,1920)':w=2:h=2:color=white@0.88:t=fill,
|
||||
drawbox=x='mod(210+75*t,1080)':y='mod(1440+180*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
|
||||
drawbox=x='mod(1030-60*t,1080)':y='mod(1680+140*t,1920)':w=2:h=2:color=white@0.82:t=fill,
|
||||
drawbox=x='mod(560+95*t,1080)':y='mod(260+260*t,1920)':w=2:h=2:color=white@0.92:t=fill,
|
||||
drawbox=x='mod(60+110*t,1080)':y='mod(860+120*t,1920)':w=3:h=3:color=0xffd46b@0.82:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=0x55c8ff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Grid => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=24,eq=brightness=-0.62:saturation=0.36,setsar=1,
|
||||
drawbox=x=0:y=0:w=1080:h=1920:color=0x090b17@0.72:t=fill,
|
||||
drawgrid=width=90:height=90:thickness=2:color=0x39d2ff@0.18,
|
||||
drawbox=x=0:y=0:w=1080:h=1120:color=0x050814@0.80:t=fill,
|
||||
drawbox=x=0:y=1120:w=1080:h=4:color=0xff54d9@0.70:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xff54d9,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Tiles => """
|
||||
[0:v]split=5[fg_src][a][b][c][d];
|
||||
[a]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[ta];
|
||||
[b]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,hflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[tb];
|
||||
[c]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,vflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[tc];
|
||||
[d]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,hflip,vflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[td];
|
||||
[ta][tb]hstack=inputs=2[top];
|
||||
[tc][td]hstack=inputs=2[bottom];
|
||||
[top][bottom]vstack=inputs=2[bg];
|
||||
[fg_src]scale=980:800:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0xffffff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Scan => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,scale=360:640:flags=neighbor,scale=1080:1920:flags=neighbor,eq=brightness=-0.44:saturation=1.05,gblur=sigma=2,setsar=1[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x55c8ff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
||||
drawbox=x=40:y='220+mod(t*380,1480)':w=1000:h=8:color=white@0.14:t=fill,
|
||||
drawbox=x=0:y='280+20*sin(t*1.40)':w=1080:h=6:color=0x55c8ff@0.45:t=fill,
|
||||
drawbox=x=0:y='1640+15*sin(t*1.20)':w=1080:h=6:color=0xff7020@0.45:t=fill[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Starfield => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=32,eq=brightness=-0.78:saturation=0.22,setsar=1,
|
||||
drawbox=x=0:y=0:w=1080:h=1920:color=0x020511@0.82:t=fill,
|
||||
drawbox=x='mod(120+75*t,1080)':y='mod(80+520*t,1920)':w=3:h=18:color=white@0.92:t=fill,
|
||||
drawbox=x='mod(970-55*t,1080)':y='mod(250+430*t,1920)':w=2:h=14:color=0xa4dcff@0.85:t=fill,
|
||||
drawbox=x='mod(370+95*t,1080)':y='mod(430+600*t,1920)':w=4:h=24:color=white@0.90:t=fill,
|
||||
drawbox=x='mod(810-80*t,1080)':y='mod(640+470*t,1920)':w=2:h=17:color=0xffdc83@0.85:t=fill,
|
||||
drawbox=x='mod(230+65*t,1080)':y='mod(900+540*t,1920)':w=3:h=20:color=white@0.88:t=fill,
|
||||
drawbox=x='mod(1020-100*t,1080)':y='mod(1110+650*t,1920)':w=4:h=26:color=0x8fd5ff@0.90:t=fill,
|
||||
drawbox=x='mod(560+85*t,1080)':y='mod(1320+490*t,1920)':w=2:h=16:color=white@0.86:t=fill,
|
||||
drawbox=x='mod(70+110*t,1080)':y='mod(1510+590*t,1920)':w=3:h=22:color=0xffdc83@0.82:t=fill,
|
||||
drawbox=x='mod(720-60*t,1080)':y='mod(1720+450*t,1920)':w=2:h=15:color=white@0.88:t=fill,
|
||||
drawbox=x='mod(440+105*t,1080)':y='mod(180+700*t,1920)':w=3:h=28:color=0xa4dcff@0.88:t=fill,
|
||||
drawbox=x='mod(900-72*t,1080)':y='mod(780+560*t,1920)':w=2:h=18:color=white@0.84:t=fill,
|
||||
drawbox=x='mod(310+90*t,1080)':y='mod(1240+620*t,1920)':w=4:h=24:color=white@0.90:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0x70cfff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Plasma => """
|
||||
[0:v]split=2[dim_src][fg_src];
|
||||
nullsrc=size=1080x1920:rate=50,format=rgb24,
|
||||
geq=r='128+105*sin(X/68+T*1.9)+22*sin(Y/91-T*1.1)':g='128+96*sin(Y/75+T*1.5)+26*sin((X+Y)/120+T*1.3)':b='128+110*sin((X-Y)/82-T*1.7)',
|
||||
gblur=sigma=18,eq=brightness=-0.16:saturation=1.22[plasma];
|
||||
[dim_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=26,eq=brightness=-0.70:saturation=0.35,setsar=1[dim];
|
||||
[plasma][dim]blend=all_mode=overlay:all_opacity=0.35[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0xffffff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Rasterbars => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.70:saturation=0.35,setsar=1,
|
||||
drawbox=x=0:y=0:w=1080:h=1920:color=0x05061b@0.80:t=fill,
|
||||
drawbox=x=0:y='250+90*sin(t*1.25)':w=1080:h=30:color=0xff3b7f@0.78:t=fill,
|
||||
drawbox=x=0:y='300+90*sin(t*1.25)':w=1080:h=24:color=0xff7b32@0.72:t=fill,
|
||||
drawbox=x=0:y='345+90*sin(t*1.25)':w=1080:h=18:color=0xffd12b@0.66:t=fill,
|
||||
drawbox=x=0:y='1510+85*sin(t*1.10+1.2)':w=1080:h=18:color=0x55e6ff@0.68:t=fill,
|
||||
drawbox=x=0:y='1550+85*sin(t*1.10+1.2)':w=1080:h=24:color=0x4b8cff@0.72:t=fill,
|
||||
drawbox=x=0:y='1600+85*sin(t*1.10+1.2)':w=1080:h=30:color=0x8b55ff@0.76:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Vhs => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=26,eq=brightness=-0.44:saturation=0.55,noise=alls=10:allf=t+u,setsar=1[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,chromashift=cbh=2:crh=-2,noise=alls=5:allf=t+u,eq=contrast=1.08:saturation=0.92,vignette=angle=PI/4.5,setsar=1,
|
||||
drawgrid=width=iw:height=5:thickness=1:color=black@0.13[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
||||
drawbox=x=0:y='300+mod(t*510,1320)':w=1080:h=10:color=white@0.08:t=fill,
|
||||
drawbox=x=0:y='420+mod(t*320,1080)':w=1080:h=3:color=0x7bd7ff@0.10:t=fill[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Monitor => """
|
||||
[0:v]split=2[bg_src][screen_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=34,eq=brightness=-0.68:saturation=0.32,setsar=1,
|
||||
drawbox=x=0:y=0:w=1080:h=1920:color=0x11131a@0.78:t=fill,
|
||||
drawbox=x=42:y=365:w=996:h=1000:color=0xaaa69a:t=fill,
|
||||
drawbox=x=68:y=392:w=944:h=944:color=0x2a2926:t=fill,
|
||||
drawbox=x=105:y=438:w=870:h=790:color=0x050505:t=fill,
|
||||
drawbox=x=792:y=1260:w=44:h=18:color=0x6aff73:t=fill,
|
||||
drawbox=x=852:y=1253:w=30:h=30:color=0x232323:t=fill,
|
||||
drawbox=x=910:y=1253:w=30:h=30:color=0x232323:t=fill,
|
||||
drawbox=x=380:y=1365:w=320:h=75:color=0x8f8b80:t=fill,
|
||||
drawbox=x=290:y=1438:w=500:h=36:color=0x77736b:t=fill[monitor];
|
||||
[screen_src]scale=840:750:force_original_aspect_ratio=decrease,lenscorrection=k1=-0.045:k2=0.012,vignette=angle=PI/5,eq=contrast=1.06:saturation=1.06,setsar=1[screen];
|
||||
[monitor][screen]overlay=x=(W-w)/2:y=458:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Split => """
|
||||
[0:v]split=4[bg_src][main_src][top_src][bottom_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=34,eq=brightness=-0.55:saturation=0.55,setsar=1[bg];
|
||||
[top_src]scale=1080:520:force_original_aspect_ratio=increase,crop=1080:360:y='(ih-360)/2',eq=brightness=-0.28:saturation=0.88,gblur=sigma=2,setsar=1[top];
|
||||
[bottom_src]scale=1080:520:force_original_aspect_ratio=increase,crop=1080:360:y='(ih-360)/2',hflip,eq=brightness=-0.32:saturation=0.80,gblur=sigma=2,setsar=1[bottom];
|
||||
[main_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[main];
|
||||
[bg][top]overlay=x=0:y=275[tmp1];
|
||||
[tmp1][bottom]overlay=x=0:y=1285[tmp2];
|
||||
[tmp2][main]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Spectrum => """
|
||||
[0:v]split=2[bg_src][fg_src];
|
||||
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.62:saturation=0.40,setsar=1,
|
||||
drawbox=x=0:y=0:w=1080:h=1920:color=0x040717@0.68:t=fill[bg];
|
||||
[fg_src]scale=1000:780:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=0x55c8ff,setsar=1[fg];
|
||||
[0:a]aformat=sample_fmts=fltp:channel_layouts=stereo,showspectrum=s=1000x250:mode=combined:color=rainbow:scale=sqrt:fscale=log:slide=scroll:win_func=hann:fps=50:opacity=0.85,format=rgba[spec];
|
||||
[bg][spec]overlay=x=(W-w)/2:y=1360:format=auto[tmp];
|
||||
[tmp][fg]overlay=x=(W-w)/2:y=480:format=auto[base]
|
||||
""",
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(style), style, "Unknown short style."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# AmigaDB YouTube Shorts generator
|
||||
# Creates a 1080x1920, 50 FPS vertical MP4 from an existing Amiga video.
|
||||
# V3 expanded style pack for 4:3 Amiga footage, with demoscene-inspired FX and optional branded background image.
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
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"
|
||||
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
|
||||
default: brand
|
||||
|
||||
--background-image Background image for brand style
|
||||
default: amigadb_short.png
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
;;
|
||||
--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
|
||||
;;
|
||||
-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"
|
||||
|
||||
case "$STYLE" in
|
||||
brand|pixel|mirror|crop|workbench|blur|crt|copper|stars|grid|tiles|scan|starfield|plasma|rasterbars|vhs|monitor|split|spectrum)
|
||||
;;
|
||||
*)
|
||||
fail "Unknown style '$STYLE'. Use brand, pixel, mirror, crop, workbench, blur, crt, copper, stars, grid, tiles, scan, starfield, plasma, rasterbars, vhs, monitor, split, or spectrum"
|
||||
;;
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
# 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:color=black@0.22: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:color=0x55c8ff@0.72:t=fill,
|
||||
drawbox=x=0:y='1610+18*sin(t*1.35)':w=1080:h=8:color=0xff7020@0.72:t=fill[base]
|
||||
FILTER
|
||||
)
|
||||
;;
|
||||
|
||||
mirror)
|
||||
BASE_FILTER=$(cat <<'FILTER'
|
||||
[0:v]split=3[fg_src][top_src][bottom_src];
|
||||
[top_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[top];
|
||||
[bottom_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[bottom];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=white@0.70,setsar=1[fg];
|
||||
[top]pad=1080:1920:0:0:color=0x050616[canvas];
|
||||
[canvas][bottom]overlay=x=0:y=1300[tmp];
|
||||
[tmp][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
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:color=black@0.18[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
||||
drawbox=x=50:y='220+mod(t*340,1480)':w=980:h=4:color=white@0.08:t=fill[base]
|
||||
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:color=0x061a5f@0.55:t=fill,
|
||||
drawbox=x=0:y='220+20*sin(t*1.00)':w=1080:h=140:color=0x123fcb@0.28:t=fill,
|
||||
drawbox=x=0:y='420+24*sin(t*1.20)':w=1080:h=100:color=0x2b8cff@0.22:t=fill,
|
||||
drawbox=x=0:y='660+16*sin(t*1.50)':w=1080:h=120:color=0x00b8ff@0.18:t=fill,
|
||||
drawbox=x=0:y='1110+22*sin(t*1.10)':w=1080:h=150:color=0xff7a1f@0.20:t=fill,
|
||||
drawbox=x=0:y='1360+18*sin(t*1.30)':w=1080:h=110:color=0xffc120@0.18:t=fill,
|
||||
drawbox=x=0:y='1660+15*sin(t*1.70)':w=1080:h=130:color=0x58d4ff@0.18:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
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:color=0x05081b@0.75:t=fill,
|
||||
drawbox=x='mod(100+80*t,1080)':y='mod(80+210*t,1920)':w=3:h=3:color=white@0.95:t=fill,
|
||||
drawbox=x='mod(880-55*t,1080)':y='mod(220+170*t,1920)':w=2:h=2:color=white@0.85:t=fill,
|
||||
drawbox=x='mod(300+120*t,1080)':y='mod(520+140*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
|
||||
drawbox=x='mod(980-75*t,1080)':y='mod(710+160*t,1920)':w=2:h=2:color=white@0.80:t=fill,
|
||||
drawbox=x='mod(420+65*t,1080)':y='mod(1040+200*t,1920)':w=4:h=4:color=0xffd46b@0.85:t=fill,
|
||||
drawbox=x='mod(750-90*t,1080)':y='mod(1280+110*t,1920)':w=2:h=2:color=white@0.88:t=fill,
|
||||
drawbox=x='mod(210+75*t,1080)':y='mod(1440+180*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
|
||||
drawbox=x='mod(1030-60*t,1080)':y='mod(1680+140*t,1920)':w=2:h=2:color=white@0.82:t=fill,
|
||||
drawbox=x='mod(560+95*t,1080)':y='mod(260+260*t,1920)':w=2:h=2:color=white@0.92:t=fill,
|
||||
drawbox=x='mod(60+110*t,1080)':y='mod(860+120*t,1920)':w=3:h=3:color=0xffd46b@0.82:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=0x55c8ff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
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:color=0x090b17@0.72:t=fill,
|
||||
drawgrid=width=90:height=90:thickness=2:color=0x39d2ff@0.18,
|
||||
drawbox=x=0:y=0:w=1080:h=1120:color=0x050814@0.80:t=fill,
|
||||
drawbox=x=0:y=1120:w=1080:h=4:color=0xff54d9@0.70:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xff54d9,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
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:color=white@0.14:t=fill,
|
||||
drawbox=x=0:y='280+20*sin(t*1.40)':w=1080:h=6:color=0x55c8ff@0.45:t=fill,
|
||||
drawbox=x=0:y='1640+15*sin(t*1.20)':w=1080:h=6:color=0xff7020@0.45:t=fill[base]
|
||||
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:color=0x020511@0.82:t=fill,
|
||||
drawbox=x='mod(120+75*t,1080)':y='mod(80+520*t,1920)':w=3:h=18:color=white@0.92:t=fill,
|
||||
drawbox=x='mod(970-55*t,1080)':y='mod(250+430*t,1920)':w=2:h=14:color=0xa4dcff@0.85:t=fill,
|
||||
drawbox=x='mod(370+95*t,1080)':y='mod(430+600*t,1920)':w=4:h=24:color=white@0.90:t=fill,
|
||||
drawbox=x='mod(810-80*t,1080)':y='mod(640+470*t,1920)':w=2:h=17:color=0xffdc83@0.85:t=fill,
|
||||
drawbox=x='mod(230+65*t,1080)':y='mod(900+540*t,1920)':w=3:h=20:color=white@0.88:t=fill,
|
||||
drawbox=x='mod(1020-100*t,1080)':y='mod(1110+650*t,1920)':w=4:h=26:color=0x8fd5ff@0.90:t=fill,
|
||||
drawbox=x='mod(560+85*t,1080)':y='mod(1320+490*t,1920)':w=2:h=16:color=white@0.86:t=fill,
|
||||
drawbox=x='mod(70+110*t,1080)':y='mod(1510+590*t,1920)':w=3:h=22:color=0xffdc83@0.82:t=fill,
|
||||
drawbox=x='mod(720-60*t,1080)':y='mod(1720+450*t,1920)':w=2:h=15:color=white@0.88:t=fill,
|
||||
drawbox=x='mod(440+105*t,1080)':y='mod(180+700*t,1920)':w=3:h=28:color=0xa4dcff@0.88:t=fill,
|
||||
drawbox=x='mod(900-72*t,1080)':y='mod(780+560*t,1920)':w=2:h=18:color=white@0.84:t=fill,
|
||||
drawbox=x='mod(310+90*t,1080)':y='mod(1240+620*t,1920)':w=4:h=24:color=white@0.90:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0x70cfff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
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:color=0x05061b@0.80:t=fill,
|
||||
drawbox=x=0:y='250+90*sin(t*1.25)':w=1080:h=30:color=0xff3b7f@0.78:t=fill,
|
||||
drawbox=x=0:y='300+90*sin(t*1.25)':w=1080:h=24:color=0xff7b32@0.72:t=fill,
|
||||
drawbox=x=0:y='345+90*sin(t*1.25)':w=1080:h=18:color=0xffd12b@0.66:t=fill,
|
||||
drawbox=x=0:y='1510+85*sin(t*1.10+1.2)':w=1080:h=18:color=0x55e6ff@0.68:t=fill,
|
||||
drawbox=x=0:y='1550+85*sin(t*1.10+1.2)':w=1080:h=24:color=0x4b8cff@0.72:t=fill,
|
||||
drawbox=x=0:y='1600+85*sin(t*1.10+1.2)':w=1080:h=30:color=0x8b55ff@0.76:t=fill[bg];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
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:color=black@0.13[fg];
|
||||
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
|
||||
drawbox=x=0:y='300+mod(t*510,1320)':w=1080:h=10:color=white@0.08:t=fill,
|
||||
drawbox=x=0:y='420+mod(t*320,1080)':w=1080:h=3:color=0x7bd7ff@0.10:t=fill[base]
|
||||
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:color=0x11131a@0.78:t=fill,
|
||||
drawbox=x=42:y=365:w=996:h=1000:color=0xaaa69a:t=fill,
|
||||
drawbox=x=68:y=392:w=944:h=944:color=0x2a2926:t=fill,
|
||||
drawbox=x=105:y=438:w=870:h=790:color=0x050505:t=fill,
|
||||
drawbox=x=792:y=1260:w=44:h=18:color=0x6aff73:t=fill,
|
||||
drawbox=x=852:y=1253:w=30:h=30:color=0x232323:t=fill,
|
||||
drawbox=x=910:y=1253:w=30:h=30:color=0x232323:t=fill,
|
||||
drawbox=x=380:y=1365:w=320:h=75:color=0x8f8b80:t=fill,
|
||||
drawbox=x=290:y=1438:w=500:h=36:color=0x77736b:t=fill[monitor];
|
||||
[screen_src]scale=840:750:force_original_aspect_ratio=decrease,lenscorrection=k1=-0.045:k2=0.012,vignette=angle=PI/5,eq=contrast=1.06:saturation=1.06,setsar=1[screen];
|
||||
[monitor][screen]overlay=x=(W-w)/2:y=458:format=auto[base]
|
||||
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:color=0x040717@0.68:t=fill[bg];
|
||||
[fg_src]scale=1000:780:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=0x55c8ff,setsar=1[fg];
|
||||
[0:a]aformat=sample_fmts=fltp:channel_layouts=stereo,showspectrum=s=1000x250:mode=combined:color=rainbow:scale=sqrt:fscale=log:slide=scroll:win_func=hann:fps=50:opacity=0.85,format=rgba[spec];
|
||||
[bg][spec]overlay=x=(W-w)/2:y=1360:format=auto[tmp];
|
||||
[tmp][fg]overlay=x=(W-w)/2:y=480:format=auto[base]
|
||||
FILTER
|
||||
)
|
||||
;;
|
||||
|
||||
esac
|
||||
|
||||
FILTER_COMPLEX=$(cat <<FILTER
|
||||
${BASE_FILTER};
|
||||
[base]
|
||||
drawbox=x=0:y=0:w=1080:h=245:color=black@0.50: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=black@0.75:shadowx=4:shadowy=4:enable='between(t,0,${HOOK_END})',
|
||||
drawbox=x=0:y=1490:w=1080:h=430:color=black@0.62: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=black@0.80:shadowx=4:shadowy=4:enable='gte(t,${INFO_START})',
|
||||
drawtext=fontfile='${FONT_ESC}':textfile='${META_ESC}':fontcolor=white@0.92:fontsize=42:x=(w-text_w)/2:y=1655:fix_bounds=true:shadowcolor=black@0.80: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=black@0.85: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 ' Output: %s\n' "$OUTPUT"
|
||||
printf ' Font: %s\n' "$FONT_FILE"
|
||||
if [[ "$STYLE" == "brand" ]]; then
|
||||
printf ' Bg image: %s\n' "$BACKGROUND_IMAGE"
|
||||
fi
|
||||
|
||||
FFMPEG_ARGS=(
|
||||
-hide_banner -y
|
||||
-ss "$START"
|
||||
-t "$DURATION"
|
||||
-i "$INPUT"
|
||||
)
|
||||
|
||||
if [[ "$STYLE" == "brand" ]]; then
|
||||
FFMPEG_ARGS+=( -loop 1 -i "$BACKGROUND_IMAGE" )
|
||||
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
|
||||
-shortest
|
||||
"$OUTPUT"
|
||||
)
|
||||
|
||||
ffmpeg "${FFMPEG_ARGS[@]}"
|
||||
|
||||
printf '\nDone: %s\n' "$OUTPUT"
|
||||
Reference in New Issue
Block a user