Compare commits

..
2 Commits
Author SHA1 Message Date
klevze a590904e89 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.
2026-08-16 09:20:56 +02:00
klevze e635b708a2 Improve crop editor and copy publish output to dist
Add resize handles, zoom, view brightness, and a multi-frame still
strip. Keep the crop box on mouse-up, and copy AmiReel.exe to dist.
2026-08-15 22:59:20 +02:00
27 changed files with 2060 additions and 199 deletions
+1
View File
@@ -4,6 +4,7 @@
[Dd]ebug/ [Dd]ebug/
[Rr]elease/ [Rr]elease/
publish/ publish/
dist/
# IDE / tooling # IDE / tooling
.vs/ .vs/
+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]
@@ -57,6 +57,19 @@ public class SourceCropSelectionTests
Assert.AreEqual(30, insets.Bottom); Assert.AreEqual(30, insets.Bottom);
} }
[TestMethod]
public void ToInsets_KeepsTheDraggedWidthAndHeight()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(3840, 2160, 768, 432, 100, 50, 200, 120);
// Assert
Assert.AreEqual(500, insets.Left);
Assert.AreEqual(250, insets.Top);
Assert.AreEqual(1000, 3840 - insets.Left - insets.Right);
Assert.AreEqual(600, 2160 - insets.Top - insets.Bottom);
}
[TestMethod] [TestMethod]
public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle() public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle()
{ {
@@ -97,4 +110,106 @@ public class SourceCropSelectionTests
Assert.AreEqual(0, x); Assert.AreEqual(0, x);
Assert.AreEqual(60, y); Assert.AreEqual(60, y);
} }
[TestMethod]
public void HitTest_OnNorthWestHandle_ReturnsNorthWest()
{
// Act
CropHit hit = SourceCropSelection.HitTest(20, 30, 80, 40, 20, 30, 12);
// Assert
Assert.AreEqual(CropHit.NorthWest, hit);
}
[TestMethod]
public void HitTest_InsideRectangle_ReturnsMove()
{
// Act
CropHit hit = SourceCropSelection.HitTest(20, 30, 80, 40, 50, 50, 12);
// Assert
Assert.AreEqual(CropHit.Move, hit);
}
[TestMethod]
public void HitTest_OutsideRectangle_ReturnsNone()
{
// Act
CropHit hit = SourceCropSelection.HitTest(20, 30, 80, 40, 2, 2, 12);
// Assert
Assert.AreEqual(CropHit.None, hit);
}
[TestMethod]
public void AdjustRect_Move_KeepsSizeAndClamps()
{
// Act
(double x, double y, double width, double height) =
SourceCropSelection.AdjustRect(10, 10, 40, 20, CropHit.Move, 100, -4, 80, 60);
// Assert
Assert.AreEqual(40, x);
Assert.AreEqual(6, y);
Assert.AreEqual(40, width);
Assert.AreEqual(20, height);
}
[TestMethod]
public void AdjustRect_EastHandle_ChangesWidthOnly()
{
// Act
(double x, double y, double width, double height) =
SourceCropSelection.AdjustRect(10, 10, 40, 20, CropHit.East, 8, 5, 80, 60);
// Assert
Assert.AreEqual(10, x);
Assert.AreEqual(10, y);
Assert.AreEqual(48, width);
Assert.AreEqual(20, height);
}
[TestMethod]
public void AdjustRect_WestHandle_MovesLeftEdge()
{
// Act
(double x, _, double width, _) =
SourceCropSelection.AdjustRect(20, 10, 40, 20, CropHit.West, 6, 0, 80, 60);
// Assert
Assert.AreEqual(26, x);
Assert.AreEqual(34, width);
}
[TestMethod]
public void Nudge_MovesBoxWithoutChangingSize()
{
// Arrange
PixelInsets start = new(10, 20, 8, 12);
// Act
PixelInsets moved = SourceCropSelection.Nudge(start, 100, 80, 4, 0);
// Assert
Assert.AreEqual(14, moved.Left);
Assert.AreEqual(16, moved.Right);
Assert.AreEqual(8, moved.Top);
Assert.AreEqual(12, moved.Bottom);
}
[TestMethod]
public void NudgeEdge_GrowsRightSide()
{
// Arrange
PixelInsets start = new(10, 20, 8, 12);
// Act
PixelInsets grown = SourceCropSelection.NudgeEdge(start, 100, 80, 0, 0, -4, 0);
// Assert
Assert.AreEqual(10, grown.Left);
Assert.AreEqual(16, grown.Right);
Assert.AreEqual(8, grown.Top);
Assert.AreEqual(12, grown.Bottom);
}
} }
@@ -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()
{ {
@@ -483,6 +530,39 @@ public class ShortsPipelineTests
CollectionAssert.Contains(args, "in.mp4"); CollectionAssert.Contains(args, "in.mp4");
} }
[TestMethod]
public void CropPreviewOffsets_SpreadsFramesAcrossDuration()
{
// Act
IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(30, 5);
// Assert
Assert.AreEqual(5, offsets.Count);
Assert.AreEqual(0, offsets[0]);
Assert.AreEqual(29.85, offsets[^1]);
Assert.AreEqual(14.925, offsets[2]);
}
[TestMethod]
public void CropPreviewOffsets_WithZeroDuration_ReturnsStartOnly()
{
// Act
IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(0);
// Assert
CollectionAssert.AreEqual(new[] { 0d }, offsets.ToArray());
}
[TestMethod]
public void FormatPreviewOffsetLabel_UnderOneHour_UsesMinutesAndSeconds()
{
// Act
string label = ShortsPipeline.FormatPreviewOffsetLabel(75);
// Assert
Assert.AreEqual("1:15", label);
}
[TestMethod] [TestMethod]
public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng() public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng()
{ {
@@ -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();
} }
} }
+86 -23
View File
@@ -9,7 +9,7 @@
<StackPanel Spacing="10" MinWidth="640"> <StackPanel Spacing="10" MinWidth="640">
<TextBlock x:Name="StatusText" <TextBlock x:Name="StatusText"
Text="Drag on the frame to keep that region. The rest is cropped away." Text="Drag a keep-region, then resize it with the handles. Zoom and brightness are for viewing only."
Style="{StaticResource MutedTextStyle}" Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" /> TextWrapping="WrapWholeWords" />
<ProgressBar x:Name="LoadProgress" <ProgressBar x:Name="LoadProgress"
@@ -17,32 +17,95 @@
Height="6" Height="6"
Visibility="Collapsed" Visibility="Collapsed"
AutomationProperties.Name="Loading source frame" /> AutomationProperties.Name="Loading source frame" />
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Slider x:Name="ZoomSlider"
Grid.Column="0"
Header="Zoom"
Minimum="1"
Maximum="4"
StepFrequency="0.25"
TickFrequency="0.5"
SnapsTo="StepValues"
Value="1"
ValueChanged="ZoomSlider_ValueChanged"
AutomationProperties.Name="View zoom"
AutomationProperties.AutomationId="CropZoomSlider" />
<Slider x:Name="BrightnessSlider"
Grid.Column="1"
Header="View brightness"
Minimum="-0.7"
Maximum="0.7"
StepFrequency="0.05"
TickFrequency="0.35"
SnapsTo="StepValues"
Value="0"
ValueChanged="BrightnessSlider_ValueChanged"
AutomationProperties.Name="View brightness"
AutomationProperties.AutomationId="CropBrightnessSlider" />
</Grid>
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
HorizontalScrollMode="Auto"
VerticalScrollMode="Disabled"
AutomationProperties.Name="Crop preview frames">
<StackPanel x:Name="FrameStrip" Orientation="Horizontal" Spacing="8" />
</ScrollViewer>
<Border Background="{ThemeResource PageBrush}" <Border Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}" BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="8"> CornerRadius="8">
<Grid x:Name="CropHost" <ScrollViewer x:Name="CropScroller"
Height="420" Height="420"
Background="Transparent" ZoomMode="Disabled"
PointerPressed="CropHost_PointerPressed" HorizontalScrollBarVisibility="Auto"
PointerMoved="CropHost_PointerMoved" VerticalScrollBarVisibility="Auto"
PointerReleased="CropHost_PointerReleased" HorizontalScrollMode="Auto"
PointerCaptureLost="CropHost_PointerCaptureLost" VerticalScrollMode="Auto"
SizeChanged="CropHost_SizeChanged" SizeChanged="CropScroller_SizeChanged"
AutomationProperties.Name="Source crop frame" PointerWheelChanged="CropScroller_PointerWheelChanged"
AutomationProperties.AutomationId="SourceCropHost"> AutomationProperties.Name="Source crop view">
<Image x:Name="SourceImage" <Grid x:Name="ImageSurface"
Stretch="Uniform" HorizontalAlignment="Center"
IsHitTestVisible="False" VerticalAlignment="Center"
AutomationProperties.Name="Source video frame" /> Background="Transparent"
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False"> IsTabStop="True"
<Rectangle x:Name="SelectionRect" UseSystemFocusVisuals="True"
Stroke="{ThemeResource AccentBrush}" PointerPressed="ImageSurface_PointerPressed"
StrokeThickness="2" PointerMoved="ImageSurface_PointerMoved"
Fill="#33007ACC" PointerReleased="ImageSurface_PointerReleased"
Visibility="Collapsed" /> PointerCaptureLost="ImageSurface_PointerCaptureLost"
</Canvas> KeyDown="ImageSurface_KeyDown"
</Grid> AutomationProperties.Name="Source crop frame"
AutomationProperties.AutomationId="SourceCropHost">
<Image x:Name="SourceImage"
Stretch="Fill"
IsHitTestVisible="False"
AutomationProperties.Name="Source video frame" />
<Rectangle x:Name="BrightnessOverlay"
IsHitTestVisible="False"
Fill="White"
Opacity="0" />
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
<Rectangle x:Name="SelectionRect"
Stroke="{ThemeResource AccentBrush}"
StrokeThickness="2"
Fill="#33007ACC"
Visibility="Collapsed" />
<Rectangle x:Name="HandleNW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleN" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleNE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleSE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleS" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleSW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
</Canvas>
</Grid>
</ScrollViewer>
</Border> </Border>
<Grid ColumnSpacing="10"> <Grid ColumnSpacing="10">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
+369 -57
View File
@@ -1,21 +1,43 @@
using System.Runtime.InteropServices.WindowsRuntime; using System.Runtime.InteropServices.WindowsRuntime;
using AmiReel.Models; using AmiReel.Models;
using AmiReel.Services; using AmiReel.Services;
using Microsoft.UI;
using Microsoft.UI.Input; using Microsoft.UI.Input;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Foundation; using Windows.Foundation;
using Windows.System;
using Windows.UI.Core;
namespace AmiReel.Controls; namespace AmiReel.Controls;
public sealed partial class SourceCropEditor : UserControl public sealed partial class SourceCropEditor : UserControl
{ {
private const double HandleHitSize = 12;
private const double HandleVisualSize = 10;
private const double MinZoom = 1;
private const double MaxZoom = 4;
private const double ZoomStep = 0.25;
private const int ThumbnailWidth = 128;
private readonly List<Button> _frameButtons = [];
private readonly List<string> _framePaths = [];
private readonly List<string> _frameLabels = [];
private readonly List<BitmapImage?> _fullFrames = [];
private int _selectedFrame;
private int _pixelWidth; private int _pixelWidth;
private int _pixelHeight; private int _pixelHeight;
private double _zoom = 1;
private bool _isDragging; private bool _isDragging;
private CropHit _dragHit = CropHit.None;
private Point _dragStart; private Point _dragStart;
private Rect _dragOriginRect;
private PixelInsets _insets; private PixelInsets _insets;
public SourceCropEditor() public SourceCropEditor()
@@ -26,26 +48,34 @@ public sealed partial class SourceCropEditor : UserControl
public PixelInsets Insets => _insets; public PixelInsets Insets => _insets;
public async Task LoadVideoFrameAsync( public async Task LoadVideoFrameAsync(
string inputFile, string start, string ffmpegPath, string ffprobePath, CancellationToken token) string inputFile, string start, double durationSeconds, string ffmpegPath, string ffprobePath, CancellationToken token)
{ {
LoadProgress.Visibility = Visibility.Visible; LoadProgress.Visibility = Visibility.Visible;
StatusText.Text = "Loading a source frame..."; StatusText.Text = "Loading preview frames...";
try try
{ {
string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor"); IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(durationSeconds);
Directory.CreateDirectory(directory); string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor", Guid.NewGuid().ToString("N"));
string framePath = Path.Combine(directory, "source-frame.png"); ShortsPipeline pipeline = new();
await new ShortsPipeline().ExtractSourceFrameAsync( IReadOnlyList<string> paths = await pipeline.ExtractSourceFramesAsync(
inputFile, start, ffmpegPath, ffprobePath, framePath, _ => { }, token); inputFile, start, offsets, ffmpegPath, ffprobePath, directory, _ => { }, token);
await using FileStream stream = File.OpenRead(framePath); _framePaths.Clear();
BitmapImage bitmap = new(); _frameLabels.Clear();
await bitmap.SetSourceAsync(stream.AsRandomAccessStream()); _fullFrames.Clear();
SourceImage.Source = bitmap; _frameButtons.Clear();
_pixelWidth = bitmap.PixelWidth; FrameStrip.Children.Clear();
_pixelHeight = bitmap.PixelHeight;
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · drag on the frame to choose the kept region."; for (int index = 0; index < paths.Count; index++)
RefreshSelectionVisual(); {
_framePaths.Add(paths[index]);
_frameLabels.Add(ShortsPipeline.FormatPreviewOffsetLabel(offsets[index]));
_fullFrames.Add(null);
FrameStrip.Children.Add(CreateFrameButton(index, paths[index], _frameLabels[index]));
}
await ShowFrameAsync(0);
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · pick a still, then drag a region and fine-tune the handles.";
} }
finally finally
{ {
@@ -60,112 +90,394 @@ public sealed partial class SourceCropEditor : UserControl
UpdateInsetsText(); UpdateInsetsText();
} }
private void CropHost_PointerPressed(object sender, PointerRoutedEventArgs e) private Button CreateFrameButton(int index, string path, string label)
{ {
if (_pixelWidth <= 0 || !TryMapToDisplay(e, out double x, out double y, out _, out _)) Image thumb = new()
{
Width = 112,
Height = 63,
Stretch = Stretch.Uniform,
IsHitTestVisible = false,
};
_ = LoadThumbnailAsync(thumb, path);
TextBlock caption = new()
{
Text = label,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
Style = (Style)Application.Current.Resources["MutedTextStyle"],
};
StackPanel content = new() { Spacing = 4 };
content.Children.Add(thumb);
content.Children.Add(caption);
Button button = new()
{
Content = content,
Padding = new Thickness(6),
Tag = index,
BorderThickness = new Thickness(2),
};
AutomationProperties.SetName(button, $"Preview at {label}");
AutomationProperties.SetAutomationId(button, $"CropPreviewFrame{index}");
button.Click += FrameButton_Click;
_frameButtons.Add(button);
return button;
}
private async void FrameButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not int index)
return; return;
try
{
await ShowFrameAsync(index);
}
catch (Exception exception)
{
StatusText.Text = exception.Message;
}
}
private async Task ShowFrameAsync(int index)
{
if (index < 0 || index >= _framePaths.Count)
return;
_selectedFrame = index;
UpdateFrameButtonStyles();
if (_fullFrames[index] is null)
_fullFrames[index] = await LoadBitmapAsync(_framePaths[index], decodePixelWidth: 0);
BitmapImage bitmap = _fullFrames[index]!;
SourceImage.Source = bitmap;
if (bitmap.PixelWidth > 0 && bitmap.PixelHeight > 0)
{
_pixelWidth = bitmap.PixelWidth;
_pixelHeight = bitmap.PixelHeight;
}
ApplyViewTransform();
RefreshSelectionVisual();
UpdateInsetsText();
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · {_frameLabels[index]} ({index + 1} of {_framePaths.Count})";
}
private void UpdateFrameButtonStyles()
{
Brush accent = (Brush)Application.Current.Resources["AccentBrush"];
Brush border = (Brush)Application.Current.Resources["BorderBrush"];
for (int index = 0; index < _frameButtons.Count; index++)
_frameButtons[index].BorderBrush = index == _selectedFrame ? accent : border;
}
private static async Task LoadThumbnailAsync(Image target, string path)
{
target.Source = await LoadBitmapAsync(path, ThumbnailWidth);
}
private static async Task<BitmapImage> LoadBitmapAsync(string path, int decodePixelWidth)
{
await using FileStream stream = File.OpenRead(path);
BitmapImage bitmap = new();
if (decodePixelWidth > 0)
bitmap.DecodePixelWidth = decodePixelWidth;
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
return bitmap;
}
private void ImageSurface_PointerPressed(object sender, PointerRoutedEventArgs e)
{
if (_pixelWidth <= 0 || !TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
return;
ImageSurface.Focus(FocusState.Pointer);
(double rectX, double rectY, double rectW, double rectH) = CurrentDisplayRect(displayWidth, displayHeight);
CropHit hit = _insets.IsEmpty
? CropHit.None
: SourceCropSelection.HitTest(rectX, rectY, rectW, rectH, x, y, HandleHitSize);
_isDragging = true; _isDragging = true;
_dragHit = hit;
_dragStart = new Point(x, y); _dragStart = new Point(x, y);
CropHost.CapturePointer(e.Pointer); _dragOriginRect = new Rect(rectX, rectY, rectW, rectH);
UpdateDraftSelection(x, y); ImageSurface.CapturePointer(e.Pointer);
e.Handled = true; e.Handled = true;
} }
private void CropHost_PointerMoved(object sender, PointerRoutedEventArgs e) private void ImageSurface_PointerMoved(object sender, PointerRoutedEventArgs e)
{ {
if (!_isDragging) if (!_isDragging)
{
UpdateHoverCursor(e);
return; return;
if (!TryMapToDisplay(e, out double x, out double y, out _, out _)) }
if (!TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
return; return;
UpdateDraftSelection(x, y); if (_dragHit == CropHit.None)
UpdateDraftSelection(x, y);
else
UpdateAdjustedSelection(x, y, displayWidth, displayHeight);
e.Handled = true; e.Handled = true;
} }
private void CropHost_PointerReleased(object sender, PointerRoutedEventArgs e) private void ImageSurface_PointerReleased(object sender, PointerRoutedEventArgs e)
{ {
if (!_isDragging) if (!_isDragging)
return; return;
_isDragging = false; EndDrag();
CropHost.ReleasePointerCapture(e.Pointer); ImageSurface.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; e.Handled = true;
} }
private void CropHost_PointerCaptureLost(object sender, PointerRoutedEventArgs e) private void ImageSurface_PointerCaptureLost(object sender, PointerRoutedEventArgs e)
{ {
_isDragging = false; if (_isDragging)
EndDrag();
} }
private void CropHost_SizeChanged(object sender, SizeChangedEventArgs e) => RefreshSelectionVisual(); private void EndDrag()
{
_isDragging = false;
_dragHit = CropHit.None;
RefreshSelectionVisual();
UpdateInsetsText();
}
private void ImageSurface_KeyDown(object sender, KeyRoutedEventArgs e)
{
if (_pixelWidth <= 0)
return;
bool shift = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
int step = 2;
PixelInsets next = _insets;
switch (e.Key)
{
case VirtualKey.Left:
next = shift
? SourceCropSelection.NudgeEdge(_insets, _pixelWidth, _pixelHeight, -step, 0, 0, 0)
: SourceCropSelection.Nudge(_insets, _pixelWidth, _pixelHeight, -step, 0);
break;
case VirtualKey.Right:
next = shift
? SourceCropSelection.NudgeEdge(_insets, _pixelWidth, _pixelHeight, 0, 0, -step, 0)
: SourceCropSelection.Nudge(_insets, _pixelWidth, _pixelHeight, step, 0);
break;
case VirtualKey.Up:
next = shift
? SourceCropSelection.NudgeEdge(_insets, _pixelWidth, _pixelHeight, 0, -step, 0, 0)
: SourceCropSelection.Nudge(_insets, _pixelWidth, _pixelHeight, 0, -step);
break;
case VirtualKey.Down:
next = shift
? SourceCropSelection.NudgeEdge(_insets, _pixelWidth, _pixelHeight, 0, 0, 0, -step)
: SourceCropSelection.Nudge(_insets, _pixelWidth, _pixelHeight, 0, step);
break;
default:
return;
}
if (_insets.IsEmpty)
return;
SetInsets(next);
e.Handled = true;
}
private void CropScroller_SizeChanged(object sender, SizeChangedEventArgs e) => ApplyViewTransform();
private void CropScroller_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
{
if (!e.KeyModifiers.HasFlag(VirtualKeyModifiers.Control))
return;
double delta = e.GetCurrentPoint(CropScroller).Properties.MouseWheelDelta > 0 ? ZoomStep : -ZoomStep;
SetZoom(_zoom + delta);
e.Handled = true;
}
private void ZoomSlider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
{
if (!IsLoaded)
return;
SetZoom(e.NewValue);
}
private void BrightnessSlider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
{
if (!IsLoaded)
return;
ApplyBrightness(e.NewValue);
}
private void ClearCrop_Click(object sender, RoutedEventArgs e) => SetInsets(default); private void ClearCrop_Click(object sender, RoutedEventArgs e) => SetInsets(default);
private void SetZoom(double zoom)
{
_zoom = Math.Clamp(Math.Round(zoom / ZoomStep) * ZoomStep, MinZoom, MaxZoom);
if (Math.Abs(ZoomSlider.Value - _zoom) > 0.001)
ZoomSlider.Value = _zoom;
ZoomSlider.Header = $"Zoom {_zoom * 100:0}%";
ApplyViewTransform();
}
private void ApplyBrightness(double value)
{
double amount = Math.Clamp(value, -0.7, 0.7);
BrightnessOverlay.Fill = new SolidColorBrush(amount >= 0 ? Colors.White : Colors.Black);
BrightnessOverlay.Opacity = Math.Abs(amount);
BrightnessSlider.Header = amount == 0
? "View brightness"
: $"View brightness {(amount > 0 ? "+" : "")}{amount:0.00}";
}
private void ApplyViewTransform()
{
if (_pixelWidth <= 0)
return;
double viewportWidth = CropScroller.ActualWidth > 0 ? CropScroller.ActualWidth : 800;
double viewportHeight = CropScroller.ActualHeight > 0 ? CropScroller.ActualHeight : 420;
(_, _, double displayWidth, double displayHeight) =
SourceCropSelection.FitUniform(viewportWidth, viewportHeight, _pixelWidth, _pixelHeight);
ImageSurface.Width = Math.Max(1, displayWidth * _zoom);
ImageSurface.Height = Math.Max(1, displayHeight * _zoom);
RefreshSelectionVisual();
}
private bool TryMapToDisplay( private bool TryMapToDisplay(
PointerRoutedEventArgs e, out double x, out double y, out double displayWidth, out double displayHeight) PointerRoutedEventArgs e, out double x, out double y, out double displayWidth, out double displayHeight)
{ {
(double offsetX, double offsetY, displayWidth, displayHeight) = CurrentFit(); displayWidth = ImageSurface.Width;
displayHeight = ImageSurface.Height;
x = 0; x = 0;
y = 0; y = 0;
if (displayWidth <= 0 || displayHeight <= 0) if (displayWidth <= 0 || displayHeight <= 0)
return false; return false;
PointerPoint point = e.GetCurrentPoint(CropHost); PointerPoint point = e.GetCurrentPoint(ImageSurface);
(x, y) = SourceCropSelection.ClampToDisplay( (x, y) = SourceCropSelection.ClampToDisplay(point.Position.X, point.Position.Y, displayWidth, displayHeight);
point.Position.X - offsetX, point.Position.Y - offsetY, displayWidth, displayHeight);
return true; return true;
} }
private void UpdateDraftSelection(double x, double y) private void UpdateDraftSelection(double x, double y)
{ {
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit(); double displayWidth = ImageSurface.Width;
double displayHeight = ImageSurface.Height;
if (displayWidth <= 0) if (displayWidth <= 0)
return; return;
double left = Math.Min(_dragStart.X, x);
double top = Math.Min(_dragStart.Y, y);
double width = Math.Abs(x - _dragStart.X);
double height = Math.Abs(y - _dragStart.Y);
ShowDisplayRect(left, top, width, height);
_insets = SourceCropSelection.ToInsets( _insets = SourceCropSelection.ToInsets(
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _pixelWidth, _pixelHeight, displayWidth, displayHeight, left, top, width, height);
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
UpdateInsetsText(); UpdateInsetsText();
} }
private void CommitSelection(double x, double y, double displayWidth, double displayHeight) private void UpdateAdjustedSelection(double x, double y, double displayWidth, double displayHeight)
{ {
_insets = SourceCropSelection.ToInsets( (double nextX, double nextY, double nextW, double nextH) = SourceCropSelection.AdjustRect(
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _dragOriginRect.X,
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y); _dragOriginRect.Y,
RefreshSelectionVisual(); _dragOriginRect.Width,
_dragOriginRect.Height,
_dragHit,
x - _dragStart.X,
y - _dragStart.Y,
displayWidth,
displayHeight);
ShowDisplayRect(nextX, nextY, nextW, nextH);
_insets = SourceCropSelection.ToInsets(_pixelWidth, _pixelHeight, displayWidth, displayHeight, nextX, nextY, nextW, nextH);
UpdateInsetsText(); UpdateInsetsText();
} }
private void RefreshSelectionVisual() private void RefreshSelectionVisual()
{ {
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit(); if (ImageSurface.Width <= 0 || _pixelWidth <= 0 || _insets.IsEmpty)
if (displayWidth <= 0 || _pixelWidth <= 0)
{ {
SelectionRect.Visibility = Visibility.Collapsed; SetHandlesVisible(false);
return; return;
} }
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight); (double x, double y, double width, double height) = CurrentDisplayRect(ImageSurface.Width, ImageSurface.Height);
ShowDisplayRect(x, y, width, height);
} }
private void PlaceSelectionRect(double offsetX, double offsetY, double displayWidth, double displayHeight) private void ShowDisplayRect(double x, double y, double width, double height)
{ {
(double x, double y, double width, double height) = SourceCropSelection.ToDisplayRect( Canvas.SetLeft(SelectionRect, x);
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _insets); Canvas.SetTop(SelectionRect, y);
Canvas.SetLeft(SelectionRect, offsetX + x); SelectionRect.Width = Math.Max(1, width);
Canvas.SetTop(SelectionRect, offsetY + y); SelectionRect.Height = Math.Max(1, height);
SelectionRect.Width = width; SelectionRect.Visibility = Visibility.Visible;
SelectionRect.Height = height; PlaceHandle(HandleNW, x, y);
SelectionRect.Visibility = _insets.IsEmpty ? Visibility.Collapsed : Visibility.Visible; PlaceHandle(HandleN, x + (width / 2), y);
PlaceHandle(HandleNE, x + width, y);
PlaceHandle(HandleE, x + width, y + (height / 2));
PlaceHandle(HandleSE, x + width, y + height);
PlaceHandle(HandleS, x + (width / 2), y + height);
PlaceHandle(HandleSW, x, y + height);
PlaceHandle(HandleW, x, y + (height / 2));
SetHandlesVisible(true);
} }
private (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) CurrentFit() => private (double X, double Y, double Width, double Height) CurrentDisplayRect(double displayWidth, double displayHeight) =>
SourceCropSelection.FitUniform(CropHost.ActualWidth, CropHost.ActualHeight, _pixelWidth, _pixelHeight); SourceCropSelection.ToDisplayRect(_pixelWidth, _pixelHeight, displayWidth, displayHeight, _insets);
private void PlaceHandle(Microsoft.UI.Xaml.Shapes.Rectangle handle, double centerX, double centerY)
{
Canvas.SetLeft(handle, centerX - (HandleVisualSize / 2));
Canvas.SetTop(handle, centerY - (HandleVisualSize / 2));
}
private void SetHandlesVisible(bool visible)
{
Visibility visibility = visible ? Visibility.Visible : Visibility.Collapsed;
SelectionRect.Visibility = visibility;
HandleNW.Visibility = visibility;
HandleN.Visibility = visibility;
HandleNE.Visibility = visibility;
HandleE.Visibility = visibility;
HandleSE.Visibility = visibility;
HandleS.Visibility = visibility;
HandleSW.Visibility = visibility;
HandleW.Visibility = visibility;
}
private void UpdateHoverCursor(PointerRoutedEventArgs e)
{
if (_pixelWidth <= 0 || _insets.IsEmpty || !TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
{
ProtectedCursor = InputSystemCursor.Create(InputSystemCursorShape.Cross);
return;
}
(double rectX, double rectY, double rectW, double rectH) = CurrentDisplayRect(displayWidth, displayHeight);
CropHit hit = SourceCropSelection.HitTest(rectX, rectY, rectW, rectH, x, y, HandleHitSize);
ProtectedCursor = InputSystemCursor.Create(CursorFor(hit));
}
private static InputSystemCursorShape CursorFor(CropHit hit) => hit switch
{
CropHit.Move => InputSystemCursorShape.SizeAll,
CropHit.North or CropHit.South => InputSystemCursorShape.SizeNorthSouth,
CropHit.East or CropHit.West => InputSystemCursorShape.SizeWestEast,
CropHit.NorthWest or CropHit.SouthEast => InputSystemCursorShape.SizeNorthwestSoutheast,
CropHit.NorthEast or CropHit.SouthWest => InputSystemCursorShape.SizeNortheastSouthwest,
_ => InputSystemCursorShape.Cross,
};
private void UpdateInsetsText() => private void UpdateInsetsText() =>
InsetsText.Text = _pixelWidth <= 0 InsetsText.Text = _pixelWidth <= 0
+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>
+243 -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();
@@ -612,9 +739,13 @@ public sealed partial class MainPage : Page
{ {
CropEditorDialog.XamlRoot = XamlRoot; CropEditorDialog.XamlRoot = XamlRoot;
CropEditorDialog.RequestedTheme = ActualTheme; CropEditorDialog.RequestedTheme = ActualTheme;
double duration = 30;
try { duration = ParsePositiveDuration(); } catch (ArgumentException) { }
await ShortCropEditor.LoadVideoFrameAsync( await ShortCropEditor.LoadVideoFrameAsync(
input, input,
string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(), string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
duration,
FfmpegPathBox.Text.Trim(), FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(), FfprobePathBox.Text.Trim(),
CancellationToken.None); CancellationToken.None);
@@ -652,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))
@@ -661,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)
@@ -684,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"
@@ -711,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;
} }
@@ -810,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
@@ -830,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(),
@@ -865,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,
} }
+118 -8
View File
@@ -6,6 +6,21 @@ public readonly record struct PixelInsets(int Left, int Right, int Top, int Bott
public bool IsEmpty => Left == 0 && Right == 0 && Top == 0 && Bottom == 0; public bool IsEmpty => Left == 0 && Right == 0 && Top == 0 && Bottom == 0;
} }
/// <summary>Which part of an existing crop rectangle a pointer is on.</summary>
public enum CropHit
{
None,
Move,
North,
South,
East,
West,
NorthWest,
NorthEast,
SouthWest,
SouthEast,
}
/// <summary>Maps a dragged display rectangle onto source-pixel crop insets.</summary> /// <summary>Maps a dragged display rectangle onto source-pixel crop insets.</summary>
public static class SourceCropSelection public static class SourceCropSelection
{ {
@@ -58,15 +73,14 @@ public static class SourceCropSelection
double scaleY = pixelHeight / displayHeight; double scaleY = pixelHeight / displayHeight;
int left = ToEvenPixel(x * scaleX, pixelWidth - MinimumSize); int left = ToEvenPixel(x * scaleX, pixelWidth - MinimumSize);
int top = ToEvenPixel(y * scaleY, pixelHeight - MinimumSize); int top = ToEvenPixel(y * scaleY, pixelHeight - MinimumSize);
int right = ToEvenPixel((displayWidth - x - width) * scaleX, pixelWidth - MinimumSize - left); int cropWidth = ToEvenPixel(width * scaleX, pixelWidth - left);
int bottom = ToEvenPixel((displayHeight - y - height) * scaleY, pixelHeight - MinimumSize - top); int cropHeight = ToEvenPixel(height * scaleY, pixelHeight - top);
if (cropWidth < MinimumSize)
cropWidth = Math.Min(MinimumSize, pixelWidth - left);
if (cropHeight < MinimumSize)
cropHeight = Math.Min(MinimumSize, pixelHeight - top);
if (pixelWidth - left - right < MinimumSize) return new PixelInsets(left, pixelWidth - left - cropWidth, top, pixelHeight - top - cropHeight);
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( public static (double X, double Y, double Width, double Height) ToDisplayRect(
@@ -87,6 +101,102 @@ public static class SourceCropSelection
public static (double X, double Y) ClampToDisplay(double x, double y, double displayWidth, double displayHeight) => 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))); (Math.Clamp(x, 0, Math.Max(0, displayWidth)), Math.Clamp(y, 0, Math.Max(0, displayHeight)));
public static CropHit HitTest(
double rectX, double rectY, double rectWidth, double rectHeight, double pointerX, double pointerY, double handleSize)
{
if (rectWidth <= 0 || rectHeight <= 0 || handleSize < 0)
return CropHit.None;
bool nearLeft = Math.Abs(pointerX - rectX) <= handleSize;
bool nearRight = Math.Abs(pointerX - (rectX + rectWidth)) <= handleSize;
bool nearTop = Math.Abs(pointerY - rectY) <= handleSize;
bool nearBottom = Math.Abs(pointerY - (rectY + rectHeight)) <= handleSize;
bool alongX = pointerX >= rectX - handleSize && pointerX <= rectX + rectWidth + handleSize;
bool alongY = pointerY >= rectY - handleSize && pointerY <= rectY + rectHeight + handleSize;
if (nearTop && nearLeft)
return CropHit.NorthWest;
if (nearTop && nearRight)
return CropHit.NorthEast;
if (nearBottom && nearLeft)
return CropHit.SouthWest;
if (nearBottom && nearRight)
return CropHit.SouthEast;
if (nearTop && alongX)
return CropHit.North;
if (nearBottom && alongX)
return CropHit.South;
if (nearLeft && alongY)
return CropHit.West;
if (nearRight && alongY)
return CropHit.East;
if (pointerX >= rectX && pointerX <= rectX + rectWidth && pointerY >= rectY && pointerY <= rectY + rectHeight)
return CropHit.Move;
return CropHit.None;
}
public static (double X, double Y, double Width, double Height) AdjustRect(
double x,
double y,
double width,
double height,
CropHit hit,
double deltaX,
double deltaY,
double displayWidth,
double displayHeight)
{
const double minSize = 8;
double left = x;
double top = y;
double right = x + width;
double bottom = y + height;
if (hit == CropHit.Move)
{
left = Math.Clamp(left + deltaX, 0, Math.Max(0, displayWidth - width));
top = Math.Clamp(top + deltaY, 0, Math.Max(0, displayHeight - height));
return (left, top, width, height);
}
if (hit is CropHit.West or CropHit.NorthWest or CropHit.SouthWest)
left = Math.Clamp(left + deltaX, 0, right - minSize);
if (hit is CropHit.East or CropHit.NorthEast or CropHit.SouthEast)
right = Math.Clamp(right + deltaX, left + minSize, displayWidth);
if (hit is CropHit.North or CropHit.NorthWest or CropHit.NorthEast)
top = Math.Clamp(top + deltaY, 0, bottom - minSize);
if (hit is CropHit.South or CropHit.SouthWest or CropHit.SouthEast)
bottom = Math.Clamp(bottom + deltaY, top + minSize, displayHeight);
return (left, top, Math.Max(minSize, right - left), Math.Max(minSize, bottom - top));
}
public static PixelInsets Nudge(PixelInsets insets, int pixelWidth, int pixelHeight, int deltaX, int deltaY)
{
int width = pixelWidth - insets.Left - insets.Right;
int height = pixelHeight - insets.Top - insets.Bottom;
if (width < MinimumSize || height < MinimumSize)
return insets;
int left = ToEvenPixel(insets.Left + deltaX, pixelWidth - width);
int top = ToEvenPixel(insets.Top + deltaY, pixelHeight - height);
return new PixelInsets(left, pixelWidth - left - width, top, pixelHeight - top - height);
}
public static PixelInsets NudgeEdge(
PixelInsets insets, int pixelWidth, int pixelHeight, int dLeft, int dTop, int dRight, int dBottom)
{
int left = ToEvenPixel(insets.Left + dLeft, pixelWidth - MinimumSize);
int top = ToEvenPixel(insets.Top + dTop, pixelHeight - MinimumSize);
int right = ToEvenPixel(insets.Right + dRight, pixelWidth - MinimumSize - left);
int bottom = ToEvenPixel(insets.Bottom + dBottom, 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);
}
private static void Normalize(ref double x, ref double y, ref double width, ref double height) private static void Normalize(ref double x, ref double y, ref double width, ref double height)
{ {
if (width < 0) if (width < 0)
+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>
+2 -3
View File
@@ -120,11 +120,10 @@ process are intentionally left to manual/integration testing rather than unit te
.\publish-win-x64.ps1 .\publish-win-x64.ps1
``` ```
This publishes a self-contained, single-file `win-x64` executable. At the end of the script This publishes a self-contained, single-file `win-x64` executable and copies it to:
you'll see the release folder and final executable path, for example:
```text ```text
bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\AmiReel.exe dist\AmiReel.exe
``` ```
## Installation ## Installation
+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);
} }
+90 -79
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();
@@ -160,6 +154,62 @@ public sealed class ShortsPipeline
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token); await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token);
} }
/// <summary>Writes several raw source frames across the clip so the crop editor can switch stills.</summary>
public async Task<IReadOnlyList<string>> ExtractSourceFramesAsync(
string inputFile,
string start,
IReadOnlyList<double> offsetsSeconds,
string ffmpegPath,
string ffprobePath,
string outputDirectory,
Action<string> log,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(inputFile) || !File.Exists(inputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
if (offsetsSeconds.Count == 0)
throw new ArgumentException("At least one preview offset is required.");
Directory.CreateDirectory(outputDirectory);
ToolPaths tools = await ToolExtractor.ResolveAsync(ffmpegPath, ffprobePath, token);
List<string> paths = [];
for (int index = 0; index < offsetsSeconds.Count; index++)
{
string seek = AddTimestampOffset(start, offsetsSeconds[index]);
string path = Path.Combine(outputDirectory, $"frame-{index:00}.png");
log($"Extracting crop preview {index + 1} of {offsetsSeconds.Count} ({seek})...");
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, seek, path), log, null, token);
paths.Add(path);
}
return paths;
}
internal const int CropPreviewFrameCount = 6;
internal static IReadOnlyList<double> CropPreviewOffsets(double durationSeconds, int count = CropPreviewFrameCount)
{
if (count <= 1 || durationSeconds <= 0)
return [0];
double last = Math.Max(0, durationSeconds - 0.15);
if (last <= 0)
return [0];
double[] offsets = new double[count];
for (int index = 0; index < count; index++)
offsets[index] = Math.Round(last * index / (count - 1), 3, MidpointRounding.AwayFromZero);
return offsets;
}
internal static string FormatPreviewOffsetLabel(double seconds)
{
TimeSpan value = TimeSpan.FromSeconds(Math.Max(0, seconds));
return value.TotalHours >= 1
? value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture)
: value.ToString(@"m\:ss", CultureInfo.InvariantCulture);
}
internal static List<string> BuildExtractFrameArguments(string inputFile, string start, string outputPath) => internal static List<string> BuildExtractFrameArguments(string inputFile, string start, string outputPath) =>
[ [
"-hide_banner", "-hide_banner",
@@ -365,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)
{ {
@@ -374,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)
@@ -424,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) {
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))
{ {
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}"); log($"Source crop: manual L={settings.CropLeft} R={settings.CropRight} T={settings.CropTop} B={settings.CropBottom}");
} }
else if (settings.SourceCropMode == ShortSourceCropMode.Auto) else if (settings.SourceCropMode == ShortSourceCropMode.None)
{ {
string? detected = await DetectHorizontalCropAsync(tools, settings, log, token); log("Source crop: none");
if (detected is not null)
{
cropFilter = detected;
}
else
{
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
}
} }
else 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);
} }
@@ -458,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;
}
}
+17 -5
View File
@@ -16,6 +16,8 @@ $releaseDir = Join-Path $projectDir 'bin\Release\net10.0-windows10.0.26100.0\win
$releaseExe = Join-Path $releaseDir 'AmiReel.exe' $releaseExe = Join-Path $releaseDir 'AmiReel.exe'
$publishDir = Join-Path $releaseDir 'publish' $publishDir = Join-Path $releaseDir 'publish'
$publishExe = Join-Path $publishDir 'AmiReel.exe' $publishExe = Join-Path $publishDir 'AmiReel.exe'
$distDir = Join-Path $projectDir 'dist'
$distExe = Join-Path $distDir 'AmiReel.exe'
$publishArguments = @( $publishArguments = @(
'publish', $projectPath, 'publish', $projectPath,
'-c', 'Release', '-c', 'Release',
@@ -27,11 +29,12 @@ $publishArguments = @(
'-p:EnableCompressionInSingleFile=true' '-p:EnableCompressionInSingleFile=true'
) )
if ((Test-Path $releaseExe) -or (Test-Path $publishExe)) { if ((Test-Path $releaseExe) -or (Test-Path $publishExe) -or (Test-Path $distExe)) {
$runningReleaseInstances = Get-Process | Where-Object { $runningReleaseInstances = Get-Process | Where-Object {
$_.Path -and ( $_.Path -and (
[string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) -or [string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) -or
[string]::Equals($_.Path, $publishExe, [System.StringComparison]::OrdinalIgnoreCase) [string]::Equals($_.Path, $publishExe, [System.StringComparison]::OrdinalIgnoreCase) -or
[string]::Equals($_.Path, $distExe, [System.StringComparison]::OrdinalIgnoreCase)
) )
} }
@@ -47,7 +50,16 @@ if (Test-Path $publishDir) {
} }
& dotnet @publishArguments & dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
Write-Host "Published AmiReel to $publishDir" -ForegroundColor Green if (-not (Test-Path $publishExe)) {
Write-Host "Release folder: $publishDir" -ForegroundColor Cyan throw "Published executable was not found at $publishExe."
Write-Host "Executable: $publishExe" -ForegroundColor Cyan }
New-Item -ItemType Directory -Path $distDir -Force | Out-Null
Copy-Item -LiteralPath $publishExe -Destination $distExe -Force
Write-Host "Published AmiReel to $distDir" -ForegroundColor Green
Write-Host "Executable: $distExe" -ForegroundColor Cyan