Compare commits
2
Commits
323324d713
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a590904e89 | ||
|
|
e635b708a2 |
@@ -4,6 +4,7 @@
|
|||||||
[Dd]ebug/
|
[Dd]ebug/
|
||||||
[Rr]elease/
|
[Rr]elease/
|
||||||
publish/
|
publish/
|
||||||
|
dist/
|
||||||
|
|
||||||
# IDE / tooling
|
# IDE / tooling
|
||||||
.vs/
|
.vs/
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
ZoomMode="Disabled"
|
||||||
|
HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollMode="Auto"
|
||||||
|
VerticalScrollMode="Auto"
|
||||||
|
SizeChanged="CropScroller_SizeChanged"
|
||||||
|
PointerWheelChanged="CropScroller_PointerWheelChanged"
|
||||||
|
AutomationProperties.Name="Source crop view">
|
||||||
|
<Grid x:Name="ImageSurface"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
PointerPressed="CropHost_PointerPressed"
|
IsTabStop="True"
|
||||||
PointerMoved="CropHost_PointerMoved"
|
UseSystemFocusVisuals="True"
|
||||||
PointerReleased="CropHost_PointerReleased"
|
PointerPressed="ImageSurface_PointerPressed"
|
||||||
PointerCaptureLost="CropHost_PointerCaptureLost"
|
PointerMoved="ImageSurface_PointerMoved"
|
||||||
SizeChanged="CropHost_SizeChanged"
|
PointerReleased="ImageSurface_PointerReleased"
|
||||||
|
PointerCaptureLost="ImageSurface_PointerCaptureLost"
|
||||||
|
KeyDown="ImageSurface_KeyDown"
|
||||||
AutomationProperties.Name="Source crop frame"
|
AutomationProperties.Name="Source crop frame"
|
||||||
AutomationProperties.AutomationId="SourceCropHost">
|
AutomationProperties.AutomationId="SourceCropHost">
|
||||||
<Image x:Name="SourceImage"
|
<Image x:Name="SourceImage"
|
||||||
Stretch="Uniform"
|
Stretch="Fill"
|
||||||
IsHitTestVisible="False"
|
IsHitTestVisible="False"
|
||||||
AutomationProperties.Name="Source video frame" />
|
AutomationProperties.Name="Source video frame" />
|
||||||
|
<Rectangle x:Name="BrightnessOverlay"
|
||||||
|
IsHitTestVisible="False"
|
||||||
|
Fill="White"
|
||||||
|
Opacity="0" />
|
||||||
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
|
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
|
||||||
<Rectangle x:Name="SelectionRect"
|
<Rectangle x:Name="SelectionRect"
|
||||||
Stroke="{ThemeResource AccentBrush}"
|
Stroke="{ThemeResource AccentBrush}"
|
||||||
StrokeThickness="2"
|
StrokeThickness="2"
|
||||||
Fill="#33007ACC"
|
Fill="#33007ACC"
|
||||||
Visibility="Collapsed" />
|
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>
|
</Canvas>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
<Grid ColumnSpacing="10">
|
<Grid ColumnSpacing="10">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
if (_dragHit == CropHit.None)
|
||||||
UpdateDraftSelection(x, y);
|
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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-80
@@ -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)
|
|
||||||
{
|
|
||||||
cropFilter = BuildManualCropFilter(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom);
|
|
||||||
log($"Source crop: manual L={settings.CropLeft} R={settings.CropRight} T={settings.CropTop} B={settings.CropBottom}");
|
|
||||||
}
|
|
||||||
else if (settings.SourceCropMode == ShortSourceCropMode.Auto)
|
|
||||||
{
|
|
||||||
string? detected = await DetectHorizontalCropAsync(tools, settings, log, token);
|
|
||||||
if (detected is not null)
|
|
||||||
{
|
|
||||||
cropFilter = detected;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
|
detected = await DetectHorizontalCropAsync(tools, settings, log, token);
|
||||||
|
if (detected is null)
|
||||||
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
|
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
|
||||||
}
|
}
|
||||||
|
else if (SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom))
|
||||||
|
{
|
||||||
|
log($"Source crop: manual L={settings.CropLeft} R={settings.CropRight} T={settings.CropTop} B={settings.CropBottom}");
|
||||||
|
}
|
||||||
|
else if (settings.SourceCropMode == ShortSourceCropMode.None)
|
||||||
|
{
|
||||||
|
log("Source crop: none");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
log("Source crop: centered 4:3");
|
log("Source crop: centered 4:3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string cropFilter = SourceCropFilters.Resolve(
|
||||||
|
settings.SourceCropMode,
|
||||||
|
settings.CropLeft,
|
||||||
|
settings.CropRight,
|
||||||
|
settings.CropTop,
|
||||||
|
settings.CropBottom,
|
||||||
|
detected);
|
||||||
|
|
||||||
return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity);
|
return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user