Add Shorts look preview and visual crop editor
Show opening/closing 9:16 stills from the same filtergraph, and let users drag a keep-region on a source frame to fill the crop insets.
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -389,4 +389,128 @@ public class ShortsPipelineTests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.AreEqual(1000, width);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}";
|
||||||
|
}
|
||||||
+82
-2
@@ -5,6 +5,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:controls="using:AmiReel.Controls"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
|
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
|
||||||
@@ -87,7 +88,12 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}" x:Name="ShortsContentPanel" Visibility="Collapsed">
|
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}" x:Name="ShortsContentPanel" Visibility="Collapsed">
|
||||||
<StackPanel Spacing="14">
|
<Grid ColumnSpacing="18">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Spacing="14">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock x:Name="ShortWizardTitleText" Text="Source clip" Style="{StaticResource SectionTitleStyle}" />
|
<TextBlock x:Name="ShortWizardTitleText" Text="Source clip" Style="{StaticResource SectionTitleStyle}" />
|
||||||
<TextBlock x:Name="ShortWizardStepText" Text="Step 1 of 4" Style="{StaticResource MutedTextStyle}" />
|
<TextBlock x:Name="ShortWizardStepText" Text="Step 1 of 4" Style="{StaticResource MutedTextStyle}" />
|
||||||
@@ -207,7 +213,7 @@
|
|||||||
<ComboBoxItem Content="Auto (remove left/right black borders)" Tag="Auto" />
|
<ComboBoxItem Content="Auto (remove left/right black borders)" Tag="Auto" />
|
||||||
<ComboBoxItem Content="Center 4:3" Tag="Center" />
|
<ComboBoxItem Content="Center 4:3" Tag="Center" />
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<TextBlock Text="Manual crop overrides auto/center. Leave at 0 to use the mode above."
|
<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" />
|
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
||||||
<Grid ColumnSpacing="10">
|
<Grid ColumnSpacing="10">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
@@ -225,6 +231,12 @@
|
|||||||
<TextBox x:Name="ShortCropBottomBox" Grid.Column="3" Header="Crop bottom" Text="0"
|
<TextBox x:Name="ShortCropBottomBox" Grid.Column="3" Header="Crop bottom" Text="0"
|
||||||
AutomationProperties.Name="Crop bottom pixels" />
|
AutomationProperties.Name="Crop bottom pixels" />
|
||||||
</Grid>
|
</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>
|
</StackPanel>
|
||||||
|
|
||||||
@@ -284,6 +296,56 @@
|
|||||||
<TextBlock x:Name="ShortSummaryText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
<TextBlock x:Name="ShortSummaryText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</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="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<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>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border x:Name="RenderProgressPanel"
|
<Border x:Name="RenderProgressPanel"
|
||||||
@@ -352,6 +414,24 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</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"
|
<ContentDialog x:Name="SettingsDialog"
|
||||||
Title="Settings"
|
Title="Settings"
|
||||||
PrimaryButtonText="Done"
|
PrimaryButtonText="Done"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Runtime.InteropServices.WindowsRuntime;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using AmiReel.Models;
|
using AmiReel.Models;
|
||||||
using AmiReel.Services;
|
using AmiReel.Services;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Media.Imaging;
|
||||||
using Windows.ApplicationModel.DataTransfer;
|
using Windows.ApplicationModel.DataTransfer;
|
||||||
using Windows.Storage;
|
using Windows.Storage;
|
||||||
using Windows.Storage.Pickers;
|
using Windows.Storage.Pickers;
|
||||||
@@ -27,6 +29,8 @@ public sealed partial class MainPage : Page
|
|||||||
private bool _keepRenderOutput;
|
private bool _keepRenderOutput;
|
||||||
private List<ShortWizardStep> _wizardSteps = [.. ShortWizard.StepsFor(ShortStyle.Brand)];
|
private List<ShortWizardStep> _wizardSteps = [.. ShortWizard.StepsFor(ShortStyle.Brand)];
|
||||||
private int _wizardIndex;
|
private int _wizardIndex;
|
||||||
|
private int _previewGeneration;
|
||||||
|
private string? _previewDirectory;
|
||||||
|
|
||||||
public MainPage()
|
public MainPage()
|
||||||
{
|
{
|
||||||
@@ -150,6 +154,8 @@ public sealed partial class MainPage : Page
|
|||||||
ShortSummaryText.Text = BuildShortSummary();
|
ShortSummaryText.Text = BuildShortSummary();
|
||||||
|
|
||||||
UpdateWizardNavigation();
|
UpdateWizardNavigation();
|
||||||
|
if (IsLoaded && step != ShortWizardStep.Clip)
|
||||||
|
_ = RefreshShortPreviewAsync(automatic: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateWizardNavigation()
|
private void UpdateWizardNavigation()
|
||||||
@@ -593,12 +599,126 @@ public sealed partial class MainPage : Page
|
|||||||
ShortFontBox.Text = file.Path;
|
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)
|
private async void PreviewShortSource_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
|
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
|
||||||
await OpenPreviewAsync(ShortInputBox.Text.Trim());
|
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)
|
private void SetRendering(bool rendering)
|
||||||
{
|
{
|
||||||
_isRendering = rendering;
|
_isRendering = rendering;
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ public enum ShortSourceCropMode
|
|||||||
Center,
|
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>
|
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short-fx-v4.sh).</summary>
|
||||||
public sealed record ShortSettings
|
public sealed record ShortSettings
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
-10
@@ -65,11 +65,7 @@ public sealed class ShortsPipeline
|
|||||||
websiteEsc: EscapeFfmpegPath(websiteFile),
|
websiteEsc: EscapeFfmpegPath(websiteFile),
|
||||||
hookEnd, infoStart, ctaStart);
|
hookEnd, infoStart, ctaStart);
|
||||||
|
|
||||||
List<string> args = ["-hide_banner", "-y", "-ss", settings.Start, "-t", F(duration), "-i", settings.InputFile];
|
List<string> args = BuildInputArguments(settings, duration);
|
||||||
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]);
|
|
||||||
|
|
||||||
args.AddRange([
|
args.AddRange([
|
||||||
"-filter_complex", filterComplex,
|
"-filter_complex", filterComplex,
|
||||||
@@ -107,6 +103,78 @@ public sealed class ShortsPipeline
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders two 9:16 stills with the same filtergraph as the final Short: opening hook and closing title card.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ShortPreviewFrames> RenderPreviewAsync(
|
||||||
|
ShortSettings settings, string outputDirectory, Action<string> log, CancellationToken token)
|
||||||
|
{
|
||||||
|
ValidatePreview(settings);
|
||||||
|
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
|
||||||
|
Directory.CreateDirectory(outputDirectory);
|
||||||
|
|
||||||
|
string workDir = Path.Combine(outputDirectory, "meta");
|
||||||
|
Directory.CreateDirectory(workDir);
|
||||||
|
|
||||||
|
string hookFile = WriteTextFile(workDir, "hook.txt", string.IsNullOrWhiteSpace(settings.Hook) ? "THIS RAN ON AN AMIGA" : settings.Hook);
|
||||||
|
string titleFile = WriteTextFile(workDir, "title.txt", string.IsNullOrWhiteSpace(settings.Title) ? "TITLE" : settings.Title);
|
||||||
|
string metaFile = WriteTextFile(workDir, "meta.txt", BuildMeta(settings));
|
||||||
|
string websiteFile = WriteTextFile(workDir, "website.txt", string.IsNullOrWhiteSpace(settings.Website) ? "AMIGADB.NET" : settings.Website);
|
||||||
|
|
||||||
|
log("Resolving preview look...");
|
||||||
|
string baseFilter = await ResolveBaseFilterAsync(tools, settings, log, token);
|
||||||
|
bool includeText = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
|
||||||
|
|
||||||
|
string openingPath = Path.Combine(outputDirectory, "opening.png");
|
||||||
|
string closingPath = Path.Combine(outputDirectory, "closing.png");
|
||||||
|
await RenderPreviewStillAsync(
|
||||||
|
tools, settings, baseFilter, includeText,
|
||||||
|
hookFile, titleFile, metaFile, websiteFile,
|
||||||
|
ShortPreviewShot.Opening, openingPath, log, token);
|
||||||
|
await RenderPreviewStillAsync(
|
||||||
|
tools, settings, baseFilter, includeText,
|
||||||
|
hookFile, titleFile, metaFile, websiteFile,
|
||||||
|
ShortPreviewShot.Closing, closingPath, log, token);
|
||||||
|
|
||||||
|
return new ShortPreviewFrames(openingPath, closingPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes one source frame (no style filters) so the crop editor can show the raw clip.</summary>
|
||||||
|
public async Task ExtractSourceFrameAsync(
|
||||||
|
string inputFile,
|
||||||
|
string start,
|
||||||
|
string ffmpegPath,
|
||||||
|
string ffprobePath,
|
||||||
|
string outputPath,
|
||||||
|
Action<string> log,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(inputFile) || !File.Exists(inputFile))
|
||||||
|
throw new FileNotFoundException("Select a valid source video for the Short.");
|
||||||
|
|
||||||
|
string? directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
ToolPaths tools = await ToolExtractor.ResolveAsync(ffmpegPath, ffprobePath, token);
|
||||||
|
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static List<string> BuildExtractFrameArguments(string inputFile, string start, string outputPath) =>
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-y",
|
||||||
|
"-ss",
|
||||||
|
string.IsNullOrWhiteSpace(start) ? "00:00:00" : start,
|
||||||
|
"-i",
|
||||||
|
inputFile,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-update",
|
||||||
|
"1",
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
|
||||||
/// <summary>Looks for a common bold system font to pre-fill the Shorts font field with.</summary>
|
/// <summary>Looks for a common bold system font to pre-fill the Shorts font field with.</summary>
|
||||||
public static string? FindDefaultFont()
|
public static string? FindDefaultFont()
|
||||||
{
|
{
|
||||||
@@ -122,16 +190,21 @@ public sealed class ShortsPipeline
|
|||||||
|
|
||||||
internal static void Validate(ShortSettings s)
|
internal static void Validate(ShortSettings s)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
|
ValidatePreview(s);
|
||||||
throw new FileNotFoundException("Select a valid source video for the Short.");
|
|
||||||
if (string.IsNullOrWhiteSpace(s.Title))
|
if (string.IsNullOrWhiteSpace(s.Title))
|
||||||
throw new ArgumentException("Enter a title.");
|
throw new ArgumentException("Enter a title.");
|
||||||
if (string.IsNullOrWhiteSpace(s.OutputPath))
|
if (string.IsNullOrWhiteSpace(s.OutputPath))
|
||||||
throw new ArgumentException("Select an output file.");
|
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))
|
if (string.IsNullOrWhiteSpace(s.FontFile) || !File.Exists(s.FontFile))
|
||||||
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
|
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)))
|
if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage)))
|
||||||
throw new FileNotFoundException("The brand style requires a background image.");
|
throw new FileNotFoundException("The brand style requires a background image.");
|
||||||
if (s.Style == ShortStyle.Video && (string.IsNullOrWhiteSpace(s.BackgroundVideo) || !File.Exists(s.BackgroundVideo)))
|
if (s.Style == ShortStyle.Video && (string.IsNullOrWhiteSpace(s.BackgroundVideo) || !File.Exists(s.BackgroundVideo)))
|
||||||
@@ -169,8 +242,11 @@ public sealed class ShortsPipeline
|
|||||||
|
|
||||||
internal static string BuildFilterComplex(
|
internal static string BuildFilterComplex(
|
||||||
string baseFilter, string fontEsc, string hookEsc, string titleEsc, string metaEsc, string websiteEsc,
|
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 hookEndStr = F(hookEnd);
|
||||||
string infoStartStr = F(infoStart);
|
string infoStartStr = F(infoStart);
|
||||||
string ctaStartStr = F(ctaStart);
|
string ctaStartStr = F(ctaStart);
|
||||||
@@ -186,8 +262,94 @@ public sealed class ShortsPipeline
|
|||||||
"fps=50,format=yuv420p[vout]";
|
"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 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)
|
internal static string ApplySourceFade(string baseFilter, double fadeInSeconds)
|
||||||
{
|
{
|
||||||
if (fadeInSeconds <= 0)
|
if (fadeInSeconds <= 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user