Add video preview, source crop, find-videos, and render toasts

Bring the Video tab closer to Shorts with 4K preview stills and crop
modes (none, auto, center, plus the visual editor). Settings store a
default source folder so Find videos can collect output.avi sequences.
Cap the FFmpeg log with auto-scroll, hide repeat spam, and show a
Windows notification when a video or short finishes or fails.
This commit is contained in:
2026-08-16 09:20:56 +02:00
parent e635b708a2
commit a590904e89
20 changed files with 1259 additions and 103 deletions
+49
View File
@@ -81,6 +81,55 @@ public class AppSettingsTests
Assert.AreEqual("3", normalized.FadeSeconds); Assert.AreEqual("3", normalized.FadeSeconds);
Assert.AreEqual("4", normalized.EndCardHoldSeconds); Assert.AreEqual("4", normalized.EndCardHoldSeconds);
Assert.AreEqual("10", normalized.ThumbnailInterval); Assert.AreEqual("10", normalized.ThumbnailInterval);
Assert.AreEqual("", normalized.SourceSearchFolder);
Assert.AreEqual("Auto", normalized.VideoSourceCropMode);
Assert.AreEqual("0", normalized.VideoCropLeft);
}
[TestMethod]
public void Normalize_WithBlankVideoSourceSettings_RestoresDefaults()
{
// Arrange
AppSettings settings = AppSettings.Default(@"C:\Videos") with
{
SourceSearchFolder = null!,
VideoSourceCropMode = "",
VideoCropLeft = " ",
VideoCropRight = "",
VideoCropTop = "",
VideoCropBottom = "",
};
// Act
AppSettings normalized = settings.Normalize(@"C:\Videos");
// Assert
Assert.AreEqual("", normalized.SourceSearchFolder);
Assert.AreEqual("Auto", normalized.VideoSourceCropMode);
Assert.AreEqual("0", normalized.VideoCropLeft);
Assert.AreEqual("0", normalized.VideoCropRight);
Assert.AreEqual("0", normalized.VideoCropTop);
Assert.AreEqual("0", normalized.VideoCropBottom);
}
[TestMethod]
public void Normalize_WithSourceSearchFolder_KeepsItUnchanged()
{
// Arrange
AppSettings settings = AppSettings.Default(@"C:\Videos") with
{
SourceSearchFolder = @"D:\AmigaIntrosVideos",
VideoSourceCropMode = "Center",
VideoCropLeft = "12",
};
// Act
AppSettings normalized = settings.Normalize(@"C:\Videos");
// Assert
Assert.AreEqual(@"D:\AmigaIntrosVideos", normalized.SourceSearchFolder);
Assert.AreEqual("Center", normalized.VideoSourceCropMode);
Assert.AreEqual("12", normalized.VideoCropLeft);
} }
[TestMethod] [TestMethod]
@@ -16,6 +16,8 @@ public class FfmpegOutputFilterTests
[DataRow("Stream #0:0: Video: mjpeg")] [DataRow("Stream #0:0: Video: mjpeg")]
[DataRow("Duration: 00:01:00.00, start: 0.000000, bitrate: 128 kb/s")] [DataRow("Duration: 00:01:00.00, start: 0.000000, bitrate: 128 kb/s")]
[DataRow("video:1024kB audio:128kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.5%")] [DataRow("video:1024kB audio:128kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.5%")]
[DataRow("Last message repeated 1 times")]
[DataRow("Last message repeated 24 times")]
public void IsBoilerplateLine_WithKnownFfmpegBanner_ReturnsTrue(string line) public void IsBoilerplateLine_WithKnownFfmpegBanner_ReturnsTrue(string line)
{ {
// Act // Act
@@ -0,0 +1,34 @@
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class ProcessRunnerTests
{
[TestMethod]
public void IsNoise_WithLastMessageRepeated_ReturnsTrue()
{
// Arrange
FfmpegOutputLine line = FfmpegProgressParser.Parse("Last message repeated 1 times");
// Act
bool result = ProcessRunner.IsNoise(line);
// Assert
Assert.IsTrue(result);
}
[TestMethod]
public void IsNoise_WithCropdetectSpec_ReturnsFalse()
{
// Arrange
FfmpegOutputLine line = FfmpegProgressParser.Parse("crop=672:ih:56:0");
// Act
bool result = ProcessRunner.IsNoise(line);
// Assert
Assert.IsFalse(result);
}
}
@@ -0,0 +1,67 @@
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class RenderNotifierTests
{
[TestMethod]
public void DescribeCompleted_ForVideo_UsesRenderCompleteCopy()
{
// Act
(string title, string body) = RenderNotifier.DescribeCompleted(shorts: false);
// Assert
Assert.AreEqual("Render complete", title);
StringAssert.Contains(body, "successfully");
}
[TestMethod]
public void DescribeCompleted_ForShorts_UsesShortCompleteCopy()
{
// Act
(string title, string body) = RenderNotifier.DescribeCompleted(shorts: true);
// Assert
Assert.AreEqual("Short complete", title);
StringAssert.Contains(body, "Short");
}
[TestMethod]
public void DescribeFailed_WithBlankSummary_UsesFallbackBody()
{
// Act
(string title, string body) = RenderNotifier.DescribeFailed(" ");
// Assert
Assert.AreEqual("Render failed", title);
Assert.AreEqual("The render failed.", body);
}
[TestMethod]
public void DescribeFailed_WithLongSummary_TruncatesBody()
{
// Arrange
string summary = new('x', 250);
// Act
(string title, string body) = RenderNotifier.DescribeFailed(summary);
// Assert
Assert.AreEqual("Render failed", title);
Assert.AreEqual(180, body.Length);
StringAssert.EndsWith(body, "...");
}
[TestMethod]
public void DescribeCancelled_ReturnsCancelledCopy()
{
// Act
(string title, string body) = RenderNotifier.DescribeCancelled();
// Assert
Assert.AreEqual("Render cancelled", title);
StringAssert.Contains(body, "cancelled");
}
}
@@ -127,6 +127,154 @@ public class RenderPipelineTests
RenderPipeline.Validate(settings); RenderPipeline.Validate(settings);
} }
[TestMethod]
public void ValidatePreview_WithMissingOutputName_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.avi");
File.WriteAllText(input, "data");
RenderSettings settings = ValidSettings([input], "") with { OutputName = "" };
// Act & Assert (no exception)
RenderPipeline.ValidatePreview(settings);
}
[TestMethod]
public void ValidatePreview_WithNoInputFiles_ThrowsArgumentException()
{
// Arrange
RenderSettings settings = ValidSettings([], @"C:\out");
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.ValidatePreview(settings));
}
[TestMethod]
public void BuildJoinVideoFilter_IncludesCropThenScaleAndPad()
{
// Arrange
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out");
// Act
string filter = RenderPipeline.BuildJoinVideoFilter(settings, "crop=iw-10-10:ih-4-4:10:4");
// Assert
StringAssert.StartsWith(filter, "crop=iw-10-10:ih-4-4:10:4,scale=2880:2160");
StringAssert.Contains(filter, "pad=3840:2160");
}
[TestMethod]
public void BuildSourceCropFilter_WithManualInsets_UsesEdgeCrop()
{
// Arrange
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with
{
CropLeft = 20,
CropRight = 10,
CropTop = 4,
CropBottom = 6,
};
// Act
string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0");
// Assert
Assert.AreEqual("crop=iw-20-10:ih-4-6:20:4", filter);
}
[TestMethod]
public void BuildSourceCropFilter_WithAutoDetection_UsesDetectedFilter()
{
// Arrange
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with
{
SourceCropMode = ShortSourceCropMode.Auto,
};
// Act
string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0");
// Assert
Assert.AreEqual("crop=800:ih:40:0", filter);
}
[TestMethod]
public void BuildSourceCropFilter_WithCenterMode_UsesFourByThreeCrop()
{
// Arrange
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with
{
SourceCropMode = ShortSourceCropMode.Center,
};
// Act
string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0");
// Assert
Assert.AreEqual(ShortsPipeline.DefaultSourceCropFilter, filter);
}
[TestMethod]
public void BuildSourceCropFilter_WithNoneMode_AppliesNoCrop()
{
// Arrange
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with
{
SourceCropMode = ShortSourceCropMode.None,
};
// Act
string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0");
// Assert
Assert.AreEqual("", filter);
}
[TestMethod]
public void BuildJoinVideoFilter_WithNoCrop_StartsAtScale()
{
// Arrange
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out");
// Act
string filter = RenderPipeline.BuildJoinVideoFilter(settings, "");
// Assert
StringAssert.StartsWith(filter, "scale=2880:2160");
Assert.IsFalse(filter.Contains("crop=", StringComparison.Ordinal));
}
[TestMethod]
public void PreviewSeekSeconds_WithDurationAfterTrim_StaysInsideRemainingClip()
{
// Act
double seek = RenderPipeline.PreviewSeekSeconds(4, 30);
// Assert
Assert.AreEqual(5.2, seek, 0.001);
}
[TestMethod]
public void PreviewSeekSeconds_WhenTrimIsPastDuration_ReturnsTrimStart()
{
// Act
double seek = RenderPipeline.PreviewSeekSeconds(40, 10);
// Assert
Assert.AreEqual(40, seek, 0.001);
}
[TestMethod]
public void FormatTimestamp_FormatsInvariantHoursMinutesSeconds()
{
// Act
string stamp = RenderPipeline.FormatTimestamp(4.414);
// Assert
Assert.AreEqual("00:00:04.414", stamp);
}
[TestMethod] [TestMethod]
public void BuildProgressMessage_WithOnlyTime_ReturnsTimeRangeOnly() public void BuildProgressMessage_WithOnlyTime_ReturnsTimeRangeOnly()
{ {
@@ -380,6 +380,53 @@ public class ShortsPipelineTests
Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter); Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter);
} }
[TestMethod]
public void SourceCropFiltersResolve_WithManualInsets_IgnoresAutoDetection()
{
// Act
string filter = SourceCropFilters.Resolve(ShortSourceCropMode.Auto, 8, 4, 2, 1, "crop=800:ih:40:0");
// Assert
Assert.AreEqual("crop=iw-8-4:ih-2-1:8:2", filter);
}
[TestMethod]
public void SourceCropFiltersResolve_WithNoneMode_ReturnsEmptyFilter()
{
// Act
string filter = SourceCropFilters.Resolve(ShortSourceCropMode.None, 0, 0, 0, 0, "crop=800:ih:40:0");
// Assert
Assert.AreEqual("", filter);
}
[TestMethod]
public void BuildVideoBaseFilter_WithNoCrop_ScalesTheFullSource()
{
// Act
string filter = ShortsPipeline.BuildVideoBaseFilter("", "scale=1000:-2:flags=lanczos", blur: 0, opacity: 1);
// Assert
StringAssert.Contains(filter, "[0:v]scale=1000:-2:flags=lanczos,setsar=1[fg]");
Assert.IsFalse(filter.Contains("[0:v]crop=", StringComparison.Ordinal));
}
[TestMethod]
public void TryBuildAutoHorizontalFilter_UsesMostCommonSpecAndEvenInset()
{
// Arrange
const string output =
"crop=800:480:100:0\n" +
"crop=800:480:100:0\n" +
"crop=760:480:140:0";
// Act
string? filter = SourceCropFilters.TryBuildAutoHorizontalFilter(output);
// Assert
Assert.AreEqual("crop=792:ih:104:0", filter);
}
[TestMethod] [TestMethod]
public void SourceTargetWidth_SubtractsBothMargins() public void SourceTargetWidth_SubtractsBothMargins()
{ {
@@ -0,0 +1,99 @@
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class SourceRecordingFinderTests
{
private readonly List<string> _tempDirectories = [];
[TestCleanup]
public void Cleanup()
{
foreach (string directory in _tempDirectories)
{
try { Directory.Delete(directory, true); } catch { }
}
}
private string CreateTempDirectory()
{
string directory = Path.Combine(Path.GetTempPath(), "AmiReelTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
_tempDirectories.Add(directory);
return directory;
}
[TestMethod]
public void FindSequentialOutputs_WithMissingFolder_ReturnsEmpty()
{
// Act
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(
Path.Combine(Path.GetTempPath(), "AmiReelTests", "missing-" + Guid.NewGuid().ToString("N")));
// Assert
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void FindSequentialOutputs_WithBlankFolder_ReturnsEmpty()
{
// Act
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(" ");
// Assert
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void FindSequentialOutputs_WithOnlyOutputAvi_ReturnsSingleFile()
{
// Arrange
string directory = CreateTempDirectory();
string first = Path.Combine(directory, "output.avi");
File.WriteAllText(first, "one");
// Act
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(directory);
// Assert
Assert.AreEqual(1, result.Count);
Assert.AreEqual(first, result[0]);
}
[TestMethod]
public void FindSequentialOutputs_WithNumberedSequence_ReturnsFilesUntilFirstGap()
{
// Arrange
string directory = CreateTempDirectory();
File.WriteAllText(Path.Combine(directory, "output.avi"), "0");
File.WriteAllText(Path.Combine(directory, "output_1.avi"), "1");
File.WriteAllText(Path.Combine(directory, "output_2.avi"), "2");
File.WriteAllText(Path.Combine(directory, "output_4.avi"), "skipped");
File.WriteAllText(Path.Combine(directory, "output.mp4"), "wrong-ext");
// Act
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(directory);
// Assert
Assert.AreEqual(3, result.Count);
Assert.AreEqual("output.avi", Path.GetFileName(result[0]));
Assert.AreEqual("output_1.avi", Path.GetFileName(result[1]));
Assert.AreEqual("output_2.avi", Path.GetFileName(result[2]));
}
[TestMethod]
public void FindSequentialOutputs_WithoutBaseOutputAvi_ReturnsEmptyEvenIfNumberedExists()
{
// Arrange
string directory = CreateTempDirectory();
File.WriteAllText(Path.Combine(directory, "output_1.avi"), "1");
// Act
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(directory);
// Assert
Assert.AreEqual(0, result.Count);
}
}
+3 -1
View File
@@ -1,4 +1,5 @@
using Microsoft.UI.Xaml; using AmiReel.Services;
using Microsoft.UI.Xaml;
using WinRT.Interop; using WinRT.Interop;
namespace AmiReel; namespace AmiReel;
@@ -29,6 +30,7 @@ public partial class App : Application
{ {
_window = new MainWindow(); _window = new MainWindow();
MainWindowHandle = WindowNative.GetWindowHandle(_window); MainWindowHandle = WindowNative.GetWindowHandle(_window);
RenderNotifier.Register();
_window.Activate(); _window.Activate();
} }
} }
+124 -10
View File
@@ -34,14 +34,22 @@
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Border Grid.Column="0" Padding="18" Style="{StaticResource CardBorderStyle}"> <Border Grid.Column="0" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="Source recordings" Style="{StaticResource SectionTitleStyle}" /> <TextBlock Text="Source recordings" Style="{StaticResource SectionTitleStyle}" />
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Add video files..." Click="AddInputs_Click" /> <Button Content="Add video files..." Click="AddInputs_Click"
<Button Content="Clear" Click="ClearInputs_Click" /> AutomationProperties.Name="Add video files" />
<Button x:Name="FindVideosButton"
Content="Find videos"
Click="FindVideos_Click"
AutomationProperties.Name="Find videos in the default source folder"
AutomationProperties.AutomationId="FindVideosButton" />
<Button Content="Clear" Click="ClearInputs_Click"
AutomationProperties.Name="Clear source recordings" />
</StackPanel> </StackPanel>
<ListView x:Name="InputList" <ListView x:Name="InputList"
Height="130" Height="130"
@@ -50,12 +58,47 @@
AllowDrop="True" AllowDrop="True"
DragOver="InputList_DragOver" DragOver="InputList_DragOver"
Drop="InputList_Drop" Drop="InputList_Drop"
AutomationProperties.Name="Source recordings"
ToolTipService.ToolTip="Drag and drop video files here to add them" /> ToolTipService.ToolTip="Drag and drop video files here to add them" />
<Button x:Name="PreviewSourceButton" <Button x:Name="PreviewSourceButton"
Content="Preview selected" Content="Preview selected"
Click="PreviewSource_Click" Click="PreviewSource_Click"
HorizontalAlignment="Left" HorizontalAlignment="Left"
IsEnabled="False" /> IsEnabled="False"
AutomationProperties.Name="Preview selected source" />
<TextBlock Text="Source crop" Style="{StaticResource SectionTitleStyle}" />
<ComboBox x:Name="VideoCropModeBox" Header="Source crop" HorizontalAlignment="Stretch"
AutomationProperties.Name="Source crop mode"
AutomationProperties.AutomationId="VideoCropModeBox">
<ComboBoxItem Content="None (keep full frame)" Tag="None" />
<ComboBoxItem Content="Auto (remove left/right black borders)" Tag="Auto" />
<ComboBoxItem Content="Center 4:3" Tag="Center" />
</ComboBox>
<TextBlock Text="Manual crop overrides the mode above. Leave at 0 to use the mode, 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="VideoCropLeftBox" Grid.Column="0" Header="Crop left" Text="0"
AutomationProperties.Name="Crop left pixels" />
<TextBox x:Name="VideoCropRightBox" Grid.Column="1" Header="Crop right" Text="0"
AutomationProperties.Name="Crop right pixels" />
<TextBox x:Name="VideoCropTopBox" Grid.Column="2" Header="Crop top" Text="0"
AutomationProperties.Name="Crop top pixels" />
<TextBox x:Name="VideoCropBottomBox" Grid.Column="3" Header="Crop bottom" Text="0"
AutomationProperties.Name="Crop bottom pixels" />
</Grid>
<Button x:Name="OpenVideoCropEditorButton"
Content="Crop visually..."
Click="OpenVideoCropEditor_Click"
HorizontalAlignment="Left"
AutomationProperties.Name="Open visual crop editor"
AutomationProperties.AutomationId="OpenVideoCropEditorButton" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -75,16 +118,64 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBox x:Name="EndCardBox" Grid.Row="0" MinHeight="40" /> <TextBox x:Name="EndCardBox" Grid.Row="0" MinHeight="40" Header="End card"
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseEndCard_Click" VerticalAlignment="Stretch" MinWidth="120" /> AutomationProperties.Name="End card image" />
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseEndCard_Click" VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse end card" />
<TextBox x:Name="OutputFolderBox" Grid.Row="1" MinHeight="40" /> <TextBox x:Name="OutputFolderBox" Grid.Row="1" MinHeight="40" Header="Output folder"
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseOutput_Click" VerticalAlignment="Stretch" MinWidth="120" /> AutomationProperties.Name="Output folder" />
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseOutput_Click" VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse output folder" />
<TextBox x:Name="OutputNameBox" Grid.Row="2" Grid.ColumnSpan="2" MinHeight="40" /> <TextBox x:Name="OutputNameBox" Grid.Row="2" Grid.ColumnSpan="2" MinHeight="40" Header="Output name"
AutomationProperties.Name="Output name" />
</Grid> </Grid>
</StackPanel> </StackPanel>
</Border> </Border>
<StackPanel Grid.Column="2" Width="320" Spacing="10">
<TextBlock Text="Preview" Style="{StaticResource SectionTitleStyle}" />
<TextBlock x:Name="VideoPreviewStatusText"
Text="Add source recordings, then update the preview to see the 4K look."
Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" />
<Button x:Name="UpdateVideoPreviewButton"
Content="Update preview"
Click="UpdateVideoPreview_Click"
HorizontalAlignment="Stretch"
AutomationProperties.Name="Update video preview"
AutomationProperties.AutomationId="UpdateVideoPreviewButton" />
<ProgressBar x:Name="VideoPreviewProgress"
IsIndeterminate="True"
Height="6"
Visibility="Collapsed"
AutomationProperties.Name="Video preview progress" />
<StackPanel Spacing="6">
<TextBlock Text="Opening" Style="{StaticResource MutedTextStyle}" />
<Border Width="292" Height="164"
Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Image x:Name="VideoPreviewOpeningImage"
Stretch="Uniform"
AutomationProperties.Name="Opening frame preview" />
</Border>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Closing" Style="{StaticResource MutedTextStyle}" />
<Border Width="292" Height="164"
Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Image x:Name="VideoPreviewClosingImage"
Stretch="Uniform"
AutomationProperties.Name="End-card preview" />
</Border>
</StackPanel>
</StackPanel>
</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">
@@ -210,10 +301,11 @@
</Grid> </Grid>
<ComboBox x:Name="ShortCropModeBox" Header="Source crop" HorizontalAlignment="Stretch" <ComboBox x:Name="ShortCropModeBox" Header="Source crop" HorizontalAlignment="Stretch"
AutomationProperties.Name="Source crop mode"> AutomationProperties.Name="Source crop mode">
<ComboBoxItem Content="None (keep full frame)" Tag="None" />
<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, or drag the kept region on a source frame." <TextBlock Text="Manual crop overrides the mode above. Leave at 0 to use the mode, 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>
@@ -382,7 +474,10 @@
FontFamily="Consolas" FontFamily="Consolas"
FontSize="12" FontSize="12"
VerticalAlignment="Stretch" VerticalAlignment="Stretch"
MinHeight="320" /> MinHeight="160"
MaxHeight="280"
AutomationProperties.Name="FFmpeg log text"
AutomationProperties.AutomationId="FfmpegLogBox" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -497,6 +592,25 @@
<TextBlock Text="After a successful render, original recordings are moved into an originals subfolder of the output folder. Uncheck to leave them where they are." <TextBlock Text="After a successful render, original recordings are moved into an originals subfolder of the output folder. Uncheck to leave them where they are."
TextWrapping="WrapWholeWords" TextWrapping="WrapWholeWords"
Style="{StaticResource MutedTextStyle}" /> Style="{StaticResource MutedTextStyle}" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="SourceSearchFolderBox"
Grid.Column="0"
Header="Default source folder"
AutomationProperties.Name="Default folder for source recordings"
AutomationProperties.AutomationId="SourceSearchFolderBox" />
<Button Grid.Column="1"
Content="Browse..."
Click="BrowseSourceSearchFolder_Click"
VerticalAlignment="Bottom"
AutomationProperties.Name="Browse default source folder" />
</Grid>
<TextBlock Text="Find videos on the Video tab looks here for output.avi, then output_1.avi, output_2.avi, and so on until a name is missing."
TextWrapping="WrapWholeWords"
Style="{StaticResource MutedTextStyle}" />
</StackPanel> </StackPanel>
</Border> </Border>
+239 -6
View File
@@ -7,6 +7,7 @@ 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;
using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer; using Windows.ApplicationModel.DataTransfer;
using Windows.Storage; using Windows.Storage;
@@ -31,6 +32,7 @@ public sealed partial class MainPage : Page
private int _wizardIndex; private int _wizardIndex;
private int _previewGeneration; private int _previewGeneration;
private string? _previewDirectory; private string? _previewDirectory;
private string? _videoPreviewDirectory;
public MainPage() public MainPage()
{ {
@@ -51,6 +53,12 @@ public sealed partial class MainPage : Page
SelectTheme(_loadedSettings.Theme); SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme); ApplyTheme(_loadedSettings.Theme);
MoveSourcesBox.IsChecked = _loadedSettings.ShouldMoveSourcesToOriginals; MoveSourcesBox.IsChecked = _loadedSettings.ShouldMoveSourcesToOriginals;
SourceSearchFolderBox.Text = _loadedSettings.SourceSearchFolder;
VideoCropLeftBox.Text = _loadedSettings.VideoCropLeft;
VideoCropRightBox.Text = _loadedSettings.VideoCropRight;
VideoCropTopBox.Text = _loadedSettings.VideoCropTop;
VideoCropBottomBox.Text = _loadedSettings.VideoCropBottom;
SelectComboTag(VideoCropModeBox, _loadedSettings.VideoSourceCropMode);
ShortHookBox.Text = _loadedSettings.ShortsHook; ShortHookBox.Text = _loadedSettings.ShortsHook;
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite; ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
@@ -227,6 +235,48 @@ public sealed partial class MainPage : Page
private void ClearInputs_Click(object sender, RoutedEventArgs e) => _inputs.Clear(); private void ClearInputs_Click(object sender, RoutedEventArgs e) => _inputs.Clear();
private async void FindVideos_Click(object sender, RoutedEventArgs e)
{
string folder = SourceSearchFolderBox.Text.Trim();
if (string.IsNullOrWhiteSpace(folder))
{
await ShowMessageAsync("Find videos", "Set a default source folder in Settings first.");
return;
}
if (!Directory.Exists(folder))
{
await ShowMessageAsync("Find videos", "The default source folder was not found.");
return;
}
IReadOnlyList<string> found = SourceRecordingFinder.FindSequentialOutputs(folder);
if (found.Count == 0)
{
await ShowMessageAsync("Find videos", "No output.avi sequence was found in the default source folder.");
return;
}
int added = 0;
foreach (string path in found)
{
if (_inputs.Contains(path, StringComparer.OrdinalIgnoreCase))
continue;
_inputs.Add(path);
added++;
}
if (added == 0)
{
await ShowMessageAsync("Find videos", "Those recordings are already in the source list.");
return;
}
if (InputList.SelectedItem is null && _inputs.Count > 0)
InputList.SelectedIndex = 0;
}
private void InputList_DragOver(object sender, DragEventArgs e) private void InputList_DragOver(object sender, DragEventArgs e)
{ {
e.AcceptedOperation = e.DataView.Contains(StandardDataFormats.StorageItems) e.AcceptedOperation = e.DataView.Contains(StandardDataFormats.StorageItems)
@@ -275,6 +325,14 @@ public sealed partial class MainPage : Page
OutputFolderBox.Text = folder.Path; OutputFolderBox.Text = folder.Path;
} }
private async void BrowseSourceSearchFolder_Click(object sender, RoutedEventArgs e)
{
FolderPicker picker = CreateFolderPicker();
var folder = await picker.PickSingleFolderAsync();
if (folder is not null)
SourceSearchFolderBox.Text = folder.Path;
}
private async void BrowseFfmpeg_Click(object sender, RoutedEventArgs e) private async void BrowseFfmpeg_Click(object sender, RoutedEventArgs e)
{ {
FileOpenPicker picker = CreateFileOpenPicker(); FileOpenPicker picker = CreateFileOpenPicker();
@@ -327,13 +385,16 @@ public sealed partial class MainPage : Page
{ {
_keepRenderOutput = true; _keepRenderOutput = true;
UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled.")); UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled."));
RenderNotifier.ShowCancelled();
} }
catch (Exception exception) catch (Exception exception)
{ {
_keepRenderOutput = true; _keepRenderOutput = true;
AppendLog("ERROR: " + exception); AppendLog("ERROR: " + exception);
StageText.Text = "Failed"; StageText.Text = "Failed";
await ShowMessageAsync("Render failed", UserFacingErrors.Summarize(exception)); string summary = UserFacingErrors.Summarize(exception);
RenderNotifier.ShowFailed(summary);
await ShowMessageAsync("Render failed", summary);
} }
finally finally
{ {
@@ -353,6 +414,7 @@ public sealed partial class MainPage : Page
_lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4"); _lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
OpenOutputButton.IsEnabled = true; OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile); PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
RenderNotifier.ShowCompleted(shorts: false);
await ShowMessageAsync("Render complete", "The AmiReel render completed successfully."); await ShowMessageAsync("Render complete", "The AmiReel render completed successfully.");
} }
@@ -366,6 +428,7 @@ public sealed partial class MainPage : Page
ShortOutputBox.Text = settings.OutputPath; ShortOutputBox.Text = settings.OutputPath;
OpenOutputButton.IsEnabled = true; OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile); PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
RenderNotifier.ShowCompleted(shorts: true);
await ShowMessageAsync("Short complete", "The YouTube Short was rendered successfully."); await ShowMessageAsync("Short complete", "The YouTube Short was rendered successfully.");
} }
@@ -432,6 +495,27 @@ public sealed partial class MainPage : Page
EndCardHoldSeconds = Number(HoldBox.Text, "end-card hold"), EndCardHoldSeconds = Number(HoldBox.Text, "end-card hold"),
ThumbnailInterval = interval, ThumbnailInterval = interval,
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true, MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
SourceCropMode = SelectedVideoCropMode(),
CropLeft = ParseNonNegativeInt(VideoCropLeftBox.Text, "Crop left"),
CropRight = ParseNonNegativeInt(VideoCropRightBox.Text, "Crop right"),
CropTop = ParseNonNegativeInt(VideoCropTopBox.Text, "Crop top"),
CropBottom = ParseNonNegativeInt(VideoCropBottomBox.Text, "Crop bottom"),
};
}
private RenderSettings ReadVideoPreviewSettings()
{
IReadOnlyList<string> inputs = InputList.SelectedItem is string selected
? [selected]
: _inputs.ToList();
return ReadSettings() with
{
InputFiles = inputs.ToList(),
OutputDirectory = string.IsNullOrWhiteSpace(OutputFolderBox.Text)
? Path.GetTempPath()
: OutputFolderBox.Text.Trim(),
OutputName = string.IsNullOrWhiteSpace(OutputNameBox.Text) ? "preview" : OutputNameBox.Text.Trim(),
}; };
} }
@@ -599,6 +683,49 @@ public sealed partial class MainPage : Page
ShortFontBox.Text = file.Path; ShortFontBox.Text = file.Path;
} }
private async void OpenVideoCropEditor_Click(object sender, RoutedEventArgs e)
{
string? input = InputList.SelectedItem as string ?? _inputs.FirstOrDefault();
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;
double duration = 30;
double trimStart = 0;
try { trimStart = ParseNonNegativeNumber(TrimBox.Text, "trim start"); } catch (ArgumentException) { }
await ShortCropEditor.LoadVideoFrameAsync(
input,
RenderPipeline.FormatTimestamp(trimStart),
duration,
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
CancellationToken.None);
ShortCropEditor.SetInsets(ReadVideoCropInsets());
ContentDialogResult result = await CropEditorDialog.ShowAsync();
if (result != ContentDialogResult.Primary)
return;
PixelInsets insets = ShortCropEditor.Insets;
VideoCropLeftBox.Text = insets.Left.ToString(CultureInfo.InvariantCulture);
VideoCropRightBox.Text = insets.Right.ToString(CultureInfo.InvariantCulture);
VideoCropTopBox.Text = insets.Top.ToString(CultureInfo.InvariantCulture);
VideoCropBottomBox.Text = insets.Bottom.ToString(CultureInfo.InvariantCulture);
_ = RefreshVideoPreviewAsync(automatic: true);
}
catch (Exception exception)
{
await ShowMessageAsync("Crop source", UserFacingErrors.Summarize(exception));
}
}
private async void OpenCropEditor_Click(object sender, RoutedEventArgs e) private async void OpenCropEditor_Click(object sender, RoutedEventArgs e)
{ {
string input = ShortInputBox.Text.Trim(); string input = ShortInputBox.Text.Trim();
@@ -656,6 +783,22 @@ public sealed partial class MainPage : Page
ReadBox(ShortCropBottomBox.Text)); ReadBox(ShortCropBottomBox.Text));
} }
private PixelInsets ReadVideoCropInsets()
{
static int ReadBox(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0
? value
: 0;
}
return new PixelInsets(
ReadBox(VideoCropLeftBox.Text),
ReadBox(VideoCropRightBox.Text),
ReadBox(VideoCropTopBox.Text),
ReadBox(VideoCropBottomBox.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))
@@ -665,6 +808,56 @@ public sealed partial class MainPage : Page
private async void UpdateShortPreview_Click(object sender, RoutedEventArgs e) => private async void UpdateShortPreview_Click(object sender, RoutedEventArgs e) =>
await RefreshShortPreviewAsync(automatic: false); await RefreshShortPreviewAsync(automatic: false);
private async void UpdateVideoPreview_Click(object sender, RoutedEventArgs e) =>
await RefreshVideoPreviewAsync(automatic: false);
private async Task RefreshVideoPreviewAsync(bool automatic)
{
if (_isRendering)
return;
int generation = ++_previewGeneration;
VideoPreviewProgress.Visibility = Visibility.Visible;
UpdateVideoPreviewButton.IsEnabled = false;
VideoPreviewStatusText.Text = "Rendering 16:9 preview frames...";
try
{
RenderSettings settings = ReadVideoPreviewSettings();
RenderPipeline.ValidatePreview(settings);
_videoPreviewDirectory ??= Path.Combine(Path.GetTempPath(), "AmiReel", "video-preview", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_videoPreviewDirectory);
VideoPreviewFrames frames = await new RenderPipeline().RenderPreviewAsync(
settings, _videoPreviewDirectory, _ => { }, CancellationToken.None);
if (generation != _previewGeneration)
return;
VideoPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath, 584);
VideoPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath, 584);
VideoPreviewStatusText.Text = "4K look · cropped source and end card";
}
catch (Exception exception)
{
if (generation != _previewGeneration)
return;
string message = UserFacingErrors.Summarize(exception);
VideoPreviewStatusText.Text = message;
if (!automatic)
await ShowMessageAsync("Preview failed", message);
}
finally
{
if (generation == _previewGeneration)
{
VideoPreviewProgress.Visibility = Visibility.Collapsed;
UpdateVideoPreviewButton.IsEnabled = true;
}
}
}
private async Task RefreshShortPreviewAsync(bool automatic) private async Task RefreshShortPreviewAsync(bool automatic)
{ {
if (_isRendering) if (_isRendering)
@@ -688,8 +881,8 @@ public sealed partial class MainPage : Page
if (generation != _previewGeneration) if (generation != _previewGeneration)
return; return;
ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath); ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath, 296);
ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath); ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath, 296);
bool hasFont = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile); bool hasFont = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
ShortPreviewStatusText.Text = hasFont ShortPreviewStatusText.Text = hasFont
? $"{settings.Style} · opening hook and closing title card" ? $"{settings.Style} · opening hook and closing title card"
@@ -715,10 +908,10 @@ public sealed partial class MainPage : Page
} }
} }
private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path) private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path, int decodePixelWidth)
{ {
await using FileStream stream = File.OpenRead(path); await using FileStream stream = File.OpenRead(path);
BitmapImage bitmap = new() { DecodePixelWidth = 296 }; BitmapImage bitmap = new() { DecodePixelWidth = decodePixelWidth };
await bitmap.SetSourceAsync(stream.AsRandomAccessStream()); await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
return bitmap; return bitmap;
} }
@@ -814,10 +1007,38 @@ public sealed partial class MainPage : Page
return; return;
LogBox.Text += chunk; LogBox.Text += chunk;
LogBox.Select(LogBox.Text.Length, 0); ScrollLogToEnd();
}); });
} }
private void ScrollLogToEnd()
{
LogBox.SelectionStart = LogBox.Text.Length;
LogBox.SelectionLength = 0;
if (FindDescendantScrollViewer(LogBox) is not ScrollViewer viewer)
return;
viewer.UpdateLayout();
double offset = Math.Max(0, viewer.ExtentHeight - viewer.ViewportHeight);
viewer.ChangeView(null, offset, null, disableAnimation: true);
}
private static ScrollViewer? FindDescendantScrollViewer(DependencyObject root)
{
if (root is ScrollViewer viewer)
return viewer;
int count = VisualTreeHelper.GetChildrenCount(root);
for (int index = 0; index < count; index++)
{
ScrollViewer? match = FindDescendantScrollViewer(VisualTreeHelper.GetChild(root, index));
if (match is not null)
return match;
}
return null;
}
private void SaveSettings() private void SaveSettings()
{ {
AppSettingsStore.Save(new AppSettings AppSettingsStore.Save(new AppSettings
@@ -834,6 +1055,12 @@ public sealed partial class MainPage : Page
EndCardHoldSeconds = HoldBox.Text.Trim(), EndCardHoldSeconds = HoldBox.Text.Trim(),
ThumbnailInterval = IntervalBox.Text.Trim(), ThumbnailInterval = IntervalBox.Text.Trim(),
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true, MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
SourceSearchFolder = SourceSearchFolderBox.Text.Trim(),
VideoSourceCropMode = SelectedVideoCropMode().ToString(),
VideoCropLeft = VideoCropLeftBox.Text.Trim(),
VideoCropRight = VideoCropRightBox.Text.Trim(),
VideoCropTop = VideoCropTopBox.Text.Trim(),
VideoCropBottom = VideoCropBottomBox.Text.Trim(),
ShortsStyle = SelectedShortStyle(), ShortsStyle = SelectedShortStyle(),
ShortsHook = ShortHookBox.Text.Trim(), ShortsHook = ShortHookBox.Text.Trim(),
ShortsWebsite = ShortWebsiteBox.Text.Trim(), ShortsWebsite = ShortWebsiteBox.Text.Trim(),
@@ -869,6 +1096,12 @@ public sealed partial class MainPage : Page
return Enum.TryParse(tag, ignoreCase: true, out ShortSourceCropMode mode) ? mode : ShortSourceCropMode.Auto; return Enum.TryParse(tag, ignoreCase: true, out ShortSourceCropMode mode) ? mode : ShortSourceCropMode.Auto;
} }
private ShortSourceCropMode SelectedVideoCropMode()
{
string tag = (VideoCropModeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto";
return Enum.TryParse(tag, ignoreCase: true, out ShortSourceCropMode mode) ? mode : ShortSourceCropMode.Auto;
}
private string SelectedShortPreset() => (ShortPresetBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "slow"; private string SelectedShortPreset() => (ShortPresetBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "slow";
private void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme); private void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme);
+18
View File
@@ -21,6 +21,12 @@ public sealed record AppSettings
public required string EndCardHoldSeconds { get; init; } public required string EndCardHoldSeconds { get; init; }
public required string ThumbnailInterval { get; init; } public required string ThumbnailInterval { get; init; }
public bool? MoveSourcesToOriginals { get; init; } public bool? MoveSourcesToOriginals { get; init; }
public string SourceSearchFolder { get; init; } = "";
public string VideoSourceCropMode { get; init; } = "Auto";
public string VideoCropLeft { get; init; } = "0";
public string VideoCropRight { get; init; } = "0";
public string VideoCropTop { get; init; } = "0";
public string VideoCropBottom { get; init; } = "0";
// Shorts tab defaults (sticky across launches, same as the fields above). // Shorts tab defaults (sticky across launches, same as the fields above).
public string ShortsStyle { get; init; } = "Brand"; public string ShortsStyle { get; init; } = "Brand";
@@ -57,6 +63,12 @@ public sealed record AppSettings
EndCardHoldSeconds = "4", EndCardHoldSeconds = "4",
ThumbnailInterval = "10", ThumbnailInterval = "10",
MoveSourcesToOriginals = true, MoveSourcesToOriginals = true,
SourceSearchFolder = "",
VideoSourceCropMode = "Auto",
VideoCropLeft = "0",
VideoCropRight = "0",
VideoCropTop = "0",
VideoCropBottom = "0",
}; };
public AppSettings Normalize(string fallbackOutputFolder) => this with public AppSettings Normalize(string fallbackOutputFolder) => this with
@@ -73,6 +85,12 @@ public sealed record AppSettings
EndCardHoldSeconds = string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds, EndCardHoldSeconds = string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds,
ThumbnailInterval = string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval, ThumbnailInterval = string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval,
MoveSourcesToOriginals = MoveSourcesToOriginals ?? true, MoveSourcesToOriginals = MoveSourcesToOriginals ?? true,
SourceSearchFolder = SourceSearchFolder ?? "",
VideoSourceCropMode = string.IsNullOrWhiteSpace(VideoSourceCropMode) ? "Auto" : VideoSourceCropMode,
VideoCropLeft = string.IsNullOrWhiteSpace(VideoCropLeft) ? "0" : VideoCropLeft,
VideoCropRight = string.IsNullOrWhiteSpace(VideoCropRight) ? "0" : VideoCropRight,
VideoCropTop = string.IsNullOrWhiteSpace(VideoCropTop) ? "0" : VideoCropTop,
VideoCropBottom = string.IsNullOrWhiteSpace(VideoCropBottom) ? "0" : VideoCropBottom,
ShortsStyle = string.IsNullOrWhiteSpace(ShortsStyle) ? "Brand" : ShortsStyle, ShortsStyle = string.IsNullOrWhiteSpace(ShortsStyle) ? "Brand" : ShortsStyle,
ShortsHook = ShortsHook ?? "THIS RAN ON AN AMIGA", ShortsHook = ShortsHook ?? "THIS RAN ON AN AMIGA",
ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET", ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET",
+15
View File
@@ -29,6 +29,11 @@ public sealed record RenderSettings
public required double EndCardHoldSeconds { get; init; } public required double EndCardHoldSeconds { get; init; }
public required int ThumbnailInterval { get; init; } public required int ThumbnailInterval { get; init; }
public bool MoveSourcesToOriginals { get; init; } = true; public bool MoveSourcesToOriginals { get; init; } = true;
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 Width { get; init; } = 3840; public int Width { get; init; } = 3840;
public int Height { get; init; } = 2160; public int Height { get; init; } = 2160;
public int FramesPerSecond { get; init; } = 50; public int FramesPerSecond { get; init; } = 50;
@@ -37,3 +42,13 @@ public sealed record RenderSettings
} }
public sealed record RenderProgress(double Percent, string Stage, string Message); public sealed record RenderProgress(double Percent, string Stage, string Message);
/// <summary>Which moment of a long-form video a preview still represents.</summary>
public enum VideoPreviewShot
{
Opening,
Closing,
}
/// <summary>PNG stills of the cropped 4K source look and the end card.</summary>
public sealed record VideoPreviewFrames(string OpeningPath, string ClosingPath);
+2 -1
View File
@@ -25,9 +25,10 @@ public enum ShortStyle
Video, Video,
} }
/// <summary>How the video style crops the Amiga source before overlaying it.</summary> /// <summary>How the Amiga source is cropped before it is scaled into the output.</summary>
public enum ShortSourceCropMode public enum ShortSourceCropMode
{ {
None,
Auto, Auto,
Center, Center,
} }
+15 -1
View File
@@ -5,7 +5,9 @@
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap"> xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
IgnorableNamespaces="uap rescap com desktop">
<Identity <Identity
Name="F004AE73-5989-46F3-B913-E9A3A355713F" Name="F004AE73-5989-46F3-B913-E9A3A355713F"
@@ -42,6 +44,18 @@
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" /> <uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" BackgroundColor="#0E1525" /> <uap:SplashScreen Image="Assets\SplashScreen.png" BackgroundColor="#0E1525" />
</uap:VisualElements> </uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.toastNotificationActivation">
<desktop:ToastNotificationActivation ToastActivatorCLSID="F004AE73-5989-46F3-B913-E9A3A3557140" />
</desktop:Extension>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="AmiReel.exe" DisplayName="AmiReel" Arguments="----AppNotificationActivated:">
<com:Class Id="F004AE73-5989-46F3-B913-E9A3A3557140" />
</com:ExeServer>
</com:ComServer>
</com:Extension>
</Extensions>
</Application> </Application>
</Applications> </Applications>
+1
View File
@@ -33,6 +33,7 @@ public static class FfmpegOutputFilter
"other streams:", "other streams:",
"global headers:", "global headers:",
"muxing overhead:", "muxing overhead:",
"Last message repeated",
]; ];
public static bool IsBoilerplateLine(string line) => public static bool IsBoilerplateLine(string line) =>
+66
View File
@@ -0,0 +1,66 @@
using Microsoft.Windows.AppNotifications;
using Microsoft.Windows.AppNotifications.Builder;
namespace AmiReel.Services;
/// <summary>Shows a Windows app notification when a Video or Shorts render finishes, fails, or is cancelled.</summary>
public static class RenderNotifier
{
public static void Register()
{
try
{
AppNotificationManager.Default.NotificationInvoked -= OnNotificationInvoked;
AppNotificationManager.Default.NotificationInvoked += OnNotificationInvoked;
AppNotificationManager.Default.Register();
}
catch
{
// Notifications are unavailable when the process is elevated or has no identity.
}
}
public static void ShowCompleted(bool shorts) => Show(DescribeCompleted(shorts));
public static void ShowFailed(string summary) => Show(DescribeFailed(summary));
public static void ShowCancelled() => Show(DescribeCancelled());
internal static (string Title, string Body) DescribeCompleted(bool shorts) => shorts
? ("Short complete", "The YouTube Short was rendered successfully.")
: ("Render complete", "The AmiReel render completed successfully.");
internal static (string Title, string Body) DescribeFailed(string summary)
{
string body = string.IsNullOrWhiteSpace(summary)
? "The render failed."
: summary.ReplaceLineEndings(" ");
if (body.Length > 180)
body = body[..177] + "...";
return ("Render failed", body);
}
internal static (string Title, string Body) DescribeCancelled() =>
("Render cancelled", "The render was cancelled.");
private static void Show((string Title, string Body) content)
{
try
{
AppNotification notification = new AppNotificationBuilder()
.AddText(content.Title)
.AddText(content.Body)
.BuildNotification();
AppNotificationManager.Default.Show(notification);
}
catch
{
// Show fails silently when notifications are unsupported.
}
}
private static void OnNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args)
{
App.MainWindowInstance?.DispatcherQueue.TryEnqueue(() => App.MainWindowInstance.Activate());
}
}
+141 -5
View File
@@ -43,8 +43,9 @@ public sealed class RenderPipeline
double joinDuration = await ProbeCombinedDurationAsync(probe, tools, settings.InputFiles, token); double joinDuration = await ProbeCombinedDurationAsync(probe, tools, settings.InputFiles, token);
progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video")); progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video"));
string cropFilter = await ResolveSourceCropFilterAsync(tools, settings, log, token);
List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat, List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
"-vf", $"scale=2880:2160:flags=lanczos:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p", "-vf", BuildJoinVideoFilter(settings, cropFilter),
"-r", settings.FramesPerSecond.ToString(CultureInfo.InvariantCulture)]; "-r", settings.FramesPerSecond.ToString(CultureInfo.InvariantCulture)];
joinArgs.AddRange(EncoderArguments(encoder)); joinArgs.AddRange(EncoderArguments(encoder));
joinArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", main]); joinArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", main]);
@@ -122,6 +123,64 @@ public sealed class RenderPipeline
} }
} }
/// <summary>
/// Renders two 16:9 stills with the same crop/scale/pad as the final video: a source frame and the end card.
/// </summary>
public async Task<VideoPreviewFrames> RenderPreviewAsync(
RenderSettings settings, string outputDirectory, Action<string> log, CancellationToken token)
{
ValidatePreview(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
Directory.CreateDirectory(outputDirectory);
string cropFilter = await ResolveSourceCropFilterAsync(tools, settings, log, token);
string joinFilter = BuildJoinVideoFilter(settings, cropFilter);
string openingPath = Path.Combine(outputDirectory, "opening.png");
string closingPath = Path.Combine(outputDirectory, "closing.png");
MediaProbe probe = new(_runner, log);
string previewInput = settings.InputFiles[0];
double duration = 0;
try
{
duration = await probe.ProbeDurationAsync(tools, previewInput, token);
}
catch (Exception exception)
{
log($"WARNING: Could not probe source duration: {exception.Message}");
}
double seek = PreviewSeekSeconds(settings.TrimStart, duration);
log($"Preview opening frame at {F(seek)}s...");
await _runner.RunAsync(
tools.Ffmpeg,
[
"-hide_banner", "-y", "-ss", F(seek), "-i", previewInput,
"-frames:v", "1", "-update", "1", "-vf", joinFilter, openingPath,
],
log,
null,
token);
string workDir = Path.Combine(outputDirectory, "meta");
Directory.CreateDirectory(workDir);
string endCardPath = await ResolveEndCardPathAsync(settings, workDir, token);
log("Preview end-card frame...");
await _runner.RunAsync(
tools.Ffmpeg,
[
"-hide_banner", "-y", "-loop", "1", "-i", endCardPath,
"-frames:v", "1", "-update", "1",
"-vf", $"scale={settings.Width}:{settings.Height}:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
closingPath,
],
log,
null,
token);
return new VideoPreviewFrames(openingPath, closingPath);
}
private async Task<string> SelectEncoderAsync(ToolPaths tools, EncoderMode requested, Action<string> log, CancellationToken token) private async Task<string> SelectEncoderAsync(ToolPaths tools, EncoderMode requested, Action<string> log, CancellationToken token)
{ {
if (requested == EncoderMode.CpuX264) return "x264"; if (requested == EncoderMode.CpuX264) return "x264";
@@ -360,16 +419,93 @@ public sealed class RenderPipeline
internal static void Validate(RenderSettings s) internal static void Validate(RenderSettings s)
{ {
if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one video input file."); ValidatePreview(s);
if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more input files no longer exist.");
if (!string.IsNullOrWhiteSpace(s.EndCardPath) && !File.Exists(s.EndCardPath))
throw new FileNotFoundException("End-card image was not found.");
if (string.IsNullOrWhiteSpace(s.OutputDirectory)) throw new ArgumentException("Select an output directory."); if (string.IsNullOrWhiteSpace(s.OutputDirectory)) throw new ArgumentException("Select an output directory.");
if (string.IsNullOrWhiteSpace(s.OutputName) || s.OutputName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) if (string.IsNullOrWhiteSpace(s.OutputName) || s.OutputName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
throw new ArgumentException("Enter a valid output name."); throw new ArgumentException("Enter a valid output name.");
if (s.ThumbnailInterval < 1) throw new ArgumentException("Thumbnail interval must be at least one second."); if (s.ThumbnailInterval < 1) throw new ArgumentException("Thumbnail interval must be at least one second.");
} }
internal static void ValidatePreview(RenderSettings s)
{
if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one video input file.");
if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more input files no longer exist.");
if (!string.IsNullOrWhiteSpace(s.EndCardPath) && !File.Exists(s.EndCardPath))
throw new FileNotFoundException("End-card image was not found.");
if (s.CropLeft < 0 || s.CropRight < 0 || s.CropTop < 0 || s.CropBottom < 0)
throw new ArgumentException("Manual crop values must be non-negative.");
}
internal static string BuildJoinVideoFilter(RenderSettings settings, string cropFilter) =>
SourceCropFilters.Prefix(
cropFilter,
$"scale=2880:2160:flags=lanczos:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p");
internal static string BuildSourceCropFilter(RenderSettings settings, string? autoDetected) =>
SourceCropFilters.Resolve(
settings.SourceCropMode,
settings.CropLeft,
settings.CropRight,
settings.CropTop,
settings.CropBottom,
autoDetected);
internal static double PreviewSeekSeconds(double trimStart, double durationSeconds)
{
double start = Math.Max(0, trimStart);
if (durationSeconds <= start)
return start;
double remaining = durationSeconds - start;
return start + Math.Min(1.2, Math.Max(0, remaining * 0.2));
}
internal static string FormatTimestamp(double seconds) =>
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
private async Task<string> ResolveSourceCropFilterAsync(
ToolPaths tools, RenderSettings settings, Action<string> log, CancellationToken token)
{
string? detected = null;
if (!SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom)
&& settings.SourceCropMode == ShortSourceCropMode.Auto)
{
detected = await DetectHorizontalCropAsync(tools, settings, log, token);
if (detected is null)
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
}
else if (SourceCropFilters.HasManualCrop(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.None)
{
log("Source crop: none");
}
else
{
log("Source crop: centered 4:3");
}
return BuildSourceCropFilter(settings, detected);
}
private async Task<string?> DetectHorizontalCropAsync(
ToolPaths tools, RenderSettings settings, Action<string> log, CancellationToken token)
{
string input = settings.InputFiles[0];
List<string> args = SourceCropFilters.BuildHorizontalCropDetectArguments(
input, FormatTimestamp(settings.TrimStart), 4);
string output = await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, allowFailure: true);
string? filter = SourceCropFilters.TryBuildAutoHorizontalFilter(output);
if (filter is null)
return null;
log($"Source crop: {filter} (auto left/right black crop)");
return filter;
}
private static string EscapeConcat(string path) => path.Replace("'", "'\\''"); private static string EscapeConcat(string path) => path.Replace("'", "'\\''");
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture); private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
} }
+35 -80
View File
@@ -1,7 +1,6 @@
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using AmiReel.Models; using AmiReel.Models;
namespace AmiReel.Services; namespace AmiReel.Services;
@@ -13,12 +12,7 @@ namespace AmiReel.Services;
/// </summary> /// </summary>
public sealed class ShortsPipeline public sealed class ShortsPipeline
{ {
internal const string DefaultSourceCropFilter = internal const string DefaultSourceCropFilter = SourceCropFilters.CenterFourByThreeFilter;
"crop='min(iw,ih*4/3)':'min(ih,iw*3/4)':'(iw-ow)/2':'(ih-oh)/2'";
private static readonly Regex CropSpecRegex = new(
@"crop=(\d+):(\d+):(\d+):(\d+)",
RegexOptions.CultureInvariant);
private readonly ProcessRunner _runner = new(); private readonly ProcessRunner _runner = new();
@@ -421,7 +415,7 @@ public sealed class ShortsPipeline
$"scale={SourceTargetWidth(sourceMarginX)}:-2:flags=lanczos"; $"scale={SourceTargetWidth(sourceMarginX)}:-2:flags=lanczos";
internal static string BuildManualCropFilter(int left, int right, int top, int bottom) => internal static string BuildManualCropFilter(int left, int right, int top, int bottom) =>
$"crop=iw-{left}-{right}:ih-{top}-{bottom}:{left}:{top}"; SourceCropFilters.BuildManualCropFilter(left, right, top, bottom);
internal static string BuildVideoBaseFilter(string cropFilter, string scaleFilter, double blur, double opacity) internal static string BuildVideoBaseFilter(string cropFilter, string scaleFilter, double blur, double opacity)
{ {
@@ -430,49 +424,18 @@ public sealed class ShortsPipeline
"color=c=black:s=1080x1920:r=50[black];" + "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];" + $"[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];" + "[black][bgvideo]overlay=x=0:y=0:format=auto[bg];" +
$"[0:v]{cropFilter},{scaleFilter},setsar=1[fg];" + $"[0:v]{SourceCropFilters.Prefix(cropFilter, scaleFilter)},setsar=1[fg];" +
"[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]"; "[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]";
} }
internal static string? SelectMostCommonCropSpec(string ffmpegOutput) internal static string? SelectMostCommonCropSpec(string ffmpegOutput) =>
{ SourceCropFilters.SelectMostCommonCropSpec(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 internal static bool TryParseCropSpec(string spec, out int width, out int height, out int x, out int y) =>
? null SourceCropFilters.TryParseCropSpec(spec, out width, out height, out x, out y);
: 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) internal static bool TryBuildAutoHorizontalCrop(int detectedWidth, int detectedX, out int width, out int x) =>
{ SourceCropFilters.TryBuildAutoHorizontalCrop(detectedWidth, detectedX, out width, out x);
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( private async Task<string> ResolveBaseFilterAsync(
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token) ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
@@ -480,33 +443,36 @@ public sealed class ShortsPipeline
if (settings.Style != ShortStyle.Video) if (settings.Style != ShortStyle.Video)
return GetBaseFilter(settings.Style); return GetBaseFilter(settings.Style);
string cropFilter = DefaultSourceCropFilter;
string scaleFilter = BuildSourceScaleFilter(settings.SourceMarginX); string scaleFilter = BuildSourceScaleFilter(settings.SourceMarginX);
bool hasManualCrop = settings.CropLeft > 0 || settings.CropRight > 0 string? detected = null;
|| settings.CropTop > 0 || settings.CropBottom > 0; if (!SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom)
&& settings.SourceCropMode == ShortSourceCropMode.Auto)
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
{ {
detected = await DetectHorizontalCropAsync(tools, settings, log, token);
if (detected is null)
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop."); log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
} }
else if (SourceCropFilters.HasManualCrop(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.None)
{
log("Source crop: none");
} }
else else
{ {
log("Source crop: centered 4:3"); log("Source crop: centered 4:3");
} }
string cropFilter = SourceCropFilters.Resolve(
settings.SourceCropMode,
settings.CropLeft,
settings.CropRight,
settings.CropTop,
settings.CropBottom,
detected);
return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity); return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity);
} }
@@ -514,27 +480,16 @@ public sealed class ShortsPipeline
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token) ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
{ {
double detectDuration = Math.Clamp(settings.DurationSeconds < 4 ? settings.DurationSeconds : 4, 0.5, 4); double detectDuration = Math.Clamp(settings.DurationSeconds < 4 ? settings.DurationSeconds : 4, 0.5, 4);
List<string> args = List<string> args = SourceCropFilters.BuildHorizontalCropDetectArguments(
[ settings.InputFile, settings.Start, detectDuration);
"-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 output = await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, allowFailure: true);
string? spec = SelectMostCommonCropSpec(output); string? filter = SourceCropFilters.TryBuildAutoHorizontalFilter(output);
if (spec is null || !TryParseCropSpec(spec, out int width, out _, out int x, out _)) if (filter is null)
return null;
if (!TryBuildAutoHorizontalCrop(width, x, out int cropWidth, out int cropX))
return null; return null;
log($"Source crop: w={cropWidth}, x={cropX} (auto left/right black crop)"); log($"Source crop: {filter} (auto left/right black crop)");
return $"crop={cropWidth}:ih:{cropX}:0"; return filter;
} }
internal static string GetBaseFilter(ShortStyle style) => style switch internal static string GetBaseFilter(ShortStyle style) => style switch
+113
View File
@@ -0,0 +1,113 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AmiReel.Models;
namespace AmiReel.Services;
/// <summary>Shared FFmpeg crop filters used by both Video and Shorts pipelines.</summary>
internal static class SourceCropFilters
{
internal const string CenterFourByThreeFilter =
"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);
internal static string BuildManualCropFilter(int left, int right, int top, int bottom) =>
$"crop=iw-{left}-{right}:ih-{top}-{bottom}:{left}:{top}";
internal static bool HasManualCrop(int left, int right, int top, int bottom) =>
left > 0 || right > 0 || top > 0 || bottom > 0;
internal static string Resolve(
ShortSourceCropMode mode,
int left,
int right,
int top,
int bottom,
string? autoDetected)
{
if (HasManualCrop(left, right, top, bottom))
return BuildManualCropFilter(left, right, top, bottom);
return mode switch
{
ShortSourceCropMode.None => "",
ShortSourceCropMode.Auto when !string.IsNullOrWhiteSpace(autoDetected) => autoDetected,
ShortSourceCropMode.Auto => CenterFourByThreeFilter,
_ => CenterFourByThreeFilter,
};
}
internal static string Prefix(string cropFilter, string nextFilter) =>
string.IsNullOrWhiteSpace(cropFilter) ? nextFilter : $"{cropFilter},{nextFilter}";
internal static List<string> BuildHorizontalCropDetectArguments(string inputFile, string start, double durationSeconds) =>
[
"-hide_banner",
"-ss",
string.IsNullOrWhiteSpace(start) ? "00:00:00" : start,
"-t",
durationSeconds.ToString("0.###", CultureInfo.InvariantCulture),
"-i",
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",
"-",
];
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;
}
internal static string? TryBuildAutoHorizontalFilter(string ffmpegOutput)
{
string? spec = SelectMostCommonCropSpec(ffmpegOutput);
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;
return $"crop={cropWidth}:ih:{cropX}:0";
}
}
+42
View File
@@ -0,0 +1,42 @@
namespace AmiReel.Services;
/// <summary>
/// Locates sequential WinUAE-style recordings: output.avi, output_1.avi, output_2.avi, ...
/// Stopping at the first missing name.
/// </summary>
public static class SourceRecordingFinder
{
public const string BaseFileName = "output";
public const string FileExtension = ".avi";
public const int MaximumFiles = 500;
/// <summary>
/// Returns existing sequential recordings in <paramref name="folder"/>, or an empty
/// list when the folder is missing or <c>output.avi</c> is not present.
/// </summary>
public static IReadOnlyList<string> FindSequentialOutputs(string? folder)
{
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
return [];
string directory = Path.GetFullPath(folder);
List<string> found = [];
string first = Path.Combine(directory, BaseFileName + FileExtension);
if (!File.Exists(first))
return found;
found.Add(first);
for (int index = 1; index < MaximumFiles; index++)
{
string next = Path.Combine(directory, $"{BaseFileName}_{index}{FileExtension}");
if (!File.Exists(next))
break;
found.Add(next);
}
return found;
}
}