Compare commits

...
4 Commits
Author SHA1 Message Date
klevze a590904e89 Add video preview, source crop, find-videos, and render toasts
Bring the Video tab closer to Shorts with 4K preview stills and crop
modes (none, auto, center, plus the visual editor). Settings store a
default source folder so Find videos can collect output.avi sequences.
Cap the FFmpeg log with auto-scroll, hide repeat spam, and show a
Windows notification when a video or short finishes or fails.
2026-08-16 09:20:56 +02:00
klevze e635b708a2 Improve crop editor and copy publish output to dist
Add resize handles, zoom, view brightness, and a multi-frame still
strip. Keep the crop box on mouse-up, and copy AmiReel.exe to dist.
2026-08-15 22:59:20 +02:00
klevze 323324d713 Add Shorts look preview and visual crop editor
Show opening/closing 9:16 stills from the same filtergraph, and let
users drag a keep-region on a source frame to fill the crop insets.
2026-08-15 21:08:36 +02:00
klevze 8a734d58d9 Add Shorts wizard and v4 video-style pipeline
Turn the Shorts tab into a step-by-step wizard and port
amigadb-short-fx-v4.sh: Video background style, auto/manual crop,
source fade-in, and blur/opacity/margin settings.
2026-08-15 17:59:18 +02:00
31 changed files with 4743 additions and 170 deletions
+1
View File
@@ -4,6 +4,7 @@
[Dd]ebug/
[Rr]elease/
publish/
dist/
# IDE / tooling
.vs/
+49
View File
@@ -81,6 +81,55 @@ public class AppSettingsTests
Assert.AreEqual("3", normalized.FadeSeconds);
Assert.AreEqual("4", normalized.EndCardHoldSeconds);
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]
+92
View File
@@ -0,0 +1,92 @@
using AmiReel.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Models;
[TestClass]
public class ShortWizardTests
{
[TestMethod]
public void StepsFor_BrandStyle_IncludesEffectSettingsPage()
{
// Act
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Brand);
// Assert
CollectionAssert.AreEqual(
new[]
{
ShortWizardStep.Clip,
ShortWizardStep.Effect,
ShortWizardStep.StyleOptions,
ShortWizardStep.Titles,
ShortWizardStep.Encode,
},
steps.ToArray());
}
[TestMethod]
public void StepsFor_VideoStyle_IncludesEffectSettingsPage()
{
// Act
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Video);
// Assert
Assert.IsTrue(steps.Contains(ShortWizardStep.StyleOptions));
Assert.AreEqual(5, steps.Count);
}
[TestMethod]
public void StepsFor_CropStyle_SkipsEffectSettingsPage()
{
// Act
IReadOnlyList<ShortWizardStep> steps = ShortWizard.StepsFor(ShortStyle.Crop);
// Assert
CollectionAssert.AreEqual(
new[]
{
ShortWizardStep.Clip,
ShortWizardStep.Effect,
ShortWizardStep.Titles,
ShortWizardStep.Encode,
},
steps.ToArray());
}
[TestMethod]
public void NeedsStyleOptions_OnlyBrandAndVideo_ReturnsTrue()
{
// Assert
Assert.IsTrue(ShortWizard.NeedsStyleOptions(ShortStyle.Brand));
Assert.IsTrue(ShortWizard.NeedsStyleOptions(ShortStyle.Video));
Assert.IsFalse(ShortWizard.NeedsStyleOptions(ShortStyle.Pixel));
Assert.IsFalse(ShortWizard.NeedsStyleOptions(ShortStyle.Spectrum));
}
[TestMethod]
public void Description_ForEveryStyle_IsNonEmpty()
{
foreach (ShortStyle style in Enum.GetValues<ShortStyle>())
{
// Act
string description = ShortWizard.Description(style);
// Assert
Assert.IsFalse(string.IsNullOrWhiteSpace(description), $"{style} is missing a description.");
}
}
[TestMethod]
public void Title_ForEveryStep_IsNonEmpty()
{
foreach (ShortWizardStep step in Enum.GetValues<ShortWizardStep>())
{
// Act
string title = ShortWizard.Title(step);
// Assert
Assert.IsFalse(string.IsNullOrWhiteSpace(title), $"{step} is missing a title.");
}
}
}
@@ -0,0 +1,215 @@
using AmiReel.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Models;
[TestClass]
public class SourceCropSelectionTests
{
[TestMethod]
public void FitUniform_WiderContainer_LetterboxesHorizontally()
{
// Act
(double offsetX, double offsetY, double width, double height) =
SourceCropSelection.FitUniform(400, 200, 100, 100);
// Assert
Assert.AreEqual(100, offsetX);
Assert.AreEqual(0, offsetY);
Assert.AreEqual(200, width);
Assert.AreEqual(200, height);
}
[TestMethod]
public void FitUniform_TallerContainer_LetterboxesVertically()
{
// Act
(double offsetX, double offsetY, double width, double height) =
SourceCropSelection.FitUniform(200, 400, 100, 100);
// Assert
Assert.AreEqual(0, offsetX);
Assert.AreEqual(100, offsetY);
Assert.AreEqual(200, width);
Assert.AreEqual(200, height);
}
[TestMethod]
public void ToInsets_FullFrameSelection_ReturnsEmptyInsets()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(640, 512, 640, 512, 0, 0, 640, 512);
// Assert
Assert.IsTrue(insets.IsEmpty);
}
[TestMethod]
public void ToInsets_CenteredSelection_ComputesAllFourEdges()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(100, 100, 100, 100, 10, 20, 70, 50);
// Assert
Assert.AreEqual(10, insets.Left);
Assert.AreEqual(20, insets.Right);
Assert.AreEqual(20, insets.Top);
Assert.AreEqual(30, insets.Bottom);
}
[TestMethod]
public void ToInsets_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]
public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(100, 100, 100, 100, 80, 70, -70, -50);
// Assert
Assert.AreEqual(10, insets.Left);
Assert.AreEqual(20, insets.Right);
Assert.AreEqual(20, insets.Top);
Assert.AreEqual(30, insets.Bottom);
}
[TestMethod]
public void ToDisplayRect_RoundTripsKnownInsets()
{
// Arrange
PixelInsets insets = new(20, 20, 10, 10);
// Act
(double x, double y, double width, double height) =
SourceCropSelection.ToDisplayRect(100, 100, 200, 200, insets);
// Assert
Assert.AreEqual(40, x);
Assert.AreEqual(20, y);
Assert.AreEqual(120, width);
Assert.AreEqual(160, height);
}
[TestMethod]
public void ClampToDisplay_OutsideBounds_ClampsToEdges()
{
// Act
(double x, double y) = SourceCropSelection.ClampToDisplay(-8, 90, 80, 60);
// Assert
Assert.AreEqual(0, x);
Assert.AreEqual(60, y);
}
[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("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("Last message repeated 1 times")]
[DataRow("Last message repeated 24 times")]
public void IsBoilerplateLine_WithKnownFfmpegBanner_ReturnsTrue(string line)
{
// 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);
}
[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]
public void BuildProgressMessage_WithOnlyTime_ReturnsTimeRangeOnly()
{
@@ -186,6 +186,74 @@ public class ShortsPipelineTests
ShortsPipeline.Validate(settings);
}
[TestMethod]
public void Validate_VideoStyleWithoutBackgroundVideo_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video);
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_VideoStyleWithBackgroundVideo_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
string background = Path.Combine(directory, "bg.mp4");
File.WriteAllText(background, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with { BackgroundVideo = background };
// Act & Assert (no exception)
ShortsPipeline.Validate(settings);
}
[TestMethod]
public void Validate_VideoOpacityAboveOne_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
string background = Path.Combine(directory, "bg.mp4");
File.WriteAllText(background, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with
{
BackgroundVideo = background,
VideoOpacity = 1.2,
};
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_SourceMarginTooWide_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font) with { SourceMarginX = 540 };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag()
{
@@ -220,4 +288,309 @@ public class ShortsPipelineTests
StringAssert.Contains(result, "enable='gte(t,26.5)'");
StringAssert.Contains(result, "[vout]");
}
[TestMethod]
public void GetBaseFilter_VideoStyle_UsesBackgroundVideoInput()
{
// Act
string filter = ShortsPipeline.GetBaseFilter(ShortStyle.Video);
// Assert
StringAssert.Contains(filter, "[1:v]");
StringAssert.Contains(filter, "colorchannelmixer=aa=1");
StringAssert.Contains(filter, "[base]");
Assert.IsFalse(filter.Contains("gblur=", StringComparison.Ordinal));
}
[TestMethod]
public void BuildVideoBaseFilter_WithBlur_InsertsGblur()
{
// Act
string filter = ShortsPipeline.BuildVideoBaseFilter(
"crop=iw:ih:0:0", "scale=1000:-2:flags=lanczos", blur: 8, opacity: 0.65);
// Assert
StringAssert.Contains(filter, "gblur=sigma=8");
StringAssert.Contains(filter, "colorchannelmixer=aa=0.65");
StringAssert.Contains(filter, "crop=iw:ih:0:0,scale=1000:-2:flags=lanczos");
}
[TestMethod]
public void ApplySourceFade_WhenFadeIsZero_ReturnsOriginalFilter()
{
// Arrange
const string baseFilter = "[0:v]split=2[bg][fg]";
// Act
string result = ShortsPipeline.ApplySourceFade(baseFilter, 0);
// Assert
Assert.AreEqual(baseFilter, result);
}
[TestMethod]
public void ApplySourceFade_WhenFadeIsPositive_RewritesSourceLabel()
{
// Act
string result = ShortsPipeline.ApplySourceFade("[0:v]split=2[bg][fg];[0:a]anull[a]", 1.5);
// Assert
StringAssert.StartsWith(result, "[0:v]format=rgba,fade=t=in:st=0:d=1.5:alpha=1[srcfade];");
StringAssert.Contains(result, "[srcfade]split=2[bg][fg]");
StringAssert.Contains(result, "[0:a]anull[a]");
Assert.IsFalse(result.Contains("[0:v]split", StringComparison.Ordinal));
}
[TestMethod]
public void SelectMostCommonCropSpec_PicksTheMostFrequentSpec()
{
// Arrange
const string output =
"crop=800:480:100:0\n" +
"crop=780:480:120:0\n" +
"crop=780:480:120:0\n" +
"crop=760:480:140:0";
// Act
string? spec = ShortsPipeline.SelectMostCommonCropSpec(output);
// Assert
Assert.AreEqual("crop=780:480:120:0", spec);
}
[TestMethod]
public void TryBuildAutoHorizontalCrop_AppliesEvenSafetyInset()
{
// Act
bool ok = ShortsPipeline.TryBuildAutoHorizontalCrop(800, 100, out int width, out int x);
// Assert
Assert.IsTrue(ok);
Assert.AreEqual(792, width);
Assert.AreEqual(104, x);
}
[TestMethod]
public void BuildManualCropFilter_UsesEdgeInsets()
{
// Act
string filter = ShortsPipeline.BuildManualCropFilter(120, 80, 10, 20);
// Assert
Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter);
}
[TestMethod]
public void 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]
public void SourceTargetWidth_SubtractsBothMargins()
{
// Act
int width = ShortsPipeline.SourceTargetWidth(40);
// Assert
Assert.AreEqual(1000, width);
}
[TestMethod]
public void ValidatePreview_WithMissingTitleAndOutput_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
ShortSettings settings = ValidSettings(input, Path.Combine(directory, "missing-font.ttf")) with
{
Title = "",
OutputPath = "",
};
// Act & Assert (no exception)
ShortsPipeline.ValidatePreview(settings);
}
[TestMethod]
public void ValidatePreview_WithMissingInput_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf");
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.ValidatePreview(settings));
}
[TestMethod]
public void PreviewOverlayWindows_Opening_HidesTitleCard()
{
// Act
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Opening, 30);
// Assert
Assert.AreEqual(30, hookEnd);
Assert.AreEqual(31, infoStart);
Assert.AreEqual(31, ctaStart);
}
[TestMethod]
public void PreviewOverlayWindows_Closing_ShowsTitlesImmediately()
{
// Act
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Closing, 30);
// Assert
Assert.AreEqual(-1, hookEnd);
Assert.AreEqual(0, infoStart);
Assert.AreEqual(0, ctaStart);
}
[TestMethod]
public void PreviewSeekSeconds_OpeningAndClosing_StayInsideDuration()
{
// Act
double opening = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Opening, 30);
double closing = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Closing, 30);
// Assert
Assert.AreEqual(1.2, opening);
Assert.AreEqual(29.6, closing);
}
[TestMethod]
public void AddTimestampOffset_AddsFractionalSeconds()
{
// Act
string result = ShortsPipeline.AddTimestampOffset("00:01:10", 1.2);
// Assert
Assert.AreEqual("00:01:11.200", result);
}
[TestMethod]
public void BuildInputArguments_VideoStyle_IncludesLoopedBackgroundVideo()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf", ShortStyle.Video) with
{
BackgroundVideo = @"D:\bg.mp4",
};
// Act
List<string> args = ShortsPipeline.BuildInputArguments(settings, 0.12, "00:00:01.200");
// Assert
CollectionAssert.Contains(args, "00:00:01.200");
CollectionAssert.Contains(args, "-stream_loop");
CollectionAssert.Contains(args, @"D:\bg.mp4");
CollectionAssert.Contains(args, "in.mp4");
}
[TestMethod]
public void 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]
public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng()
{
// Act
List<string> args = ShortsPipeline.BuildExtractFrameArguments(@"D:\clip.mp4", "00:00:05", @"C:\tmp\frame.png");
// Assert
CollectionAssert.Contains(args, "00:00:05");
CollectionAssert.Contains(args, @"D:\clip.mp4");
CollectionAssert.Contains(args, "-frames:v");
CollectionAssert.Contains(args, "1");
CollectionAssert.Contains(args, @"C:\tmp\frame.png");
}
[TestMethod]
public void BuildFilterComplex_WithoutTextOverlays_SkipsDrawtext()
{
// Act
string result = ShortsPipeline.BuildFilterComplex(
"[0:v]copy[base]",
fontEsc: "", hookEsc: "", titleEsc: "", metaEsc: "", websiteEsc: "",
hookEnd: 2.7, infoStart: 23, ctaStart: 26.5,
includeTextOverlays: false);
// Assert
StringAssert.Contains(result, "[0:v]copy[base]");
StringAssert.Contains(result, "[vout]");
Assert.IsFalse(result.Contains("drawtext", StringComparison.Ordinal));
Assert.IsFalse(result.Contains("drawbox", StringComparison.Ordinal));
}
}
@@ -0,0 +1,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);
}
}
+6
View File
@@ -181,6 +181,12 @@
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style x:Key="TabButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
<Setter Property="Padding" Value="16,9" />
<Setter Property="FontSize" Value="13" />
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{ThemeResource AccentBrush}" />
<Setter Property="Foreground" Value="{ThemeResource PrimaryTextBrush}" />
+3 -1
View File
@@ -1,4 +1,5 @@
using Microsoft.UI.Xaml;
using AmiReel.Services;
using Microsoft.UI.Xaml;
using WinRT.Interop;
namespace AmiReel;
@@ -29,6 +30,7 @@ public partial class App : Application
{
_window = new MainWindow();
MainWindowHandle = WindowNative.GetWindowHandle(_window);
RenderNotifier.Register();
_window.Activate();
}
}
+128
View File
@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="AmiReel.Controls.SourceCropEditor"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<StackPanel Spacing="10" MinWidth="640">
<TextBlock x:Name="StatusText"
Text="Drag a keep-region, then resize it with the handles. Zoom and brightness are for viewing only."
Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" />
<ProgressBar x:Name="LoadProgress"
IsIndeterminate="True"
Height="6"
Visibility="Collapsed"
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}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<ScrollViewer x:Name="CropScroller"
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"
IsTabStop="True"
UseSystemFocusVisuals="True"
PointerPressed="ImageSurface_PointerPressed"
PointerMoved="ImageSurface_PointerMoved"
PointerReleased="ImageSurface_PointerReleased"
PointerCaptureLost="ImageSurface_PointerCaptureLost"
KeyDown="ImageSurface_KeyDown"
AutomationProperties.Name="Source crop frame"
AutomationProperties.AutomationId="SourceCropHost">
<Image x:Name="SourceImage"
Stretch="Fill"
IsHitTestVisible="False"
AutomationProperties.Name="Source video frame" />
<Rectangle x:Name="BrightnessOverlay"
IsHitTestVisible="False"
Fill="White"
Opacity="0" />
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
<Rectangle x:Name="SelectionRect"
Stroke="{ThemeResource AccentBrush}"
StrokeThickness="2"
Fill="#33007ACC"
Visibility="Collapsed" />
<Rectangle x:Name="HandleNW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleN" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleNE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleSE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleS" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleSW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
</Canvas>
</Grid>
</ScrollViewer>
</Border>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock x:Name="InsetsText"
Grid.Column="0"
Text="Crop L0 R0 T0 B0"
Style="{StaticResource MutedTextStyle}"
VerticalAlignment="Center" />
<Button x:Name="ClearCropButton"
Grid.Column="1"
Content="Clear crop"
Click="ClearCrop_Click"
AutomationProperties.Name="Clear crop"
AutomationProperties.AutomationId="ClearSourceCropButton" />
</Grid>
</StackPanel>
</UserControl>
+486
View File
@@ -0,0 +1,486 @@
using System.Runtime.InteropServices.WindowsRuntime;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Foundation;
using Windows.System;
using Windows.UI.Core;
namespace AmiReel.Controls;
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 _pixelHeight;
private double _zoom = 1;
private bool _isDragging;
private CropHit _dragHit = CropHit.None;
private Point _dragStart;
private Rect _dragOriginRect;
private PixelInsets _insets;
public SourceCropEditor()
{
InitializeComponent();
}
public PixelInsets Insets => _insets;
public async Task LoadVideoFrameAsync(
string inputFile, string start, double durationSeconds, string ffmpegPath, string ffprobePath, CancellationToken token)
{
LoadProgress.Visibility = Visibility.Visible;
StatusText.Text = "Loading preview frames...";
try
{
IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(durationSeconds);
string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor", Guid.NewGuid().ToString("N"));
ShortsPipeline pipeline = new();
IReadOnlyList<string> paths = await pipeline.ExtractSourceFramesAsync(
inputFile, start, offsets, ffmpegPath, ffprobePath, directory, _ => { }, token);
_framePaths.Clear();
_frameLabels.Clear();
_fullFrames.Clear();
_frameButtons.Clear();
FrameStrip.Children.Clear();
for (int index = 0; index < paths.Count; index++)
{
_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
{
LoadProgress.Visibility = Visibility.Collapsed;
}
}
public void SetInsets(PixelInsets insets)
{
_insets = insets;
RefreshSelectionVisual();
UpdateInsetsText();
}
private Button CreateFrameButton(int index, string path, string label)
{
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;
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;
_dragHit = hit;
_dragStart = new Point(x, y);
_dragOriginRect = new Rect(rectX, rectY, rectW, rectH);
ImageSurface.CapturePointer(e.Pointer);
e.Handled = true;
}
private void ImageSurface_PointerMoved(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
{
UpdateHoverCursor(e);
return;
}
if (!TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
return;
if (_dragHit == CropHit.None)
UpdateDraftSelection(x, y);
else
UpdateAdjustedSelection(x, y, displayWidth, displayHeight);
e.Handled = true;
}
private void ImageSurface_PointerReleased(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
return;
EndDrag();
ImageSurface.ReleasePointerCapture(e.Pointer);
e.Handled = true;
}
private void ImageSurface_PointerCaptureLost(object sender, PointerRoutedEventArgs e)
{
if (_isDragging)
EndDrag();
}
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 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(
PointerRoutedEventArgs e, out double x, out double y, out double displayWidth, out double displayHeight)
{
displayWidth = ImageSurface.Width;
displayHeight = ImageSurface.Height;
x = 0;
y = 0;
if (displayWidth <= 0 || displayHeight <= 0)
return false;
PointerPoint point = e.GetCurrentPoint(ImageSurface);
(x, y) = SourceCropSelection.ClampToDisplay(point.Position.X, point.Position.Y, displayWidth, displayHeight);
return true;
}
private void UpdateDraftSelection(double x, double y)
{
double displayWidth = ImageSurface.Width;
double displayHeight = ImageSurface.Height;
if (displayWidth <= 0)
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(
_pixelWidth, _pixelHeight, displayWidth, displayHeight, left, top, width, height);
UpdateInsetsText();
}
private void UpdateAdjustedSelection(double x, double y, double displayWidth, double displayHeight)
{
(double nextX, double nextY, double nextW, double nextH) = SourceCropSelection.AdjustRect(
_dragOriginRect.X,
_dragOriginRect.Y,
_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();
}
private void RefreshSelectionVisual()
{
if (ImageSurface.Width <= 0 || _pixelWidth <= 0 || _insets.IsEmpty)
{
SetHandlesVisible(false);
return;
}
(double x, double y, double width, double height) = CurrentDisplayRect(ImageSurface.Width, ImageSurface.Height);
ShowDisplayRect(x, y, width, height);
}
private void ShowDisplayRect(double x, double y, double width, double height)
{
Canvas.SetLeft(SelectionRect, x);
Canvas.SetTop(SelectionRect, y);
SelectionRect.Width = Math.Max(1, width);
SelectionRect.Height = Math.Max(1, height);
SelectionRect.Visibility = Visibility.Visible;
PlaceHandle(HandleNW, x, y);
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 X, double Y, double Width, double Height) CurrentDisplayRect(double displayWidth, double displayHeight) =>
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() =>
InsetsText.Text = _pixelWidth <= 0
? "Crop L0 R0 T0 B0"
: $"Crop L{_insets.Left} R{_insets.Right} T{_insets.Top} B{_insets.Bottom} · {_pixelWidth - _insets.Left - _insets.Right}×{_pixelHeight - _insets.Top - _insets.Bottom}";
}
+408 -118
View File
@@ -5,34 +5,51 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="using:AmiReel.Controls"
mc:Ignorable="d">
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid RowSpacing="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Button x:Name="VideoTabButton" Content="Video" Click="VideoTab_Click" Style="{StaticResource PrimaryButtonStyle}" MinWidth="120" />
<Button x:Name="ShortsTabButton" Content="Shorts" Click="ShortsTab_Click" MinWidth="120" />
<Button x:Name="ShortsTabButton" Content="Shorts" Click="ShortsTab_Click" Style="{StaticResource TabButtonStyle}" MinWidth="120" />
</StackPanel>
<Grid Grid.Row="1" ColumnSpacing="16" x:Name="VideoContentPanel">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Source recordings" Style="{StaticResource SectionTitleStyle}" />
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Add video files..." Click="AddInputs_Click" />
<Button Content="Clear" Click="ClearInputs_Click" />
<Button Content="Add video files..." Click="AddInputs_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>
<ListView x:Name="InputList"
Height="130"
@@ -41,12 +58,47 @@
AllowDrop="True"
DragOver="InputList_DragOver"
Drop="InputList_Drop"
AutomationProperties.Name="Source recordings"
ToolTipService.ToolTip="Drag and drop video files here to add them" />
<Button x:Name="PreviewSourceButton"
Content="Preview selected"
Click="PreviewSource_Click"
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>
</Border>
@@ -66,115 +118,261 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox x:Name="EndCardBox" Grid.Row="0" MinHeight="40" />
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseEndCard_Click" VerticalAlignment="Stretch" MinWidth="120" />
<TextBox x:Name="EndCardBox" Grid.Row="0" MinHeight="40" Header="End card"
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" />
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseOutput_Click" VerticalAlignment="Stretch" MinWidth="120" />
<TextBox x:Name="OutputFolderBox" Grid.Row="1" MinHeight="40" Header="Output folder"
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>
</StackPanel>
</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 Grid.Row="1" RowSpacing="16" x:Name="ShortsContentPanel" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid ColumnSpacing="16">
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}" x:Name="ShortsContentPanel" Visibility="Collapsed">
<Grid ColumnSpacing="18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Spacing="14">
<StackPanel Spacing="4">
<TextBlock x:Name="ShortWizardTitleText" Text="Source clip" Style="{StaticResource SectionTitleStyle}" />
<TextBlock x:Name="ShortWizardStepText" Text="Step 1 of 4" Style="{StaticResource MutedTextStyle}" />
<ProgressBar x:Name="ShortWizardProgress" Minimum="0" Maximum="4" Value="1" Height="6" />
</StackPanel>
<Border Grid.Column="0" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Short source and clip" Style="{StaticResource SectionTitleStyle}" />
<Grid ColumnSpacing="10" RowSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox x:Name="ShortInputBox" Grid.Row="0" MinHeight="40" ToolTipService.ToolTip="Source video for this Short" />
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseShortInput_Click" VerticalAlignment="Stretch" MinWidth="120" />
<TextBox x:Name="ShortOutputBox" Grid.Row="1" MinHeight="40" ToolTipService.ToolTip="Output .mp4 path" />
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseShortOutput_Click" VerticalAlignment="Stretch" MinWidth="120" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Start (HH:MM:SS)" Style="{StaticResource FieldLabelStyle}" />
<TextBox x:Name="ShortStartBox" Text="00:00:00" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="Duration (seconds)" Style="{StaticResource FieldLabelStyle}" />
<TextBox x:Name="ShortDurationBox" Text="30" />
</StackPanel>
</Grid>
<Button x:Name="PreviewShortSourceButton" Content="Preview source" Click="PreviewShortSource_Click" HorizontalAlignment="Left" />
</StackPanel>
</Border>
<Border Grid.Column="1" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Title card" Style="{StaticResource SectionTitleStyle}" />
<TextBox x:Name="ShortTitleBox" Header="Title" ToolTipService.ToolTip="Production title, e.g. Ami-Back V1.04A" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortGroupBox" Grid.Column="0" Header="Group" />
<TextBox x:Name="ShortYearBox" Grid.Column="1" Header="Year" />
<TextBox x:Name="ShortTypeBox" Grid.Column="2" Header="Type" />
</Grid>
<TextBox x:Name="ShortHookBox" Header="Opening hook" />
<TextBox x:Name="ShortWebsiteBox" Header="Closing website text" />
</StackPanel>
</Border>
</Grid>
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12">
<TextBlock Text="Style and encoding" Style="{StaticResource SectionTitleStyle}" />
<StackPanel x:Name="ShortStepClipPanel" Spacing="12">
<TextBlock Text="Choose the Amiga clip and how much of it to use." Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<Grid ColumnSpacing="10" RowSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox x:Name="ShortInputBox" Grid.Row="0" Header="Source video" MinHeight="40"
AutomationProperties.Name="Source video"
ToolTipService.ToolTip="Source video for this Short" />
<Button Grid.Row="0" Grid.Column="1" Content="Browse..." Click="BrowseShortInput_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse source video" />
<TextBox x:Name="ShortOutputBox" Grid.Row="1" Header="Output file" MinHeight="40"
AutomationProperties.Name="Output file"
ToolTipService.ToolTip="Output .mp4 path. Leave blank to name it next to the source." />
<Button Grid.Row="1" Grid.Column="1" Content="Browse..." Click="BrowseShortOutput_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse output file" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ComboBox x:Name="ShortStyleBox" Grid.Column="0" Header="Style" SelectionChanged="ShortStyleBox_SelectionChanged" HorizontalAlignment="Stretch">
<ComboBoxItem Content="Brand (title card image)" Tag="Brand" />
<ComboBoxItem Content="Pixel" Tag="Pixel" />
<ComboBoxItem Content="Mirror" Tag="Mirror" />
<ComboBoxItem Content="Crop" Tag="Crop" />
<ComboBoxItem Content="Workbench" Tag="Workbench" />
<ComboBoxItem Content="Blur" Tag="Blur" />
<ComboBoxItem Content="CRT" Tag="Crt" />
<ComboBoxItem Content="Copper bars" Tag="Copper" />
<ComboBoxItem Content="Stars" Tag="Stars" />
<ComboBoxItem Content="Grid" Tag="Grid" />
<ComboBoxItem Content="Tiles" Tag="Tiles" />
<ComboBoxItem Content="Scanlines" Tag="Scan" />
<ComboBoxItem Content="Starfield" Tag="Starfield" />
<ComboBoxItem Content="Plasma" Tag="Plasma" />
<ComboBoxItem Content="Rasterbars" Tag="Rasterbars" />
<ComboBoxItem Content="VHS" Tag="Vhs" />
<ComboBoxItem Content="Monitor" Tag="Monitor" />
<ComboBoxItem Content="Split" Tag="Split" />
<ComboBoxItem Content="Spectrum (needs audio)" Tag="Spectrum" />
<TextBox x:Name="ShortStartBox" Grid.Column="0" Header="Start (HH:MM:SS)" Text="00:00:00"
AutomationProperties.Name="Start time" />
<TextBox x:Name="ShortDurationBox" Grid.Column="1" Header="Duration (seconds)" Text="30"
AutomationProperties.Name="Duration in seconds" />
</Grid>
<Button x:Name="PreviewShortSourceButton" Content="Preview source" Click="PreviewShortSource_Click"
HorizontalAlignment="Left" AutomationProperties.Name="Preview source" />
</StackPanel>
<StackPanel x:Name="ShortStepEffectPanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="Pick a look. Extra settings appear on the next step only when that effect needs them."
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<ComboBox x:Name="ShortStyleBox" Header="Effect" SelectionChanged="ShortStyleBox_SelectionChanged"
HorizontalAlignment="Stretch" AutomationProperties.Name="Visual effect">
<ComboBoxItem Content="Brand (title card image)" Tag="Brand" />
<ComboBoxItem Content="Pixel" Tag="Pixel" />
<ComboBoxItem Content="Mirror" Tag="Mirror" />
<ComboBoxItem Content="Crop" Tag="Crop" />
<ComboBoxItem Content="Workbench" Tag="Workbench" />
<ComboBoxItem Content="Blur" Tag="Blur" />
<ComboBoxItem Content="CRT" Tag="Crt" />
<ComboBoxItem Content="Copper bars" Tag="Copper" />
<ComboBoxItem Content="Stars" Tag="Stars" />
<ComboBoxItem Content="Grid" Tag="Grid" />
<ComboBoxItem Content="Tiles" Tag="Tiles" />
<ComboBoxItem Content="Scanlines" Tag="Scan" />
<ComboBoxItem Content="Starfield" Tag="Starfield" />
<ComboBoxItem Content="Plasma" Tag="Plasma" />
<ComboBoxItem Content="Rasterbars" Tag="Rasterbars" />
<ComboBoxItem Content="VHS" Tag="Vhs" />
<ComboBoxItem Content="Monitor" Tag="Monitor" />
<ComboBoxItem Content="Split" Tag="Split" />
<ComboBoxItem Content="Spectrum (needs audio)" Tag="Spectrum" />
<ComboBoxItem Content="Video (background clip)" Tag="Video" />
</ComboBox>
<TextBlock x:Name="ShortStyleDescriptionText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<TextBox x:Name="ShortFadeInBox" Header="Source fade-in (seconds)" Text="1.5"
AutomationProperties.Name="Source fade-in seconds"
ToolTipService.ToolTip="Fade only the Amiga clip in. The background stays visible from the first frame. Use 0 to disable." />
</StackPanel>
<StackPanel x:Name="ShortStepStyleOptionsPanel" Spacing="12" Visibility="Collapsed">
<Grid x:Name="ShortBackgroundRow" ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortBackgroundBox" Grid.Column="0" Header="Background image"
AutomationProperties.Name="Background image" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortBackground_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse background image" />
</Grid>
<StackPanel x:Name="ShortVideoOptionsPanel" Spacing="12">
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortBackgroundVideoBox" Grid.Column="0" Header="Background video"
AutomationProperties.Name="Background video" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortBackgroundVideo_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse background video" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortVideoBlurBox" Grid.Column="0" Header="Background blur" Text="0"
AutomationProperties.Name="Background video blur" />
<TextBox x:Name="ShortVideoOpacityBox" Grid.Column="1" Header="Background opacity (0-1)" Text="1.0"
AutomationProperties.Name="Background video opacity" />
<TextBox x:Name="ShortSourceMarginBox" Grid.Column="2" Header="Side margin (px)" Text="40"
AutomationProperties.Name="Source side margin" />
</Grid>
<ComboBox x:Name="ShortCropModeBox" Header="Source crop" HorizontalAlignment="Stretch"
AutomationProperties.Name="Source crop mode">
<ComboBoxItem Content="None (keep full frame)" Tag="None" />
<ComboBoxItem Content="Auto (remove left/right black borders)" Tag="Auto" />
<ComboBoxItem Content="Center 4:3" Tag="Center" />
</ComboBox>
<ComboBox x:Name="ShortPresetBox" Grid.Column="1" Header="x264 preset" HorizontalAlignment="Stretch">
<TextBlock Text="Manual crop overrides 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="ShortCropLeftBox" Grid.Column="0" Header="Crop left" Text="0"
AutomationProperties.Name="Crop left pixels" />
<TextBox x:Name="ShortCropRightBox" Grid.Column="1" Header="Crop right" Text="0"
AutomationProperties.Name="Crop right pixels" />
<TextBox x:Name="ShortCropTopBox" Grid.Column="2" Header="Crop top" Text="0"
AutomationProperties.Name="Crop top pixels" />
<TextBox x:Name="ShortCropBottomBox" Grid.Column="3" Header="Crop bottom" Text="0"
AutomationProperties.Name="Crop bottom pixels" />
</Grid>
<Button x:Name="OpenCropEditorButton"
Content="Crop visually..."
Click="OpenCropEditor_Click"
HorizontalAlignment="Left"
AutomationProperties.Name="Open visual crop editor"
AutomationProperties.AutomationId="OpenCropEditorButton" />
</StackPanel>
</StackPanel>
<StackPanel x:Name="ShortStepTitlesPanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="These lines appear as the opening hook and the closing title card."
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<TextBox x:Name="ShortTitleBox" Header="Title"
AutomationProperties.Name="Title"
ToolTipService.ToolTip="Production title, e.g. Ami-Back V1.04A" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortGroupBox" Grid.Column="0" Header="Group" AutomationProperties.Name="Group" />
<TextBox x:Name="ShortYearBox" Grid.Column="1" Header="Year" AutomationProperties.Name="Year" />
<TextBox x:Name="ShortTypeBox" Grid.Column="2" Header="Type" AutomationProperties.Name="Type" />
</Grid>
<TextBox x:Name="ShortHookBox" Header="Opening hook" AutomationProperties.Name="Opening hook" />
<TextBox x:Name="ShortWebsiteBox" Header="Closing website text" AutomationProperties.Name="Closing website text" />
</StackPanel>
<StackPanel x:Name="ShortStepEncodePanel" Spacing="12" Visibility="Collapsed">
<TextBlock Text="Encoding quality and the font used for the title card."
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortFontBox" Grid.Column="0" Header="Bold font file (.ttf/.otf)"
AutomationProperties.Name="Bold font file" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortFont_Click"
VerticalAlignment="Bottom" MinWidth="120"
AutomationProperties.Name="Browse font file" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="120" />
</Grid.ColumnDefinitions>
<ComboBox x:Name="ShortPresetBox" Grid.Column="0" Header="x264 preset"
HorizontalAlignment="Stretch" AutomationProperties.Name="x264 preset">
<ComboBoxItem Content="ultrafast" Tag="ultrafast" />
<ComboBoxItem Content="superfast" Tag="superfast" />
<ComboBoxItem Content="veryfast" Tag="veryfast" />
@@ -185,30 +383,69 @@
<ComboBoxItem Content="slower" Tag="slower" />
<ComboBoxItem Content="veryslow" Tag="veryslow" />
</ComboBox>
<TextBox x:Name="ShortCrfBox" Grid.Column="1" Header="CRF" AutomationProperties.Name="CRF" />
</Grid>
<Grid x:Name="ShortBackgroundRow" ColumnSpacing="10">
<TextBlock x:Name="ShortSummaryText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Width="308" Spacing="10">
<TextBlock Text="Preview" Style="{StaticResource SectionTitleStyle}" />
<TextBlock x:Name="ShortPreviewStatusText"
Text="Pick a source clip, then update the preview to see the 9:16 look."
Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" />
<Button x:Name="UpdateShortPreviewButton"
Content="Update preview"
Click="UpdateShortPreview_Click"
HorizontalAlignment="Stretch"
AutomationProperties.Name="Update short preview"
AutomationProperties.AutomationId="UpdateShortPreviewButton" />
<ProgressBar x:Name="ShortPreviewProgress"
IsIndeterminate="True"
Height="6"
Visibility="Collapsed"
AutomationProperties.Name="Preview progress" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortBackgroundBox" Grid.Column="0" Header="Background image (brand style)" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortBackground_Click" VerticalAlignment="Bottom" MinWidth="120" />
</Grid>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="120" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ShortFontBox" Grid.Column="0" Header="Bold font file (.ttf/.otf)" />
<Button Grid.Column="1" Content="Browse..." Click="BrowseShortFont_Click" VerticalAlignment="Bottom" MinWidth="120" />
<TextBox x:Name="ShortCrfBox" Grid.Column="2" Header="CRF" />
<StackPanel Spacing="6">
<TextBlock Text="Opening" Style="{StaticResource MutedTextStyle}" />
<Border Width="148" Height="263"
Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Image x:Name="ShortPreviewOpeningImage"
Stretch="Uniform"
AutomationProperties.Name="Opening hook preview" />
</Border>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="6">
<TextBlock Text="Closing" Style="{StaticResource MutedTextStyle}" />
<Border Width="148" Height="263"
Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Image x:Name="ShortPreviewClosingImage"
Stretch="Uniform"
AutomationProperties.Name="Closing title-card preview" />
</Border>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
<Border Grid.Row="2" Padding="18" Style="{StaticResource CardBorderStyle}">
<Border x:Name="RenderProgressPanel"
Grid.Row="2"
Padding="18"
Style="{StaticResource CardBorderStyle}"
Visibility="Collapsed"
AutomationProperties.Name="Render progress">
<StackPanel Spacing="12">
<TextBlock Text="Render progress" Style="{StaticResource SectionTitleStyle}" />
<StackPanel Orientation="Horizontal" Spacing="6">
@@ -220,7 +457,12 @@
</StackPanel>
</Border>
<Border Grid.Row="3" Padding="18" Style="{StaticResource CardBorderStyle}">
<Border x:Name="FfmpegLogPanel"
Grid.Row="3"
Padding="18"
Style="{StaticResource CardBorderStyle}"
Visibility="Collapsed"
AutomationProperties.Name="FFmpeg log">
<StackPanel Spacing="12">
<TextBlock Text="FFmpeg log" Style="{StaticResource SectionTitleStyle}" />
<TextBox x:Name="LogBox"
@@ -232,11 +474,17 @@
FontFamily="Consolas"
FontSize="12"
VerticalAlignment="Stretch"
Height="260" />
MinHeight="160"
MaxHeight="280"
AutomationProperties.Name="FFmpeg log text"
AutomationProperties.AutomationId="FfmpegLogBox" />
</StackPanel>
</Border>
<Grid Grid.Row="4">
</Grid>
</ScrollViewer>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -248,6 +496,11 @@
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button x:Name="ShortBackButton" Content="Previous" Click="ShortBack_Click" Visibility="Collapsed"
MinWidth="120" AutomationProperties.Name="Previous short step" />
<Button x:Name="ShortNextButton" Content="Next" Click="ShortNext_Click" Visibility="Collapsed"
MinWidth="120" Style="{StaticResource PrimaryButtonStyle}"
AutomationProperties.Name="Next short step" />
<Button x:Name="OpenSettingsButton" Content="&#xE713;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets"
Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}"
ToolTipService.ToolTip="Settings" AutomationProperties.Name="Settings" />
@@ -256,6 +509,24 @@
</StackPanel>
</Grid>
<ContentDialog x:Name="CropEditorDialog"
Title="Crop source"
PrimaryButtonText="Use this crop"
CloseButtonText="Cancel"
DefaultButton="Primary"
Background="{ThemeResource PanelBrush}"
Foreground="{ThemeResource TextBrush}"
BorderBrush="{ThemeResource HeroBorderBrush}"
BorderThickness="1.5"
CornerRadius="18"
PrimaryButtonStyle="{StaticResource DialogPrimaryButtonStyle}"
CloseButtonStyle="{StaticResource DialogCloseButtonStyle}">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMaxWidth">920</x:Double>
</ContentDialog.Resources>
<controls:SourceCropEditor x:Name="ShortCropEditor" />
</ContentDialog>
<ContentDialog x:Name="SettingsDialog"
Title="Settings"
PrimaryButtonText="Done"
@@ -321,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."
TextWrapping="WrapWholeWords"
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>
</Border>
+612 -17
View File
@@ -1,11 +1,14 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Text;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage;
using Windows.Storage.Pickers;
@@ -23,6 +26,13 @@ public sealed partial class MainPage : Page
private string? _lastRenderedFile;
private bool _logFlushScheduled;
private bool _isShortsTab;
private bool _isRendering;
private bool _keepRenderOutput;
private List<ShortWizardStep> _wizardSteps = [.. ShortWizard.StepsFor(ShortStyle.Brand)];
private int _wizardIndex;
private int _previewGeneration;
private string? _previewDirectory;
private string? _videoPreviewDirectory;
public MainPage()
{
@@ -43,17 +53,34 @@ public sealed partial class MainPage : Page
SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme);
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;
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
ShortCrfBox.Text = _loadedSettings.ShortsCrf;
ShortBackgroundBox.Text = _loadedSettings.ShortsBackgroundImage;
ShortBackgroundVideoBox.Text = _loadedSettings.ShortsBackgroundVideo;
ShortVideoBlurBox.Text = _loadedSettings.ShortsVideoBlur;
ShortVideoOpacityBox.Text = _loadedSettings.ShortsVideoOpacity;
ShortSourceMarginBox.Text = _loadedSettings.ShortsSourceMarginX;
ShortFadeInBox.Text = _loadedSettings.ShortsSourceFadeIn;
ShortCropLeftBox.Text = _loadedSettings.ShortsCropLeft;
ShortCropRightBox.Text = _loadedSettings.ShortsCropRight;
ShortCropTopBox.Text = _loadedSettings.ShortsCropTop;
ShortCropBottomBox.Text = _loadedSettings.ShortsCropBottom;
ShortFontBox.Text = string.IsNullOrWhiteSpace(_loadedSettings.ShortsFontFile)
? ShortsPipeline.FindDefaultFont() ?? ""
: _loadedSettings.ShortsFontFile;
SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle);
SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset);
UpdateShortBackgroundVisibility();
SelectComboTag(ShortCropModeBox, _loadedSettings.ShortsSourceCropMode);
RefreshWizardSteps();
ShowWizardStep();
}
private void VideoTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: false);
@@ -62,23 +89,137 @@ public sealed partial class MainPage : Page
private void SetActiveTab(bool shorts)
{
if (!_isRendering)
_keepRenderOutput = false;
_isShortsTab = shorts;
VideoContentPanel.Visibility = shorts ? Visibility.Collapsed : Visibility.Visible;
ShortsContentPanel.Visibility = shorts ? Visibility.Visible : Visibility.Collapsed;
Style primaryButtonStyle = (Style)Application.Current.Resources["PrimaryButtonStyle"];
VideoTabButton.Style = shorts ? null : primaryButtonStyle;
ShortsTabButton.Style = shorts ? primaryButtonStyle : null;
Style tabButtonStyle = (Style)Application.Current.Resources["TabButtonStyle"];
VideoTabButton.Style = shorts ? tabButtonStyle : primaryButtonStyle;
ShortsTabButton.Style = shorts ? primaryButtonStyle : tabButtonStyle;
RenderButton.Content = shorts ? "Render short" : "Start render";
UpdateWorkspaceVisibility();
}
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateShortBackgroundVisibility();
private void UpdateShortBackgroundVisibility()
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
bool isBrand = string.Equals(SelectedShortStyle(), nameof(ShortStyle.Brand), StringComparison.OrdinalIgnoreCase);
ShortBackgroundRow.Visibility = isBrand ? Visibility.Visible : Visibility.Collapsed;
if (!IsLoaded) return;
RefreshWizardSteps();
ShowWizardStep();
}
private void ShortBack_Click(object sender, RoutedEventArgs e)
{
if (_wizardIndex <= 0) return;
_keepRenderOutput = false;
_wizardIndex--;
ShowWizardStep();
}
private async void ShortNext_Click(object sender, RoutedEventArgs e)
{
try
{
ValidateCurrentWizardStep();
if (_wizardIndex >= _wizardSteps.Count - 1) return;
_keepRenderOutput = false;
_wizardIndex++;
ShowWizardStep();
}
catch (Exception exception)
{
await ShowMessageAsync("Check this step", exception.Message);
}
}
private void RefreshWizardSteps()
{
ShortWizardStep current = _wizardSteps[Math.Clamp(_wizardIndex, 0, _wizardSteps.Count - 1)];
_wizardSteps = [.. ShortWizard.StepsFor(SelectedShortStyleEnum())];
int index = _wizardSteps.IndexOf(current);
_wizardIndex = index >= 0 ? index : Math.Min(_wizardIndex, _wizardSteps.Count - 1);
}
private void ShowWizardStep()
{
ShortWizardStep step = _wizardSteps[Math.Clamp(_wizardIndex, 0, _wizardSteps.Count - 1)];
ShortWizardTitleText.Text = ShortWizard.Title(step);
ShortWizardStepText.Text = $"Step {_wizardIndex + 1} of {_wizardSteps.Count}";
ShortWizardProgress.Maximum = _wizardSteps.Count;
ShortWizardProgress.Value = _wizardIndex + 1;
ShortStepClipPanel.Visibility = VisibleIf(step == ShortWizardStep.Clip);
ShortStepEffectPanel.Visibility = VisibleIf(step == ShortWizardStep.Effect);
ShortStepStyleOptionsPanel.Visibility = VisibleIf(step == ShortWizardStep.StyleOptions);
ShortStepTitlesPanel.Visibility = VisibleIf(step == ShortWizardStep.Titles);
ShortStepEncodePanel.Visibility = VisibleIf(step == ShortWizardStep.Encode);
ShortStyle style = SelectedShortStyleEnum();
ShortStyleDescriptionText.Text = ShortWizard.Description(style);
ShortBackgroundRow.Visibility = VisibleIf(style == ShortStyle.Brand);
ShortVideoOptionsPanel.Visibility = VisibleIf(style == ShortStyle.Video);
if (step == ShortWizardStep.Encode)
ShortSummaryText.Text = BuildShortSummary();
UpdateWizardNavigation();
if (IsLoaded && step != ShortWizardStep.Clip)
_ = RefreshShortPreviewAsync(automatic: true);
}
private void UpdateWizardNavigation()
{
bool shorts = _isShortsTab && !_isRendering;
bool lastStep = _wizardIndex >= _wizardSteps.Count - 1;
ShortBackButton.Visibility = VisibleIf(shorts);
ShortNextButton.Visibility = VisibleIf(shorts && !lastStep);
ShortBackButton.IsEnabled = shorts && _wizardIndex > 0;
ShortNextButton.IsEnabled = shorts;
RenderButton.Visibility = VisibleIf(!_isRendering && (!shorts || lastStep));
}
private void ValidateCurrentWizardStep()
{
ShortWizardStep step = _wizardSteps[_wizardIndex];
switch (step)
{
case ShortWizardStep.Clip:
if (string.IsNullOrWhiteSpace(ShortInputBox.Text) || !File.Exists(ShortInputBox.Text.Trim()))
throw new FileNotFoundException("Select a valid source video for the Short.");
ParsePositiveDuration();
break;
case ShortWizardStep.Effect:
ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
break;
case ShortWizardStep.StyleOptions:
if (SelectedShortStyleEnum() == ShortStyle.Brand
&& (string.IsNullOrWhiteSpace(ShortBackgroundBox.Text) || !File.Exists(ShortBackgroundBox.Text.Trim())))
throw new FileNotFoundException("The brand style requires a background image.");
if (SelectedShortStyleEnum() == ShortStyle.Video)
ReadVideoStyleOptions();
break;
case ShortWizardStep.Titles:
if (string.IsNullOrWhiteSpace(ShortTitleBox.Text))
throw new ArgumentException("Enter a title.");
break;
case ShortWizardStep.Encode:
if (string.IsNullOrWhiteSpace(ShortFontBox.Text) || !File.Exists(ShortFontBox.Text.Trim()))
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
ParseCrf();
break;
}
}
private string BuildShortSummary()
{
string input = string.IsNullOrWhiteSpace(ShortInputBox.Text)
? "(no source yet)"
: Path.GetFileName(ShortInputBox.Text.Trim());
string title = string.IsNullOrWhiteSpace(ShortTitleBox.Text) ? "(no title yet)" : ShortTitleBox.Text.Trim();
return $"{SelectedShortStyleEnum()} · {input} · {ShortStartBox.Text.Trim()} + {ShortDurationBox.Text.Trim()}s · {title}";
}
private static Visibility VisibleIf(bool visible) => visible ? Visibility.Visible : Visibility.Collapsed;
private async void AddInputs_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
@@ -94,6 +235,48 @@ public sealed partial class MainPage : Page
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)
{
e.AcceptedOperation = e.DataView.Contains(StandardDataFormats.StorageItems)
@@ -142,6 +325,14 @@ public sealed partial class MainPage : Page
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)
{
FileOpenPicker picker = CreateFileOpenPicker();
@@ -180,6 +371,7 @@ public sealed partial class MainPage : Page
{
try
{
_keepRenderOutput = false;
SetRendering(true);
LogBox.Text = string.Empty;
_renderCancellation = new CancellationTokenSource();
@@ -191,13 +383,18 @@ public sealed partial class MainPage : Page
}
catch (OperationCanceledException)
{
_keepRenderOutput = true;
UpdateProgress(new(RenderProgressBar.Value, "Cancelled", "The render was cancelled."));
RenderNotifier.ShowCancelled();
}
catch (Exception exception)
{
_keepRenderOutput = true;
AppendLog("ERROR: " + exception);
StageText.Text = "Failed";
await ShowMessageAsync("Render failed", UserFacingErrors.Summarize(exception));
string summary = UserFacingErrors.Summarize(exception);
RenderNotifier.ShowFailed(summary);
await ShowMessageAsync("Render failed", summary);
}
finally
{
@@ -217,6 +414,7 @@ public sealed partial class MainPage : Page
_lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
RenderNotifier.ShowCompleted(shorts: false);
await ShowMessageAsync("Render complete", "The AmiReel render completed successfully.");
}
@@ -230,6 +428,7 @@ public sealed partial class MainPage : Page
ShortOutputBox.Text = settings.OutputPath;
OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
RenderNotifier.ShowCompleted(shorts: true);
await ShowMessageAsync("Short complete", "The YouTube Short was rendered successfully.");
}
@@ -296,6 +495,27 @@ public sealed partial class MainPage : Page
EndCardHoldSeconds = Number(HoldBox.Text, "end-card hold"),
ThumbnailInterval = interval,
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(),
};
}
@@ -308,14 +528,15 @@ public sealed partial class MainPage : Page
private ShortSettings ReadShortSettings()
{
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
throw new ArgumentException("Enter a valid duration in seconds.");
if (!int.TryParse(ShortCrfBox.Text, out int crf))
throw new ArgumentException("CRF must be an integer.");
double duration = ParsePositiveDuration();
int crf = ParseCrf();
(double blur, double opacity, int marginX, ShortSourceCropMode cropMode, int left, int right, int top, int bottom)
= ReadVideoStyleOptions();
double fadeIn = ParseNonNegativeNumber(ShortFadeInBox.Text, "source fade-in");
string input = ShortInputBox.Text.Trim();
string output = ShortOutputBox.Text.Trim();
ShortStyle style = Enum.Parse<ShortStyle>(SelectedShortStyle());
ShortStyle style = SelectedShortStyleEnum();
if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(input))
{
string baseName = Path.GetFileNameWithoutExtension(input);
@@ -337,6 +558,16 @@ public sealed partial class MainPage : Page
DurationSeconds = duration,
Style = style,
BackgroundImage = ShortBackgroundBox.Text.Trim(),
BackgroundVideo = ShortBackgroundVideoBox.Text.Trim(),
VideoBlur = blur,
VideoOpacity = opacity,
SourceCropMode = cropMode,
CropLeft = left,
CropRight = right,
CropTop = top,
CropBottom = bottom,
SourceMarginX = marginX,
SourceFadeInSeconds = fadeIn,
FontFile = ShortFontBox.Text.Trim(),
FfmpegPath = FfmpegPathBox.Text.Trim(),
FfprobePath = FfprobePathBox.Text.Trim(),
@@ -345,6 +576,59 @@ public sealed partial class MainPage : Page
};
}
private double ParsePositiveDuration()
{
if (!double.TryParse(ShortDurationBox.Text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double duration) || duration <= 0)
throw new ArgumentException("Enter a valid duration in seconds.");
return duration;
}
private int ParseCrf()
{
if (!int.TryParse(ShortCrfBox.Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int crf))
throw new ArgumentException("CRF must be an integer.");
return crf;
}
private static double ParseNonNegativeNumber(string text, string name)
{
if (!double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0)
throw new ArgumentException($"Enter a valid non-negative value for {name}.");
return value;
}
private static int ParseNonNegativeInt(string text, string name)
{
if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) || value < 0)
throw new ArgumentException($"{name} must be a non-negative integer.");
return value;
}
private (double Blur, double Opacity, int MarginX, ShortSourceCropMode CropMode, int Left, int Right, int Top, int Bottom)
ReadVideoStyleOptions()
{
double blur = ParseNonNegativeNumber(ShortVideoBlurBox.Text, "background blur");
double opacity = ParseNonNegativeNumber(ShortVideoOpacityBox.Text, "background opacity");
if (opacity > 1)
throw new ArgumentException("Background opacity must be between 0.0 and 1.0.");
int marginX = ParseNonNegativeInt(ShortSourceMarginBox.Text, "Side margin");
if (marginX >= 540)
throw new ArgumentException("Source side margin must be less than 540 pixels.");
if (SelectedShortStyleEnum() == ShortStyle.Video
&& (string.IsNullOrWhiteSpace(ShortBackgroundVideoBox.Text) || !File.Exists(ShortBackgroundVideoBox.Text.Trim())))
throw new FileNotFoundException("The video style requires a background video.");
return (
blur,
opacity,
marginX,
SelectedShortCropMode(),
ParseNonNegativeInt(ShortCropLeftBox.Text, "Crop left"),
ParseNonNegativeInt(ShortCropRightBox.Text, "Crop right"),
ParseNonNegativeInt(ShortCropTopBox.Text, "Crop top"),
ParseNonNegativeInt(ShortCropBottomBox.Text, "Crop bottom"));
}
private async void BrowseShortInput_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
@@ -378,6 +662,17 @@ public sealed partial class MainPage : Page
ShortBackgroundBox.Text = file.Path;
}
private async void BrowseShortBackgroundVideo_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
foreach (string extension in SupportedVideoFormats.Extensions)
picker.FileTypeFilter.Add(extension);
picker.SuggestedStartLocation = PickerLocationId.VideosLibrary;
var file = await picker.PickSingleFileAsync();
if (file is not null)
ShortBackgroundVideoBox.Text = file.Path;
}
private async void BrowseShortFont_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
@@ -388,19 +683,260 @@ public sealed partial class MainPage : Page
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)
{
string input = ShortInputBox.Text.Trim();
if (string.IsNullOrWhiteSpace(input) || !File.Exists(input))
{
await ShowMessageAsync("Crop source", "Select a valid source video first.");
return;
}
try
{
CropEditorDialog.XamlRoot = XamlRoot;
CropEditorDialog.RequestedTheme = ActualTheme;
double duration = 30;
try { duration = ParsePositiveDuration(); } catch (ArgumentException) { }
await ShortCropEditor.LoadVideoFrameAsync(
input,
string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
duration,
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
CancellationToken.None);
ShortCropEditor.SetInsets(ReadCropInsets());
ContentDialogResult result = await CropEditorDialog.ShowAsync();
if (result != ContentDialogResult.Primary)
return;
PixelInsets insets = ShortCropEditor.Insets;
ShortCropLeftBox.Text = insets.Left.ToString(CultureInfo.InvariantCulture);
ShortCropRightBox.Text = insets.Right.ToString(CultureInfo.InvariantCulture);
ShortCropTopBox.Text = insets.Top.ToString(CultureInfo.InvariantCulture);
ShortCropBottomBox.Text = insets.Bottom.ToString(CultureInfo.InvariantCulture);
}
catch (Exception exception)
{
await ShowMessageAsync("Crop source", UserFacingErrors.Summarize(exception));
}
}
private PixelInsets ReadCropInsets()
{
static int ReadBox(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0
? value
: 0;
}
return new PixelInsets(
ReadBox(ShortCropLeftBox.Text),
ReadBox(ShortCropRightBox.Text),
ReadBox(ShortCropTopBox.Text),
ReadBox(ShortCropBottomBox.Text));
}
private 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)
{
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
await OpenPreviewAsync(ShortInputBox.Text.Trim());
}
private async void UpdateShortPreview_Click(object sender, RoutedEventArgs e) =>
await RefreshShortPreviewAsync(automatic: false);
private async 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)
{
if (_isRendering)
return;
int generation = ++_previewGeneration;
ShortPreviewProgress.Visibility = Visibility.Visible;
UpdateShortPreviewButton.IsEnabled = false;
ShortPreviewStatusText.Text = "Rendering 9:16 preview frames...";
try
{
ShortSettings settings = ReadShortSettings();
ShortsPipeline.ValidatePreview(settings);
_previewDirectory ??= Path.Combine(Path.GetTempPath(), "AmiReel", "short-preview", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_previewDirectory);
ShortPreviewFrames frames = await new ShortsPipeline().RenderPreviewAsync(
settings, _previewDirectory, _ => { }, CancellationToken.None);
if (generation != _previewGeneration)
return;
ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath, 296);
ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath, 296);
bool hasFont = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
ShortPreviewStatusText.Text = hasFont
? $"{settings.Style} · opening hook and closing title card"
: "Look only — pick a font on the last step to include the title-card text.";
}
catch (Exception exception)
{
if (generation != _previewGeneration)
return;
string message = UserFacingErrors.Summarize(exception);
ShortPreviewStatusText.Text = message;
if (!automatic)
await ShowMessageAsync("Preview failed", message);
}
finally
{
if (generation == _previewGeneration)
{
ShortPreviewProgress.Visibility = Visibility.Collapsed;
UpdateShortPreviewButton.IsEnabled = true;
}
}
}
private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path, int decodePixelWidth)
{
await using FileStream stream = File.OpenRead(path);
BitmapImage bitmap = new() { DecodePixelWidth = decodePixelWidth };
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
return bitmap;
}
private void SetRendering(bool rendering)
{
_isRendering = rendering;
RenderButton.IsEnabled = !rendering;
CancelButton.IsEnabled = rendering;
OpenSettingsButton.IsEnabled = !rendering;
VideoTabButton.IsEnabled = !rendering;
ShortsTabButton.IsEnabled = !rendering;
UpdateWorkspaceVisibility();
}
private void UpdateWorkspaceVisibility()
{
bool showSetup = !_isRendering;
bool showOutput = _isRendering || _keepRenderOutput;
VideoContentPanel.Visibility = VisibleIf(showSetup && !_isShortsTab);
ShortsContentPanel.Visibility = VisibleIf(showSetup && _isShortsTab);
RenderProgressPanel.Visibility = VisibleIf(showOutput);
FfmpegLogPanel.Visibility = VisibleIf(showOutput);
UpdateWizardNavigation();
}
private void UpdateProgress(RenderProgress value)
@@ -471,10 +1007,38 @@ public sealed partial class MainPage : Page
return;
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()
{
AppSettingsStore.Save(new AppSettings
@@ -491,11 +1055,27 @@ public sealed partial class MainPage : Page
EndCardHoldSeconds = HoldBox.Text.Trim(),
ThumbnailInterval = IntervalBox.Text.Trim(),
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(),
ShortsHook = ShortHookBox.Text.Trim(),
ShortsWebsite = ShortWebsiteBox.Text.Trim(),
ShortsFontFile = ShortFontBox.Text.Trim(),
ShortsBackgroundImage = ShortBackgroundBox.Text.Trim(),
ShortsBackgroundVideo = ShortBackgroundVideoBox.Text.Trim(),
ShortsVideoBlur = ShortVideoBlurBox.Text.Trim(),
ShortsVideoOpacity = ShortVideoOpacityBox.Text.Trim(),
ShortsSourceCropMode = SelectedShortCropMode().ToString(),
ShortsCropLeft = ShortCropLeftBox.Text.Trim(),
ShortsCropRight = ShortCropRightBox.Text.Trim(),
ShortsCropTop = ShortCropTopBox.Text.Trim(),
ShortsCropBottom = ShortCropBottomBox.Text.Trim(),
ShortsSourceMarginX = ShortSourceMarginBox.Text.Trim(),
ShortsSourceFadeIn = ShortFadeInBox.Text.Trim(),
ShortsCrf = ShortCrfBox.Text.Trim(),
ShortsPreset = SelectedShortPreset(),
});
@@ -507,6 +1087,21 @@ public sealed partial class MainPage : Page
private string SelectedShortStyle() => (ShortStyleBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Brand";
private ShortStyle SelectedShortStyleEnum() =>
Enum.TryParse(SelectedShortStyle(), ignoreCase: true, out ShortStyle style) ? style : ShortStyle.Brand;
private ShortSourceCropMode SelectedShortCropMode()
{
string tag = (ShortCropModeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto";
return Enum.TryParse(tag, ignoreCase: true, out ShortSourceCropMode mode) ? mode : ShortSourceCropMode.Auto;
}
private 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 void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme);
+38
View File
@@ -21,6 +21,12 @@ public sealed record AppSettings
public required string EndCardHoldSeconds { get; init; }
public required string ThumbnailInterval { 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).
public string ShortsStyle { get; init; } = "Brand";
@@ -28,6 +34,16 @@ public sealed record AppSettings
public string ShortsWebsite { get; init; } = "AMIGADB.NET";
public string ShortsFontFile { get; init; } = "";
public string ShortsBackgroundImage { get; init; } = "";
public string ShortsBackgroundVideo { get; init; } = "";
public string ShortsVideoBlur { get; init; } = "0";
public string ShortsVideoOpacity { get; init; } = "1.0";
public string ShortsSourceCropMode { get; init; } = "Auto";
public string ShortsCropLeft { get; init; } = "0";
public string ShortsCropRight { get; init; } = "0";
public string ShortsCropTop { get; init; } = "0";
public string ShortsCropBottom { get; init; } = "0";
public string ShortsSourceMarginX { get; init; } = "40";
public string ShortsSourceFadeIn { get; init; } = "1.5";
public string ShortsCrf { get; init; } = "16";
public string ShortsPreset { get; init; } = "slow";
@@ -47,6 +63,12 @@ public sealed record AppSettings
EndCardHoldSeconds = "4",
ThumbnailInterval = "10",
MoveSourcesToOriginals = true,
SourceSearchFolder = "",
VideoSourceCropMode = "Auto",
VideoCropLeft = "0",
VideoCropRight = "0",
VideoCropTop = "0",
VideoCropBottom = "0",
};
public AppSettings Normalize(string fallbackOutputFolder) => this with
@@ -63,11 +85,27 @@ public sealed record AppSettings
EndCardHoldSeconds = string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds,
ThumbnailInterval = string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval,
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,
ShortsHook = ShortsHook ?? "THIS RAN ON AN AMIGA",
ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET",
ShortsFontFile = ShortsFontFile ?? "",
ShortsBackgroundImage = ShortsBackgroundImage ?? "",
ShortsBackgroundVideo = ShortsBackgroundVideo ?? "",
ShortsVideoBlur = string.IsNullOrWhiteSpace(ShortsVideoBlur) ? "0" : ShortsVideoBlur,
ShortsVideoOpacity = string.IsNullOrWhiteSpace(ShortsVideoOpacity) ? "1.0" : ShortsVideoOpacity,
ShortsSourceCropMode = string.IsNullOrWhiteSpace(ShortsSourceCropMode) ? "Auto" : ShortsSourceCropMode,
ShortsCropLeft = string.IsNullOrWhiteSpace(ShortsCropLeft) ? "0" : ShortsCropLeft,
ShortsCropRight = string.IsNullOrWhiteSpace(ShortsCropRight) ? "0" : ShortsCropRight,
ShortsCropTop = string.IsNullOrWhiteSpace(ShortsCropTop) ? "0" : ShortsCropTop,
ShortsCropBottom = string.IsNullOrWhiteSpace(ShortsCropBottom) ? "0" : ShortsCropBottom,
ShortsSourceMarginX = string.IsNullOrWhiteSpace(ShortsSourceMarginX) ? "40" : ShortsSourceMarginX,
ShortsSourceFadeIn = string.IsNullOrWhiteSpace(ShortsSourceFadeIn) ? "1.5" : ShortsSourceFadeIn,
ShortsCrf = string.IsNullOrWhiteSpace(ShortsCrf) ? "16" : ShortsCrf,
ShortsPreset = string.IsNullOrWhiteSpace(ShortsPreset) ? "slow" : ShortsPreset,
};
+15
View File
@@ -29,6 +29,11 @@ public sealed record RenderSettings
public required double EndCardHoldSeconds { get; init; }
public required int ThumbnailInterval { get; init; }
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 Height { get; init; } = 2160;
public int FramesPerSecond { get; init; } = 50;
@@ -37,3 +42,13 @@ public sealed record RenderSettings
}
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);
+31 -2
View File
@@ -1,6 +1,6 @@
namespace AmiReel.Models;
/// <summary>Visual style applied to a YouTube Short. Mirrors the --style values of amigadb-short.sh.</summary>
/// <summary>Visual style applied to a YouTube Short. Mirrors the --style values of amigadb-short-fx-v4.sh.</summary>
public enum ShortStyle
{
Brand,
@@ -22,9 +22,28 @@ public enum ShortStyle
Monitor,
Split,
Spectrum,
Video,
}
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short.sh's arguments).</summary>
/// <summary>How the Amiga source is cropped before it is scaled into the output.</summary>
public enum ShortSourceCropMode
{
None,
Auto,
Center,
}
/// <summary>Which moment of the Short a preview still represents.</summary>
public enum ShortPreviewShot
{
Opening,
Closing,
}
/// <summary>PNG stills of the opening hook and closing title card.</summary>
public sealed record ShortPreviewFrames(string OpeningPath, string ClosingPath);
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short-fx-v4.sh).</summary>
public sealed record ShortSettings
{
public required string InputFile { get; init; }
@@ -39,6 +58,16 @@ public sealed record ShortSettings
public required double DurationSeconds { get; init; }
public required ShortStyle Style { get; init; }
public string BackgroundImage { get; init; } = "";
public string BackgroundVideo { get; init; } = "";
public double VideoBlur { get; init; }
public double VideoOpacity { get; init; } = 1.0;
public ShortSourceCropMode SourceCropMode { get; init; } = ShortSourceCropMode.Auto;
public int CropLeft { get; init; }
public int CropRight { get; init; }
public int CropTop { get; init; }
public int CropBottom { get; init; }
public int SourceMarginX { get; init; } = 40;
public double SourceFadeInSeconds { get; init; } = 1.5;
public required string FontFile { get; init; }
public required string FfmpegPath { get; init; }
public required string FfprobePath { get; init; }
+63
View File
@@ -0,0 +1,63 @@
namespace AmiReel.Models;
/// <summary>One page of the Shorts create-wizard. Extra effect pages are inserted only when the style needs them.</summary>
public enum ShortWizardStep
{
Clip,
Effect,
StyleOptions,
Titles,
Encode,
}
/// <summary>Builds the Shorts wizard page list for a chosen visual style.</summary>
public static class ShortWizard
{
public static IReadOnlyList<ShortWizardStep> StepsFor(ShortStyle style)
{
List<ShortWizardStep> steps = [ShortWizardStep.Clip, ShortWizardStep.Effect];
if (NeedsStyleOptions(style))
steps.Add(ShortWizardStep.StyleOptions);
steps.Add(ShortWizardStep.Titles);
steps.Add(ShortWizardStep.Encode);
return steps;
}
public static bool NeedsStyleOptions(ShortStyle style) =>
style is ShortStyle.Brand or ShortStyle.Video;
public static string Title(ShortWizardStep step) => step switch
{
ShortWizardStep.Clip => "Source clip",
ShortWizardStep.Effect => "Visual effect",
ShortWizardStep.StyleOptions => "Effect settings",
ShortWizardStep.Titles => "Title card",
ShortWizardStep.Encode => "Encoding",
_ => "Short",
};
public static string Description(ShortStyle style) => style switch
{
ShortStyle.Brand => "Title-card background image with a framed source clip.",
ShortStyle.Pixel => "Pixelated backdrop with neon scan bars.",
ShortStyle.Mirror => "Reflected source strips above and below the clip.",
ShortStyle.Crop => "Full-frame vertical crop of the source.",
ShortStyle.Workbench => "Workbench-style window around the source.",
ShortStyle.Blur => "Blurred backdrop with a centered source.",
ShortStyle.Crt => "CRT curvature, scanlines, and a rolling highlight.",
ShortStyle.Copper => "Amiga copper bars behind the source.",
ShortStyle.Stars => "Twinkling star field behind a framed clip.",
ShortStyle.Grid => "Neon grid horizon behind the source.",
ShortStyle.Tiles => "Four-tile mirrored backdrop.",
ShortStyle.Scan => "Chunky scanlines and moving bars.",
ShortStyle.Starfield => "Fast-moving star streaks.",
ShortStyle.Plasma => "Demoscene plasma backdrop.",
ShortStyle.Rasterbars => "Classic raster bars above and below the clip.",
ShortStyle.Vhs => "Tape noise, chroma shift, and scanlines.",
ShortStyle.Monitor => "CRT monitor bezel around the source.",
ShortStyle.Split => "Split-band source strips around the main clip.",
ShortStyle.Spectrum => "Scrolling audio spectrum. The source video must have audio.",
ShortStyle.Video => "Looping background video with the Amiga clip overlaid.",
_ => "",
};
}
+221
View File
@@ -0,0 +1,221 @@
namespace AmiReel.Models;
/// <summary>Edge insets, in source pixels, removed by a manual crop.</summary>
public readonly record struct PixelInsets(int Left, int Right, int Top, int Bottom)
{
public bool IsEmpty => Left == 0 && Right == 0 && Top == 0 && Bottom == 0;
}
/// <summary>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>
public static class SourceCropSelection
{
public const int MinimumSize = 16;
public static (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) FitUniform(
double containerWidth, double containerHeight, int pixelWidth, int pixelHeight)
{
if (containerWidth <= 0 || containerHeight <= 0 || pixelWidth <= 0 || pixelHeight <= 0)
return (0, 0, 0, 0);
double containerAspect = containerWidth / containerHeight;
double imageAspect = pixelWidth / (double)pixelHeight;
double displayWidth;
double displayHeight;
if (containerAspect > imageAspect)
{
displayHeight = containerHeight;
displayWidth = containerHeight * imageAspect;
}
else
{
displayWidth = containerWidth;
displayHeight = containerWidth / imageAspect;
}
return ((containerWidth - displayWidth) / 2, (containerHeight - displayHeight) / 2, displayWidth, displayHeight);
}
public static PixelInsets ToInsets(
int pixelWidth,
int pixelHeight,
double displayWidth,
double displayHeight,
double x,
double y,
double width,
double height)
{
if (pixelWidth <= 0 || pixelHeight <= 0 || displayWidth <= 0 || displayHeight <= 0)
return default;
Normalize(ref x, ref y, ref width, ref height);
x = Math.Clamp(x, 0, displayWidth);
y = Math.Clamp(y, 0, displayHeight);
width = Math.Clamp(width, 0, displayWidth - x);
height = Math.Clamp(height, 0, displayHeight - y);
double scaleX = pixelWidth / displayWidth;
double scaleY = pixelHeight / displayHeight;
int left = ToEvenPixel(x * scaleX, pixelWidth - MinimumSize);
int top = ToEvenPixel(y * scaleY, pixelHeight - MinimumSize);
int cropWidth = ToEvenPixel(width * scaleX, pixelWidth - left);
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);
return new PixelInsets(left, pixelWidth - left - cropWidth, top, pixelHeight - top - cropHeight);
}
public static (double X, double Y, double Width, double Height) ToDisplayRect(
int pixelWidth, int pixelHeight, double displayWidth, double displayHeight, PixelInsets insets)
{
if (pixelWidth <= 0 || pixelHeight <= 0 || displayWidth <= 0 || displayHeight <= 0)
return (0, 0, Math.Max(0, displayWidth), Math.Max(0, displayHeight));
double scaleX = displayWidth / pixelWidth;
double scaleY = displayHeight / pixelHeight;
double x = insets.Left * scaleX;
double y = insets.Top * scaleY;
double width = Math.Max(1, (pixelWidth - insets.Left - insets.Right) * scaleX);
double height = Math.Max(1, (pixelHeight - insets.Top - insets.Bottom) * scaleY);
return (x, y, width, height);
}
public static (double X, double Y) ClampToDisplay(double x, double y, double displayWidth, double displayHeight) =>
(Math.Clamp(x, 0, Math.Max(0, displayWidth)), Math.Clamp(y, 0, Math.Max(0, displayHeight)));
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)
{
if (width < 0)
{
x += width;
width = -width;
}
if (height < 0)
{
y += height;
height = -height;
}
}
private static int ToEvenPixel(double value, int max)
{
int rounded = (int)Math.Round(value, MidpointRounding.AwayFromZero);
int clamped = Math.Clamp(rounded, 0, Math.Max(0, max));
return (clamped / 2) * 2;
}
}
+15 -1
View File
@@ -5,7 +5,9 @@
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
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
Name="F004AE73-5989-46F3-B913-E9A3A355713F"
@@ -42,6 +44,18 @@
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" BackgroundColor="#0E1525" />
</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>
</Applications>
+2 -3
View File
@@ -120,11 +120,10 @@ process are intentionally left to manual/integration testing rather than unit te
.\publish-win-x64.ps1
```
This publishes a self-contained, single-file `win-x64` executable. At the end of the script
you'll see the release folder and final executable path, for example:
This publishes a self-contained, single-file `win-x64` executable and copies it to:
```text
bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\AmiReel.exe
dist\AmiReel.exe
```
## Installation
+1
View File
@@ -33,6 +33,7 @@ public static class FfmpegOutputFilter
"other streams:",
"global headers:",
"muxing overhead:",
"Last message repeated",
];
public static bool IsBoilerplateLine(string line) =>
+66
View File
@@ -0,0 +1,66 @@
using Microsoft.Windows.AppNotifications;
using Microsoft.Windows.AppNotifications.Builder;
namespace AmiReel.Services;
/// <summary>Shows a Windows app notification when a Video or Shorts render finishes, fails, or is cancelled.</summary>
public static class RenderNotifier
{
public static void Register()
{
try
{
AppNotificationManager.Default.NotificationInvoked -= OnNotificationInvoked;
AppNotificationManager.Default.NotificationInvoked += OnNotificationInvoked;
AppNotificationManager.Default.Register();
}
catch
{
// Notifications are unavailable when the process is elevated or has no identity.
}
}
public static void ShowCompleted(bool shorts) => Show(DescribeCompleted(shorts));
public static void ShowFailed(string summary) => Show(DescribeFailed(summary));
public static void ShowCancelled() => Show(DescribeCancelled());
internal static (string Title, string Body) DescribeCompleted(bool shorts) => shorts
? ("Short complete", "The YouTube Short was rendered successfully.")
: ("Render complete", "The AmiReel render completed successfully.");
internal static (string Title, string Body) DescribeFailed(string summary)
{
string body = string.IsNullOrWhiteSpace(summary)
? "The render failed."
: summary.ReplaceLineEndings(" ");
if (body.Length > 180)
body = body[..177] + "...";
return ("Render failed", body);
}
internal static (string Title, string Body) DescribeCancelled() =>
("Render cancelled", "The render was cancelled.");
private static void Show((string Title, string Body) content)
{
try
{
AppNotification notification = new AppNotificationBuilder()
.AddText(content.Title)
.AddText(content.Body)
.BuildNotification();
AppNotificationManager.Default.Show(notification);
}
catch
{
// Show fails silently when notifications are unsupported.
}
}
private static void OnNotificationInvoked(AppNotificationManager sender, AppNotificationActivatedEventArgs args)
{
App.MainWindowInstance?.DispatcherQueue.TryEnqueue(() => App.MainWindowInstance.Activate());
}
}
+141 -5
View File
@@ -43,8 +43,9 @@ public sealed class RenderPipeline
double joinDuration = await ProbeCombinedDurationAsync(probe, tools, settings.InputFiles, token);
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,
"-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)];
joinArgs.AddRange(EncoderArguments(encoder));
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)
{
if (requested == EncoderMode.CpuX264) return "x264";
@@ -360,16 +419,93 @@ public sealed class RenderPipeline
internal static void Validate(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.");
ValidatePreview(s);
if (string.IsNullOrWhiteSpace(s.OutputDirectory)) throw new ArgumentException("Select an output directory.");
if (string.IsNullOrWhiteSpace(s.OutputName) || s.OutputName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
throw new ArgumentException("Enter a valid output name.");
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 F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
}
+351 -18
View File
@@ -7,11 +7,13 @@ namespace AmiReel.Services;
/// <summary>
/// Renders a vertical (1080x1920, 50 FPS) YouTube Short from an existing video, with a chosen
/// visual style and title-card text overlays. A C# port of amigadb-short.sh so end users don't
/// need bash/WSL — the FFmpeg filtergraphs below are copied verbatim from that script.
/// visual style and title-card text overlays. A C# port of amigadb-short-fx-v4.sh so end users
/// don't need bash/WSL — the FFmpeg filtergraphs below are copied from that script.
/// </summary>
public sealed class ShortsPipeline
{
internal const string DefaultSourceCropFilter = SourceCropFilters.CenterFourByThreeFilter;
private readonly ProcessRunner _runner = new();
public async Task RenderAsync(ShortSettings settings, IProgress<RenderProgress> progress, Action<string> log, CancellationToken token)
@@ -45,9 +47,11 @@ public sealed class ShortsPipeline
double infoStart = Math.Max(0, duration - 7);
double ctaStart = Math.Max(0, duration - 3.5);
double hookEnd = Math.Min(2.7, duration);
double fadeIn = Math.Clamp(settings.SourceFadeInSeconds, 0, duration);
string baseFilter = await ResolveBaseFilterAsync(tools, settings, log, token);
string filterComplex = BuildFilterComplex(
GetBaseFilter(settings.Style),
ApplySourceFade(baseFilter, fadeIn),
fontEsc: EscapeFfmpegPath(settings.FontFile),
hookEsc: EscapeFfmpegPath(hookFile),
titleEsc: EscapeFfmpegPath(titleFile),
@@ -55,9 +59,7 @@ public sealed class ShortsPipeline
websiteEsc: EscapeFfmpegPath(websiteFile),
hookEnd, infoStart, ctaStart);
List<string> args = ["-hide_banner", "-y", "-ss", settings.Start, "-t", F(duration), "-i", settings.InputFile];
if (settings.Style == ShortStyle.Brand)
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
List<string> args = BuildInputArguments(settings, duration);
args.AddRange([
"-filter_complex", filterComplex,
@@ -73,6 +75,7 @@ public sealed class ShortsPipeline
"-b:a", "320k",
"-ar", "48000",
"-movflags", "+faststart",
"-t", F(duration),
"-shortest",
settings.OutputPath,
]);
@@ -94,6 +97,134 @@ public sealed class ShortsPipeline
}
}
/// <summary>
/// Renders two 9:16 stills with the same filtergraph as the final Short: opening hook and closing title card.
/// </summary>
public async Task<ShortPreviewFrames> RenderPreviewAsync(
ShortSettings settings, string outputDirectory, Action<string> log, CancellationToken token)
{
ValidatePreview(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
Directory.CreateDirectory(outputDirectory);
string workDir = Path.Combine(outputDirectory, "meta");
Directory.CreateDirectory(workDir);
string hookFile = WriteTextFile(workDir, "hook.txt", string.IsNullOrWhiteSpace(settings.Hook) ? "THIS RAN ON AN AMIGA" : settings.Hook);
string titleFile = WriteTextFile(workDir, "title.txt", string.IsNullOrWhiteSpace(settings.Title) ? "TITLE" : settings.Title);
string metaFile = WriteTextFile(workDir, "meta.txt", BuildMeta(settings));
string websiteFile = WriteTextFile(workDir, "website.txt", string.IsNullOrWhiteSpace(settings.Website) ? "AMIGADB.NET" : settings.Website);
log("Resolving preview look...");
string baseFilter = await ResolveBaseFilterAsync(tools, settings, log, token);
bool includeText = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
string openingPath = Path.Combine(outputDirectory, "opening.png");
string closingPath = Path.Combine(outputDirectory, "closing.png");
await RenderPreviewStillAsync(
tools, settings, baseFilter, includeText,
hookFile, titleFile, metaFile, websiteFile,
ShortPreviewShot.Opening, openingPath, log, token);
await RenderPreviewStillAsync(
tools, settings, baseFilter, includeText,
hookFile, titleFile, metaFile, websiteFile,
ShortPreviewShot.Closing, closingPath, log, token);
return new ShortPreviewFrames(openingPath, closingPath);
}
/// <summary>Writes one source frame (no style filters) so the crop editor can show the raw clip.</summary>
public async Task ExtractSourceFrameAsync(
string inputFile,
string start,
string ffmpegPath,
string ffprobePath,
string outputPath,
Action<string> log,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(inputFile) || !File.Exists(inputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
string? directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
ToolPaths tools = await ToolExtractor.ResolveAsync(ffmpegPath, ffprobePath, token);
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token);
}
/// <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) =>
[
"-hide_banner",
"-y",
"-ss",
string.IsNullOrWhiteSpace(start) ? "00:00:00" : start,
"-i",
inputFile,
"-frames:v",
"1",
"-update",
"1",
outputPath,
];
/// <summary>Looks for a common bold system font to pre-fill the Shorts font field with.</summary>
public static string? FindDefaultFont()
{
@@ -109,18 +240,35 @@ public sealed class ShortsPipeline
internal static void Validate(ShortSettings s)
{
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
ValidatePreview(s);
if (string.IsNullOrWhiteSpace(s.Title))
throw new ArgumentException("Enter a title.");
if (string.IsNullOrWhiteSpace(s.OutputPath))
throw new ArgumentException("Select an output file.");
if (s.DurationSeconds <= 0)
throw new ArgumentException("Duration must be greater than zero.");
if (string.IsNullOrWhiteSpace(s.FontFile) || !File.Exists(s.FontFile))
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
}
internal static void ValidatePreview(ShortSettings s)
{
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
if (s.DurationSeconds <= 0)
throw new ArgumentException("Duration must be greater than zero.");
if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage)))
throw new FileNotFoundException("The brand style requires a background image.");
if (s.Style == ShortStyle.Video && (string.IsNullOrWhiteSpace(s.BackgroundVideo) || !File.Exists(s.BackgroundVideo)))
throw new FileNotFoundException("The video style requires a background video.");
if (s.VideoBlur < 0)
throw new ArgumentException("Background video blur must be zero or greater.");
if (s.VideoOpacity is < 0 or > 1)
throw new ArgumentException("Background video opacity must be between 0.0 and 1.0.");
if (s.CropLeft < 0 || s.CropRight < 0 || s.CropTop < 0 || s.CropBottom < 0)
throw new ArgumentException("Manual crop values must be non-negative.");
if (s.SourceMarginX < 0 || s.SourceMarginX >= 540)
throw new ArgumentException("Source side margin must be between 0 and 539 pixels.");
if (s.SourceFadeInSeconds < 0)
throw new ArgumentException("Source fade-in must be zero or greater.");
}
internal static string BuildMeta(ShortSettings s)
@@ -144,8 +292,11 @@ public sealed class ShortsPipeline
internal static string BuildFilterComplex(
string baseFilter, string fontEsc, string hookEsc, string titleEsc, string metaEsc, string websiteEsc,
double hookEnd, double infoStart, double ctaStart)
double hookEnd, double infoStart, double ctaStart, bool includeTextOverlays = true)
{
if (!includeTextOverlays)
return baseFilter + ";[base]fps=50,format=yuv420p[vout]";
string hookEndStr = F(hookEnd);
string infoStartStr = F(infoStart);
string ctaStartStr = F(ctaStart);
@@ -161,8 +312,186 @@ public sealed class ShortsPipeline
"fps=50,format=yuv420p[vout]";
}
internal static List<string> BuildInputArguments(ShortSettings settings, double clipDuration, string? seek = null)
{
List<string> args =
[
"-hide_banner",
"-y",
"-ss",
seek ?? settings.Start,
"-t",
F(clipDuration),
"-i",
settings.InputFile,
];
if (settings.Style == ShortStyle.Brand)
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
if (settings.Style == ShortStyle.Video)
args.AddRange(["-stream_loop", "-1", "-i", settings.BackgroundVideo]);
return args;
}
internal static (double HookEnd, double InfoStart, double CtaStart) PreviewOverlayWindows(ShortPreviewShot shot, double duration)
{
if (shot == ShortPreviewShot.Opening)
return (Math.Max(duration, 1), duration + 1, duration + 1);
return (-1, 0, 0);
}
internal static double PreviewSeekSeconds(ShortPreviewShot shot, double duration)
{
if (shot == ShortPreviewShot.Opening)
return Math.Min(1.2, Math.Max(0, duration * 0.2));
return Math.Max(0, duration - 0.4);
}
internal static string AddTimestampOffset(string start, double seconds)
{
if (!TimeSpan.TryParse(start, CultureInfo.InvariantCulture, out TimeSpan parsed))
parsed = TimeSpan.Zero;
if (seconds < 0)
seconds = 0;
return (parsed + TimeSpan.FromSeconds(seconds)).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
}
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
private async Task RenderPreviewStillAsync(
ToolPaths tools,
ShortSettings settings,
string baseFilter,
bool includeText,
string hookFile,
string titleFile,
string metaFile,
string websiteFile,
ShortPreviewShot shot,
string outputPath,
Action<string> log,
CancellationToken token)
{
(double hookEnd, double infoStart, double ctaStart) = PreviewOverlayWindows(shot, settings.DurationSeconds);
string filterComplex = BuildFilterComplex(
baseFilter,
fontEsc: includeText ? EscapeFfmpegPath(settings.FontFile) : "",
hookEsc: EscapeFfmpegPath(hookFile),
titleEsc: EscapeFfmpegPath(titleFile),
metaEsc: EscapeFfmpegPath(metaFile),
websiteEsc: EscapeFfmpegPath(websiteFile),
hookEnd,
infoStart,
ctaStart,
includeTextOverlays: includeText);
string seek = AddTimestampOffset(settings.Start, PreviewSeekSeconds(shot, settings.DurationSeconds));
List<string> args = BuildInputArguments(settings, 0.12, seek);
args.AddRange([
"-filter_complex", filterComplex,
"-map", "[vout]",
"-frames:v", "1",
"-update", "1",
outputPath,
]);
log($"Preview {shot.ToString().ToLowerInvariant()} frame...");
await _runner.RunAsync(tools.Ffmpeg, args, log, null, token);
}
internal static string ApplySourceFade(string baseFilter, double fadeInSeconds)
{
if (fadeInSeconds <= 0)
return baseFilter;
return $"[0:v]format=rgba,fade=t=in:st=0:d={F(fadeInSeconds)}:alpha=1[srcfade];" +
baseFilter.Replace("[0:v]", "[srcfade]", StringComparison.Ordinal);
}
internal static int SourceTargetWidth(int sourceMarginX) => 1080 - (sourceMarginX * 2);
internal static string BuildSourceScaleFilter(int sourceMarginX) =>
$"scale={SourceTargetWidth(sourceMarginX)}:-2:flags=lanczos";
internal static string BuildManualCropFilter(int left, int right, int top, int bottom) =>
SourceCropFilters.BuildManualCropFilter(left, right, top, bottom);
internal static string BuildVideoBaseFilter(string cropFilter, string scaleFilter, double blur, double opacity)
{
string blurFilter = blur > 0 ? $",gblur=sigma={F(blur)}" : "";
return
"color=c=black:s=1080x1920:r=50[black];" +
$"[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1{blurFilter},format=rgba,colorchannelmixer=aa={F(opacity)}[bgvideo];" +
"[black][bgvideo]overlay=x=0:y=0:format=auto[bg];" +
$"[0:v]{SourceCropFilters.Prefix(cropFilter, scaleFilter)},setsar=1[fg];" +
"[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]";
}
internal static string? SelectMostCommonCropSpec(string ffmpegOutput) =>
SourceCropFilters.SelectMostCommonCropSpec(ffmpegOutput);
internal static bool TryParseCropSpec(string spec, out int width, out int height, out int x, out int y) =>
SourceCropFilters.TryParseCropSpec(spec, out width, out height, out x, out y);
internal static bool TryBuildAutoHorizontalCrop(int detectedWidth, int detectedX, out int width, out int x) =>
SourceCropFilters.TryBuildAutoHorizontalCrop(detectedWidth, detectedX, out width, out x);
private async Task<string> ResolveBaseFilterAsync(
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
{
if (settings.Style != ShortStyle.Video)
return GetBaseFilter(settings.Style);
string scaleFilter = BuildSourceScaleFilter(settings.SourceMarginX);
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");
}
string cropFilter = SourceCropFilters.Resolve(
settings.SourceCropMode,
settings.CropLeft,
settings.CropRight,
settings.CropTop,
settings.CropBottom,
detected);
return BuildVideoBaseFilter(cropFilter, scaleFilter, settings.VideoBlur, settings.VideoOpacity);
}
private async Task<string?> DetectHorizontalCropAsync(
ToolPaths tools, ShortSettings settings, Action<string> log, CancellationToken token)
{
double detectDuration = Math.Clamp(settings.DurationSeconds < 4 ? settings.DurationSeconds : 4, 0.5, 4);
List<string> args = SourceCropFilters.BuildHorizontalCropDetectArguments(
settings.InputFile, settings.Start, detectDuration);
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;
}
internal static string GetBaseFilter(ShortStyle style) => style switch
{
ShortStyle.Brand => """
@@ -182,13 +511,14 @@ public sealed class ShortsPipeline
""",
ShortStyle.Mirror => """
[0:v]split=3[fg_src][top_src][bottom_src];
[top_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[top];
[bottom_src]scale=1080:620:force_original_aspect_ratio=increase,crop=1080:620,vflip,eq=brightness=-0.34:saturation=0.82,gblur=sigma=9,setsar=1[bottom];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=white@0.70,setsar=1[fg];
[top]pad=1080:1920:0:0:color=0x050616[canvas];
[canvas][bottom]overlay=x=0:y=1300[tmp];
[tmp][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
[0:v]split=4[canvas_src][fg_src][top_src][bottom_src];
[canvas_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,drawbox=x=0:y=0:w=1080:h=1920:color=0x010203:t=fill[canvas];
[fg_src]scale=940:760:force_original_aspect_ratio=decrease,setsar=1[fg];
[top_src]scale=1400:470:flags=lanczos,vflip,eq=brightness=-0.48:contrast=0.92:saturation=0.68,gblur=sigma=7,setsar=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*0.62*(Y/H)'[top];
[bottom_src]scale=1400:470:flags=lanczos,vflip,eq=brightness=-0.50:contrast=0.90:saturation=0.64,gblur=sigma=7,setsar=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*0.58*(1-Y/H)'[bottom];
[canvas][top]overlay=x=(W-w)/2:y=250:format=auto[tmp1];
[tmp1][bottom]overlay=x=(W-w)/2:y=1435:format=auto[tmp2];
[tmp2][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
""",
ShortStyle.Crop => """
@@ -381,6 +711,9 @@ public sealed class ShortsPipeline
[tmp][fg]overlay=x=(W-w)/2:y=480:format=auto[base]
""",
ShortStyle.Video => BuildVideoBaseFilter(
DefaultSourceCropFilter, BuildSourceScaleFilter(40), blur: 0, opacity: 1),
_ => throw new ArgumentOutOfRangeException(nameof(style), style, "Unknown short style."),
};
}
+113
View File
@@ -0,0 +1,113 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AmiReel.Models;
namespace AmiReel.Services;
/// <summary>Shared FFmpeg crop filters used by both Video and Shorts pipelines.</summary>
internal static class SourceCropFilters
{
internal const string CenterFourByThreeFilter =
"crop='min(iw,ih*4/3)':'min(ih,iw*3/4)':'(iw-ow)/2':'(ih-oh)/2'";
private static readonly Regex CropSpecRegex = new(
@"crop=(\d+):(\d+):(\d+):(\d+)",
RegexOptions.CultureInvariant);
internal static string BuildManualCropFilter(int left, int right, int top, int bottom) =>
$"crop=iw-{left}-{right}:ih-{top}-{bottom}:{left}:{top}";
internal static bool HasManualCrop(int left, int right, int top, int bottom) =>
left > 0 || right > 0 || top > 0 || bottom > 0;
internal static string Resolve(
ShortSourceCropMode mode,
int left,
int right,
int top,
int bottom,
string? autoDetected)
{
if (HasManualCrop(left, right, top, bottom))
return BuildManualCropFilter(left, right, top, bottom);
return mode switch
{
ShortSourceCropMode.None => "",
ShortSourceCropMode.Auto when !string.IsNullOrWhiteSpace(autoDetected) => autoDetected,
ShortSourceCropMode.Auto => CenterFourByThreeFilter,
_ => CenterFourByThreeFilter,
};
}
internal static string Prefix(string cropFilter, string nextFilter) =>
string.IsNullOrWhiteSpace(cropFilter) ? nextFilter : $"{cropFilter},{nextFilter}";
internal static List<string> BuildHorizontalCropDetectArguments(string inputFile, string start, double durationSeconds) =>
[
"-hide_banner",
"-ss",
string.IsNullOrWhiteSpace(start) ? "00:00:00" : start,
"-t",
durationSeconds.ToString("0.###", CultureInfo.InvariantCulture),
"-i",
inputFile,
"-an",
"-vf",
"crop=iw:trunc(ih*0.70/2)*2:0:trunc(ih*0.15/2)*2,cropdetect=limit=28:round=2:reset=0",
"-f",
"null",
"-",
];
internal static string? SelectMostCommonCropSpec(string ffmpegOutput)
{
Dictionary<string, int> counts = new(StringComparer.Ordinal);
foreach (Match match in CropSpecRegex.Matches(ffmpegOutput))
{
string spec = match.Value;
counts[spec] = counts.GetValueOrDefault(spec) + 1;
}
return counts.Count == 0
? null
: counts.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal).First().Key;
}
internal static bool TryParseCropSpec(string spec, out int width, out int height, out int x, out int y)
{
Match match = CropSpecRegex.Match(spec);
if (!match.Success)
{
width = height = x = y = 0;
return false;
}
width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
height = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
x = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
y = int.Parse(match.Groups[4].Value, CultureInfo.InvariantCulture);
return true;
}
internal static bool TryBuildAutoHorizontalCrop(int detectedWidth, int detectedX, out int width, out int x)
{
int inset = (int)(detectedWidth * 0.005 / 2) * 2;
width = ((detectedWidth - (2 * inset)) / 2) * 2;
x = ((detectedX + inset) / 2) * 2;
if (width < 16)
return false;
return true;
}
internal static string? TryBuildAutoHorizontalFilter(string ffmpegOutput)
{
string? spec = SelectMostCommonCropSpec(ffmpegOutput);
if (spec is null || !TryParseCropSpec(spec, out int width, out _, out int x, out _))
return null;
if (!TryBuildAutoHorizontalCrop(width, x, out int cropWidth, out int cropX))
return null;
return $"crop={cropWidth}:ih:{cropX}:0";
}
}
+42
View File
@@ -0,0 +1,42 @@
namespace AmiReel.Services;
/// <summary>
/// Locates sequential WinUAE-style recordings: output.avi, output_1.avi, output_2.avi, ...
/// Stopping at the first missing name.
/// </summary>
public static class SourceRecordingFinder
{
public const string BaseFileName = "output";
public const string FileExtension = ".avi";
public const int MaximumFiles = 500;
/// <summary>
/// Returns existing sequential recordings in <paramref name="folder"/>, or an empty
/// list when the folder is missing or <c>output.avi</c> is not present.
/// </summary>
public static IReadOnlyList<string> FindSequentialOutputs(string? folder)
{
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
return [];
string directory = Path.GetFullPath(folder);
List<string> found = [];
string first = Path.Combine(directory, BaseFileName + FileExtension);
if (!File.Exists(first))
return found;
found.Add(first);
for (int index = 1; index < MaximumFiles; index++)
{
string next = Path.Combine(directory, $"{BaseFileName}_{index}{FileExtension}");
if (!File.Exists(next))
break;
found.Add(next);
}
return found;
}
}
+904
View File
@@ -0,0 +1,904 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# AmigaDB YouTube Shorts generator
# Creates a 1080x1920, 50 FPS vertical MP4 from an existing Amiga video.
# V4 expanded style pack for 4:3 Amiga footage, with demoscene-inspired FX and optional image/video backgrounds.
SCRIPT_NAME="$(basename "$0")"
SCRIPT_VERSION="4.2"
INPUT=""
OUTPUT=""
START="00:00:00"
DURATION="30"
TITLE=""
GROUP=""
YEAR=""
TYPE=""
HOOK="THIS RAN ON AN AMIGA"
WEBSITE="AMIGADB.NET"
FONT_FILE=""
STYLE="brand"
BACKGROUND_IMAGE="amigadb_short.png"
BACKGROUND_VIDEO=""
VIDEO_BLUR="0"
VIDEO_OPACITY="1.0"
SOURCE_CROP_MODE="auto"
CROP_LEFT="0"
CROP_RIGHT="0"
CROP_TOP="0"
CROP_BOTTOM="0"
SOURCE_MARGIN_X="40"
SOURCE_FADE_IN="1.5"
CRF="16"
PRESET="slow"
usage() {
cat <<USAGE
Usage:
$SCRIPT_NAME --input VIDEO --title TITLE [options]
Required:
--input FILE Source video
--title TEXT Production title
Production metadata:
--group TEXT Group / creator
--year YEAR Release year
--type TEXT Demo, Intro, Cracktro, Music Disk, ...
--hook TEXT Opening hook (default: THIS RAN ON AN AMIGA)
--website TEXT Closing website text (default: AMIGADB.NET)
Clip settings:
--start TIME Start position, e.g. 00:01:23.500 (default: 00:00:00)
--duration SECONDS Output duration in seconds (default: 30)
--output FILE Output MP4 path
Visual style:
--style NAME brand, pixel, mirror, crop, workbench, blur,
crt, copper, stars, grid, tiles, scan,
starfield, plasma, rasterbars, vhs,
monitor, split, spectrum, video
default: brand
--background-image Background image for brand style
default: amigadb_short.png
--background-video Background video file for video style
--video-blur NUMBER Background video blur sigma (default: 0 = no blur)
--video-opacity NUM Background video opacity 0.0-1.0 (default: 1.0)
--source-crop MODE Source crop for video style: auto or center (default: auto)
auto detects/removes black left/right borders
--crop-left PX Manually crop PX pixels from source left edge (default: 0)
--crop-right PX Manually crop PX pixels from source right edge (default: 0)
--crop-top PX Manually crop PX pixels from source top edge (default: 0)
--crop-bottom PX Manually crop PX pixels from source bottom edge (default: 0)
Any manual crop value overrides --source-crop auto/center
--source-margin-x PX Left/right margin around source video in final 1080px frame
(default: 40; use 0 for edge-to-edge)
--source-fade-in SEC Source overlay fade-in at start, in seconds
(default: 1.5; use 0 to disable)
Encoding:
--font FILE Bold TrueType/OpenType font file
--crf NUMBER H.264 quality; lower is better (default: 16)
--preset NAME x264 preset (default: slow)
Other:
-h, --help Show this help
--version Show script version
Examples:
$SCRIPT_NAME \
--input "ami-back.mp4" \
--start "00:00:34" \
--duration 30 \
--title "Ami-Back V1.04A" \
--group "Pirates" \
--year "1991" \
--type "Cracktro" \
--hook "A 1991 AMIGA CRACKTRO" \
--style brand \
--background-image "amigadb_short.png" \
--output "ami-back-brand-short.mp4"
$SCRIPT_NAME \
--input "demo.mp4" \
--title "Demo Title" \
--group "Demo Group" \
--year "1992" \
--type "Demo" \
--style crt
$SCRIPT_NAME \
--input "demo.mp4" \
--title "Demo Title" \
--style video \
--background-video "backgrounds/neon-tunnel.mp4" \
--video-blur 8 \
--video-opacity 0.65 \
--crop-left 120 \
--crop-right 120 \
--crop-top 0 \
--crop-bottom 0 \
--source-margin-x 40 \
--output "demo-video-short.mp4"
USAGE
}
fail() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
find_default_font() {
local candidate=""
if command_exists fc-match; then
candidate="$(fc-match -f '%{file}\n' 'DejaVu Sans:style=Bold' 2>/dev/null | head -n 1 || true)"
if [[ -n "$candidate" && -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
fi
for candidate in \
/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf \
/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf \
/usr/share/fonts/opentype/noto/NotoSans-Bold.ttf; do
if [[ -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
while (($#)); do
case "$1" in
--input)
(($# >= 2)) || fail "Missing value after --input"
INPUT="$2"
shift 2
;;
--output)
(($# >= 2)) || fail "Missing value after --output"
OUTPUT="$2"
shift 2
;;
--start)
(($# >= 2)) || fail "Missing value after --start"
START="$2"
shift 2
;;
--duration)
(($# >= 2)) || fail "Missing value after --duration"
DURATION="$2"
shift 2
;;
--title)
(($# >= 2)) || fail "Missing value after --title"
TITLE="$2"
shift 2
;;
--group)
(($# >= 2)) || fail "Missing value after --group"
GROUP="$2"
shift 2
;;
--year)
(($# >= 2)) || fail "Missing value after --year"
YEAR="$2"
shift 2
;;
--type)
(($# >= 2)) || fail "Missing value after --type"
TYPE="$2"
shift 2
;;
--hook)
(($# >= 2)) || fail "Missing value after --hook"
HOOK="$2"
shift 2
;;
--website)
(($# >= 2)) || fail "Missing value after --website"
WEBSITE="$2"
shift 2
;;
--font)
(($# >= 2)) || fail "Missing value after --font"
FONT_FILE="$2"
shift 2
;;
--background-image|--background)
(($# >= 2)) || fail "Missing value after --background-image"
BACKGROUND_IMAGE="$2"
shift 2
;;
--background-video)
(($# >= 2)) || fail "Missing value after --background-video"
BACKGROUND_VIDEO="$2"
shift 2
;;
--video-blur|--background-video-blur)
(($# >= 2)) || fail "Missing value after --video-blur"
VIDEO_BLUR="$2"
shift 2
;;
--video-opacity|--background-video-opacity)
(($# >= 2)) || fail "Missing value after --video-opacity"
VIDEO_OPACITY="$2"
shift 2
;;
--source-crop)
(($# >= 2)) || fail "Missing value after --source-crop"
SOURCE_CROP_MODE="${2,,}"
shift 2
;;
--crop-left|--source-crop-left)
(($# >= 2)) || fail "Missing value after --crop-left"
CROP_LEFT="$2"
shift 2
;;
--crop-right|--source-crop-right)
(($# >= 2)) || fail "Missing value after --crop-right"
CROP_RIGHT="$2"
shift 2
;;
--crop-top|--source-crop-top)
(($# >= 2)) || fail "Missing value after --crop-top"
CROP_TOP="$2"
shift 2
;;
--crop-bottom|--source-crop-bottom)
(($# >= 2)) || fail "Missing value after --crop-bottom"
CROP_BOTTOM="$2"
shift 2
;;
--source-margin-x|--video-margin-x|--source-margin)
(($# >= 2)) || fail "Missing value after --source-margin-x"
SOURCE_MARGIN_X="$2"
shift 2
;;
--source-fade-in|--source-fade|--fade-in)
(($# >= 2)) || fail "Missing value after --source-fade-in"
SOURCE_FADE_IN="$2"
shift 2
;;
--style)
(($# >= 2)) || fail "Missing value after --style"
STYLE="${2,,}"
shift 2
;;
--crf)
(($# >= 2)) || fail "Missing value after --crf"
CRF="$2"
shift 2
;;
--preset)
(($# >= 2)) || fail "Missing value after --preset"
PRESET="$2"
shift 2
;;
--version)
printf "%s v%s\n" "$SCRIPT_NAME" "$SCRIPT_VERSION"
exit 0
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
command_exists ffmpeg || fail "ffmpeg is not installed or not in PATH"
[[ -n "$INPUT" ]] || fail "--input is required"
[[ -f "$INPUT" ]] || fail "Input file not found: $INPUT"
[[ -n "$TITLE" ]] || fail "--title is required"
[[ "$DURATION" =~ ^[0-9]+([.][0-9]+)?$ ]] || fail "--duration must be a positive number"
awk -v d="$DURATION" 'BEGIN { exit !(d > 0) }' || fail "--duration must be greater than zero"
[[ "$CRF" =~ ^[0-9]+$ ]] || fail "--crf must be an integer"
[[ "$VIDEO_BLUR" =~ ^[0-9]+([.][0-9]+)?$ ]] || fail "--video-blur must be zero or a positive number"
awk -v v="$VIDEO_BLUR" 'BEGIN { exit !(v >= 0) }' || fail "--video-blur must be zero or greater"
[[ "$VIDEO_OPACITY" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]] || fail "--video-opacity must be a number between 0.0 and 1.0"
awk -v v="$VIDEO_OPACITY" 'BEGIN { exit !(v >= 0 && v <= 1) }' || fail "--video-opacity must be between 0.0 and 1.0"
case "$SOURCE_CROP_MODE" in
auto|center) ;;
*) fail "--source-crop must be 'auto' or 'center'" ;;
esac
for crop_value in "$CROP_LEFT" "$CROP_RIGHT" "$CROP_TOP" "$CROP_BOTTOM"; do
[[ "$crop_value" =~ ^[0-9]+$ ]] || fail "Manual crop values must be non-negative integer pixel values"
done
[[ "$SOURCE_MARGIN_X" =~ ^[0-9]+$ ]] || fail "--source-margin-x must be a non-negative integer pixel value"
(( SOURCE_MARGIN_X < 540 )) || fail "--source-margin-x must be less than 540"
SOURCE_TARGET_WIDTH=$((1080 - (SOURCE_MARGIN_X * 2)))
[[ "$SOURCE_FADE_IN" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]] || fail "--source-fade-in must be zero or a positive number"
awk -v v="$SOURCE_FADE_IN" 'BEGIN { exit !(v >= 0) }' || fail "--source-fade-in must be zero or greater"
SOURCE_FADE_IN="$(awk -v v="$SOURCE_FADE_IN" -v d="$DURATION" 'BEGIN {
if (v > d) v = d;
printf "%.3f", v;
}')"
case "$STYLE" in
brand|pixel|mirror|crop|workbench|blur|crt|copper|stars|grid|tiles|scan|starfield|plasma|rasterbars|vhs|monitor|split|spectrum|video)
;;
*)
fail "Unknown style '$STYLE'. Use brand, pixel, mirror, crop, workbench, blur, crt, copper, stars, grid, tiles, scan, starfield, plasma, rasterbars, vhs, monitor, split, spectrum, or video"
;;
esac
if [[ -z "$FONT_FILE" ]]; then
FONT_FILE="$(find_default_font || true)"
fi
[[ -n "$FONT_FILE" && -f "$FONT_FILE" ]] || fail "No usable font found. Install fonts-dejavu-core or use --font FILE"
if [[ -z "$OUTPUT" ]]; then
base="$(basename "$INPUT")"
base="${base%.*}"
OUTPUT="${base}-${STYLE}-short.mp4"
fi
mkdir -p "$(dirname "$OUTPUT")"
TMP_DIR="$(mktemp -d -t amigadb-short.XXXXXX)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
printf '%s\n' "$HOOK" > "$TMP_DIR/hook.txt"
printf '%s\n' "$TITLE" > "$TMP_DIR/title.txt"
META_PARTS=()
[[ -n "$GROUP" ]] && META_PARTS+=("$GROUP")
[[ -n "$YEAR" ]] && META_PARTS+=("$YEAR")
[[ -n "$TYPE" ]] && META_PARTS+=("$TYPE")
META=""
if ((${#META_PARTS[@]})); then
for part in "${META_PARTS[@]}"; do
if [[ -n "$META" ]]; then
META+=" · "
fi
META+="$part"
done
fi
printf '%s\n' "$META" > "$TMP_DIR/meta.txt"
printf '%s\n' "$WEBSITE" > "$TMP_DIR/website.txt"
INFO_START="$(awk -v d="$DURATION" 'BEGIN { v=d-7; if (v<0) v=0; printf "%.3f", v }')"
CTA_START="$(awk -v d="$DURATION" 'BEGIN { v=d-3.5; if (v<0) v=0; printf "%.3f", v }')"
HOOK_END="$(awk -v d="$DURATION" 'BEGIN { v=2.7; if (d<v) v=d; printf "%.3f", v }')"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
resolve_path() {
local p="$1"
if [[ -f "$p" ]]; then
printf "%s\n" "$p"
return 0
fi
if [[ -f "$SCRIPT_DIR/$p" ]]; then
printf "%s\n" "$SCRIPT_DIR/$p"
return 0
fi
return 1
}
# Detect left/right black capture borders while deliberately ignoring the top
# and bottom 15% of the frame. This prevents full-width overscan/raster lines
# near the top/bottom from fooling cropdetect. Used by --style video.
detect_source_horizontal_crop() {
local detect_duration candidate crop_spec
detect_duration="$(awk -v d="$DURATION" 'BEGIN { v=(d<4?d:4); if (v<0.5) v=0.5; printf "%.3f", v }')"
# Inspect only the middle 70% vertically, then use cropdetect to locate the
# active horizontal picture. We only keep W and X; the source keeps its full
# height so this specifically removes left/right black borders.
candidate="$({
ffmpeg -hide_banner -ss "$START" -t "$detect_duration" -i "$INPUT" \
-an -vf "crop=iw:trunc(ih*0.70/2)*2:0:trunc(ih*0.15/2)*2,cropdetect=limit=28:round=2:reset=0" \
-f null - 2>&1 || true
} | grep -oE 'crop=[0-9]+:[0-9]+:[0-9]+:[0-9]+' \
| sort | uniq -c | sort -nr | head -n 1 | awk '{print $2}' || true)"
[[ "$candidate" =~ ^crop=([0-9]+):([0-9]+):([0-9]+):([0-9]+)$ ]] || return 1
local w="${BASH_REMATCH[1]}"
local x="${BASH_REMATCH[3]}"
# cropdetect can occasionally leave a few black pixels at each side. Trim a
# tiny extra safety margin (0.5% of detected width per side), still keeping
# even dimensions/offsets for H.264 compatibility.
crop_spec="$(awk -v w="$w" -v x="$x" 'BEGIN {
inset = int((w * 0.005) / 2) * 2;
nw = w - (2 * inset);
nx = x + inset;
nw = int(nw / 2) * 2;
nx = int(nx / 2) * 2;
if (nw < 16) exit 1;
printf "%d:%d", nw, nx;
}')" || return 1
[[ -n "$crop_spec" ]] || return 1
printf '%s\n' "$crop_spec"
}
if [[ "$STYLE" == "brand" ]]; then
BACKGROUND_IMAGE="$(resolve_path "$BACKGROUND_IMAGE" || true)"
[[ -n "$BACKGROUND_IMAGE" && -f "$BACKGROUND_IMAGE" ]] || fail "Brand style requires background image. Put amigadb_short.png next to the script or pass --background-image FILE"
fi
if [[ "$STYLE" == "video" ]]; then
[[ -n "$BACKGROUND_VIDEO" ]] || fail "Video style requires --background-video FILE"
BACKGROUND_VIDEO="$(resolve_path "$BACKGROUND_VIDEO" || true)"
[[ -n "$BACKGROUND_VIDEO" && -f "$BACKGROUND_VIDEO" ]] || fail "Background video not found. Pass a valid file with --background-video FILE"
fi
SOURCE_CROP_FILTER="crop='min(iw,ih*4/3)':'min(ih,iw*3/4)':'(iw-ow)/2':'(ih-oh)/2'"
SOURCE_SCALE_FILTER="scale=${SOURCE_TARGET_WIDTH}:-2:flags=lanczos"
SOURCE_CROP_SPEC="centered 4:3"
MANUAL_CROP=0
if (( CROP_LEFT > 0 || CROP_RIGHT > 0 || CROP_TOP > 0 || CROP_BOTTOM > 0 )); then
MANUAL_CROP=1
fi
if [[ "$STYLE" == "video" && "$MANUAL_CROP" -eq 1 ]]; then
# Manual values are pixels removed from each source edge. Manual crop takes
# precedence over automatic/center crop modes. Scale the remaining picture
# to the configured Short source width while preserving its aspect ratio.
SOURCE_CROP_FILTER="crop=iw-${CROP_LEFT}-${CROP_RIGHT}:ih-${CROP_TOP}-${CROP_BOTTOM}:${CROP_LEFT}:${CROP_TOP}"
SOURCE_SCALE_FILTER="scale=${SOURCE_TARGET_WIDTH}:-2:flags=lanczos"
SOURCE_CROP_SPEC="manual L=${CROP_LEFT} R=${CROP_RIGHT} T=${CROP_TOP} B=${CROP_BOTTOM}"
elif [[ "$STYLE" == "video" && "$SOURCE_CROP_MODE" == "auto" ]]; then
DETECTED_SOURCE_CROP="$(detect_source_horizontal_crop || true)"
if [[ -n "$DETECTED_SOURCE_CROP" ]]; then
DETECTED_W="${DETECTED_SOURCE_CROP%%:*}"
DETECTED_X="${DETECTED_SOURCE_CROP##*:}"
SOURCE_CROP_FILTER="crop=${DETECTED_W}:ih:${DETECTED_X}:0"
SOURCE_SCALE_FILTER="scale=${SOURCE_TARGET_WIDTH}:-2:flags=lanczos"
SOURCE_CROP_SPEC="w=${DETECTED_W}, x=${DETECTED_X} (auto left/right black crop)"
else
printf 'Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.\n' >&2
fi
fi
# Escape paths for FFmpeg filter option parsing.
ffmpeg_escape_path() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//:/\\:}"
value="${value//\'/\\\'}"
printf '%s' "$value"
}
FONT_ESC="$(ffmpeg_escape_path "$FONT_FILE")"
HOOK_ESC="$(ffmpeg_escape_path "$TMP_DIR/hook.txt")"
TITLE_ESC="$(ffmpeg_escape_path "$TMP_DIR/title.txt")"
META_ESC="$(ffmpeg_escape_path "$TMP_DIR/meta.txt")"
WEBSITE_ESC="$(ffmpeg_escape_path "$TMP_DIR/website.txt")"
BACKGROUND_ESC=""
if [[ "$STYLE" == "brand" ]]; then
BACKGROUND_ESC="$(ffmpeg_escape_path "$BACKGROUND_IMAGE")"
fi
HOOK_BOX_Y=0
HOOK_BOX_H=245
HOOK_TEXT_Y=70
HOOK_FONT_SIZE=70
INFO_BOX_Y=1490
INFO_BOX_H=430
TITLE_TEXT_Y=1560
TITLE_FONT_SIZE=62
META_TEXT_Y=1655
META_FONT_SIZE=42
WEBSITE_TEXT_Y=1780
WEBSITE_FONT_SIZE=52
if [[ "$STYLE" == "brand" ]]; then
HOOK_BOX_Y=250
HOOK_BOX_H=140
HOOK_TEXT_Y=285
HOOK_FONT_SIZE=60
INFO_BOX_Y=1310
INFO_BOX_H=270
TITLE_TEXT_Y=1360
TITLE_FONT_SIZE=56
META_TEXT_Y=1435
META_FONT_SIZE=38
WEBSITE_TEXT_Y=1510
WEBSITE_FONT_SIZE=48
fi
HAS_AUDIO=0
if command_exists ffprobe && ffprobe -v error -select_streams a:0 -show_entries stream=index -of csv=p=0 "$INPUT" | grep -q .; then
HAS_AUDIO=1
fi
if [[ "$STYLE" == "spectrum" && "$HAS_AUDIO" -ne 1 ]]; then
fail "The spectrum style requires an audio stream in the source video"
fi
case "$STYLE" in
brand)
BASE_FILTER=$(cat <<'FILTER'
[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1[bg];
[0:v]scale=920:700:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x07111f,setsar=1,eq=contrast=1.02:saturation=1.02[fg];
[bg]drawbox=x=65:y=505:w=950:h=730:color=[email protected]:t=fill[bg2];
[bg2][fg]overlay=x=(W-w)/2:y=520:format=auto[base]
FILTER
)
;;
pixel)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=270:480:force_original_aspect_ratio=increase,crop=270:480,scale=1080:1920:flags=neighbor,eq=brightness=-0.36:saturation=1.18,gblur=sigma=2,setsar=1[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x55c8ff,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
drawbox=x=0:y='300+20*sin(t*1.70)':w=1080:h=8:color=[email protected]:t=fill,
drawbox=x=0:y='1610+18*sin(t*1.35)':w=1080:h=8:color=[email protected]:t=fill[base]
FILTER
)
;;
mirror)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=4[canvas_src][fg_src][top_src][bottom_src];
[canvas_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,drawbox=x=0:y=0:w=1080:h=1920:color=0x010203:t=fill[canvas];
[fg_src]scale=940:760:force_original_aspect_ratio=decrease,setsar=1[fg];
[top_src]scale=1400:470:flags=lanczos,vflip,eq=brightness=-0.48:contrast=0.92:saturation=0.68,gblur=sigma=7,setsar=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*0.62*(Y/H)'[top];
[bottom_src]scale=1400:470:flags=lanczos,vflip,eq=brightness=-0.50:contrast=0.90:saturation=0.64,gblur=sigma=7,setsar=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*0.58*(1-Y/H)'[bottom];
[canvas][top]overlay=x=(W-w)/2:y=250:format=auto[tmp1];
[tmp1][bottom]overlay=x=(W-w)/2:y=1435:format=auto[tmp2];
[tmp2][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
crop)
BASE_FILTER=$(cat <<'FILTER'
[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,eq=brightness=-0.04:saturation=1.05[base]
FILTER
)
;;
workbench)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[canvas_src][screen_src];
[canvas_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,
drawbox=x=0:y=0:w=1080:h=1920:color=0x090c20:t=fill,
drawbox=x=55:y=410:w=970:h=930:color=0xc4c8d0:t=fill,
drawbox=x=70:y=425:w=940:h=900:color=0x1b4f9c:t=fill,
drawbox=x=70:y=425:w=940:h=74:color=0xf28c28:t=fill,
drawbox=x=88:y=445:w=34:h=34:color=0x111111:t=fill[window];
[screen_src]scale=960:760:force_original_aspect_ratio=decrease,pad=iw+10:ih+10:5:5:color=0xffffff,setsar=1[screen];
[window][screen]overlay=x=(W-w)/2:y=525:format=auto[base]
FILTER
)
;;
blur)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=35,eq=brightness=-0.22:saturation=0.85,setsar=1[bg];
[fg_src]scale=1080:900:force_original_aspect_ratio=decrease,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
crt)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=28,eq=brightness=-0.45:saturation=0.55,setsar=1[bg];
[fg_src]scale=980:800:force_original_aspect_ratio=decrease,lenscorrection=k1=-0.060:k2=0.020,vignette=angle=PI/5,eq=contrast=1.08:saturation=1.10,setsar=1,
drawgrid=width=iw:height=4:thickness=1:color=[email protected][fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
drawbox=x=50:y='220+mod(t*340,1480)':w=980:h=4:color=[email protected]:t=fill[base]
FILTER
)
;;
copper)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,eq=brightness=-0.50:saturation=0.80,gblur=sigma=18,setsar=1,
drawbox=x=0:y=0:w=1080:h=1920:color=[email protected]:t=fill,
drawbox=x=0:y='220+20*sin(t*1.00)':w=1080:h=140:color=[email protected]:t=fill,
drawbox=x=0:y='420+24*sin(t*1.20)':w=1080:h=100:color=[email protected]:t=fill,
drawbox=x=0:y='660+16*sin(t*1.50)':w=1080:h=120:color=[email protected]:t=fill,
drawbox=x=0:y='1110+22*sin(t*1.10)':w=1080:h=150:color=[email protected]:t=fill,
drawbox=x=0:y='1360+18*sin(t*1.30)':w=1080:h=110:color=[email protected]:t=fill,
drawbox=x=0:y='1660+15*sin(t*1.70)':w=1080:h=130:color=[email protected]:t=fill[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
stars)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.72:saturation=0.28,setsar=1,
drawbox=x=0:y=0:w=1080:h=1920:color=[email protected]:t=fill,
drawbox=x='mod(100+80*t,1080)':y='mod(80+210*t,1920)':w=3:h=3:color=[email protected]:t=fill,
drawbox=x='mod(880-55*t,1080)':y='mod(220+170*t,1920)':w=2:h=2:color=[email protected]:t=fill,
drawbox=x='mod(300+120*t,1080)':y='mod(520+140*t,1920)':w=3:h=3:color=[email protected]:t=fill,
drawbox=x='mod(980-75*t,1080)':y='mod(710+160*t,1920)':w=2:h=2:color=[email protected]:t=fill,
drawbox=x='mod(420+65*t,1080)':y='mod(1040+200*t,1920)':w=4:h=4:color=[email protected]:t=fill,
drawbox=x='mod(750-90*t,1080)':y='mod(1280+110*t,1920)':w=2:h=2:color=[email protected]:t=fill,
drawbox=x='mod(210+75*t,1080)':y='mod(1440+180*t,1920)':w=3:h=3:color=[email protected]:t=fill,
drawbox=x='mod(1030-60*t,1080)':y='mod(1680+140*t,1920)':w=2:h=2:color=[email protected]:t=fill,
drawbox=x='mod(560+95*t,1080)':y='mod(260+260*t,1920)':w=2:h=2:color=[email protected]:t=fill,
drawbox=x='mod(60+110*t,1080)':y='mod(860+120*t,1920)':w=3:h=3:color=[email protected]:t=fill[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=0x55c8ff,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
grid)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=24,eq=brightness=-0.62:saturation=0.36,setsar=1,
drawbox=x=0:y=0:w=1080:h=1920:color=[email protected]:t=fill,
drawgrid=width=90:height=90:thickness=2:color=[email protected],
drawbox=x=0:y=0:w=1080:h=1120:color=[email protected]:t=fill,
drawbox=x=0:y=1120:w=1080:h=4:color=[email protected]:t=fill[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xff54d9,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
tiles)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=5[fg_src][a][b][c][d];
[a]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[ta];
[b]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,hflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[tb];
[c]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,vflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[tc];
[d]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,hflip,vflip,eq=brightness=-0.45:saturation=0.68,gblur=sigma=4,setsar=1[td];
[ta][tb]hstack=inputs=2[top];
[tc][td]hstack=inputs=2[bottom];
[top][bottom]vstack=inputs=2[bg];
[fg_src]scale=980:800:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0xffffff,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
scan)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,scale=360:640:flags=neighbor,scale=1080:1920:flags=neighbor,eq=brightness=-0.44:saturation=1.05,gblur=sigma=2,setsar=1[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0x55c8ff,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
drawbox=x=40:y='220+mod(t*380,1480)':w=1000:h=8:color=[email protected]:t=fill,
drawbox=x=0:y='280+20*sin(t*1.40)':w=1080:h=6:color=[email protected]:t=fill,
drawbox=x=0:y='1640+15*sin(t*1.20)':w=1080:h=6:color=[email protected]:t=fill[base]
FILTER
)
;;
starfield)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=32,eq=brightness=-0.78:saturation=0.22,setsar=1,
drawbox=x=0:y=0:w=1080:h=1920:color=[email protected]:t=fill,
drawbox=x='mod(120+75*t,1080)':y='mod(80+520*t,1920)':w=3:h=18:color=[email protected]:t=fill,
drawbox=x='mod(970-55*t,1080)':y='mod(250+430*t,1920)':w=2:h=14:color=[email protected]:t=fill,
drawbox=x='mod(370+95*t,1080)':y='mod(430+600*t,1920)':w=4:h=24:color=[email protected]:t=fill,
drawbox=x='mod(810-80*t,1080)':y='mod(640+470*t,1920)':w=2:h=17:color=[email protected]:t=fill,
drawbox=x='mod(230+65*t,1080)':y='mod(900+540*t,1920)':w=3:h=20:color=[email protected]:t=fill,
drawbox=x='mod(1020-100*t,1080)':y='mod(1110+650*t,1920)':w=4:h=26:color=[email protected]:t=fill,
drawbox=x='mod(560+85*t,1080)':y='mod(1320+490*t,1920)':w=2:h=16:color=[email protected]:t=fill,
drawbox=x='mod(70+110*t,1080)':y='mod(1510+590*t,1920)':w=3:h=22:color=[email protected]:t=fill,
drawbox=x='mod(720-60*t,1080)':y='mod(1720+450*t,1920)':w=2:h=15:color=[email protected]:t=fill,
drawbox=x='mod(440+105*t,1080)':y='mod(180+700*t,1920)':w=3:h=28:color=[email protected]:t=fill,
drawbox=x='mod(900-72*t,1080)':y='mod(780+560*t,1920)':w=2:h=18:color=[email protected]:t=fill,
drawbox=x='mod(310+90*t,1080)':y='mod(1240+620*t,1920)':w=4:h=24:color=[email protected]:t=fill[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0x70cfff,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
plasma)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[dim_src][fg_src];
nullsrc=size=1080x1920:rate=50,format=rgb24,
geq=r='128+105*sin(X/68+T*1.9)+22*sin(Y/91-T*1.1)':g='128+96*sin(Y/75+T*1.5)+26*sin((X+Y)/120+T*1.3)':b='128+110*sin((X-Y)/82-T*1.7)',
gblur=sigma=18,eq=brightness=-0.16:saturation=1.22[plasma];
[dim_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=26,eq=brightness=-0.70:saturation=0.35,setsar=1[dim];
[plasma][dim]blend=all_mode=overlay:all_opacity=0.35[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+16:ih+16:8:8:color=0xffffff,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
rasterbars)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.70:saturation=0.35,setsar=1,
drawbox=x=0:y=0:w=1080:h=1920:color=[email protected]:t=fill,
drawbox=x=0:y='250+90*sin(t*1.25)':w=1080:h=30:color=[email protected]:t=fill,
drawbox=x=0:y='300+90*sin(t*1.25)':w=1080:h=24:color=[email protected]:t=fill,
drawbox=x=0:y='345+90*sin(t*1.25)':w=1080:h=18:color=[email protected]:t=fill,
drawbox=x=0:y='1510+85*sin(t*1.10+1.2)':w=1080:h=18:color=[email protected]:t=fill,
drawbox=x=0:y='1550+85*sin(t*1.10+1.2)':w=1080:h=24:color=[email protected]:t=fill,
drawbox=x=0:y='1600+85*sin(t*1.10+1.2)':w=1080:h=30:color=[email protected]:t=fill[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
vhs)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=26,eq=brightness=-0.44:saturation=0.55,noise=alls=10:allf=t+u,setsar=1[bg];
[fg_src]scale=1000:820:force_original_aspect_ratio=decrease,chromashift=cbh=2:crh=-2,noise=alls=5:allf=t+u,eq=contrast=1.08:saturation=0.92,vignette=angle=PI/4.5,setsar=1,
drawgrid=width=iw:height=5:thickness=1:color=[email protected][fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,
drawbox=x=0:y='300+mod(t*510,1320)':w=1080:h=10:color=[email protected]:t=fill,
drawbox=x=0:y='420+mod(t*320,1080)':w=1080:h=3:color=[email protected]:t=fill[base]
FILTER
)
;;
monitor)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][screen_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=34,eq=brightness=-0.68:saturation=0.32,setsar=1,
drawbox=x=0:y=0:w=1080:h=1920:color=[email protected]:t=fill,
drawbox=x=42:y=365:w=996:h=1000:color=0xaaa69a:t=fill,
drawbox=x=68:y=392:w=944:h=944:color=0x2a2926:t=fill,
drawbox=x=105:y=438:w=870:h=790:color=0x050505:t=fill,
drawbox=x=792:y=1260:w=44:h=18:color=0x6aff73:t=fill,
drawbox=x=852:y=1253:w=30:h=30:color=0x232323:t=fill,
drawbox=x=910:y=1253:w=30:h=30:color=0x232323:t=fill,
drawbox=x=380:y=1365:w=320:h=75:color=0x8f8b80:t=fill,
drawbox=x=290:y=1438:w=500:h=36:color=0x77736b:t=fill[monitor];
[screen_src]scale=840:750:force_original_aspect_ratio=decrease,lenscorrection=k1=-0.045:k2=0.012,vignette=angle=PI/5,eq=contrast=1.06:saturation=1.06,setsar=1[screen];
[monitor][screen]overlay=x=(W-w)/2:y=458:format=auto[base]
FILTER
)
;;
split)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=4[bg_src][main_src][top_src][bottom_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=34,eq=brightness=-0.55:saturation=0.55,setsar=1[bg];
[top_src]scale=1080:520:force_original_aspect_ratio=increase,crop=1080:360:y='(ih-360)/2',eq=brightness=-0.28:saturation=0.88,gblur=sigma=2,setsar=1[top];
[bottom_src]scale=1080:520:force_original_aspect_ratio=increase,crop=1080:360:y='(ih-360)/2',hflip,eq=brightness=-0.32:saturation=0.80,gblur=sigma=2,setsar=1[bottom];
[main_src]scale=1000:820:force_original_aspect_ratio=decrease,pad=iw+14:ih+14:7:7:color=0xffffff,setsar=1[main];
[bg][top]overlay=x=0:y=275[tmp1];
[tmp1][bottom]overlay=x=0:y=1285[tmp2];
[tmp2][main]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
spectrum)
BASE_FILTER=$(cat <<'FILTER'
[0:v]split=2[bg_src][fg_src];
[bg_src]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30,eq=brightness=-0.62:saturation=0.40,setsar=1,
drawbox=x=0:y=0:w=1080:h=1920:color=[email protected]:t=fill[bg];
[fg_src]scale=1000:780:force_original_aspect_ratio=decrease,pad=iw+12:ih+12:6:6:color=0x55c8ff,setsar=1[fg];
[0:a]aformat=sample_fmts=fltp:channel_layouts=stereo,showspectrum=s=1000x250:mode=combined:color=rainbow:scale=sqrt:fscale=log:slide=scroll:win_func=hann:fps=50:opacity=0.85,format=rgba[spec];
[bg][spec]overlay=x=(W-w)/2:y=1360:format=auto[tmp];
[tmp][fg]overlay=x=(W-w)/2:y=480:format=auto[base]
FILTER
)
;;
video)
VIDEO_BLUR_FILTER=""
if awk -v v="$VIDEO_BLUR" 'BEGIN { exit !(v > 0) }'; then
VIDEO_BLUR_FILTER=",gblur=sigma=${VIDEO_BLUR}"
fi
BASE_FILTER=$(cat <<FILTER
color=c=black:s=1080x1920:r=50[black];
[1:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1${VIDEO_BLUR_FILTER},format=rgba,colorchannelmixer=aa=${VIDEO_OPACITY}[bgvideo];
[black][bgvideo]overlay=x=0:y=0:format=auto[bg];
[0:v]${SOURCE_CROP_FILTER},${SOURCE_SCALE_FILTER},setsar=1[fg];
[bg][fg]overlay=x=(W-w)/2:y=(H-h)/2:format=auto[base]
FILTER
)
;;
esac
SOURCE_FADE_PREFIX=""
if awk -v v="$SOURCE_FADE_IN" 'BEGIN { exit !(v > 0) }'; then
# Fade only the Amiga source stream. Background video/image stays fully
# visible from frame 0; the source overlay (and source-derived layers)
# ramp from transparent to opaque.
SOURCE_FADE_PREFIX="[0:v]format=rgba,fade=t=in:st=0:d=${SOURCE_FADE_IN}:alpha=1[srcfade];"
BASE_FILTER="${BASE_FILTER//\[0:v]/[srcfade]}"
fi
FILTER_COMPLEX=$(cat <<FILTER
${SOURCE_FADE_PREFIX}${BASE_FILTER};
[base]
drawbox=x=0:y=0:w=1080:h=245:color=[email protected]:t=fill:enable='between(t,0,${HOOK_END})',
drawtext=fontfile='${FONT_ESC}':textfile='${HOOK_ESC}':fontcolor=white:fontsize=70:line_spacing=10:x=(w-text_w)/2:y=70:fix_bounds=true:shadowcolor=[email protected]:shadowx=4:shadowy=4:enable='between(t,0,${HOOK_END})',
drawbox=x=0:y=1490:w=1080:h=430:color=[email protected]:t=fill:enable='gte(t,${INFO_START})',
drawtext=fontfile='${FONT_ESC}':textfile='${TITLE_ESC}':fontcolor=white:fontsize=62:line_spacing=8:x=(w-text_w)/2:y=1560:fix_bounds=true:shadowcolor=[email protected]:shadowx=4:shadowy=4:enable='gte(t,${INFO_START})',
drawtext=fontfile='${FONT_ESC}':textfile='${META_ESC}':fontcolor=[email protected]:fontsize=42:x=(w-text_w)/2:y=1655:fix_bounds=true:shadowcolor=[email protected]:shadowx=3:shadowy=3:enable='gte(t,${INFO_START})',
drawtext=fontfile='${FONT_ESC}':textfile='${WEBSITE_ESC}':fontcolor=0x55c8ff:fontsize=52:x=(w-text_w)/2:y=1780:fix_bounds=true:shadowcolor=[email protected]:shadowx=4:shadowy=4:enable='gte(t,${CTA_START})',
fps=50,format=yuv420p[vout]
FILTER
)
printf 'Creating AmigaDB YouTube Short...\n'
printf ' Input: %s\n' "$INPUT"
printf ' Segment: %s + %ss\n' "$START" "$DURATION"
printf ' Style: %s\n' "$STYLE"
printf ' Src fade: %ss\n' "$SOURCE_FADE_IN"
printf ' Output: %s\n' "$OUTPUT"
printf ' Font: %s\n' "$FONT_FILE"
if [[ "$STYLE" == "brand" ]]; then
printf ' Bg image: %s\n' "$BACKGROUND_IMAGE"
fi
if [[ "$STYLE" == "video" ]]; then
printf ' Bg video: %s\n' "$BACKGROUND_VIDEO"
printf ' Bg blur: %s\n' "$VIDEO_BLUR"
printf ' Bg alpha: %s\n' "$VIDEO_OPACITY"
printf ' Src crop: %s\n' "$SOURCE_CROP_SPEC"
printf ' Src margin:%s px each side (target width %s)\n' "$SOURCE_MARGIN_X" "$SOURCE_TARGET_WIDTH"
fi
FFMPEG_ARGS=(
-hide_banner -y
-ss "$START"
-t "$DURATION"
-i "$INPUT"
)
if [[ "$STYLE" == "brand" ]]; then
FFMPEG_ARGS+=( -loop 1 -i "$BACKGROUND_IMAGE" )
fi
if [[ "$STYLE" == "video" ]]; then
FFMPEG_ARGS+=( -stream_loop -1 -i "$BACKGROUND_VIDEO" )
fi
FFMPEG_ARGS+=(
-filter_complex "$FILTER_COMPLEX"
-map "[vout]"
-map 0:a?
-c:v libx264
-preset "$PRESET"
-crf "$CRF"
-profile:v high
-level:v 4.2
-pix_fmt yuv420p
-c:a aac
-b:a 320k
-ar 48000
-movflags +faststart
-t "$DURATION"
-shortest
"$OUTPUT"
)
ffmpeg "${FFMPEG_ARGS[@]}"
printf '\nDone: %s\n' "$OUTPUT"
+17 -5
View File
@@ -16,6 +16,8 @@ $releaseDir = Join-Path $projectDir 'bin\Release\net10.0-windows10.0.26100.0\win
$releaseExe = Join-Path $releaseDir 'AmiReel.exe'
$publishDir = Join-Path $releaseDir 'publish'
$publishExe = Join-Path $publishDir 'AmiReel.exe'
$distDir = Join-Path $projectDir 'dist'
$distExe = Join-Path $distDir 'AmiReel.exe'
$publishArguments = @(
'publish', $projectPath,
'-c', 'Release',
@@ -27,11 +29,12 @@ $publishArguments = @(
'-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 {
$_.Path -and (
[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
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
Write-Host "Published AmiReel to $publishDir" -ForegroundColor Green
Write-Host "Release folder: $publishDir" -ForegroundColor Cyan
Write-Host "Executable: $publishExe" -ForegroundColor Cyan
if (-not (Test-Path $publishExe)) {
throw "Published executable was not found at $publishExe."
}
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