Compare commits
2
Commits
58e11ba4e3
...
323324d713
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
323324d713 | ||
|
|
8a734d58d9 |
@@ -0,0 +1,92 @@
|
||||
using AmiReel.Models;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Models;
|
||||
|
||||
[TestClass]
|
||||
public class ShortWizardTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void StepsFor_BrandStyle_IncludesEffectSettingsPage()
|
||||
{
|
||||
// Act
|
||||
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Brand);
|
||||
|
||||
// Assert
|
||||
CollectionAssert.AreEqual(
|
||||
new[]
|
||||
{
|
||||
ShortWizardStep.Clip,
|
||||
ShortWizardStep.Effect,
|
||||
ShortWizardStep.StyleOptions,
|
||||
ShortWizardStep.Titles,
|
||||
ShortWizardStep.Encode,
|
||||
},
|
||||
steps.ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StepsFor_VideoStyle_IncludesEffectSettingsPage()
|
||||
{
|
||||
// Act
|
||||
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Video);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(steps.Contains(ShortWizardStep.StyleOptions));
|
||||
Assert.AreEqual(5, steps.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StepsFor_CropStyle_SkipsEffectSettingsPage()
|
||||
{
|
||||
// Act
|
||||
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Crop);
|
||||
|
||||
// Assert
|
||||
CollectionAssert.AreEqual(
|
||||
new[]
|
||||
{
|
||||
ShortWizardStep.Clip,
|
||||
ShortWizardStep.Effect,
|
||||
ShortWizardStep.Titles,
|
||||
ShortWizardStep.Encode,
|
||||
},
|
||||
steps.ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NeedsStyleOptions_OnlyBrandAndVideo_ReturnsTrue()
|
||||
{
|
||||
// Assert
|
||||
Assert.IsTrue(ShortWizard.NeedsStyleOptions(ShortStyle.Brand));
|
||||
Assert.IsTrue(ShortWizard.NeedsStyleOptions(ShortStyle.Video));
|
||||
Assert.IsFalse(ShortWizard.NeedsStyleOptions(ShortStyle.Pixel));
|
||||
Assert.IsFalse(ShortWizard.NeedsStyleOptions(ShortStyle.Spectrum));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Description_ForEveryStyle_IsNonEmpty()
|
||||
{
|
||||
foreach (ShortStyle style in Enum.GetValues<ShortStyle>())
|
||||
{
|
||||
// Act
|
||||
string description = ShortWizard.Description(style);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(description), $"{style} is missing a description.");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Title_ForEveryStep_IsNonEmpty()
|
||||
{
|
||||
foreach (ShortWizardStep step in Enum.GetValues<ShortWizardStep>())
|
||||
{
|
||||
// Act
|
||||
string title = ShortWizard.Title(step);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(title), $"{step} is missing a title.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using AmiReel.Models;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Models;
|
||||
|
||||
[TestClass]
|
||||
public class SourceCropSelectionTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void FitUniform_WiderContainer_LetterboxesHorizontally()
|
||||
{
|
||||
// Act
|
||||
(double offsetX, double offsetY, double width, double height) =
|
||||
SourceCropSelection.FitUniform(400, 200, 100, 100);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(100, offsetX);
|
||||
Assert.AreEqual(0, offsetY);
|
||||
Assert.AreEqual(200, width);
|
||||
Assert.AreEqual(200, height);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FitUniform_TallerContainer_LetterboxesVertically()
|
||||
{
|
||||
// Act
|
||||
(double offsetX, double offsetY, double width, double height) =
|
||||
SourceCropSelection.FitUniform(200, 400, 100, 100);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(0, offsetX);
|
||||
Assert.AreEqual(100, offsetY);
|
||||
Assert.AreEqual(200, width);
|
||||
Assert.AreEqual(200, height);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToInsets_FullFrameSelection_ReturnsEmptyInsets()
|
||||
{
|
||||
// Act
|
||||
PixelInsets insets = SourceCropSelection.ToInsets(640, 512, 640, 512, 0, 0, 640, 512);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(insets.IsEmpty);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToInsets_CenteredSelection_ComputesAllFourEdges()
|
||||
{
|
||||
// Act
|
||||
PixelInsets insets = SourceCropSelection.ToInsets(100, 100, 100, 100, 10, 20, 70, 50);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(10, insets.Left);
|
||||
Assert.AreEqual(20, insets.Right);
|
||||
Assert.AreEqual(20, insets.Top);
|
||||
Assert.AreEqual(30, insets.Bottom);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle()
|
||||
{
|
||||
// Act
|
||||
PixelInsets insets = SourceCropSelection.ToInsets(100, 100, 100, 100, 80, 70, -70, -50);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(10, insets.Left);
|
||||
Assert.AreEqual(20, insets.Right);
|
||||
Assert.AreEqual(20, insets.Top);
|
||||
Assert.AreEqual(30, insets.Bottom);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToDisplayRect_RoundTripsKnownInsets()
|
||||
{
|
||||
// Arrange
|
||||
PixelInsets insets = new(20, 20, 10, 10);
|
||||
|
||||
// Act
|
||||
(double x, double y, double width, double height) =
|
||||
SourceCropSelection.ToDisplayRect(100, 100, 200, 200, insets);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(40, x);
|
||||
Assert.AreEqual(20, y);
|
||||
Assert.AreEqual(120, width);
|
||||
Assert.AreEqual(160, height);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ClampToDisplay_OutsideBounds_ClampsToEdges()
|
||||
{
|
||||
// Act
|
||||
(double x, double y) = SourceCropSelection.ClampToDisplay(-8, 90, 80, 60);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(0, x);
|
||||
Assert.AreEqual(60, y);
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,74 @@ public class ShortsPipelineTests
|
||||
ShortsPipeline.Validate(settings);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_VideoStyleWithoutBackgroundVideo_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.mp4");
|
||||
File.WriteAllText(input, "data");
|
||||
string font = Path.Combine(directory, "font.ttf");
|
||||
File.WriteAllText(font, "data");
|
||||
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video);
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_VideoStyleWithBackgroundVideo_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.mp4");
|
||||
File.WriteAllText(input, "data");
|
||||
string font = Path.Combine(directory, "font.ttf");
|
||||
File.WriteAllText(font, "data");
|
||||
string background = Path.Combine(directory, "bg.mp4");
|
||||
File.WriteAllText(background, "data");
|
||||
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with { BackgroundVideo = background };
|
||||
|
||||
// Act & Assert (no exception)
|
||||
ShortsPipeline.Validate(settings);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_VideoOpacityAboveOne_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.mp4");
|
||||
File.WriteAllText(input, "data");
|
||||
string font = Path.Combine(directory, "font.ttf");
|
||||
File.WriteAllText(font, "data");
|
||||
string background = Path.Combine(directory, "bg.mp4");
|
||||
File.WriteAllText(background, "data");
|
||||
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with
|
||||
{
|
||||
BackgroundVideo = background,
|
||||
VideoOpacity = 1.2,
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_SourceMarginTooWide_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.mp4");
|
||||
File.WriteAllText(input, "data");
|
||||
string font = Path.Combine(directory, "font.ttf");
|
||||
File.WriteAllText(font, "data");
|
||||
ShortSettings settings = ValidSettings(input, font) with { SourceMarginX = 540 };
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag()
|
||||
{
|
||||
@@ -220,4 +288,229 @@ public class ShortsPipelineTests
|
||||
StringAssert.Contains(result, "enable='gte(t,26.5)'");
|
||||
StringAssert.Contains(result, "[vout]");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetBaseFilter_VideoStyle_UsesBackgroundVideoInput()
|
||||
{
|
||||
// Act
|
||||
string filter = ShortsPipeline.GetBaseFilter(ShortStyle.Video);
|
||||
|
||||
// Assert
|
||||
StringAssert.Contains(filter, "[1:v]");
|
||||
StringAssert.Contains(filter, "colorchannelmixer=aa=1");
|
||||
StringAssert.Contains(filter, "[base]");
|
||||
Assert.IsFalse(filter.Contains("gblur=", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildVideoBaseFilter_WithBlur_InsertsGblur()
|
||||
{
|
||||
// Act
|
||||
string filter = ShortsPipeline.BuildVideoBaseFilter(
|
||||
"crop=iw:ih:0:0", "scale=1000:-2:flags=lanczos", blur: 8, opacity: 0.65);
|
||||
|
||||
// Assert
|
||||
StringAssert.Contains(filter, "gblur=sigma=8");
|
||||
StringAssert.Contains(filter, "colorchannelmixer=aa=0.65");
|
||||
StringAssert.Contains(filter, "crop=iw:ih:0:0,scale=1000:-2:flags=lanczos");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ApplySourceFade_WhenFadeIsZero_ReturnsOriginalFilter()
|
||||
{
|
||||
// Arrange
|
||||
const string baseFilter = "[0:v]split=2[bg][fg]";
|
||||
|
||||
// Act
|
||||
string result = ShortsPipeline.ApplySourceFade(baseFilter, 0);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(baseFilter, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ApplySourceFade_WhenFadeIsPositive_RewritesSourceLabel()
|
||||
{
|
||||
// Act
|
||||
string result = ShortsPipeline.ApplySourceFade("[0:v]split=2[bg][fg];[0:a]anull[a]", 1.5);
|
||||
|
||||
// Assert
|
||||
StringAssert.StartsWith(result, "[0:v]format=rgba,fade=t=in:st=0:d=1.5:alpha=1[srcfade];");
|
||||
StringAssert.Contains(result, "[srcfade]split=2[bg][fg]");
|
||||
StringAssert.Contains(result, "[0:a]anull[a]");
|
||||
Assert.IsFalse(result.Contains("[0:v]split", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SelectMostCommonCropSpec_PicksTheMostFrequentSpec()
|
||||
{
|
||||
// Arrange
|
||||
const string output =
|
||||
"crop=800:480:100:0\n" +
|
||||
"crop=780:480:120:0\n" +
|
||||
"crop=780:480:120:0\n" +
|
||||
"crop=760:480:140:0";
|
||||
|
||||
// Act
|
||||
string? spec = ShortsPipeline.SelectMostCommonCropSpec(output);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("crop=780:480:120:0", spec);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryBuildAutoHorizontalCrop_AppliesEvenSafetyInset()
|
||||
{
|
||||
// Act
|
||||
bool ok = ShortsPipeline.TryBuildAutoHorizontalCrop(800, 100, out int width, out int x);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual(792, width);
|
||||
Assert.AreEqual(104, x);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildManualCropFilter_UsesEdgeInsets()
|
||||
{
|
||||
// Act
|
||||
string filter = ShortsPipeline.BuildManualCropFilter(120, 80, 10, 20);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SourceTargetWidth_SubtractsBothMargins()
|
||||
{
|
||||
// Act
|
||||
int width = ShortsPipeline.SourceTargetWidth(40);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(1000, width);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidatePreview_WithMissingTitleAndOutput_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.mp4");
|
||||
File.WriteAllText(input, "data");
|
||||
ShortSettings settings = ValidSettings(input, Path.Combine(directory, "missing-font.ttf")) with
|
||||
{
|
||||
Title = "",
|
||||
OutputPath = "",
|
||||
};
|
||||
|
||||
// Act & Assert (no exception)
|
||||
ShortsPipeline.ValidatePreview(settings);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidatePreview_WithMissingInput_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf");
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.ValidatePreview(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PreviewOverlayWindows_Opening_HidesTitleCard()
|
||||
{
|
||||
// Act
|
||||
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Opening, 30);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(30, hookEnd);
|
||||
Assert.AreEqual(31, infoStart);
|
||||
Assert.AreEqual(31, ctaStart);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PreviewOverlayWindows_Closing_ShowsTitlesImmediately()
|
||||
{
|
||||
// Act
|
||||
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Closing, 30);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(-1, hookEnd);
|
||||
Assert.AreEqual(0, infoStart);
|
||||
Assert.AreEqual(0, ctaStart);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PreviewSeekSeconds_OpeningAndClosing_StayInsideDuration()
|
||||
{
|
||||
// Act
|
||||
double opening = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Opening, 30);
|
||||
double closing = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Closing, 30);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(1.2, opening);
|
||||
Assert.AreEqual(29.6, closing);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AddTimestampOffset_AddsFractionalSeconds()
|
||||
{
|
||||
// Act
|
||||
string result = ShortsPipeline.AddTimestampOffset("00:01:10", 1.2);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("00:01:11.200", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildInputArguments_VideoStyle_IncludesLoopedBackgroundVideo()
|
||||
{
|
||||
// Arrange
|
||||
ShortSettings settings = ValidSettings("in.mp4", "font.ttf", ShortStyle.Video) with
|
||||
{
|
||||
BackgroundVideo = @"D:\bg.mp4",
|
||||
};
|
||||
|
||||
// Act
|
||||
List<string> args = ShortsPipeline.BuildInputArguments(settings, 0.12, "00:00:01.200");
|
||||
|
||||
// Assert
|
||||
CollectionAssert.Contains(args, "00:00:01.200");
|
||||
CollectionAssert.Contains(args, "-stream_loop");
|
||||
CollectionAssert.Contains(args, @"D:\bg.mp4");
|
||||
CollectionAssert.Contains(args, "in.mp4");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng()
|
||||
{
|
||||
// Act
|
||||
List<string> args = ShortsPipeline.BuildExtractFrameArguments(@"D:\clip.mp4", "00:00:05", @"C:\tmp\frame.png");
|
||||
|
||||
// Assert
|
||||
CollectionAssert.Contains(args, "00:00:05");
|
||||
CollectionAssert.Contains(args, @"D:\clip.mp4");
|
||||
CollectionAssert.Contains(args, "-frames:v");
|
||||
CollectionAssert.Contains(args, "1");
|
||||
CollectionAssert.Contains(args, @"C:\tmp\frame.png");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildFilterComplex_WithoutTextOverlays_SkipsDrawtext()
|
||||
{
|
||||
// Act
|
||||
string result = ShortsPipeline.BuildFilterComplex(
|
||||
"[0:v]copy[base]",
|
||||
fontEsc: "", hookEsc: "", titleEsc: "", metaEsc: "", websiteEsc: "",
|
||||
hookEnd: 2.7, infoStart: 23, ctaStart: 26.5,
|
||||
includeTextOverlays: false);
|
||||
|
||||
// Assert
|
||||
StringAssert.Contains(result, "[0:v]copy[base]");
|
||||
StringAssert.Contains(result, "[vout]");
|
||||
Assert.IsFalse(result.Contains("drawtext", StringComparison.Ordinal));
|
||||
Assert.IsFalse(result.Contains("drawbox", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,12 @@
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TabButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
|
||||
<Setter Property="Padding" Value="16,9" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
|
||||
<Setter Property="Background" Value="{ThemeResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource PrimaryTextBrush}" />
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="AmiReel.Controls.SourceCropEditor"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<StackPanel Spacing="10" MinWidth="640">
|
||||
<TextBlock x:Name="StatusText"
|
||||
Text="Drag on the frame to keep that region. The rest is cropped away."
|
||||
Style="{StaticResource MutedTextStyle}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<ProgressBar x:Name="LoadProgress"
|
||||
IsIndeterminate="True"
|
||||
Height="6"
|
||||
Visibility="Collapsed"
|
||||
AutomationProperties.Name="Loading source frame" />
|
||||
<Border Background="{ThemeResource PageBrush}"
|
||||
BorderBrush="{ThemeResource BorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Grid x:Name="CropHost"
|
||||
Height="420"
|
||||
Background="Transparent"
|
||||
PointerPressed="CropHost_PointerPressed"
|
||||
PointerMoved="CropHost_PointerMoved"
|
||||
PointerReleased="CropHost_PointerReleased"
|
||||
PointerCaptureLost="CropHost_PointerCaptureLost"
|
||||
SizeChanged="CropHost_SizeChanged"
|
||||
AutomationProperties.Name="Source crop frame"
|
||||
AutomationProperties.AutomationId="SourceCropHost">
|
||||
<Image x:Name="SourceImage"
|
||||
Stretch="Uniform"
|
||||
IsHitTestVisible="False"
|
||||
AutomationProperties.Name="Source video frame" />
|
||||
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
|
||||
<Rectangle x:Name="SelectionRect"
|
||||
Stroke="{ThemeResource AccentBrush}"
|
||||
StrokeThickness="2"
|
||||
Fill="#33007ACC"
|
||||
Visibility="Collapsed" />
|
||||
</Canvas>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="InsetsText"
|
||||
Grid.Column="0"
|
||||
Text="Crop L0 R0 T0 B0"
|
||||
Style="{StaticResource MutedTextStyle}"
|
||||
VerticalAlignment="Center" />
|
||||
<Button x:Name="ClearCropButton"
|
||||
Grid.Column="1"
|
||||
Content="Clear crop"
|
||||
Click="ClearCrop_Click"
|
||||
AutomationProperties.Name="Clear crop"
|
||||
AutomationProperties.AutomationId="ClearSourceCropButton" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using AmiReel.Models;
|
||||
using AmiReel.Services;
|
||||
using Microsoft.UI.Input;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace AmiReel.Controls;
|
||||
|
||||
public sealed partial class SourceCropEditor : UserControl
|
||||
{
|
||||
private int _pixelWidth;
|
||||
private int _pixelHeight;
|
||||
private bool _isDragging;
|
||||
private Point _dragStart;
|
||||
private PixelInsets _insets;
|
||||
|
||||
public SourceCropEditor()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PixelInsets Insets => _insets;
|
||||
|
||||
public async Task LoadVideoFrameAsync(
|
||||
string inputFile, string start, string ffmpegPath, string ffprobePath, CancellationToken token)
|
||||
{
|
||||
LoadProgress.Visibility = Visibility.Visible;
|
||||
StatusText.Text = "Loading a source frame...";
|
||||
try
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor");
|
||||
Directory.CreateDirectory(directory);
|
||||
string framePath = Path.Combine(directory, "source-frame.png");
|
||||
await new ShortsPipeline().ExtractSourceFrameAsync(
|
||||
inputFile, start, ffmpegPath, ffprobePath, framePath, _ => { }, token);
|
||||
|
||||
await using FileStream stream = File.OpenRead(framePath);
|
||||
BitmapImage bitmap = new();
|
||||
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
|
||||
SourceImage.Source = bitmap;
|
||||
_pixelWidth = bitmap.PixelWidth;
|
||||
_pixelHeight = bitmap.PixelHeight;
|
||||
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · drag on the frame to choose the kept region.";
|
||||
RefreshSelectionVisual();
|
||||
}
|
||||
finally
|
||||
{
|
||||
LoadProgress.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetInsets(PixelInsets insets)
|
||||
{
|
||||
_insets = insets;
|
||||
RefreshSelectionVisual();
|
||||
UpdateInsetsText();
|
||||
}
|
||||
|
||||
private void CropHost_PointerPressed(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
if (_pixelWidth <= 0 || !TryMapToDisplay(e, out double x, out double y, out _, out _))
|
||||
return;
|
||||
|
||||
_isDragging = true;
|
||||
_dragStart = new Point(x, y);
|
||||
CropHost.CapturePointer(e.Pointer);
|
||||
UpdateDraftSelection(x, y);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void CropHost_PointerMoved(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
if (!_isDragging)
|
||||
return;
|
||||
if (!TryMapToDisplay(e, out double x, out double y, out _, out _))
|
||||
return;
|
||||
|
||||
UpdateDraftSelection(x, y);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void CropHost_PointerReleased(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
if (!_isDragging)
|
||||
return;
|
||||
|
||||
_isDragging = false;
|
||||
CropHost.ReleasePointerCapture(e.Pointer);
|
||||
if (TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
|
||||
CommitSelection(x, y, displayWidth, displayHeight);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void CropHost_PointerCaptureLost(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private void CropHost_SizeChanged(object sender, SizeChangedEventArgs e) => RefreshSelectionVisual();
|
||||
|
||||
private void ClearCrop_Click(object sender, RoutedEventArgs e) => SetInsets(default);
|
||||
|
||||
private bool TryMapToDisplay(
|
||||
PointerRoutedEventArgs e, out double x, out double y, out double displayWidth, out double displayHeight)
|
||||
{
|
||||
(double offsetX, double offsetY, displayWidth, displayHeight) = CurrentFit();
|
||||
x = 0;
|
||||
y = 0;
|
||||
if (displayWidth <= 0 || displayHeight <= 0)
|
||||
return false;
|
||||
|
||||
PointerPoint point = e.GetCurrentPoint(CropHost);
|
||||
(x, y) = SourceCropSelection.ClampToDisplay(
|
||||
point.Position.X - offsetX, point.Position.Y - offsetY, displayWidth, displayHeight);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateDraftSelection(double x, double y)
|
||||
{
|
||||
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit();
|
||||
if (displayWidth <= 0)
|
||||
return;
|
||||
|
||||
_insets = SourceCropSelection.ToInsets(
|
||||
_pixelWidth, _pixelHeight, displayWidth, displayHeight,
|
||||
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
|
||||
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
|
||||
UpdateInsetsText();
|
||||
}
|
||||
|
||||
private void CommitSelection(double x, double y, double displayWidth, double displayHeight)
|
||||
{
|
||||
_insets = SourceCropSelection.ToInsets(
|
||||
_pixelWidth, _pixelHeight, displayWidth, displayHeight,
|
||||
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
|
||||
RefreshSelectionVisual();
|
||||
UpdateInsetsText();
|
||||
}
|
||||
|
||||
private void RefreshSelectionVisual()
|
||||
{
|
||||
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit();
|
||||
if (displayWidth <= 0 || _pixelWidth <= 0)
|
||||
{
|
||||
SelectionRect.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
|
||||
}
|
||||
|
||||
private void PlaceSelectionRect(double offsetX, double offsetY, double displayWidth, double displayHeight)
|
||||
{
|
||||
(double x, double y, double width, double height) = SourceCropSelection.ToDisplayRect(
|
||||
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _insets);
|
||||
Canvas.SetLeft(SelectionRect, offsetX + x);
|
||||
Canvas.SetTop(SelectionRect, offsetY + y);
|
||||
SelectionRect.Width = width;
|
||||
SelectionRect.Height = height;
|
||||
SelectionRect.Visibility = _insets.IsEmpty ? Visibility.Collapsed : Visibility.Visible;
|
||||
}
|
||||
|
||||
private (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) CurrentFit() =>
|
||||
SourceCropSelection.FitUniform(CropHost.ActualWidth, CropHost.ActualHeight, _pixelWidth, _pixelHeight);
|
||||
|
||||
private void UpdateInsetsText() =>
|
||||
InsetsText.Text = _pixelWidth <= 0
|
||||
? "Crop L0 R0 T0 B0"
|
||||
: $"Crop L{_insets.Left} R{_insets.Right} T{_insets.Top} B{_insets.Bottom} · {_pixelWidth - _insets.Left - _insets.Right}×{_pixelHeight - _insets.Top - _insets.Bottom}";
|
||||
}
|
||||
+286
-110
@@ -5,20 +5,29 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="using:AmiReel.Controls"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ScrollViewer Grid.Row="0"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<Grid RowSpacing="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="VideoTabButton" Content="Video" Click="VideoTab_Click" Style="{StaticResource PrimaryButtonStyle}" MinWidth="120" />
|
||||
<Button x:Name="ShortsTabButton" Content="Shorts" Click="ShortsTab_Click" MinWidth="120" />
|
||||
<Button x:Name="ShortsTabButton" Content="Shorts" Click="ShortsTab_Click" Style="{StaticResource TabButtonStyle}" MinWidth="120" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnSpacing="16" x:Name="VideoContentPanel">
|
||||
@@ -78,103 +87,200 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" RowSpacing="16" x:Name="ShortsContentPanel" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid ColumnSpacing="16">
|
||||
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}" x:Name="ShortsContentPanel" Visibility="Collapsed">
|
||||
<Grid ColumnSpacing="18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Spacing="14">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock x:Name="ShortWizardTitleText" Text="Source clip" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBlock x:Name="ShortWizardStepText" Text="Step 1 of 4" Style="{StaticResource MutedTextStyle}" />
|
||||
<ProgressBar x:Name="ShortWizardProgress" Minimum="0" Maximum="4" Value="1" Height="6" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Column="0" Padding="18" Style="{StaticResource CardBorderStyle}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Short source and clip" Style="{StaticResource SectionTitleStyle}" />
|
||||
<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}" />
|
||||
<StackPanel x:Name="ShortStepClipPanel" Spacing="12">
|
||||
<TextBlock Text="Choose the Amiga clip and how much of it to use." Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
||||
<Grid ColumnSpacing="10" RowSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBox x:Name="ShortInputBox" Grid.Row="0" Header="Source video" MinHeight="40"
|
||||
AutomationProperties.Name="Source video"
|
||||
ToolTipService.ToolTip="Source video for this Short" />
|
||||
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseShortInput_Click"
|
||||
VerticalAlignment="Bottom" MinWidth="120"
|
||||
AutomationProperties.Name="Browse source video" />
|
||||
<TextBox x:Name="ShortOutputBox" Grid.Row="1" Header="Output file" MinHeight="40"
|
||||
AutomationProperties.Name="Output file"
|
||||
ToolTipService.ToolTip="Output .mp4 path. Leave blank to name it next to the source." />
|
||||
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseShortOutput_Click"
|
||||
VerticalAlignment="Bottom" MinWidth="120"
|
||||
AutomationProperties.Name="Browse output file" />
|
||||
</Grid>
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<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" />
|
||||
<TextBox x:Name="ShortStartBox" Grid.Column="0" Header="Start (HH:MM:SS)" Text="00:00:00"
|
||||
AutomationProperties.Name="Start time" />
|
||||
<TextBox x:Name="ShortDurationBox" Grid.Column="1" Header="Duration (seconds)" Text="30"
|
||||
AutomationProperties.Name="Duration in seconds" />
|
||||
</Grid>
|
||||
<Button x:Name="PreviewShortSourceButton" Content="Preview source" Click="PreviewShortSource_Click"
|
||||
HorizontalAlignment="Left" AutomationProperties.Name="Preview source" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="ShortStepEffectPanel" Spacing="12" Visibility="Collapsed">
|
||||
<TextBlock Text="Pick a look. Extra settings appear on the next step only when that effect needs them."
|
||||
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
||||
<ComboBox x:Name="ShortStyleBox" Header="Effect" SelectionChanged="ShortStyleBox_SelectionChanged"
|
||||
HorizontalAlignment="Stretch" AutomationProperties.Name="Visual effect">
|
||||
<ComboBoxItem Content="Brand (title card image)" Tag="Brand" />
|
||||
<ComboBoxItem Content="Pixel" Tag="Pixel" />
|
||||
<ComboBoxItem Content="Mirror" Tag="Mirror" />
|
||||
<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" />
|
||||
<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 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, or drag the kept region on a source frame."
|
||||
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="ShortCropLeftBox" Grid.Column="0" Header="Crop left" Text="0"
|
||||
AutomationProperties.Name="Crop left pixels" />
|
||||
<TextBox x:Name="ShortCropRightBox" Grid.Column="1" Header="Crop right" Text="0"
|
||||
AutomationProperties.Name="Crop right pixels" />
|
||||
<TextBox x:Name="ShortCropTopBox" Grid.Column="2" Header="Crop top" Text="0"
|
||||
AutomationProperties.Name="Crop top pixels" />
|
||||
<TextBox x:Name="ShortCropBottomBox" Grid.Column="3" Header="Crop bottom" Text="0"
|
||||
AutomationProperties.Name="Crop bottom pixels" />
|
||||
</Grid>
|
||||
<Button x:Name="OpenCropEditorButton"
|
||||
Content="Crop visually..."
|
||||
Click="OpenCropEditor_Click"
|
||||
HorizontalAlignment="Left"
|
||||
AutomationProperties.Name="Open visual crop editor"
|
||||
AutomationProperties.AutomationId="OpenCropEditorButton" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="ShortStepTitlesPanel" Spacing="12" Visibility="Collapsed">
|
||||
<TextBlock Text="These lines appear as the opening hook and the closing title card."
|
||||
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
||||
<TextBox x:Name="ShortTitleBox" Header="Title"
|
||||
AutomationProperties.Name="Title"
|
||||
ToolTipService.ToolTip="Production title, e.g. Ami-Back V1.04A" />
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="ShortGroupBox" Grid.Column="0" Header="Group" AutomationProperties.Name="Group" />
|
||||
<TextBox x:Name="ShortYearBox" Grid.Column="1" Header="Year" AutomationProperties.Name="Year" />
|
||||
<TextBox x:Name="ShortTypeBox" Grid.Column="2" Header="Type" AutomationProperties.Name="Type" />
|
||||
</Grid>
|
||||
<TextBox x:Name="ShortHookBox" Header="Opening hook" AutomationProperties.Name="Opening hook" />
|
||||
<TextBox x:Name="ShortWebsiteBox" Header="Closing website text" AutomationProperties.Name="Closing website text" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="ShortStepEncodePanel" Spacing="12" Visibility="Collapsed">
|
||||
<TextBlock Text="Encoding quality and the font used for the title card."
|
||||
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="ShortFontBox" Grid.Column="0" Header="Bold font file (.ttf/.otf)"
|
||||
AutomationProperties.Name="Bold font file" />
|
||||
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortFont_Click"
|
||||
VerticalAlignment="Bottom" MinWidth="120"
|
||||
AutomationProperties.Name="Browse font file" />
|
||||
</Grid>
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="120" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ComboBox x:Name="ShortPresetBox" Grid.Column="0" Header="x264 preset"
|
||||
HorizontalAlignment="Stretch" AutomationProperties.Name="x264 preset">
|
||||
<ComboBoxItem Content="ultrafast" Tag="ultrafast" />
|
||||
<ComboBoxItem Content="superfast" Tag="superfast" />
|
||||
<ComboBoxItem Content="veryfast" Tag="veryfast" />
|
||||
@@ -185,30 +291,69 @@
|
||||
<ComboBoxItem Content="slower" Tag="slower" />
|
||||
<ComboBoxItem Content="veryslow" Tag="veryslow" />
|
||||
</ComboBox>
|
||||
<TextBox x:Name="ShortCrfBox" Grid.Column="1" Header="CRF" AutomationProperties.Name="CRF" />
|
||||
</Grid>
|
||||
<Grid x:Name="ShortBackgroundRow" ColumnSpacing="10">
|
||||
<TextBlock x:Name="ShortSummaryText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Width="308" Spacing="10">
|
||||
<TextBlock Text="Preview" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBlock x:Name="ShortPreviewStatusText"
|
||||
Text="Pick a source clip, then update the preview to see the 9:16 look."
|
||||
Style="{StaticResource MutedTextStyle}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<Button x:Name="UpdateShortPreviewButton"
|
||||
Content="Update preview"
|
||||
Click="UpdateShortPreview_Click"
|
||||
HorizontalAlignment="Stretch"
|
||||
AutomationProperties.Name="Update short preview"
|
||||
AutomationProperties.AutomationId="UpdateShortPreviewButton" />
|
||||
<ProgressBar x:Name="ShortPreviewProgress"
|
||||
IsIndeterminate="True"
|
||||
Height="6"
|
||||
Visibility="Collapsed"
|
||||
AutomationProperties.Name="Preview progress" />
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="ShortBackgroundBox" Grid.Column="0" Header="Background image (brand style)" />
|
||||
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortBackground_Click" VerticalAlignment="Bottom" MinWidth="120" />
|
||||
</Grid>
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="120" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="ShortFontBox" Grid.Column="0" Header="Bold font file (.ttf/.otf)" />
|
||||
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortFont_Click" VerticalAlignment="Bottom" MinWidth="120" />
|
||||
<TextBox x:Name="ShortCrfBox" Grid.Column="2" Header="CRF" />
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Opening" Style="{StaticResource MutedTextStyle}" />
|
||||
<Border Width="148" Height="263"
|
||||
Background="{ThemeResource PageBrush}"
|
||||
BorderBrush="{ThemeResource BorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Image x:Name="ShortPreviewOpeningImage"
|
||||
Stretch="Uniform"
|
||||
AutomationProperties.Name="Opening hook preview" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="6">
|
||||
<TextBlock Text="Closing" Style="{StaticResource MutedTextStyle}" />
|
||||
<Border Width="148" Height="263"
|
||||
Background="{ThemeResource PageBrush}"
|
||||
BorderBrush="{ThemeResource BorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Image x:Name="ShortPreviewClosingImage"
|
||||
Stretch="Uniform"
|
||||
AutomationProperties.Name="Closing title-card preview" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2" Padding="18" Style="{StaticResource CardBorderStyle}">
|
||||
<Border x:Name="RenderProgressPanel"
|
||||
Grid.Row="2"
|
||||
Padding="18"
|
||||
Style="{StaticResource CardBorderStyle}"
|
||||
Visibility="Collapsed"
|
||||
AutomationProperties.Name="Render progress">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Render progress" Style="{StaticResource SectionTitleStyle}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -220,7 +365,12 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" Padding="18" Style="{StaticResource CardBorderStyle}">
|
||||
<Border x:Name="FfmpegLogPanel"
|
||||
Grid.Row="3"
|
||||
Padding="18"
|
||||
Style="{StaticResource CardBorderStyle}"
|
||||
Visibility="Collapsed"
|
||||
AutomationProperties.Name="FFmpeg log">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="FFmpeg log" Style="{StaticResource SectionTitleStyle}" />
|
||||
<TextBox x:Name="LogBox"
|
||||
@@ -232,11 +382,14 @@
|
||||
FontFamily="Consolas"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Stretch"
|
||||
Height="260" />
|
||||
MinHeight="320" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="4">
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -248,6 +401,11 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="ShortBackButton" Content="Previous" Click="ShortBack_Click" Visibility="Collapsed"
|
||||
MinWidth="120" AutomationProperties.Name="Previous short step" />
|
||||
<Button x:Name="ShortNextButton" Content="Next" Click="ShortNext_Click" Visibility="Collapsed"
|
||||
MinWidth="120" Style="{StaticResource PrimaryButtonStyle}"
|
||||
AutomationProperties.Name="Next short step" />
|
||||
<Button x:Name="OpenSettingsButton" Content="" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets"
|
||||
Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}"
|
||||
ToolTipService.ToolTip="Settings" AutomationProperties.Name="Settings" />
|
||||
@@ -256,6 +414,24 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<ContentDialog x:Name="CropEditorDialog"
|
||||
Title="Crop source"
|
||||
PrimaryButtonText="Use this crop"
|
||||
CloseButtonText="Cancel"
|
||||
DefaultButton="Primary"
|
||||
Background="{ThemeResource PanelBrush}"
|
||||
Foreground="{ThemeResource TextBrush}"
|
||||
BorderBrush="{ThemeResource HeroBorderBrush}"
|
||||
BorderThickness="1.5"
|
||||
CornerRadius="18"
|
||||
PrimaryButtonStyle="{StaticResource DialogPrimaryButtonStyle}"
|
||||
CloseButtonStyle="{StaticResource DialogCloseButtonStyle}">
|
||||
<ContentDialog.Resources>
|
||||
<x:Double x:Key="ContentDialogMaxWidth">920</x:Double>
|
||||
</ContentDialog.Resources>
|
||||
<controls:SourceCropEditor x:Name="ShortCropEditor" />
|
||||
</ContentDialog>
|
||||
|
||||
<ContentDialog x:Name="SettingsDialog"
|
||||
Title="Settings"
|
||||
PrimaryButtonText="Done"
|
||||
|
||||
+373
-15
@@ -1,11 +1,13 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using System.Text;
|
||||
using AmiReel.Models;
|
||||
using AmiReel.Services;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
using Windows.Storage;
|
||||
using Windows.Storage.Pickers;
|
||||
@@ -23,6 +25,12 @@ public sealed partial class MainPage : Page
|
||||
private string? _lastRenderedFile;
|
||||
private bool _logFlushScheduled;
|
||||
private bool _isShortsTab;
|
||||
private bool _isRendering;
|
||||
private bool _keepRenderOutput;
|
||||
private List<ShortWizardStep> _wizardSteps = [.. ShortWizard.StepsFor(ShortStyle.Brand)];
|
||||
private int _wizardIndex;
|
||||
private int _previewGeneration;
|
||||
private string? _previewDirectory;
|
||||
|
||||
public MainPage()
|
||||
{
|
||||
@@ -48,12 +56,23 @@ public sealed partial class MainPage : Page
|
||||
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
|
||||
ShortCrfBox.Text = _loadedSettings.ShortsCrf;
|
||||
ShortBackgroundBox.Text = _loadedSettings.ShortsBackgroundImage;
|
||||
ShortBackgroundVideoBox.Text = _loadedSettings.ShortsBackgroundVideo;
|
||||
ShortVideoBlurBox.Text = _loadedSettings.ShortsVideoBlur;
|
||||
ShortVideoOpacityBox.Text = _loadedSettings.ShortsVideoOpacity;
|
||||
ShortSourceMarginBox.Text = _loadedSettings.ShortsSourceMarginX;
|
||||
ShortFadeInBox.Text = _loadedSettings.ShortsSourceFadeIn;
|
||||
ShortCropLeftBox.Text = _loadedSettings.ShortsCropLeft;
|
||||
ShortCropRightBox.Text = _loadedSettings.ShortsCropRight;
|
||||
ShortCropTopBox.Text = _loadedSettings.ShortsCropTop;
|
||||
ShortCropBottomBox.Text = _loadedSettings.ShortsCropBottom;
|
||||
ShortFontBox.Text = string.IsNullOrWhiteSpace(_loadedSettings.ShortsFontFile)
|
||||
? ShortsPipeline.FindDefaultFont() ?? ""
|
||||
: _loadedSettings.ShortsFontFile;
|
||||
SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle);
|
||||
SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset);
|
||||
UpdateShortBackgroundVisibility();
|
||||
SelectComboTag(ShortCropModeBox, _loadedSettings.ShortsSourceCropMode);
|
||||
RefreshWizardSteps();
|
||||
ShowWizardStep();
|
||||
}
|
||||
|
||||
private void VideoTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: false);
|
||||
@@ -62,23 +81,137 @@ public sealed partial class MainPage : Page
|
||||
|
||||
private void SetActiveTab(bool shorts)
|
||||
{
|
||||
if (!_isRendering)
|
||||
_keepRenderOutput = false;
|
||||
_isShortsTab = shorts;
|
||||
VideoContentPanel.Visibility = shorts ? Visibility.Collapsed : Visibility.Visible;
|
||||
ShortsContentPanel.Visibility = shorts ? Visibility.Visible : Visibility.Collapsed;
|
||||
Style primaryButtonStyle = (Style)Application.Current.Resources["PrimaryButtonStyle"];
|
||||
VideoTabButton.Style = shorts ? null : primaryButtonStyle;
|
||||
ShortsTabButton.Style = shorts ? primaryButtonStyle : null;
|
||||
Style tabButtonStyle = (Style)Application.Current.Resources["TabButtonStyle"];
|
||||
VideoTabButton.Style = shorts ? tabButtonStyle : primaryButtonStyle;
|
||||
ShortsTabButton.Style = shorts ? primaryButtonStyle : tabButtonStyle;
|
||||
RenderButton.Content = shorts ? "Render short" : "Start render";
|
||||
UpdateWorkspaceVisibility();
|
||||
}
|
||||
|
||||
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateShortBackgroundVisibility();
|
||||
|
||||
private void UpdateShortBackgroundVisibility()
|
||||
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
bool isBrand = string.Equals(SelectedShortStyle(), nameof(ShortStyle.Brand), StringComparison.OrdinalIgnoreCase);
|
||||
ShortBackgroundRow.Visibility = isBrand ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (!IsLoaded) return;
|
||||
RefreshWizardSteps();
|
||||
ShowWizardStep();
|
||||
}
|
||||
|
||||
private void ShortBack_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_wizardIndex <= 0) return;
|
||||
_keepRenderOutput = false;
|
||||
_wizardIndex--;
|
||||
ShowWizardStep();
|
||||
}
|
||||
|
||||
private async void ShortNext_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
ValidateCurrentWizardStep();
|
||||
if (_wizardIndex >= _wizardSteps.Count - 1) return;
|
||||
_keepRenderOutput = false;
|
||||
_wizardIndex++;
|
||||
ShowWizardStep();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await ShowMessageAsync("Check this step", exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshWizardSteps()
|
||||
{
|
||||
ShortWizardStep current = _wizardSteps[Math.Clamp(_wizardIndex, 0, _wizardSteps.Count - 1)];
|
||||
_wizardSteps = [.. ShortWizard.StepsFor(SelectedShortStyleEnum())];
|
||||
int index = _wizardSteps.IndexOf(current);
|
||||
_wizardIndex = index >= 0 ? index : Math.Min(_wizardIndex, _wizardSteps.Count - 1);
|
||||
}
|
||||
|
||||
private void ShowWizardStep()
|
||||
{
|
||||
ShortWizardStep step = _wizardSteps[Math.Clamp(_wizardIndex, 0, _wizardSteps.Count - 1)];
|
||||
ShortWizardTitleText.Text = ShortWizard.Title(step);
|
||||
ShortWizardStepText.Text = $"Step {_wizardIndex + 1} of {_wizardSteps.Count}";
|
||||
ShortWizardProgress.Maximum = _wizardSteps.Count;
|
||||
ShortWizardProgress.Value = _wizardIndex + 1;
|
||||
|
||||
ShortStepClipPanel.Visibility = VisibleIf(step == ShortWizardStep.Clip);
|
||||
ShortStepEffectPanel.Visibility = VisibleIf(step == ShortWizardStep.Effect);
|
||||
ShortStepStyleOptionsPanel.Visibility = VisibleIf(step == ShortWizardStep.StyleOptions);
|
||||
ShortStepTitlesPanel.Visibility = VisibleIf(step == ShortWizardStep.Titles);
|
||||
ShortStepEncodePanel.Visibility = VisibleIf(step == ShortWizardStep.Encode);
|
||||
|
||||
ShortStyle style = SelectedShortStyleEnum();
|
||||
ShortStyleDescriptionText.Text = ShortWizard.Description(style);
|
||||
ShortBackgroundRow.Visibility = VisibleIf(style == ShortStyle.Brand);
|
||||
ShortVideoOptionsPanel.Visibility = VisibleIf(style == ShortStyle.Video);
|
||||
|
||||
if (step == ShortWizardStep.Encode)
|
||||
ShortSummaryText.Text = BuildShortSummary();
|
||||
|
||||
UpdateWizardNavigation();
|
||||
if (IsLoaded && step != ShortWizardStep.Clip)
|
||||
_ = RefreshShortPreviewAsync(automatic: true);
|
||||
}
|
||||
|
||||
private void UpdateWizardNavigation()
|
||||
{
|
||||
bool shorts = _isShortsTab && !_isRendering;
|
||||
bool lastStep = _wizardIndex >= _wizardSteps.Count - 1;
|
||||
ShortBackButton.Visibility = VisibleIf(shorts);
|
||||
ShortNextButton.Visibility = VisibleIf(shorts && !lastStep);
|
||||
ShortBackButton.IsEnabled = shorts && _wizardIndex > 0;
|
||||
ShortNextButton.IsEnabled = shorts;
|
||||
RenderButton.Visibility = VisibleIf(!_isRendering && (!shorts || lastStep));
|
||||
}
|
||||
|
||||
private void ValidateCurrentWizardStep()
|
||||
{
|
||||
ShortWizardStep step = _wizardSteps[_wizardIndex];
|
||||
switch (step)
|
||||
{
|
||||
case ShortWizardStep.Clip:
|
||||
if (string.IsNullOrWhiteSpace(ShortInputBox.Text) || !File.Exists(ShortInputBox.Text.Trim()))
|
||||
throw new FileNotFoundException("Select a valid source video for the Short.");
|
||||
ParsePositiveDuration();
|
||||
break;
|
||||
case ShortWizardStep.Effect:
|
||||
ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
|
||||
break;
|
||||
case ShortWizardStep.StyleOptions:
|
||||
if (SelectedShortStyleEnum() == ShortStyle.Brand
|
||||
&& (string.IsNullOrWhiteSpace(ShortBackgroundBox.Text) || !File.Exists(ShortBackgroundBox.Text.Trim())))
|
||||
throw new FileNotFoundException("The brand style requires a background image.");
|
||||
if (SelectedShortStyleEnum() == ShortStyle.Video)
|
||||
ReadVideoStyleOptions();
|
||||
break;
|
||||
case ShortWizardStep.Titles:
|
||||
if (string.IsNullOrWhiteSpace(ShortTitleBox.Text))
|
||||
throw new ArgumentException("Enter a title.");
|
||||
break;
|
||||
case ShortWizardStep.Encode:
|
||||
if (string.IsNullOrWhiteSpace(ShortFontBox.Text) || !File.Exists(ShortFontBox.Text.Trim()))
|
||||
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
|
||||
ParseCrf();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildShortSummary()
|
||||
{
|
||||
string input = string.IsNullOrWhiteSpace(ShortInputBox.Text)
|
||||
? "(no source yet)"
|
||||
: Path.GetFileName(ShortInputBox.Text.Trim());
|
||||
string title = string.IsNullOrWhiteSpace(ShortTitleBox.Text) ? "(no title yet)" : ShortTitleBox.Text.Trim();
|
||||
return $"{SelectedShortStyleEnum()} · {input} · {ShortStartBox.Text.Trim()} + {ShortDurationBox.Text.Trim()}s · {title}";
|
||||
}
|
||||
|
||||
private static Visibility VisibleIf(bool visible) => visible ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
private async void AddInputs_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FileOpenPicker picker = CreateFileOpenPicker();
|
||||
@@ -180,6 +313,7 @@ public sealed partial class MainPage : Page
|
||||
{
|
||||
try
|
||||
{
|
||||
_keepRenderOutput = false;
|
||||
SetRendering(true);
|
||||
LogBox.Text = string.Empty;
|
||||
_renderCancellation = new CancellationTokenSource();
|
||||
@@ -191,10 +325,12 @@ public sealed partial class MainPage : Page
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_keepRenderOutput = true;
|
||||
UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled."));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_keepRenderOutput = true;
|
||||
AppendLog("ERROR: " + exception);
|
||||
StageText.Text = "Failed";
|
||||
await ShowMessageAsync("Render failed", UserFacingErrors.Summarize(exception));
|
||||
@@ -308,14 +444,15 @@ public sealed partial class MainPage : Page
|
||||
|
||||
private ShortSettings ReadShortSettings()
|
||||
{
|
||||
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
|
||||
throw new ArgumentException("Enter a valid duration in seconds.");
|
||||
if (!int.TryParse(ShortCrfBox.Text, out int crf))
|
||||
throw new ArgumentException("CRF must be an integer.");
|
||||
double duration = ParsePositiveDuration();
|
||||
int crf = ParseCrf();
|
||||
(double blur, double opacity, int marginX, ShortSourceCropMode cropMode, int left, int right, int top, int bottom)
|
||||
= ReadVideoStyleOptions();
|
||||
double fadeIn = ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
|
||||
|
||||
string input = ShortInputBox.Text.Trim();
|
||||
string output = ShortOutputBox.Text.Trim();
|
||||
ShortStyle style = Enum.Parse<ShortStyle>(SelectedShortStyle());
|
||||
ShortStyle style = SelectedShortStyleEnum();
|
||||
if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
string baseName = Path.GetFileNameWithoutExtension(input);
|
||||
@@ -337,6 +474,16 @@ public sealed partial class MainPage : Page
|
||||
DurationSeconds = duration,
|
||||
Style = style,
|
||||
BackgroundImage = ShortBackgroundBox.Text.Trim(),
|
||||
BackgroundVideo = ShortBackgroundVideoBox.Text.Trim(),
|
||||
VideoBlur = blur,
|
||||
VideoOpacity = opacity,
|
||||
SourceCropMode = cropMode,
|
||||
CropLeft = left,
|
||||
CropRight = right,
|
||||
CropTop = top,
|
||||
CropBottom = bottom,
|
||||
SourceMarginX = marginX,
|
||||
SourceFadeInSeconds = fadeIn,
|
||||
FontFile = ShortFontBox.Text.Trim(),
|
||||
FfmpegPath = FfmpegPathBox.Text.Trim(),
|
||||
FfprobePath = FfprobePathBox.Text.Trim(),
|
||||
@@ -345,6 +492,59 @@ public sealed partial class MainPage : Page
|
||||
};
|
||||
}
|
||||
|
||||
private double ParsePositiveDuration()
|
||||
{
|
||||
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
|
||||
throw new ArgumentException("Enter a valid duration in seconds.");
|
||||
return duration;
|
||||
}
|
||||
|
||||
private int ParseCrf()
|
||||
{
|
||||
if (!int.TryParse(ShortCrfBox.Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int crf))
|
||||
throw new ArgumentException("CRF must be an integer.");
|
||||
return crf;
|
||||
}
|
||||
|
||||
private static double ParseNonNegativeNumber(string text, string name)
|
||||
{
|
||||
if (!double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0)
|
||||
throw new ArgumentException($"Enter a valid non-negative value for {name}.");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int ParseNonNegativeInt(string text, string name)
|
||||
{
|
||||
if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) || value < 0)
|
||||
throw new ArgumentException($"{name} must be a non-negative integer.");
|
||||
return value;
|
||||
}
|
||||
|
||||
private (double Blur, double Opacity, int MarginX, ShortSourceCropMode CropMode, int Left, int Right, int Top, int Bottom)
|
||||
ReadVideoStyleOptions()
|
||||
{
|
||||
double blur = ParseNonNegativeNumber(ShortVideoBlurBox.Text, "background blur");
|
||||
double opacity = ParseNonNegativeNumber(ShortVideoOpacityBox.Text, "background opacity");
|
||||
if (opacity > 1)
|
||||
throw new ArgumentException("Background opacity must be between 0.0 and 1.0.");
|
||||
int marginX = ParseNonNegativeInt(ShortSourceMarginBox.Text, "Side margin");
|
||||
if (marginX >= 540)
|
||||
throw new ArgumentException("Source side margin must be less than 540 pixels.");
|
||||
if (SelectedShortStyleEnum() == ShortStyle.Video
|
||||
&& (string.IsNullOrWhiteSpace(ShortBackgroundVideoBox.Text) || !File.Exists(ShortBackgroundVideoBox.Text.Trim())))
|
||||
throw new FileNotFoundException("The video style requires a background video.");
|
||||
|
||||
return (
|
||||
blur,
|
||||
opacity,
|
||||
marginX,
|
||||
SelectedShortCropMode(),
|
||||
ParseNonNegativeInt(ShortCropLeftBox.Text, "Crop left"),
|
||||
ParseNonNegativeInt(ShortCropRightBox.Text, "Crop right"),
|
||||
ParseNonNegativeInt(ShortCropTopBox.Text, "Crop top"),
|
||||
ParseNonNegativeInt(ShortCropBottomBox.Text, "Crop bottom"));
|
||||
}
|
||||
|
||||
private async void BrowseShortInput_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FileOpenPicker picker = CreateFileOpenPicker();
|
||||
@@ -378,6 +578,17 @@ public sealed partial class MainPage : Page
|
||||
ShortBackgroundBox.Text = file.Path;
|
||||
}
|
||||
|
||||
private async void BrowseShortBackgroundVideo_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FileOpenPicker picker = CreateFileOpenPicker();
|
||||
foreach (string extension in SupportedVideoFormats.Extensions)
|
||||
picker.FileTypeFilter.Add(extension);
|
||||
picker.SuggestedStartLocation = PickerLocationId.VideosLibrary;
|
||||
var file = await picker.PickSingleFileAsync();
|
||||
if (file is not null)
|
||||
ShortBackgroundVideoBox.Text = file.Path;
|
||||
}
|
||||
|
||||
private async void BrowseShortFont_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FileOpenPicker picker = CreateFileOpenPicker();
|
||||
@@ -388,19 +599,147 @@ public sealed partial class MainPage : Page
|
||||
ShortFontBox.Text = file.Path;
|
||||
}
|
||||
|
||||
private async void OpenCropEditor_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string input = ShortInputBox.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input) || !File.Exists(input))
|
||||
{
|
||||
await ShowMessageAsync("Crop source", "Select a valid source video first.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
CropEditorDialog.XamlRoot = XamlRoot;
|
||||
CropEditorDialog.RequestedTheme = ActualTheme;
|
||||
await ShortCropEditor.LoadVideoFrameAsync(
|
||||
input,
|
||||
string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
|
||||
FfmpegPathBox.Text.Trim(),
|
||||
FfprobePathBox.Text.Trim(),
|
||||
CancellationToken.None);
|
||||
ShortCropEditor.SetInsets(ReadCropInsets());
|
||||
|
||||
ContentDialogResult result = await CropEditorDialog.ShowAsync();
|
||||
if (result != ContentDialogResult.Primary)
|
||||
return;
|
||||
|
||||
PixelInsets insets = ShortCropEditor.Insets;
|
||||
ShortCropLeftBox.Text = insets.Left.ToString(CultureInfo.InvariantCulture);
|
||||
ShortCropRightBox.Text = insets.Right.ToString(CultureInfo.InvariantCulture);
|
||||
ShortCropTopBox.Text = insets.Top.ToString(CultureInfo.InvariantCulture);
|
||||
ShortCropBottomBox.Text = insets.Bottom.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await ShowMessageAsync("Crop source", UserFacingErrors.Summarize(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private PixelInsets ReadCropInsets()
|
||||
{
|
||||
static int ReadBox(string text)
|
||||
{
|
||||
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
|
||||
return new PixelInsets(
|
||||
ReadBox(ShortCropLeftBox.Text),
|
||||
ReadBox(ShortCropRightBox.Text),
|
||||
ReadBox(ShortCropTopBox.Text),
|
||||
ReadBox(ShortCropBottomBox.Text));
|
||||
}
|
||||
|
||||
private async void PreviewShortSource_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
|
||||
await OpenPreviewAsync(ShortInputBox.Text.Trim());
|
||||
}
|
||||
|
||||
private async void UpdateShortPreview_Click(object sender, RoutedEventArgs e) =>
|
||||
await RefreshShortPreviewAsync(automatic: false);
|
||||
|
||||
private async Task RefreshShortPreviewAsync(bool automatic)
|
||||
{
|
||||
if (_isRendering)
|
||||
return;
|
||||
|
||||
int generation = ++_previewGeneration;
|
||||
ShortPreviewProgress.Visibility = Visibility.Visible;
|
||||
UpdateShortPreviewButton.IsEnabled = false;
|
||||
ShortPreviewStatusText.Text = "Rendering 9:16 preview frames...";
|
||||
|
||||
try
|
||||
{
|
||||
ShortSettings settings = ReadShortSettings();
|
||||
ShortsPipeline.ValidatePreview(settings);
|
||||
_previewDirectory ??= Path.Combine(Path.GetTempPath(), "AmiReel", "short-preview", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_previewDirectory);
|
||||
|
||||
ShortPreviewFrames frames = await new ShortsPipeline().RenderPreviewAsync(
|
||||
settings, _previewDirectory, _ => { }, CancellationToken.None);
|
||||
|
||||
if (generation != _previewGeneration)
|
||||
return;
|
||||
|
||||
ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath);
|
||||
ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath);
|
||||
bool hasFont = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
|
||||
ShortPreviewStatusText.Text = hasFont
|
||||
? $"{settings.Style} · opening hook and closing title card"
|
||||
: "Look only — pick a font on the last step to include the title-card text.";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (generation != _previewGeneration)
|
||||
return;
|
||||
|
||||
string message = UserFacingErrors.Summarize(exception);
|
||||
ShortPreviewStatusText.Text = message;
|
||||
if (!automatic)
|
||||
await ShowMessageAsync("Preview failed", message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (generation == _previewGeneration)
|
||||
{
|
||||
ShortPreviewProgress.Visibility = Visibility.Collapsed;
|
||||
UpdateShortPreviewButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path)
|
||||
{
|
||||
await using FileStream stream = File.OpenRead(path);
|
||||
BitmapImage bitmap = new() { DecodePixelWidth = 296 };
|
||||
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private void SetRendering(bool rendering)
|
||||
{
|
||||
_isRendering = rendering;
|
||||
RenderButton.IsEnabled = !rendering;
|
||||
CancelButton.IsEnabled = rendering;
|
||||
OpenSettingsButton.IsEnabled = !rendering;
|
||||
VideoTabButton.IsEnabled = !rendering;
|
||||
ShortsTabButton.IsEnabled = !rendering;
|
||||
UpdateWorkspaceVisibility();
|
||||
}
|
||||
|
||||
private void UpdateWorkspaceVisibility()
|
||||
{
|
||||
bool showSetup = !_isRendering;
|
||||
bool showOutput = _isRendering || _keepRenderOutput;
|
||||
|
||||
VideoContentPanel.Visibility = VisibleIf(showSetup && !_isShortsTab);
|
||||
ShortsContentPanel.Visibility = VisibleIf(showSetup && _isShortsTab);
|
||||
RenderProgressPanel.Visibility = VisibleIf(showOutput);
|
||||
FfmpegLogPanel.Visibility = VisibleIf(showOutput);
|
||||
UpdateWizardNavigation();
|
||||
}
|
||||
|
||||
private void UpdateProgress(RenderProgress value)
|
||||
@@ -496,6 +835,16 @@ public sealed partial class MainPage : Page
|
||||
ShortsWebsite = ShortWebsiteBox.Text.Trim(),
|
||||
ShortsFontFile = ShortFontBox.Text.Trim(),
|
||||
ShortsBackgroundImage = ShortBackgroundBox.Text.Trim(),
|
||||
ShortsBackgroundVideo = ShortBackgroundVideoBox.Text.Trim(),
|
||||
ShortsVideoBlur = ShortVideoBlurBox.Text.Trim(),
|
||||
ShortsVideoOpacity = ShortVideoOpacityBox.Text.Trim(),
|
||||
ShortsSourceCropMode = SelectedShortCropMode().ToString(),
|
||||
ShortsCropLeft = ShortCropLeftBox.Text.Trim(),
|
||||
ShortsCropRight = ShortCropRightBox.Text.Trim(),
|
||||
ShortsCropTop = ShortCropTopBox.Text.Trim(),
|
||||
ShortsCropBottom = ShortCropBottomBox.Text.Trim(),
|
||||
ShortsSourceMarginX = ShortSourceMarginBox.Text.Trim(),
|
||||
ShortsSourceFadeIn = ShortFadeInBox.Text.Trim(),
|
||||
ShortsCrf = ShortCrfBox.Text.Trim(),
|
||||
ShortsPreset = SelectedShortPreset(),
|
||||
});
|
||||
@@ -507,6 +856,15 @@ public sealed partial class MainPage : Page
|
||||
|
||||
private string SelectedShortStyle() => (ShortStyleBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Brand";
|
||||
|
||||
private ShortStyle SelectedShortStyleEnum() =>
|
||||
Enum.TryParse(SelectedShortStyle(), ignoreCase: true, out ShortStyle style) ? style : ShortStyle.Brand;
|
||||
|
||||
private ShortSourceCropMode SelectedShortCropMode()
|
||||
{
|
||||
string tag = (ShortCropModeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto";
|
||||
return Enum.TryParse(tag, ignoreCase: true, out ShortSourceCropMode mode) ? mode : ShortSourceCropMode.Auto;
|
||||
}
|
||||
|
||||
private string SelectedShortPreset() => (ShortPresetBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "slow";
|
||||
|
||||
private void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme);
|
||||
|
||||
@@ -28,6 +28,16 @@ public sealed record AppSettings
|
||||
public string ShortsWebsite { get; init; } = "AMIGADB.NET";
|
||||
public string ShortsFontFile { get; init; } = "";
|
||||
public string ShortsBackgroundImage { get; init; } = "";
|
||||
public string ShortsBackgroundVideo { get; init; } = "";
|
||||
public string ShortsVideoBlur { get; init; } = "0";
|
||||
public string ShortsVideoOpacity { get; init; } = "1.0";
|
||||
public string ShortsSourceCropMode { get; init; } = "Auto";
|
||||
public string ShortsCropLeft { get; init; } = "0";
|
||||
public string ShortsCropRight { get; init; } = "0";
|
||||
public string ShortsCropTop { get; init; } = "0";
|
||||
public string ShortsCropBottom { get; init; } = "0";
|
||||
public string ShortsSourceMarginX { get; init; } = "40";
|
||||
public string ShortsSourceFadeIn { get; init; } = "1.5";
|
||||
public string ShortsCrf { get; init; } = "16";
|
||||
public string ShortsPreset { get; init; } = "slow";
|
||||
|
||||
@@ -68,6 +78,16 @@ public sealed record AppSettings
|
||||
ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET",
|
||||
ShortsFontFile = ShortsFontFile ?? "",
|
||||
ShortsBackgroundImage = ShortsBackgroundImage ?? "",
|
||||
ShortsBackgroundVideo = ShortsBackgroundVideo ?? "",
|
||||
ShortsVideoBlur = string.IsNullOrWhiteSpace(ShortsVideoBlur) ? "0" : ShortsVideoBlur,
|
||||
ShortsVideoOpacity = string.IsNullOrWhiteSpace(ShortsVideoOpacity) ? "1.0" : ShortsVideoOpacity,
|
||||
ShortsSourceCropMode = string.IsNullOrWhiteSpace(ShortsSourceCropMode) ? "Auto" : ShortsSourceCropMode,
|
||||
ShortsCropLeft = string.IsNullOrWhiteSpace(ShortsCropLeft) ? "0" : ShortsCropLeft,
|
||||
ShortsCropRight = string.IsNullOrWhiteSpace(ShortsCropRight) ? "0" : ShortsCropRight,
|
||||
ShortsCropTop = string.IsNullOrWhiteSpace(ShortsCropTop) ? "0" : ShortsCropTop,
|
||||
ShortsCropBottom = string.IsNullOrWhiteSpace(ShortsCropBottom) ? "0" : ShortsCropBottom,
|
||||
ShortsSourceMarginX = string.IsNullOrWhiteSpace(ShortsSourceMarginX) ? "40" : ShortsSourceMarginX,
|
||||
ShortsSourceFadeIn = string.IsNullOrWhiteSpace(ShortsSourceFadeIn) ? "1.5" : ShortsSourceFadeIn,
|
||||
ShortsCrf = string.IsNullOrWhiteSpace(ShortsCrf) ? "16" : ShortsCrf,
|
||||
ShortsPreset = string.IsNullOrWhiteSpace(ShortsPreset) ? "slow" : ShortsPreset,
|
||||
};
|
||||
|
||||
+30
-2
@@ -1,6 +1,6 @@
|
||||
namespace AmiReel.Models;
|
||||
|
||||
/// <summary>Visual style applied to a YouTube Short. Mirrors the --style values of amigadb-short.sh.</summary>
|
||||
/// <summary>Visual style applied to a YouTube Short. Mirrors the --style values of amigadb-short-fx-v4.sh.</summary>
|
||||
public enum ShortStyle
|
||||
{
|
||||
Brand,
|
||||
@@ -22,9 +22,27 @@ public enum ShortStyle
|
||||
Monitor,
|
||||
Split,
|
||||
Spectrum,
|
||||
Video,
|
||||
}
|
||||
|
||||
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short.sh's arguments).</summary>
|
||||
/// <summary>How the video style crops the Amiga source before overlaying it.</summary>
|
||||
public enum ShortSourceCropMode
|
||||
{
|
||||
Auto,
|
||||
Center,
|
||||
}
|
||||
|
||||
/// <summary>Which moment of the Short a preview still represents.</summary>
|
||||
public enum ShortPreviewShot
|
||||
{
|
||||
Opening,
|
||||
Closing,
|
||||
}
|
||||
|
||||
/// <summary>PNG stills of the opening hook and closing title card.</summary>
|
||||
public sealed record ShortPreviewFrames(string OpeningPath, string ClosingPath);
|
||||
|
||||
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short-fx-v4.sh).</summary>
|
||||
public sealed record ShortSettings
|
||||
{
|
||||
public required string InputFile { get; init; }
|
||||
@@ -39,6 +57,16 @@ public sealed record ShortSettings
|
||||
public required double DurationSeconds { get; init; }
|
||||
public required ShortStyle Style { get; init; }
|
||||
public string BackgroundImage { get; init; } = "";
|
||||
public string BackgroundVideo { get; init; } = "";
|
||||
public double VideoBlur { get; init; }
|
||||
public double VideoOpacity { get; init; } = 1.0;
|
||||
public ShortSourceCropMode SourceCropMode { get; init; } = ShortSourceCropMode.Auto;
|
||||
public int CropLeft { get; init; }
|
||||
public int CropRight { get; init; }
|
||||
public int CropTop { get; init; }
|
||||
public int CropBottom { get; init; }
|
||||
public int SourceMarginX { get; init; } = 40;
|
||||
public double SourceFadeInSeconds { get; init; } = 1.5;
|
||||
public required string FontFile { get; init; }
|
||||
public required string FfmpegPath { get; init; }
|
||||
public required string FfprobePath { get; init; }
|
||||
|
||||
@@ -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.",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace AmiReel.Models;
|
||||
|
||||
/// <summary>Edge insets, in source pixels, removed by a manual crop.</summary>
|
||||
public readonly record struct PixelInsets(int Left, int Right, int Top, int Bottom)
|
||||
{
|
||||
public bool IsEmpty => Left == 0 && Right == 0 && Top == 0 && Bottom == 0;
|
||||
}
|
||||
|
||||
/// <summary>Maps a dragged display rectangle onto source-pixel crop insets.</summary>
|
||||
public static class SourceCropSelection
|
||||
{
|
||||
public const int MinimumSize = 16;
|
||||
|
||||
public static (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) FitUniform(
|
||||
double containerWidth, double containerHeight, int pixelWidth, int pixelHeight)
|
||||
{
|
||||
if (containerWidth <= 0 || containerHeight <= 0 || pixelWidth <= 0 || pixelHeight <= 0)
|
||||
return (0, 0, 0, 0);
|
||||
|
||||
double containerAspect = containerWidth / containerHeight;
|
||||
double imageAspect = pixelWidth / (double)pixelHeight;
|
||||
double displayWidth;
|
||||
double displayHeight;
|
||||
if (containerAspect > imageAspect)
|
||||
{
|
||||
displayHeight = containerHeight;
|
||||
displayWidth = containerHeight * imageAspect;
|
||||
}
|
||||
else
|
||||
{
|
||||
displayWidth = containerWidth;
|
||||
displayHeight = containerWidth / imageAspect;
|
||||
}
|
||||
|
||||
return ((containerWidth - displayWidth) / 2, (containerHeight - displayHeight) / 2, displayWidth, displayHeight);
|
||||
}
|
||||
|
||||
public static PixelInsets ToInsets(
|
||||
int pixelWidth,
|
||||
int pixelHeight,
|
||||
double displayWidth,
|
||||
double displayHeight,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height)
|
||||
{
|
||||
if (pixelWidth <= 0 || pixelHeight <= 0 || displayWidth <= 0 || displayHeight <= 0)
|
||||
return default;
|
||||
|
||||
Normalize(ref x, ref y, ref width, ref height);
|
||||
x = Math.Clamp(x, 0, displayWidth);
|
||||
y = Math.Clamp(y, 0, displayHeight);
|
||||
width = Math.Clamp(width, 0, displayWidth - x);
|
||||
height = Math.Clamp(height, 0, displayHeight - y);
|
||||
|
||||
double scaleX = pixelWidth / displayWidth;
|
||||
double scaleY = pixelHeight / displayHeight;
|
||||
int left = ToEvenPixel(x * scaleX, pixelWidth - MinimumSize);
|
||||
int top = ToEvenPixel(y * scaleY, pixelHeight - MinimumSize);
|
||||
int right = ToEvenPixel((displayWidth - x - width) * scaleX, pixelWidth - MinimumSize - left);
|
||||
int bottom = ToEvenPixel((displayHeight - y - height) * scaleY, pixelHeight - MinimumSize - top);
|
||||
|
||||
if (pixelWidth - left - right < MinimumSize)
|
||||
right = Math.Max(0, pixelWidth - left - MinimumSize);
|
||||
if (pixelHeight - top - bottom < MinimumSize)
|
||||
bottom = Math.Max(0, pixelHeight - top - MinimumSize);
|
||||
|
||||
return new PixelInsets(left, right, top, bottom);
|
||||
}
|
||||
|
||||
public static (double X, double Y, double Width, double Height) ToDisplayRect(
|
||||
int pixelWidth, int pixelHeight, double displayWidth, double displayHeight, PixelInsets insets)
|
||||
{
|
||||
if (pixelWidth <= 0 || pixelHeight <= 0 || displayWidth <= 0 || displayHeight <= 0)
|
||||
return (0, 0, Math.Max(0, displayWidth), Math.Max(0, displayHeight));
|
||||
|
||||
double scaleX = displayWidth / pixelWidth;
|
||||
double scaleY = displayHeight / pixelHeight;
|
||||
double x = insets.Left * scaleX;
|
||||
double y = insets.Top * scaleY;
|
||||
double width = Math.Max(1, (pixelWidth - insets.Left - insets.Right) * scaleX);
|
||||
double height = Math.Max(1, (pixelHeight - insets.Top - insets.Bottom) * scaleY);
|
||||
return (x, y, width, height);
|
||||
}
|
||||
|
||||
public static (double X, double Y) ClampToDisplay(double x, double y, double displayWidth, double displayHeight) =>
|
||||
(Math.Clamp(x, 0, Math.Max(0, displayWidth)), Math.Clamp(y, 0, Math.Max(0, displayHeight)));
|
||||
|
||||
private static void Normalize(ref double x, ref double y, ref double width, ref double height)
|
||||
{
|
||||
if (width < 0)
|
||||
{
|
||||
x += width;
|
||||
width = -width;
|
||||
}
|
||||
|
||||
if (height < 0)
|
||||
{
|
||||
y += height;
|
||||
height = -height;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ToEvenPixel(double value, int max)
|
||||
{
|
||||
int rounded = (int)Math.Round(value, MidpointRounding.AwayFromZero);
|
||||
int clamped = Math.Clamp(rounded, 0, Math.Max(0, max));
|
||||
return (clamped / 2) * 2;
|
||||
}
|
||||
}
|
||||
+340
-18
@@ -1,17 +1,25 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AmiReel.Models;
|
||||
|
||||
namespace AmiReel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a vertical (1080x1920, 50 FPS) YouTube Short from an existing video, with a chosen
|
||||
/// visual style and title-card text overlays. A C# port of amigadb-short.sh so end users don't
|
||||
/// need bash/WSL — the FFmpeg filtergraphs below are copied verbatim from that script.
|
||||
/// visual style and title-card text overlays. A C# port of amigadb-short-fx-v4.sh so end users
|
||||
/// don't need bash/WSL — the FFmpeg filtergraphs below are copied from that script.
|
||||
/// </summary>
|
||||
public sealed class ShortsPipeline
|
||||
{
|
||||
internal const string DefaultSourceCropFilter =
|
||||
"crop='min(iw,ih*4/3)':'min(ih,iw*3/4)':'(iw-ow)/2':'(ih-oh)/2'";
|
||||
|
||||
private static readonly Regex CropSpecRegex = new(
|
||||
@"crop=(\d+):(\d+):(\d+):(\d+)",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly ProcessRunner _runner = new();
|
||||
|
||||
public async Task RenderAsync(ShortSettings settings, IProgress<RenderProgress> progress, Action<string> log, CancellationToken token)
|
||||
@@ -45,9 +53,11 @@ public sealed class ShortsPipeline
|
||||
double infoStart = Math.Max(0, duration - 7);
|
||||
double ctaStart = Math.Max(0, duration - 3.5);
|
||||
double hookEnd = Math.Min(2.7, duration);
|
||||
double fadeIn = Math.Clamp(settings.SourceFadeInSeconds, 0, duration);
|
||||
|
||||
string baseFilter = await ResolveBaseFilterAsync(tools, settings, log, token);
|
||||
string filterComplex = BuildFilterComplex(
|
||||
GetBaseFilter(settings.Style),
|
||||
ApplySourceFade(baseFilter, fadeIn),
|
||||
fontEsc: EscapeFfmpegPath(settings.FontFile),
|
||||
hookEsc: EscapeFfmpegPath(hookFile),
|
||||
titleEsc: EscapeFfmpegPath(titleFile),
|
||||
@@ -55,9 +65,7 @@ public sealed class ShortsPipeline
|
||||
websiteEsc: EscapeFfmpegPath(websiteFile),
|
||||
hookEnd, infoStart, ctaStart);
|
||||
|
||||
List<string> args = ["-hide_banner", "-y", "-ss", settings.Start, "-t", F(duration), "-i", settings.InputFile];
|
||||
if (settings.Style == ShortStyle.Brand)
|
||||
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
|
||||
List<string> args = BuildInputArguments(settings, duration);
|
||||
|
||||
args.AddRange([
|
||||
"-filter_complex", filterComplex,
|
||||
@@ -73,6 +81,7 @@ public sealed class ShortsPipeline
|
||||
"-b:a", "320k",
|
||||
"-ar", "48000",
|
||||
"-movflags", "+faststart",
|
||||
"-t", F(duration),
|
||||
"-shortest",
|
||||
settings.OutputPath,
|
||||
]);
|
||||
@@ -94,6 +103,78 @@ public sealed class ShortsPipeline
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders two 9:16 stills with the same filtergraph as the final Short: opening hook and closing title card.
|
||||
/// </summary>
|
||||
public async Task<ShortPreviewFrames> RenderPreviewAsync(
|
||||
ShortSettings settings, string outputDirectory, Action<string> log, CancellationToken token)
|
||||
{
|
||||
ValidatePreview(settings);
|
||||
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
string workDir = Path.Combine(outputDirectory, "meta");
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
string hookFile = WriteTextFile(workDir, "hook.txt", string.IsNullOrWhiteSpace(settings.Hook) ? "THIS RAN ON AN AMIGA" : settings.Hook);
|
||||
string titleFile = WriteTextFile(workDir, "title.txt", string.IsNullOrWhiteSpace(settings.Title) ? "TITLE" : settings.Title);
|
||||
string metaFile = WriteTextFile(workDir, "meta.txt", BuildMeta(settings));
|
||||
string websiteFile = WriteTextFile(workDir, "website.txt", string.IsNullOrWhiteSpace(settings.Website) ? "AMIGADB.NET" : settings.Website);
|
||||
|
||||
log("Resolving preview look...");
|
||||
string baseFilter = await ResolveBaseFilterAsync(tools, settings, log, token);
|
||||
bool includeText = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
|
||||
|
||||
string openingPath = Path.Combine(outputDirectory, "opening.png");
|
||||
string closingPath = Path.Combine(outputDirectory, "closing.png");
|
||||
await RenderPreviewStillAsync(
|
||||
tools, settings, baseFilter, includeText,
|
||||
hookFile, titleFile, metaFile, websiteFile,
|
||||
ShortPreviewShot.Opening, openingPath, log, token);
|
||||
await RenderPreviewStillAsync(
|
||||
tools, settings, baseFilter, includeText,
|
||||
hookFile, titleFile, metaFile, websiteFile,
|
||||
ShortPreviewShot.Closing, closingPath, log, token);
|
||||
|
||||
return new ShortPreviewFrames(openingPath, closingPath);
|
||||
}
|
||||
|
||||
/// <summary>Writes one source frame (no style filters) so the crop editor can show the raw clip.</summary>
|
||||
public async Task ExtractSourceFrameAsync(
|
||||
string inputFile,
|
||||
string start,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
string outputPath,
|
||||
Action<string> log,
|
||||
CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inputFile) || !File.Exists(inputFile))
|
||||
throw new FileNotFoundException("Select a valid source video for the Short.");
|
||||
|
||||
string? directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
ToolPaths tools = await ToolExtractor.ResolveAsync(ffmpegPath, ffprobePath, token);
|
||||
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token);
|
||||
}
|
||||
|
||||
internal static List<string> BuildExtractFrameArguments(string inputFile, string start, string outputPath) =>
|
||||
[
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-ss",
|
||||
string.IsNullOrWhiteSpace(start) ? "00:00:00" : start,
|
||||
"-i",
|
||||
inputFile,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-update",
|
||||
"1",
|
||||
outputPath,
|
||||
];
|
||||
|
||||
/// <summary>Looks for a common bold system font to pre-fill the Shorts font field with.</summary>
|
||||
public static string? FindDefaultFont()
|
||||
{
|
||||
@@ -109,18 +190,35 @@ public sealed class ShortsPipeline
|
||||
|
||||
internal static void Validate(ShortSettings s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
|
||||
throw new FileNotFoundException("Select a valid source video for the Short.");
|
||||
ValidatePreview(s);
|
||||
if (string.IsNullOrWhiteSpace(s.Title))
|
||||
throw new ArgumentException("Enter a title.");
|
||||
if (string.IsNullOrWhiteSpace(s.OutputPath))
|
||||
throw new ArgumentException("Select an output file.");
|
||||
if (s.DurationSeconds <= 0)
|
||||
throw new ArgumentException("Duration must be greater than zero.");
|
||||
if (string.IsNullOrWhiteSpace(s.FontFile) || !File.Exists(s.FontFile))
|
||||
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
|
||||
}
|
||||
|
||||
internal static void ValidatePreview(ShortSettings s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
|
||||
throw new FileNotFoundException("Select a valid source video for the Short.");
|
||||
if (s.DurationSeconds <= 0)
|
||||
throw new ArgumentException("Duration must be greater than zero.");
|
||||
if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage)))
|
||||
throw new FileNotFoundException("The brand style requires a background image.");
|
||||
if (s.Style == ShortStyle.Video && (string.IsNullOrWhiteSpace(s.BackgroundVideo) || !File.Exists(s.BackgroundVideo)))
|
||||
throw new FileNotFoundException("The video style requires a background video.");
|
||||
if (s.VideoBlur < 0)
|
||||
throw new ArgumentException("Background video blur must be zero or greater.");
|
||||
if (s.VideoOpacity is < 0 or > 1)
|
||||
throw new ArgumentException("Background video opacity must be between 0.0 and 1.0.");
|
||||
if (s.CropLeft < 0 || s.CropRight < 0 || s.CropTop < 0 || s.CropBottom < 0)
|
||||
throw new ArgumentException("Manual crop values must be non-negative.");
|
||||
if (s.SourceMarginX < 0 || s.SourceMarginX >= 540)
|
||||
throw new ArgumentException("Source side margin must be between 0 and 539 pixels.");
|
||||
if (s.SourceFadeInSeconds < 0)
|
||||
throw new ArgumentException("Source fade-in must be zero or greater.");
|
||||
}
|
||||
|
||||
internal static string BuildMeta(ShortSettings s)
|
||||
@@ -144,8 +242,11 @@ public sealed class ShortsPipeline
|
||||
|
||||
internal static string BuildFilterComplex(
|
||||
string baseFilter, string fontEsc, string hookEsc, string titleEsc, string metaEsc, string websiteEsc,
|
||||
double hookEnd, double infoStart, double ctaStart)
|
||||
double hookEnd, double infoStart, double ctaStart, bool includeTextOverlays = true)
|
||||
{
|
||||
if (!includeTextOverlays)
|
||||
return baseFilter + ";[base]fps=50,format=yuv420p[vout]";
|
||||
|
||||
string hookEndStr = F(hookEnd);
|
||||
string infoStartStr = F(infoStart);
|
||||
string ctaStartStr = F(ctaStart);
|
||||
@@ -161,8 +262,225 @@ public sealed class ShortsPipeline
|
||||
"fps=50,format=yuv420p[vout]";
|
||||
}
|
||||
|
||||
internal static List<string> BuildInputArguments(ShortSettings settings, double clipDuration, string? seek = null)
|
||||
{
|
||||
List<string> args =
|
||||
[
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-ss",
|
||||
seek ?? settings.Start,
|
||||
"-t",
|
||||
F(clipDuration),
|
||||
"-i",
|
||||
settings.InputFile,
|
||||
];
|
||||
if (settings.Style == ShortStyle.Brand)
|
||||
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
|
||||
if (settings.Style == ShortStyle.Video)
|
||||
args.AddRange(["-stream_loop", "-1", "-i", settings.BackgroundVideo]);
|
||||
return args;
|
||||
}
|
||||
|
||||
internal static (double HookEnd, double InfoStart, double CtaStart) PreviewOverlayWindows(ShortPreviewShot shot, double duration)
|
||||
{
|
||||
if (shot == ShortPreviewShot.Opening)
|
||||
return (Math.Max(duration, 1), duration + 1, duration + 1);
|
||||
|
||||
return (-1, 0, 0);
|
||||
}
|
||||
|
||||
internal static double PreviewSeekSeconds(ShortPreviewShot shot, double duration)
|
||||
{
|
||||
if (shot == ShortPreviewShot.Opening)
|
||||
return Math.Min(1.2, Math.Max(0, duration * 0.2));
|
||||
|
||||
return Math.Max(0, duration - 0.4);
|
||||
}
|
||||
|
||||
internal static string AddTimestampOffset(string start, double seconds)
|
||||
{
|
||||
if (!TimeSpan.TryParse(start, CultureInfo.InvariantCulture, out TimeSpan parsed))
|
||||
parsed = TimeSpan.Zero;
|
||||
if (seconds < 0)
|
||||
seconds = 0;
|
||||
return (parsed + TimeSpan.FromSeconds(seconds)).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
private async Task RenderPreviewStillAsync(
|
||||
ToolPaths tools,
|
||||
ShortSettings settings,
|
||||
string baseFilter,
|
||||
bool includeText,
|
||||
string hookFile,
|
||||
string titleFile,
|
||||
string metaFile,
|
||||
string websiteFile,
|
||||
ShortPreviewShot shot,
|
||||
string outputPath,
|
||||
Action<string> log,
|
||||
CancellationToken token)
|
||||
{
|
||||
(double hookEnd, double infoStart, double ctaStart) = PreviewOverlayWindows(shot, settings.DurationSeconds);
|
||||
string filterComplex = BuildFilterComplex(
|
||||
baseFilter,
|
||||
fontEsc: includeText ? EscapeFfmpegPath(settings.FontFile) : "",
|
||||
hookEsc: EscapeFfmpegPath(hookFile),
|
||||
titleEsc: EscapeFfmpegPath(titleFile),
|
||||
metaEsc: EscapeFfmpegPath(metaFile),
|
||||
websiteEsc: EscapeFfmpegPath(websiteFile),
|
||||
hookEnd,
|
||||
infoStart,
|
||||
ctaStart,
|
||||
includeTextOverlays: includeText);
|
||||
|
||||
string seek = AddTimestampOffset(settings.Start, PreviewSeekSeconds(shot, settings.DurationSeconds));
|
||||
List<string> args = BuildInputArguments(settings, 0.12, seek);
|
||||
args.AddRange([
|
||||
"-filter_complex", filterComplex,
|
||||
"-map", "[vout]",
|
||||
"-frames:v", "1",
|
||||
"-update", "1",
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
log($"Preview {shot.ToString().ToLowerInvariant()} frame...");
|
||||
await _runner.RunAsync(tools.Ffmpeg, args, log, null, token);
|
||||
}
|
||||
|
||||
internal static string ApplySourceFade(string baseFilter, double fadeInSeconds)
|
||||
{
|
||||
if (fadeInSeconds <= 0)
|
||||
return baseFilter;
|
||||
|
||||
return $"[0:v]format=rgba,fade=t=in:st=0:d={F(fadeInSeconds)}:alpha=1[srcfade];" +
|
||||
baseFilter.Replace("[0:v]", "[srcfade]", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
internal static int SourceTargetWidth(int sourceMarginX) => 1080 - (sourceMarginX * 2);
|
||||
|
||||
internal static string BuildSourceScaleFilter(int sourceMarginX) =>
|
||||
$"scale={SourceTargetWidth(sourceMarginX)}:-2:flags=lanczos";
|
||||
|
||||
internal static string BuildManualCropFilter(int left, int right, int top, int bottom) =>
|
||||
$"crop=iw-{left}-{right}:ih-{top}-{bottom}:{left}:{top}";
|
||||
|
||||
internal static string BuildVideoBaseFilter(string cropFilter, string scaleFilter, double blur, double opacity)
|
||||
{
|
||||
string blurFilter = blur > 0 ? $",gblur=sigma={F(blur)}" : "";
|
||||
return
|
||||
"color=c=black:s=1080x1920:r=50[black];" +
|
||||
$"[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1{blurFilter},format=rgba,colorchannelmixer=aa={F(opacity)}[bgvideo];" +
|
||||
"[black][bgvideo]overlay=x=0:y=0:format=auto[bg];" +
|
||||
$"[0:v]{cropFilter},{scaleFilter},setsar=1[fg];" +
|
||||
"[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]";
|
||||
}
|
||||
|
||||
internal static string? SelectMostCommonCropSpec(string ffmpegOutput)
|
||||
{
|
||||
Dictionary<string, int> counts = new(StringComparer.Ordinal);
|
||||
foreach (Match match in CropSpecRegex.Matches(ffmpegOutput))
|
||||
{
|
||||
string spec = match.Value;
|
||||
counts[spec] = counts.GetValueOrDefault(spec) + 1;
|
||||
}
|
||||
|
||||
return counts.Count == 0
|
||||
? null
|
||||
: counts.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal).First().Key;
|
||||
}
|
||||
|
||||
internal static bool TryParseCropSpec(string spec, out int width, out int height, out int x, out int y)
|
||||
{
|
||||
Match match = CropSpecRegex.Match(spec);
|
||||
if (!match.Success)
|
||||
{
|
||||
width = height = x = y = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
height = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
x = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
|
||||
y = int.Parse(match.Groups[4].Value, CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryBuildAutoHorizontalCrop(int detectedWidth, int detectedX, out int width, out int x)
|
||||
{
|
||||
int inset = (int)(detectedWidth * 0.005 / 2) * 2;
|
||||
width = ((detectedWidth - (2 * inset)) / 2) * 2;
|
||||
x = ((detectedX + inset) / 2) * 2;
|
||||
if (width < 16)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<string> ResolveBaseFilterAsync(
|
||||
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
|
||||
{
|
||||
if (settings.Style != ShortStyle.Video)
|
||||
return GetBaseFilter(settings.Style);
|
||||
|
||||
string cropFilter = DefaultSourceCropFilter;
|
||||
string scaleFilter = BuildSourceScaleFilter(settings.SourceMarginX);
|
||||
bool hasManualCrop = settings.CropLeft > 0 || settings.CropRight > 0
|
||||
|| settings.CropTop > 0 || settings.CropBottom > 0;
|
||||
|
||||
if (hasManualCrop)
|
||||
{
|
||||
cropFilter = BuildManualCropFilter(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom);
|
||||
log($"Source crop: manual L={settings.CropLeft} R={settings.CropRight} T={settings.CropTop} B={settings.CropBottom}");
|
||||
}
|
||||
else if (settings.SourceCropMode == ShortSourceCropMode.Auto)
|
||||
{
|
||||
string? detected = await DetectHorizontalCropAsync(tools, settings, log, token);
|
||||
if (detected is not null)
|
||||
{
|
||||
cropFilter = detected;
|
||||
}
|
||||
else
|
||||
{
|
||||
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
log("Source crop: centered 4:3");
|
||||
}
|
||||
|
||||
return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity);
|
||||
}
|
||||
|
||||
private async Task<string?> DetectHorizontalCropAsync(
|
||||
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
|
||||
{
|
||||
double detectDuration = Math.Clamp(settings.DurationSeconds < 4 ? settings.DurationSeconds : 4, 0.5, 4);
|
||||
List<string> args =
|
||||
[
|
||||
"-hide_banner",
|
||||
"-ss", settings.Start,
|
||||
"-t", F(detectDuration),
|
||||
"-i", settings.InputFile,
|
||||
"-an",
|
||||
"-vf", "crop=iw:trunc(ih*0.70/2)*2:0:trunc(ih*0.15/2)*2,cropdetect=limit=28:round=2:reset=0",
|
||||
"-f", "null",
|
||||
"-",
|
||||
];
|
||||
|
||||
string output = await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, allowFailure: true);
|
||||
string? spec = SelectMostCommonCropSpec(output);
|
||||
if (spec is null || !TryParseCropSpec(spec, out int width, out _, out int x, out _))
|
||||
return null;
|
||||
if (!TryBuildAutoHorizontalCrop(width, x, out int cropWidth, out int cropX))
|
||||
return null;
|
||||
|
||||
log($"Source crop: w={cropWidth}, x={cropX} (auto left/right black crop)");
|
||||
return $"crop={cropWidth}:ih:{cropX}:0";
|
||||
}
|
||||
|
||||
internal static string GetBaseFilter(ShortStyle style) => style switch
|
||||
{
|
||||
ShortStyle.Brand => """
|
||||
@@ -182,13 +500,14 @@ public sealed class ShortsPipeline
|
||||
""",
|
||||
|
||||
ShortStyle.Mirror => """
|
||||
[0:v]split=3[fg_src][top_src][bottom_src];
|
||||
[top_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[top];
|
||||
[bottom_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[bottom];
|
||||
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=white@0.70,setsar=1[fg];
|
||||
[top]pad=1080:1920:0:0:color=0x050616[canvas];
|
||||
[canvas][bottom]overlay=x=0:y=1300[tmp];
|
||||
[tmp][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
[0:v]split=4[canvas_src][fg_src][top_src][bottom_src];
|
||||
[canvas_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,drawbox=x=0:y=0:w=1080:h=1920:color=0x010203:t=fill[canvas];
|
||||
[fg_src]scale=940:760:force_original_aspect_ratio=decrease,setsar=1[fg];
|
||||
[top_src]scale=1400:470:flags=lanczos,vflip,eq=brightness=-0.48:contrast=0.92:saturation=0.68,gblur=sigma=7,setsar=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*0.62*(Y/H)'[top];
|
||||
[bottom_src]scale=1400:470:flags=lanczos,vflip,eq=brightness=-0.50:contrast=0.90:saturation=0.64,gblur=sigma=7,setsar=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*0.58*(1-Y/H)'[bottom];
|
||||
[canvas][top]overlay=x=(W-w)/2:y=250:format=auto[tmp1];
|
||||
[tmp1][bottom]overlay=x=(W-w)/2:y=1435:format=auto[tmp2];
|
||||
[tmp2][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Crop => """
|
||||
@@ -381,6 +700,9 @@ public sealed class ShortsPipeline
|
||||
[tmp][fg]overlay=x=(W-w)/2:y=480:format=auto[base]
|
||||
""",
|
||||
|
||||
ShortStyle.Video => BuildVideoBaseFilter(
|
||||
DefaultSourceCropFilter, BuildSourceScaleFilter(40), blur: 0, opacity: 1),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(style), style, "Unknown short style."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user