Compare commits

..

6 Commits

Author SHA1 Message Date
klevze 58e11ba4e3 Add YouTube Shorts tab (C# port of amigadb-short.sh)
New "Video | Shorts" tab strip on the main page. The existing Video tab and
its render pipeline are unchanged; Shorts is a new, independent workflow
sharing the same render-progress/log/output controls.

- Services/ShortsPipeline.cs: C# port of amigadb-short.sh rather than
  shelling out to bash — end users on plain Windows don't have bash/WSL, and
  shipping this as a native part of the app keeps distribution self-contained.
  All 19 visual style filtergraphs (brand, pixel, mirror, crop, workbench,
  blur, crt, copper, stars, grid, tiles, scan, starfield, plasma,
  rasterbars, vhs, monitor, split, spectrum) are copied verbatim from the
  bash heredocs. Per explicit decision, kept parity with the script's actual
  behavior where the brand-style HOOK_BOX_Y/INFO_BOX_Y/font-size variables
  are computed but never actually used in the final filter (dead code in
  the original) rather than "fixing" it and changing rendered output.
  Reuses ToolExtractor/MediaProbe/ProcessRunner exactly as RenderPipeline
  does, so FFmpeg resolution and NVENC fallback behave identically.

- Models/ShortSettings.cs: job parameters + ShortStyle enum.

- AppSettings gains sticky Shorts defaults (style, hook, website, font,
  background image, CRF, preset) alongside the existing Video defaults.

- MainPage: Video/Shorts tab buttons toggle two content panels; the
  background-image field auto-hides for non-brand styles; font field
  defaults to the first bold system font found (Segoe UI Bold, Segoe UI
  Semibold, Arial Bold, Calibri Bold); blank output path is auto-derived
  from the input filename and style, matching the script's default.

- 13 new unit tests (escaping order, meta-line joining, validation,
  every style producing a non-empty filter, the drawtext trailer's timing
  gates). Full run is 72/72 passing.

- Fixed a bug caught during manual testing: MainPage.Resources["PrimaryButtonStyle"]
  looked in the Page's own (empty) resource dictionary instead of
  Application.Current.Resources, throwing on every tab switch.

Verified end-to-end: rendered a real Short from an existing video through
the actual running app (not just unit tests) and inspected extracted
frames — hook/title/meta/website text overlays appear and disappear at the
correct timestamps with correct styling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:30:25 +02:00
klevze f1f1be778b Fix missing taskbar/Alt+Tab icon
AppWindow.SetIcon(executablePath) was passing the .exe's own path, but that
API requires an actual .ico file path — passing an .exe silently does
nothing, so the window fell back to a generic placeholder icon in the
taskbar and Alt+Tab (the custom title bar icon fixed earlier is a separate,
XAML-drawn Image and was unaffected).

Embed branding/AmiReel.ico as a resource, extract it once to
%LOCALAPPDATA%\AmiReel\app-icon.ico on startup, and call
AppWindow.SetIcon() with that file path instead. Verified via WM_GETICON
against the running window's HWND that both the big and small icons now
resolve to the real AmiReel logo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:38:12 +02:00
klevze fc02cd9602 Update README project layout for tests and new Services files
Reflect the current repo structure: AmiReel.Tests/, Properties/AssemblyInfo.cs,
and the FfmpegProgressParser/FfmpegOutputFilter split out of ProcessRunner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:27:15 +02:00
klevze 8f27aee11a Fix title bar icon missing in single-file published builds
The title bar Image used Source="ms-appx:///Assets/Square44x44Logo.scale-200.png".
That resolves fine in a normal build (Assets/ and AmiReel.pri sit next to the exe),
but a single-file self-contained publish (IncludeAllContentForSelfExtract=true)
bundles Content items into the exe in a way ms-appx:// can no longer resolve at
runtime, leaving the title bar icon blank — reproduced with the actual
publish-win-x64.ps1 output, confirmed fixed the same way.

Load the icon from a plain embedded resource instead (same mechanism already
used for ffmpeg.exe/ffprobe.exe): the PNG is embedded via
<EmbeddedResource LogicalName="AmiReel.Assets.TitleBarIcon.png">, and
MainWindow loads it at startup via GetManifestResourceStream + BitmapImage.
SetSourceAsync. This bypasses the ms-appx/MRT resource pipeline entirely, so
it behaves identically in dotnet build, dotnet run, and a single-file publish.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:01:20 +02:00
klevze c412469773 Add unit tests, dedupe FFmpeg output filtering, harden settings records
Implements the suggestions from the last review pass:

- Add AmiReel.Tests (MSTest), covering the pure logic in Models/ and
  Services/: SupportedVideoFormats, AppSettings normalization,
  UserFacingErrors.Summarize, the new FfmpegProgressParser/
  FfmpegOutputFilter, RenderPipeline.Validate, BuildProgressMessage, and
  MoveSourceVideos. 59 tests, all passing. UI code-behind and anything that
  spawns an actual FFmpeg process are left to manual/integration testing.
  Exclude AmiReel.Tests\**\*.cs from AmiReel.csproj's default item glob —
  it's a subfolder of the app project now, so without the exclude the app
  itself was compiling the MSTest-only test files.

- Extract the FFmpeg version/library-banner boilerplate list that
  ProcessRunner (live log filter) and UserFacingErrors (error summarizer)
  had each duplicated into a shared FfmpegOutputFilter.IsBoilerplateLine;
  each caller keeps its own remaining context-specific checks on top.
  Also extract ProcessRunner's line-parsing regexes into a standalone
  FfmpegProgressParser so it's directly unit-testable without spawning a
  process.

- Convert AppSettings and RenderSettings from positional record
  constructors to named `required` init properties. Both records had
  runs of same-typed consecutive parameters (three string timing fields
  in AppSettings; three doubles then five ints in RenderSettings) that a
  positional constructor would let get silently transposed at a call site
  without the compiler catching it. Update the two call sites
  (MainPage.xaml.cs) to object-initializer syntax.

- Add Properties/AssemblyInfo.cs with InternalsVisibleTo("AmiReel.Tests")
  and make Validate/BuildProgressMessage/IsNoise internal so tests can
  reach them directly instead of only through process-spawning entry
  points.

- README: document `dotnet test`, and note that Package.appxmanifest's
  Identity is a local-dev placeholder that needs a real publisher/cert
  before MSIX distribution.

Verified: dotnet build (solution + test project) is 0 warnings/errors,
dotnet test is 59/59 passing, and the app still launches unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:27:57 +02:00
klevze 3de3357d23 Code review cleanup: dead code, unused capability, accessibility gap
- Remove unused `using Microsoft.Win32;` from MainPage.xaml.cs (leftover
  from the WPF OpenFileDialog era; WinUI uses Windows.Storage.Pickers)
- Trim App.xaml.cs down to the two usings it actually needs instead of the
  full WinUI3 template boilerplate list
- Change App.MainWindowInstance to an internal setter (only MainWindow
  itself should assign it)
- Add AutomationProperties.Name to the icon-only Settings button so screen
  readers announce it (it only had a mouse tooltip before)
- Delete Assets/AppIcon.ico: unreferenced by both the manifest and code,
  the real app icon comes from ApplicationIcon (branding/AmiReel.ico) and
  AppWindow.SetIcon at runtime
- Drop the systemAIModels capability from Package.appxmanifest; the app
  doesn't call any Windows AI API, so declaring it is an unnecessary
  privilege request

Verified: dotnet build produces 0 warnings/errors, app launches unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:13:22 +02:00
29 changed files with 2666 additions and 245 deletions
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest.TestAdapter" Version="*" />
<PackageReference Include="MSTest.TestFramework" Version="*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AmiReel.csproj" />
</ItemGroup>
</Project>
+107
View File
@@ -0,0 +1,107 @@
using AmiReel.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Models;
[TestClass]
public class AppSettingsTests
{
[TestMethod]
public void Default_SetsFallbackOutputFolderAndSensibleDefaults()
{
// Arrange
const string outputFolder = @"C:\Videos";
// Act
AppSettings settings = AppSettings.Default(outputFolder);
// Assert
Assert.AreEqual(outputFolder, settings.OutputFolder);
Assert.AreEqual("Dark", settings.Theme);
Assert.AreEqual("Auto", settings.Encoder);
Assert.IsTrue(settings.ShouldMoveSourcesToOriginals);
}
[TestMethod]
public void ShouldMoveSourcesToOriginals_WhenFlagIsNull_DefaultsToTrue()
{
// Arrange
AppSettings settings = AppSettings.Default(@"C:\Videos") with { MoveSourcesToOriginals = null };
// Act
bool result = settings.ShouldMoveSourcesToOriginals;
// Assert
Assert.IsTrue(result);
}
[TestMethod]
public void ShouldMoveSourcesToOriginals_WhenFlagIsExplicitlyFalse_ReturnsFalse()
{
// Arrange
AppSettings settings = AppSettings.Default(@"C:\Videos") with { MoveSourcesToOriginals = false };
// Act
bool result = settings.ShouldMoveSourcesToOriginals;
// Assert
Assert.IsFalse(result);
}
[TestMethod]
public void Normalize_WithBlankOutputFolder_UsesFallback()
{
// Arrange
AppSettings settings = AppSettings.Default("") with { OutputFolder = " " };
// Act
AppSettings normalized = settings.Normalize(@"C:\Fallback");
// Assert
Assert.AreEqual(@"C:\Fallback", normalized.OutputFolder);
}
[TestMethod]
public void Normalize_WithBlankTimingValues_RestoresDefaults()
{
// Arrange
AppSettings settings = AppSettings.Default(@"C:\Videos") with
{
TrimStart = "",
FadeSeconds = " ",
EndCardHoldSeconds = "",
ThumbnailInterval = "",
};
// Act
AppSettings normalized = settings.Normalize(@"C:\Videos");
// Assert
Assert.AreEqual("4.414", normalized.TrimStart);
Assert.AreEqual("3", normalized.FadeSeconds);
Assert.AreEqual("4", normalized.EndCardHoldSeconds);
Assert.AreEqual("10", normalized.ThumbnailInterval);
}
[TestMethod]
public void Normalize_WithPopulatedValues_KeepsThemUnchanged()
{
// Arrange
AppSettings settings = AppSettings.Default(@"C:\Videos") with
{
TrimStart = "1.5",
FadeSeconds = "2.5",
Theme = "Light",
Encoder = "NvidiaNvenc",
};
// Act
AppSettings normalized = settings.Normalize(@"C:\Videos");
// Assert
Assert.AreEqual("1.5", normalized.TrimStart);
Assert.AreEqual("2.5", normalized.FadeSeconds);
Assert.AreEqual("Light", normalized.Theme);
Assert.AreEqual("NvidiaNvenc", normalized.Encoder);
}
}
@@ -0,0 +1,69 @@
using AmiReel.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Models;
[TestClass]
public class SupportedVideoFormatsTests
{
[TestMethod]
[DataRow(@"C:\videos\clip.avi")]
[DataRow(@"C:\videos\clip.mp4")]
[DataRow(@"C:\videos\clip.MOV")]
[DataRow(@"C:\videos\clip.mkv")]
[DataRow(@"C:\videos\clip.webm")]
public void IsSupported_WithAcceptedExtension_ReturnsTrue(string path)
{
// Act
bool result = SupportedVideoFormats.IsSupported(path);
// Assert
Assert.IsTrue(result);
}
[TestMethod]
[DataRow(@"C:\documents\report.pdf")]
[DataRow(@"C:\images\photo.png")]
[DataRow(@"C:\audio\track.mp3")]
public void IsSupported_WithUnsupportedExtension_ReturnsFalse(string path)
{
// Act
bool result = SupportedVideoFormats.IsSupported(path);
// Assert
Assert.IsFalse(result);
}
[TestMethod]
public void IsSupported_IsCaseInsensitive()
{
// Arrange
const string lower = @"C:\videos\clip.avi";
const string upper = @"C:\videos\clip.AVI";
// Act & Assert
Assert.IsTrue(SupportedVideoFormats.IsSupported(lower));
Assert.IsTrue(SupportedVideoFormats.IsSupported(upper));
}
[TestMethod]
public void IsSupported_WithNoExtension_ReturnsFalse()
{
// Act
bool result = SupportedVideoFormats.IsSupported(@"C:\videos\clip");
// Assert
Assert.IsFalse(result);
}
[TestMethod]
public void PickerFilter_ContainsEveryExtensionAsAGlobPattern()
{
// Act
string filter = SupportedVideoFormats.PickerFilter;
// Assert
foreach (string extension in SupportedVideoFormats.Extensions)
StringAssert.Contains(filter, "*" + extension);
}
}
@@ -0,0 +1,48 @@
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class FfmpegOutputFilterTests
{
[TestMethod]
[DataRow("ffmpeg version 6.0-full_build-www.gyan.dev")]
[DataRow("built with gcc 12.2.0")]
[DataRow("configuration: --enable-gpl")]
[DataRow("libavutil 58. 2.100 / 58. 2.100")]
[DataRow("Input #0, avi, from 'clip.avi':")]
[DataRow("Output #0, mp4, to 'out.mp4':")]
[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%")]
public void IsBoilerplateLine_WithKnownFfmpegBanner_ReturnsTrue(string line)
{
// Act
bool result = FfmpegOutputFilter.IsBoilerplateLine(line);
// Assert
Assert.IsTrue(result);
}
[TestMethod]
[DataRow("Error while opening encoder for output stream #0:0")]
[DataRow("Cannot load nvcuda.dll")]
[DataRow("Unknown encoder 'h264_nvenc'")]
public void IsBoilerplateLine_WithRealErrorText_ReturnsFalse(string line)
{
// Act
bool result = FfmpegOutputFilter.IsBoilerplateLine(line);
// Assert
Assert.IsFalse(result);
}
[TestMethod]
public void IsBoilerplateLine_IsCaseInsensitive()
{
// Act & Assert
Assert.IsTrue(FfmpegOutputFilter.IsBoilerplateLine("DURATION: 00:00:01.00"));
Assert.IsTrue(FfmpegOutputFilter.IsBoilerplateLine("duration: 00:00:01.00"));
}
}
@@ -0,0 +1,70 @@
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class FfmpegProgressParserTests
{
[TestMethod]
public void Parse_WithFullProgressLine_ExtractsAllFields()
{
// Arrange
const string line = "frame= 1234 fps=49.8 q=-1.0 size= 102400kB time=00:00:24.68 bitrate=33987.6kbits/s speed=0.996x";
// Act
FfmpegOutputLine result = FfmpegProgressParser.Parse(line);
// Assert
Assert.AreEqual(new TimeSpan(0, 0, 0, 24, 680), result.Time);
Assert.AreEqual(1234, result.Frame);
Assert.AreEqual(49.8, result.FramesPerSecond);
Assert.AreEqual(0.996, result.Speed);
}
[TestMethod]
public void Parse_WithNoRecognizedFields_ReturnsAllNulls()
{
// Arrange
const string line = "Metadata:";
// Act
FfmpegOutputLine result = FfmpegProgressParser.Parse(line);
// Assert
Assert.IsNull(result.Time);
Assert.IsNull(result.Frame);
Assert.IsNull(result.FramesPerSecond);
Assert.IsNull(result.Speed);
Assert.AreEqual(line, result.Line);
}
[TestMethod]
public void Parse_WithOnlyTimeField_ExtractsTimeAndLeavesFrameFieldsNull()
{
// Arrange: has a "time=" field but no matching frame=/fps=/speed=x group
const string line = "size= 1024kB time=00:01:02.03 bitrate= 100.0kbits/s";
// Act
FfmpegOutputLine result = FfmpegProgressParser.Parse(line);
// Assert
Assert.AreEqual(new TimeSpan(0, 0, 1, 2, 30), result.Time);
Assert.IsNull(result.Frame);
Assert.IsNull(result.FramesPerSecond);
Assert.IsNull(result.Speed);
}
[TestMethod]
public void Parse_PreservesOriginalLineText()
{
// Arrange
const string line = "some arbitrary ffmpeg output";
// Act
FfmpegOutputLine result = FfmpegProgressParser.Parse(line);
// Assert
Assert.AreEqual(line, result.Line);
}
}
@@ -0,0 +1,216 @@
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class RenderPipelineTests
{
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;
}
private static RenderSettings ValidSettings(IReadOnlyList<string> inputFiles, string outputDirectory, string endCardPath = "") => new()
{
InputFiles = inputFiles,
EndCardPath = endCardPath,
OutputDirectory = outputDirectory,
OutputName = "output",
FfmpegPath = "",
FfprobePath = "",
Encoder = EncoderMode.Auto,
TrimStart = 0,
FadeSeconds = 1,
EndCardHoldSeconds = 1,
ThumbnailInterval = 10,
};
[TestMethod]
public void Validate_WithNoInputFiles_ThrowsArgumentException()
{
// Arrange
RenderSettings settings = ValidSettings([], @"C:\out");
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithMissingInputFile_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
RenderSettings settings = ValidSettings([Path.Combine(directory, "missing.avi")], directory);
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => RenderPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithMissingEndCard_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.avi");
File.WriteAllText(input, "data");
RenderSettings settings = ValidSettings([input], directory, endCardPath: Path.Combine(directory, "missing.png"));
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => RenderPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithBlankOutputDirectory_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.avi");
File.WriteAllText(input, "data");
RenderSettings settings = ValidSettings([input], "");
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithInvalidOutputName_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.avi");
File.WriteAllText(input, "data");
RenderSettings settings = ValidSettings([input], directory) with { OutputName = "bad" + Path.GetInvalidFileNameChars()[0] };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithThumbnailIntervalLessThanOne_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.avi");
File.WriteAllText(input, "data");
RenderSettings settings = ValidSettings([input], directory) with { ThumbnailInterval = 0 };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithAllRequirementsMet_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.avi");
File.WriteAllText(input, "data");
RenderSettings settings = ValidSettings([input], directory);
// Act & Assert (no exception)
RenderPipeline.Validate(settings);
}
[TestMethod]
public void BuildProgressMessage_WithOnlyTime_ReturnsTimeRangeOnly()
{
// Act
string message = RenderPipeline.BuildProgressMessage(TimeSpan.FromSeconds(30), 60, null, null, null, 50);
// Assert
Assert.AreEqual("00:00:30 / 00:01:00", message);
}
[TestMethod]
public void BuildProgressMessage_WithAllOptionalFields_IncludesFpsSpeedAndFrame()
{
// Arrange: numbers render using the current culture (this is user-facing text),
// so build the expected fragments the same way rather than hard-coding "." / ",".
const double fps = 49.8;
const double speed = 0.99;
const int frame = 1500;
const int totalFrames = 3000; // ceil(60s * 50fps)
// Act
string message = RenderPipeline.BuildProgressMessage(TimeSpan.FromSeconds(30), 60, fps, speed, frame, 50);
// Assert
StringAssert.Contains(message, "00:00:30 / 00:01:00");
StringAssert.Contains(message, $"{fps:0.#} fps");
StringAssert.Contains(message, $"{speed:0.##}x");
StringAssert.Contains(message, $"frame {frame:N0} / {totalFrames:N0}");
}
[TestMethod]
public void MoveSourceVideos_WithNewDestination_MovesFileIntoOriginalsSubfolder()
{
// Arrange
string outputDirectory = CreateTempDirectory();
string sourceDirectory = CreateTempDirectory();
string source = Path.Combine(sourceDirectory, "clip.avi");
File.WriteAllText(source, "data");
List<string> logs = [];
// Act
IReadOnlyList<string> result = RenderPipeline.MoveSourceVideos([source], outputDirectory, logs.Add);
// Assert
string expected = Path.Combine(outputDirectory, "originals", "clip.avi");
Assert.AreEqual(expected, result.Single());
Assert.IsTrue(File.Exists(expected));
Assert.IsFalse(File.Exists(source));
}
[TestMethod]
public void MoveSourceVideos_WithDuplicateFilename_AppendsNumericSuffix()
{
// Arrange
string outputDirectory = CreateTempDirectory();
string sourceDirectory = CreateTempDirectory();
string sourceA = Path.Combine(sourceDirectory, "a", "clip.avi");
string sourceB = Path.Combine(sourceDirectory, "b", "clip.avi");
Directory.CreateDirectory(Path.GetDirectoryName(sourceA)!);
Directory.CreateDirectory(Path.GetDirectoryName(sourceB)!);
File.WriteAllText(sourceA, "data-a");
File.WriteAllText(sourceB, "data-b");
// Act
IReadOnlyList<string> result = RenderPipeline.MoveSourceVideos([sourceA, sourceB], outputDirectory, _ => { });
// Assert
Assert.AreEqual(2, result.Distinct(StringComparer.OrdinalIgnoreCase).Count());
Assert.IsTrue(result.All(File.Exists));
}
[TestMethod]
public void MoveSourceVideos_WithMissingSourceFile_LogsWarningAndKeepsOriginalPath()
{
// Arrange
string outputDirectory = CreateTempDirectory();
string missingSource = Path.Combine(CreateTempDirectory(), "gone.avi");
List<string> logs = [];
// Act
IReadOnlyList<string> result = RenderPipeline.MoveSourceVideos([missingSource], outputDirectory, logs.Add);
// Assert
Assert.AreEqual(missingSource, result.Single());
Assert.IsTrue(logs.Any(line => line.Contains("no longer exists", StringComparison.OrdinalIgnoreCase)));
}
}
@@ -0,0 +1,223 @@
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class ShortsPipelineTests
{
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;
}
private static ShortSettings ValidSettings(string input, string font, ShortStyle style = ShortStyle.Crop, string background = "") => new()
{
InputFile = input,
OutputPath = Path.Combine(Path.GetDirectoryName(input) ?? "", "out.mp4"),
Title = "Test Short",
Hook = "THIS RAN ON AN AMIGA",
Website = "AMIGADB.NET",
Start = "00:00:00",
DurationSeconds = 30,
Style = style,
BackgroundImage = background,
FontFile = font,
FfmpegPath = "",
FfprobePath = "",
};
[TestMethod]
public void EscapeFfmpegPath_WithBackslashesColonsAndQuotes_EscapesAllThree()
{
// Act
string result = ShortsPipeline.EscapeFfmpegPath(@"C:\Fonts\font's.ttf");
// Assert
Assert.AreEqual(@"C\:\\Fonts\\font\'s.ttf", result);
}
[TestMethod]
public void EscapeFfmpegPath_EscapesBackslashesBeforeIntroducingNewOnes()
{
// Act: if colon/quote escaping ran before backslash escaping, the backslashes
// they introduce would themselves get doubled, which must not happen.
string result = ShortsPipeline.EscapeFfmpegPath("C:\\a");
// Assert
Assert.AreEqual("C\\:\\\\a", result);
}
[TestMethod]
public void BuildMeta_WithAllFieldsPopulated_JoinsWithMiddleDot()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf") with { Group = "Pirates", Year = "1991", Type = "Cracktro" };
// Act
string meta = ShortsPipeline.BuildMeta(settings);
// Assert
Assert.AreEqual("Pirates · 1991 · Cracktro", meta);
}
[TestMethod]
public void BuildMeta_WithSomeFieldsBlank_SkipsBlankFields()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf") with { Group = "Pirates", Year = "", Type = " " };
// Act
string meta = ShortsPipeline.BuildMeta(settings);
// Assert
Assert.AreEqual("Pirates", meta);
}
[TestMethod]
public void BuildMeta_WithAllFieldsBlank_ReturnsEmptyString()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf");
// Act
string meta = ShortsPipeline.BuildMeta(settings);
// Assert
Assert.AreEqual("", meta);
}
[TestMethod]
public void Validate_WithMissingInputFile_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf");
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithBlankTitle_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 { Title = "" };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithZeroDuration_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 { DurationSeconds = 0 };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithMissingFontFile_ThrowsFileNotFoundException()
{
// 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"));
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_BrandStyleWithoutBackgroundImage_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.Brand);
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_NonBrandStyleWithoutBackgroundImage_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");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Crop);
// Act & Assert (no exception)
ShortsPipeline.Validate(settings);
}
[TestMethod]
public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag()
{
foreach (ShortStyle style in Enum.GetValues<ShortStyle>())
{
// Act
string filter = ShortsPipeline.GetBaseFilter(style);
// Assert
Assert.IsFalse(string.IsNullOrWhiteSpace(filter), $"{style} produced an empty filter.");
StringAssert.Contains(filter, "[base]", $"{style} filter does not label an output as [base].");
}
}
[TestMethod]
public void BuildFilterComplex_IncludesAllFourTextOverlaysAndTheirTimingGates()
{
// Act
string result = ShortsPipeline.BuildFilterComplex(
"[0:v]copy[base]",
fontEsc: "C\\:\\\\font.ttf",
hookEsc: "hook.txt", titleEsc: "title.txt", metaEsc: "meta.txt", websiteEsc: "website.txt",
hookEnd: 2.7, infoStart: 23, ctaStart: 26.5);
// Assert
StringAssert.Contains(result, "textfile='hook.txt'");
StringAssert.Contains(result, "textfile='title.txt'");
StringAssert.Contains(result, "textfile='meta.txt'");
StringAssert.Contains(result, "textfile='website.txt'");
StringAssert.Contains(result, "enable='between(t,0,2.7)'");
StringAssert.Contains(result, "enable='gte(t,23)'");
StringAssert.Contains(result, "enable='gte(t,26.5)'");
StringAssert.Contains(result, "[vout]");
}
}
@@ -0,0 +1,120 @@
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class UserFacingErrorsTests
{
[TestMethod]
public void Summarize_WithBlankMessage_ReturnsGenericMessage()
{
// Arrange
Exception exception = new(" ");
// Act
string result = UserFacingErrors.Summarize(exception);
// Assert
Assert.AreEqual("An unexpected error occurred.", result);
}
[TestMethod]
public void Summarize_WithNonProcessExitMessage_ReturnsMessageUnchanged()
{
// Arrange
Exception exception = new("The output directory could not be created.");
// Act
string result = UserFacingErrors.Summarize(exception);
// Assert
Assert.AreEqual("The output directory could not be created.", result);
}
[TestMethod]
public void Summarize_WithProcessExitAndOnlyBoilerplateLines_ReturnsFirstLineOnly()
{
// Arrange
Exception exception = new(
"Process exited with code 1.\n" +
"ffmpeg version 6.0\n" +
"built with gcc\n" +
"Input #0, avi, from 'clip.avi':\n" +
"Duration: 00:01:00.00\n");
// Act
string result = UserFacingErrors.Summarize(exception);
// Assert
Assert.AreEqual("Process exited with code 1.", result);
}
[TestMethod]
public void Summarize_WithProcessExitAndRealErrorLines_IncludesTheRealErrorLines()
{
// Arrange
Exception exception = new(
"Process exited with code 1.\n" +
"ffmpeg version 6.0\n" +
"[h264_nvenc @ 0x1] Cannot load libnvidia-encode.so.1\n" +
"Error initializing output stream 0:0 -- Error while opening encoder\n");
// Act
string result = UserFacingErrors.Summarize(exception);
// Assert
StringAssert.Contains(result, "Process exited with code 1.");
StringAssert.Contains(result, "Cannot load libnvidia-encode.so.1");
StringAssert.Contains(result, "Error while opening encoder");
}
[TestMethod]
public void Summarize_WithMoreThanThreeErrorLines_KeepsOnlyTheLastThree()
{
// Arrange
Exception exception = new(
"Process exited with code 1.\n" +
"error line 1\n" +
"error line 2\n" +
"error line 3\n" +
"error line 4\n");
// Act
string result = UserFacingErrors.Summarize(exception);
string[] lines = result.Split(Environment.NewLine);
// Assert
Assert.AreEqual(4, lines.Length);
CollectionAssert.DoesNotContain(lines, "error line 1");
CollectionAssert.Contains(lines, "error line 4");
}
[TestMethod]
[DataRow("ffmpeg version 6.0")]
[DataRow("built with gcc 12")]
[DataRow("Input #0, avi, from 'clip.avi':")]
[DataRow("Duration: 00:01:00.00, start: 0.000000")]
[DataRow("Stream #0:0: Video: mjpeg")]
public void IsNoise_WithFfmpegBoilerplate_ReturnsTrue(string line)
{
// Act
bool result = UserFacingErrors.IsNoise(line);
// Assert
Assert.IsTrue(result);
}
[TestMethod]
[DataRow("Error while opening encoder for output stream")]
[DataRow("Cannot load libnvidia-encode.so.1")]
[DataRow("No such file or directory")]
public void IsNoise_WithRealErrorText_ReturnsFalse(string line)
{
// Act
bool result = UserFacingErrors.IsNoise(line);
// Assert
Assert.IsFalse(result);
}
}
+18 -1
View File
@@ -19,6 +19,15 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- AmiReel.Tests is a subfolder of this project's directory; exclude it from the
default SDK glob so its test files (and MSTest-only usings) aren't compiled
into the app itself. -->
<Compile Remove="AmiReel.Tests\**\*.cs" />
<EmbeddedResource Remove="AmiReel.Tests\**\*" />
<None Remove="AmiReel.Tests\**\*" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" /> <Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\LockScreenLogo.scale-200.png" /> <Content Include="Assets\LockScreenLogo.scale-200.png" />
@@ -27,7 +36,6 @@
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" /> <Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" /> <Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" />
<Content Include="Assets\StoreLogo.png" /> <Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\AppIcon.ico" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" /> <Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup> </ItemGroup>
@@ -38,6 +46,15 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="ThirdParty\ffmpeg.exe" Condition="Exists('ThirdParty\ffmpeg.exe')" LogicalName="AmiReel.Tools.ffmpeg.exe" /> <EmbeddedResource Include="ThirdParty\ffmpeg.exe" Condition="Exists('ThirdParty\ffmpeg.exe')" LogicalName="AmiReel.Tools.ffmpeg.exe" />
<EmbeddedResource Include="ThirdParty\ffprobe.exe" Condition="Exists('ThirdParty\ffprobe.exe')" LogicalName="AmiReel.Tools.ffprobe.exe" /> <EmbeddedResource Include="ThirdParty\ffprobe.exe" Condition="Exists('ThirdParty\ffprobe.exe')" LogicalName="AmiReel.Tools.ffprobe.exe" />
<!-- Loaded at runtime via GetManifestResourceStream for the title bar icon (MainWindow.xaml.cs).
The Assets\*.png Content items above go through the ms-appx:// / MRT resource pipeline,
which a single-file self-contained publish bundles into the exe in a way that ms-appx://
can no longer resolve at runtime — this embedded copy sidesteps that entirely. -->
<EmbeddedResource Include="Assets\Square44x44Logo.scale-200.png" LogicalName="AmiReel.Assets.TitleBarIcon.png" />
<!-- Loaded at runtime and extracted to a real .ico file for AppWindow.SetIcon (MainWindow.xaml.cs),
which sets the taskbar/Alt+Tab window icon and requires an actual .ico file path — passing
the .exe path there does not work. Embedding it keeps this independent of publish mode too. -->
<EmbeddedResource Include="branding\AmiReel.ico" LogicalName="AmiReel.Assets.AppIcon.ico" />
</ItemGroup> </ItemGroup>
<!-- <!--
+1
View File
@@ -1,3 +1,4 @@
<Solution> <Solution>
<Project Path="AmiReel.csproj" /> <Project Path="AmiReel.csproj" />
<Project Path="AmiReel.Tests/AmiReel.Tests.csproj" />
</Solution> </Solution>
+2 -16
View File
@@ -1,20 +1,6 @@
using Windows.ApplicationModel; using Microsoft.UI.Xaml;
using Windows.ApplicationModel.Activation;
using Windows.Foundation;
using Windows.Foundation.Collections;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using Microsoft.UI.Xaml.Shapes;
using WinRT.Interop; using WinRT.Interop;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace AmiReel; namespace AmiReel;
/// <summary> /// <summary>
@@ -24,7 +10,7 @@ public partial class App : Application
{ {
private Window? _window; private Window? _window;
public static IntPtr MainWindowHandle { get; private set; } public static IntPtr MainWindowHandle { get; private set; }
public static MainWindow? MainWindowInstance { get; set; } public static MainWindow? MainWindowInstance { get; internal set; }
/// <summary> /// <summary>
/// Initializes the singleton application object. This is the first line of authored code /// Initializes the singleton application object. This is the first line of authored code
Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

+142 -5
View File
@@ -9,13 +9,19 @@
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}"> <Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid Grid.Row="0" ColumnSpacing="16"> <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" />
</StackPanel>
<Grid Grid.Row="1" ColumnSpacing="16" x:Name="VideoContentPanel">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@@ -72,7 +78,137 @@
</Border> </Border>
</Grid> </Grid>
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}"> <Grid Grid.Row="1" RowSpacing="16" x:Name="ShortsContentPanel" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<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}" />
<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" />
</ComboBox>
<ComboBox x:Name="ShortPresetBox" Grid.Column="1" Header="x264 preset" HorizontalAlignment="Stretch">
<ComboBoxItem Content="ultrafast" Tag="ultrafast" />
<ComboBoxItem Content="superfast" Tag="superfast" />
<ComboBoxItem Content="veryfast" Tag="veryfast" />
<ComboBoxItem Content="faster" Tag="faster" />
<ComboBoxItem Content="fast" Tag="fast" />
<ComboBoxItem Content="medium" Tag="medium" />
<ComboBoxItem Content="slow" Tag="slow" />
<ComboBoxItem Content="slower" Tag="slower" />
<ComboBoxItem Content="veryslow" Tag="veryslow" />
</ComboBox>
</Grid>
<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 (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" />
</Grid>
</StackPanel>
</Border>
</Grid>
<Border Grid.Row="2" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="Render progress" Style="{StaticResource SectionTitleStyle}" /> <TextBlock Text="Render progress" Style="{StaticResource SectionTitleStyle}" />
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
@@ -84,7 +220,7 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Border Grid.Row="2" Padding="18" Style="{StaticResource CardBorderStyle}"> <Border Grid.Row="3" Padding="18" Style="{StaticResource CardBorderStyle}">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="FFmpeg log" Style="{StaticResource SectionTitleStyle}" /> <TextBlock Text="FFmpeg log" Style="{StaticResource SectionTitleStyle}" />
<TextBox x:Name="LogBox" <TextBox x:Name="LogBox"
@@ -100,7 +236,7 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Grid Grid.Row="3"> <Grid Grid.Row="4">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@@ -113,7 +249,8 @@
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button x:Name="OpenSettingsButton" Content="&#xE713;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets" <Button x:Name="OpenSettingsButton" Content="&#xE713;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets"
Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}" ToolTipService.ToolTip="Settings" /> Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}"
ToolTipService.ToolTip="Settings" AutomationProperties.Name="Settings" />
<Button x:Name="CancelButton" Content="Cancel" Click="Cancel_Click" IsEnabled="False" /> <Button x:Name="CancelButton" Content="Cancel" Click="Cancel_Click" IsEnabled="False" />
<Button x:Name="RenderButton" Content="Start render" Click="Render_Click" Style="{StaticResource PrimaryButtonStyle}" /> <Button x:Name="RenderButton" Content="Start render" Click="Render_Click" Style="{StaticResource PrimaryButtonStyle}" />
</StackPanel> </StackPanel>
+211 -57
View File
@@ -6,7 +6,6 @@ using AmiReel.Models;
using AmiReel.Services; using AmiReel.Services;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.Win32;
using Windows.ApplicationModel.DataTransfer; using Windows.ApplicationModel.DataTransfer;
using Windows.Storage; using Windows.Storage;
using Windows.Storage.Pickers; using Windows.Storage.Pickers;
@@ -23,6 +22,7 @@ public sealed partial class MainPage : Page
private CancellationTokenSource? _renderCancellation; private CancellationTokenSource? _renderCancellation;
private string? _lastRenderedFile; private string? _lastRenderedFile;
private bool _logFlushScheduled; private bool _logFlushScheduled;
private bool _isShortsTab;
public MainPage() public MainPage()
{ {
@@ -43,6 +43,40 @@ public sealed partial class MainPage : Page
SelectTheme(_loadedSettings.Theme); SelectTheme(_loadedSettings.Theme);
ApplyTheme(_loadedSettings.Theme); ApplyTheme(_loadedSettings.Theme);
MoveSourcesBox.IsChecked = _loadedSettings.ShouldMoveSourcesToOriginals; MoveSourcesBox.IsChecked = _loadedSettings.ShouldMoveSourcesToOriginals;
ShortHookBox.Text = _loadedSettings.ShortsHook;
ShortWebsiteBox.Text = _loadedSettings.ShortsWebsite;
ShortCrfBox.Text = _loadedSettings.ShortsCrf;
ShortBackgroundBox.Text = _loadedSettings.ShortsBackgroundImage;
ShortFontBox.Text = string.IsNullOrWhiteSpace(_loadedSettings.ShortsFontFile)
? ShortsPipeline.FindDefaultFont() ?? ""
: _loadedSettings.ShortsFontFile;
SelectComboTag(ShortStyleBox, _loadedSettings.ShortsStyle);
SelectComboTag(ShortPresetBox, _loadedSettings.ShortsPreset);
UpdateShortBackgroundVisibility();
}
private void VideoTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: false);
private void ShortsTab_Click(object sender, RoutedEventArgs e) => SetActiveTab(shorts: true);
private void SetActiveTab(bool shorts)
{
_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;
RenderButton.Content = shorts ? "Render short" : "Start render";
}
private void ShortStyleBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => UpdateShortBackgroundVisibility();
private void UpdateShortBackgroundVisibility()
{
bool isBrand = string.Equals(SelectedShortStyle(), nameof(ShortStyle.Brand), StringComparison.OrdinalIgnoreCase);
ShortBackgroundRow.Visibility = isBrand ? Visibility.Visible : Visibility.Collapsed;
} }
private async void AddInputs_Click(object sender, RoutedEventArgs e) private async void AddInputs_Click(object sender, RoutedEventArgs e)
@@ -146,18 +180,14 @@ public sealed partial class MainPage : Page
{ {
try try
{ {
RenderSettings settings = ReadSettings();
SaveSettings();
SetRendering(true); SetRendering(true);
LogBox.Text = string.Empty; LogBox.Text = string.Empty;
_renderCancellation = new CancellationTokenSource(); _renderCancellation = new CancellationTokenSource();
Progress<RenderProgress> progress = new(UpdateProgress);
IReadOnlyList<string> resolvedInputs = await new RenderPipeline().RenderAsync(settings, progress, AppendLog, _renderCancellation.Token); if (_isShortsTab)
ReplaceInputs(resolvedInputs); await RenderShortAsync(_renderCancellation.Token);
_lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4"); else
OpenOutputButton.IsEnabled = true; await RenderVideoAsync(_renderCancellation.Token);
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
await ShowMessageAsync("Render complete", "The AmiReel render completed successfully.");
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -177,6 +207,32 @@ public sealed partial class MainPage : Page
} }
} }
private async Task RenderVideoAsync(CancellationToken token)
{
RenderSettings settings = ReadSettings();
SaveSettings();
Progress<RenderProgress> progress = new(UpdateProgress);
IReadOnlyList<string> resolvedInputs = await new RenderPipeline().RenderAsync(settings, progress, AppendLog, token);
ReplaceInputs(resolvedInputs);
_lastRenderedFile = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
await ShowMessageAsync("Render complete", "The AmiReel render completed successfully.");
}
private async Task RenderShortAsync(CancellationToken token)
{
ShortSettings settings = ReadShortSettings();
SaveSettings();
Progress<RenderProgress> progress = new(UpdateProgress);
await new ShortsPipeline().RenderAsync(settings, progress, AppendLog, token);
_lastRenderedFile = settings.OutputPath;
ShortOutputBox.Text = settings.OutputPath;
OpenOutputButton.IsEnabled = true;
PreviewRenderedButton.IsEnabled = File.Exists(_lastRenderedFile);
await ShowMessageAsync("Short complete", "The YouTube Short was rendered successfully.");
}
private void Cancel_Click(object sender, RoutedEventArgs e) private void Cancel_Click(object sender, RoutedEventArgs e)
{ {
CancelButton.IsEnabled = false; CancelButton.IsEnabled = false;
@@ -186,8 +242,11 @@ public sealed partial class MainPage : Page
private void OpenOutput_Click(object sender, RoutedEventArgs e) private void OpenOutput_Click(object sender, RoutedEventArgs e)
{ {
if (Directory.Exists(OutputFolderBox.Text)) string? directory = !string.IsNullOrWhiteSpace(_lastRenderedFile)
Process.Start(new ProcessStartInfo("explorer.exe", OutputFolderBox.Text) { UseShellExecute = true }); ? Path.GetDirectoryName(_lastRenderedFile)
: OutputFolderBox.Text;
if (!string.IsNullOrWhiteSpace(directory) && Directory.Exists(directory))
Process.Start(new ProcessStartInfo("explorer.exe", directory) { UseShellExecute = true });
} }
private async void PreviewSource_Click(object sender, RoutedEventArgs e) private async void PreviewSource_Click(object sender, RoutedEventArgs e)
@@ -223,19 +282,21 @@ public sealed partial class MainPage : Page
throw new ArgumentException("Thumbnail interval must be at least one second."); throw new ArgumentException("Thumbnail interval must be at least one second.");
EncoderMode encoder = Enum.Parse<EncoderMode>(SelectedEncoder()); EncoderMode encoder = Enum.Parse<EncoderMode>(SelectedEncoder());
return new( return new RenderSettings
_inputs.ToList(), {
EndCardBox.Text.Trim(), InputFiles = _inputs.ToList(),
OutputFolderBox.Text.Trim(), EndCardPath = EndCardBox.Text.Trim(),
OutputNameBox.Text.Trim(), OutputDirectory = OutputFolderBox.Text.Trim(),
FfmpegPathBox.Text.Trim(), OutputName = OutputNameBox.Text.Trim(),
FfprobePathBox.Text.Trim(), FfmpegPath = FfmpegPathBox.Text.Trim(),
encoder, FfprobePath = FfprobePathBox.Text.Trim(),
Number(TrimBox.Text, "trim start"), Encoder = encoder,
Number(FadeBox.Text, "fade"), TrimStart = Number(TrimBox.Text, "trim start"),
Number(HoldBox.Text, "end-card hold"), FadeSeconds = Number(FadeBox.Text, "fade"),
interval, EndCardHoldSeconds = Number(HoldBox.Text, "end-card hold"),
MoveSourcesBox.IsChecked == true); ThumbnailInterval = interval,
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
};
} }
private void ReplaceInputs(IReadOnlyList<string> paths) private void ReplaceInputs(IReadOnlyList<string> paths)
@@ -245,11 +306,101 @@ public sealed partial class MainPage : Page
_inputs.Add(path); _inputs.Add(path);
} }
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.");
string input = ShortInputBox.Text.Trim();
string output = ShortOutputBox.Text.Trim();
ShortStyle style = Enum.Parse<ShortStyle>(SelectedShortStyle());
if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(input))
{
string baseName = Path.GetFileNameWithoutExtension(input);
string? directory = Path.GetDirectoryName(input);
output = Path.Combine(directory ?? "", $"{baseName}-{style.ToString().ToLowerInvariant()}-short.mp4");
}
return new ShortSettings
{
InputFile = input,
OutputPath = output,
Title = ShortTitleBox.Text.Trim(),
Group = ShortGroupBox.Text.Trim(),
Year = ShortYearBox.Text.Trim(),
Type = ShortTypeBox.Text.Trim(),
Hook = ShortHookBox.Text.Trim(),
Website = ShortWebsiteBox.Text.Trim(),
Start = string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
DurationSeconds = duration,
Style = style,
BackgroundImage = ShortBackgroundBox.Text.Trim(),
FontFile = ShortFontBox.Text.Trim(),
FfmpegPath = FfmpegPathBox.Text.Trim(),
FfprobePath = FfprobePathBox.Text.Trim(),
Crf = crf,
Preset = SelectedShortPreset(),
};
}
private async void BrowseShortInput_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)
ShortInputBox.Text = file.Path;
}
private async void BrowseShortOutput_Click(object sender, RoutedEventArgs e)
{
FileSavePicker picker = new();
picker.FileTypeChoices.Add("MP4 video", [".mp4"]);
picker.SuggestedFileName = "short.mp4";
InitializeWithWindow.Initialize(picker, App.MainWindowHandle);
var file = await picker.PickSaveFileAsync();
if (file is not null)
ShortOutputBox.Text = file.Path;
}
private async void BrowseShortBackground_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
picker.FileTypeFilter.Add(".png");
picker.FileTypeFilter.Add(".jpg");
picker.FileTypeFilter.Add(".jpeg");
var file = await picker.PickSingleFileAsync();
if (file is not null)
ShortBackgroundBox.Text = file.Path;
}
private async void BrowseShortFont_Click(object sender, RoutedEventArgs e)
{
FileOpenPicker picker = CreateFileOpenPicker();
picker.FileTypeFilter.Add(".ttf");
picker.FileTypeFilter.Add(".otf");
var file = await picker.PickSingleFileAsync();
if (file is not null)
ShortFontBox.Text = file.Path;
}
private async void PreviewShortSource_Click(object sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
await OpenPreviewAsync(ShortInputBox.Text.Trim());
}
private void SetRendering(bool rendering) private void SetRendering(bool rendering)
{ {
RenderButton.IsEnabled = !rendering; RenderButton.IsEnabled = !rendering;
CancelButton.IsEnabled = rendering; CancelButton.IsEnabled = rendering;
OpenSettingsButton.IsEnabled = !rendering; OpenSettingsButton.IsEnabled = !rendering;
VideoTabButton.IsEnabled = !rendering;
ShortsTabButton.IsEnabled = !rendering;
} }
private void UpdateProgress(RenderProgress value) private void UpdateProgress(RenderProgress value)
@@ -326,51 +477,54 @@ public sealed partial class MainPage : Page
private void SaveSettings() private void SaveSettings()
{ {
AppSettingsStore.Save(new AppSettings( AppSettingsStore.Save(new AppSettings
OutputFolderBox.Text.Trim(), {
EndCardBox.Text.Trim(), OutputFolder = OutputFolderBox.Text.Trim(),
FfmpegPathBox.Text.Trim(), EndCardPath = EndCardBox.Text.Trim(),
FfprobePathBox.Text.Trim(), FfmpegPath = FfmpegPathBox.Text.Trim(),
PreviewPlayerPathBox.Text.Trim(), FfprobePath = FfprobePathBox.Text.Trim(),
SelectedTheme(), PreviewPlayerPath = PreviewPlayerPathBox.Text.Trim(),
SelectedEncoder(), Theme = SelectedTheme(),
TrimBox.Text.Trim(), Encoder = SelectedEncoder(),
FadeBox.Text.Trim(), TrimStart = TrimBox.Text.Trim(),
HoldBox.Text.Trim(), FadeSeconds = FadeBox.Text.Trim(),
IntervalBox.Text.Trim(), EndCardHoldSeconds = HoldBox.Text.Trim(),
MoveSourcesBox.IsChecked == true)); ThumbnailInterval = IntervalBox.Text.Trim(),
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
ShortsStyle = SelectedShortStyle(),
ShortsHook = ShortHookBox.Text.Trim(),
ShortsWebsite = ShortWebsiteBox.Text.Trim(),
ShortsFontFile = ShortFontBox.Text.Trim(),
ShortsBackgroundImage = ShortBackgroundBox.Text.Trim(),
ShortsCrf = ShortCrfBox.Text.Trim(),
ShortsPreset = SelectedShortPreset(),
});
} }
private string SelectedTheme() => (ThemeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Dark"; private string SelectedTheme() => (ThemeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Dark";
private string SelectedEncoder() => (EncoderBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto"; private string SelectedEncoder() => (EncoderBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Auto";
private void SelectTheme(string theme) private string SelectedShortStyle() => (ShortStyleBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Brand";
private string SelectedShortPreset() => (ShortPresetBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "slow";
private void SelectTheme(string theme) => SelectComboTag(ThemeBox, theme);
private void SelectEncoder(string encoder) => SelectComboTag(EncoderBox, encoder);
private static void SelectComboTag(ComboBox comboBox, string tag)
{ {
foreach (ComboBoxItem item in ThemeBox.Items) foreach (ComboBoxItem item in comboBox.Items.Cast<ComboBoxItem>())
{ {
if (string.Equals(item.Tag?.ToString(), theme, StringComparison.OrdinalIgnoreCase)) if (string.Equals(item.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase))
{ {
ThemeBox.SelectedItem = item; comboBox.SelectedItem = item;
return; return;
} }
} }
ThemeBox.SelectedIndex = 0; comboBox.SelectedIndex = 0;
}
private void SelectEncoder(string encoder)
{
foreach (ComboBoxItem item in EncoderBox.Items)
{
if (string.Equals(item.Tag?.ToString(), encoder, StringComparison.OrdinalIgnoreCase))
{
EncoderBox.SelectedItem = item;
return;
}
}
EncoderBox.SelectedIndex = 0;
} }
private void InputList_SelectionChanged(object sender, SelectionChangedEventArgs e) private void InputList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+1 -1
View File
@@ -16,7 +16,7 @@
<Grid x:Name="AppTitleBar" Grid.Row="0" Height="44" Background="{ThemeResource PanelBrush}"> <Grid x:Name="AppTitleBar" Grid.Row="0" Height="44" Background="{ThemeResource PanelBrush}">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="14,0,0,0"> <StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="14,0,0,0">
<Image Source="ms-appx:///Assets/Square44x44Logo.scale-200.png" <Image x:Name="TitleBarIcon"
Width="20" Width="20"
Height="20" Height="20"
Stretch="Uniform" /> Stretch="Uniform" />
+61 -4
View File
@@ -2,9 +2,12 @@ using Microsoft.UI;
using Microsoft.UI.Windowing; using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using System; using System;
using System.IO; using System.IO;
using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Graphics; using Windows.Graphics;
using Windows.UI; using Windows.UI;
using WinRT.Interop; using WinRT.Interop;
@@ -31,11 +34,9 @@ public sealed partial class MainWindow : Window
ExtendsContentIntoTitleBar = true; ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar); SetTitleBar(AppTitleBar);
string? executablePath = Environment.ProcessPath; SetWindowIcon();
if (!string.IsNullOrWhiteSpace(executablePath) && File.Exists(executablePath))
AppWindow.SetIcon(executablePath);
SetDefaultSizeAndPosition(); SetDefaultSizeAndPosition();
LoadTitleBarIcon();
// Navigate the root frame to the main page on startup. // Navigate the root frame to the main page on startup.
RootFrame.Navigate(typeof(MainPage)); RootFrame.Navigate(typeof(MainPage));
@@ -43,6 +44,62 @@ public sealed partial class MainWindow : Window
AppWindow.Closing += AppWindow_Closing; AppWindow.Closing += AppWindow_Closing;
} }
/// <summary>
/// Sets the window's taskbar/Alt+Tab icon. <see cref="AppWindow.SetIcon(string)"/> requires an
/// actual .ico file path (an .exe path is silently ignored), so the embedded .ico is extracted
/// once to a stable file under %LOCALAPPDATA% and that path is used instead.
/// </summary>
private void SetWindowIcon()
{
try
{
string iconPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AmiReel", "app-icon.ico");
if (!File.Exists(iconPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(iconPath)!);
Assembly assembly = Assembly.GetExecutingAssembly();
using Stream? resource = assembly.GetManifestResourceStream("AmiReel.Assets.AppIcon.ico");
if (resource is null) return;
using FileStream file = File.Create(iconPath);
resource.CopyTo(file);
}
AppWindow.SetIcon(iconPath);
}
catch
{
// Non-critical: the window simply keeps whatever default icon Windows assigned.
}
}
/// <summary>
/// Loads the title bar icon from an embedded resource instead of an "ms-appx:///Assets/..."
/// URI. A single-file self-contained publish bundles Content/Assets items into the exe in a
/// way the ms-appx resource pipeline can no longer resolve at runtime, leaving the title bar
/// blank; a plain embedded resource stream works the same in every publish mode.
/// </summary>
private async void LoadTitleBarIcon()
{
try
{
Assembly assembly = Assembly.GetExecutingAssembly();
using Stream? stream = assembly.GetManifestResourceStream("AmiReel.Assets.TitleBarIcon.png");
if (stream is null) return;
BitmapImage bitmap = new();
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
TitleBarIcon.Source = bitmap;
}
catch
{
// Non-critical: the title bar simply shows no icon if this fails.
}
}
private void SetDefaultSizeAndPosition() private void SetDefaultSizeAndPosition()
{ {
const int DefaultWidth = 1280; const int DefaultWidth = 1280;
+67 -39
View File
@@ -1,46 +1,74 @@
namespace AmiReel.Models; namespace AmiReel.Models;
public sealed record AppSettings( /// <summary>
string OutputFolder, /// Persisted user settings (%LOCALAPPDATA%\AmiReel\settings.json). Uses named init
string EndCardPath, /// properties rather than a positional constructor: several members share the same
string FfmpegPath, /// <see langword="string"/> type (e.g. TrimStart/FadeSeconds/EndCardHoldSeconds), so a
string FfprobePath, /// positional record would let two arguments be silently transposed at a call site
string PreviewPlayerPath, /// without the compiler catching it.
string Theme, /// </summary>
string Encoder, public sealed record AppSettings
string TrimStart,
string FadeSeconds,
string EndCardHoldSeconds,
string ThumbnailInterval,
bool? MoveSourcesToOriginals)
{ {
public required string OutputFolder { get; init; }
public required string EndCardPath { get; init; }
public required string FfmpegPath { get; init; }
public required string FfprobePath { get; init; }
public required string PreviewPlayerPath { get; init; }
public required string Theme { get; init; }
public required string Encoder { get; init; }
public required string TrimStart { get; init; }
public required string FadeSeconds { get; init; }
public required string EndCardHoldSeconds { get; init; }
public required string ThumbnailInterval { get; init; }
public bool? MoveSourcesToOriginals { get; init; }
// Shorts tab defaults (sticky across launches, same as the fields above).
public string ShortsStyle { get; init; } = "Brand";
public string ShortsHook { get; init; } = "THIS RAN ON AN AMIGA";
public string ShortsWebsite { get; init; } = "AMIGADB.NET";
public string ShortsFontFile { get; init; } = "";
public string ShortsBackgroundImage { get; init; } = "";
public string ShortsCrf { get; init; } = "16";
public string ShortsPreset { get; init; } = "slow";
public bool ShouldMoveSourcesToOriginals => MoveSourcesToOriginals != false; public bool ShouldMoveSourcesToOriginals => MoveSourcesToOriginals != false;
public static AppSettings Default(string outputFolder) => new( public static AppSettings Default(string outputFolder) => new()
outputFolder, {
"", OutputFolder = outputFolder,
"", EndCardPath = "",
"", FfmpegPath = "",
"", FfprobePath = "",
"Dark", PreviewPlayerPath = "",
"Auto", Theme = "Dark",
"4.414", Encoder = "Auto",
"3", TrimStart = "4.414",
"4", FadeSeconds = "3",
"10", EndCardHoldSeconds = "4",
true); ThumbnailInterval = "10",
MoveSourcesToOriginals = true,
};
public AppSettings Normalize(string fallbackOutputFolder) => new( public AppSettings Normalize(string fallbackOutputFolder) => this with
string.IsNullOrWhiteSpace(OutputFolder) ? fallbackOutputFolder : OutputFolder, {
EndCardPath ?? "", OutputFolder = string.IsNullOrWhiteSpace(OutputFolder) ? fallbackOutputFolder : OutputFolder,
FfmpegPath ?? "", EndCardPath = EndCardPath ?? "",
FfprobePath ?? "", FfmpegPath = FfmpegPath ?? "",
PreviewPlayerPath ?? "", FfprobePath = FfprobePath ?? "",
string.IsNullOrWhiteSpace(Theme) ? "Dark" : Theme, PreviewPlayerPath = PreviewPlayerPath ?? "",
string.IsNullOrWhiteSpace(Encoder) ? "Auto" : Encoder, Theme = string.IsNullOrWhiteSpace(Theme) ? "Dark" : Theme,
string.IsNullOrWhiteSpace(TrimStart) ? "4.414" : TrimStart, Encoder = string.IsNullOrWhiteSpace(Encoder) ? "Auto" : Encoder,
string.IsNullOrWhiteSpace(FadeSeconds) ? "3" : FadeSeconds, TrimStart = string.IsNullOrWhiteSpace(TrimStart) ? "4.414" : TrimStart,
string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds, FadeSeconds = string.IsNullOrWhiteSpace(FadeSeconds) ? "3" : FadeSeconds,
string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval, EndCardHoldSeconds = string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds,
MoveSourcesToOriginals ?? true); ThumbnailInterval = string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval,
MoveSourcesToOriginals = MoveSourcesToOriginals ?? true,
ShortsStyle = string.IsNullOrWhiteSpace(ShortsStyle) ? "Brand" : ShortsStyle,
ShortsHook = ShortsHook ?? "THIS RAN ON AN AMIGA",
ShortsWebsite = ShortsWebsite ?? "AMIGADB.NET",
ShortsFontFile = ShortsFontFile ?? "",
ShortsBackgroundImage = ShortsBackgroundImage ?? "",
ShortsCrf = string.IsNullOrWhiteSpace(ShortsCrf) ? "16" : ShortsCrf,
ShortsPreset = string.IsNullOrWhiteSpace(ShortsPreset) ? "slow" : ShortsPreset,
};
} }
+28 -18
View File
@@ -7,23 +7,33 @@ public enum EncoderMode
CpuX264 CpuX264
} }
public sealed record RenderSettings( /// <summary>
IReadOnlyList<string> InputFiles, /// One render job's parameters. Uses named init properties rather than a positional
string EndCardPath, /// constructor: TrimStart/FadeSeconds/EndCardHoldSeconds are three consecutive
string OutputDirectory, /// <see langword="double"/> members (and Width/Height/FramesPerSecond/ThumbnailWidth/
string OutputName, /// ThumbnailHeight five consecutive <see langword="int"/> members), so a positional
string FfmpegPath, /// record would let arguments be silently transposed at a call site without the
string FfprobePath, /// compiler catching it.
EncoderMode Encoder, /// </summary>
double TrimStart, public sealed record RenderSettings
double FadeSeconds, {
double EndCardHoldSeconds, public required IReadOnlyList<string> InputFiles { get; init; }
int ThumbnailInterval, public required string EndCardPath { get; init; }
bool MoveSourcesToOriginals = true, public required string OutputDirectory { get; init; }
int Width = 3840, public required string OutputName { get; init; }
int Height = 2160, public required string FfmpegPath { get; init; }
int FramesPerSecond = 50, public required string FfprobePath { get; init; }
int ThumbnailWidth = 1280, public required EncoderMode Encoder { get; init; }
int ThumbnailHeight = 720); public required double TrimStart { get; init; }
public required double FadeSeconds { get; init; }
public required double EndCardHoldSeconds { get; init; }
public required int ThumbnailInterval { get; init; }
public bool MoveSourcesToOriginals { get; init; } = true;
public int Width { get; init; } = 3840;
public int Height { get; init; } = 2160;
public int FramesPerSecond { get; init; } = 50;
public int ThumbnailWidth { get; init; } = 1280;
public int ThumbnailHeight { get; init; } = 720;
}
public sealed record RenderProgress(double Percent, string Stage, string Message); public sealed record RenderProgress(double Percent, string Stage, string Message);
+47
View File
@@ -0,0 +1,47 @@
namespace AmiReel.Models;
/// <summary>Visual style applied to a YouTube Short. Mirrors the --style values of amigadb-short.sh.</summary>
public enum ShortStyle
{
Brand,
Pixel,
Mirror,
Crop,
Workbench,
Blur,
Crt,
Copper,
Stars,
Grid,
Tiles,
Scan,
Starfield,
Plasma,
Rasterbars,
Vhs,
Monitor,
Split,
Spectrum,
}
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short.sh's arguments).</summary>
public sealed record ShortSettings
{
public required string InputFile { get; init; }
public required string OutputPath { get; init; }
public required string Title { get; init; }
public string Group { get; init; } = "";
public string Year { get; init; } = "";
public string Type { get; init; } = "";
public required string Hook { get; init; }
public required string Website { get; init; }
public required string Start { get; init; }
public required double DurationSeconds { get; init; }
public required ShortStyle Style { get; init; }
public string BackgroundImage { get; init; } = "";
public required string FontFile { get; init; }
public required string FfmpegPath { get; init; }
public required string FfprobePath { get; init; }
public int Crf { get; init; } = 16;
public string Preset { get; init; } = "slow";
}
+1 -3
View File
@@ -5,8 +5,7 @@
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:systemai="http://schemas.microsoft.com/appx/manifest/systemai/windows10" IgnorableNamespaces="uap rescap">
IgnorableNamespaces="uap rescap systemai">
<Identity <Identity
Name="F004AE73-5989-46F3-B913-E9A3A355713F" Name="F004AE73-5989-46F3-B913-E9A3A355713F"
@@ -48,6 +47,5 @@
<Capabilities> <Capabilities>
<rescap:Capability Name="runFullTrust" /> <rescap:Capability Name="runFullTrust" />
<systemai:Capability Name="systemAIModels"/>
</Capabilities> </Capabilities>
</Package> </Package>
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AmiReel.Tests")]
+54 -9
View File
@@ -4,22 +4,38 @@ AmiReel is a Windows desktop app for turning Amiga (or any) screen recordings in
- a 4K 50 FPS final video, - a 4K 50 FPS final video,
- PNG/JPG thumbnails, - PNG/JPG thumbnails,
- an animated WebP preview. - an animated WebP preview,
- a vertical (1080×1920, 50 FPS) YouTube Short with a styled title card.
Built with **WinUI 3** on the Windows App SDK, driving FFmpeg under the hood. Built with **WinUI 3** on the Windows App SDK, driving FFmpeg under the hood. The app has two
tabs — **Video** (the full-length render pipeline above) and **Shorts** (see below) — sharing
the same render progress, log, and output controls.
## Features ## Features
### Video tab
- Multiple ordered video inputs — `.avi`, `.mp4`, `.m4v`, `.mov`, `.mkv`, `.webm`, `.wmv`, `.flv`, `.mpg`, `.mpeg`, `.ts`, `.3gp` - Multiple ordered video inputs — `.avi`, `.mp4`, `.m4v`, `.mov`, `.mkv`, `.webm`, `.wmv`, `.flv`, `.mpg`, `.mpeg`, `.ts`, `.3gp`
- Drag-and-drop or file-picker source selection - Drag-and-drop or file-picker source selection
- 3840×2160 output at 50 FPS - 3840×2160 output at 50 FPS
- NVIDIA NVENC with automatic CPU `libx264` fallback
- Configurable trim, fade, end-card hold, and thumbnail interval - Configurable trim, fade, end-card hold, and thumbnail interval
- Optional end-card image - Optional end-card image
- Optional move of source recordings into `originals/` in the output folder
### Shorts tab
- Turns one source video into a vertical YouTube Short with an opening hook, title/group/year/
type card, and closing website call-to-action, all as timed text overlays
- 19 visual styles ported from `amigadb-short.sh``brand` (title card image), `pixel`,
`mirror`, `crop`, `workbench`, `blur`, `crt`, `copper`, `stars`, `grid`, `tiles`, `scan`,
`starfield`, `plasma`, `rasterbars`, `vhs`, `monitor`, `split`, `spectrum` (needs audio)
— see [`Services/ShortsPipeline.cs`](Services/ShortsPipeline.cs) for the exact FFmpeg filtergraphs
- Configurable clip start/duration, CRF, and x264 preset
- Style, font, background image, hook, and website text are sticky across launches
### Shared
- NVIDIA NVENC with automatic CPU `libx264` fallback
- Live render progress with frame counters and FFmpeg log output - Live render progress with frame counters and FFmpeg log output
- Source and final-render preview (built-in or a custom player) - Source and final-render preview (built-in or a custom player)
- Dark and light themes - Dark and light themes
- Optional move of source recordings into `originals/` in the output folder
- Optional custom `ffmpeg.exe` / `ffprobe.exe` paths, with automatic detection from `PATH` - Optional custom `ffmpeg.exe` / `ffprobe.exe` paths, with automatic detection from `PATH`
- Self-contained single-file publish for easy distribution - Self-contained single-file publish for easy distribution
@@ -37,17 +53,27 @@ app.manifest Win32 manifest (DPI awareness, OS compatibility)
Assets/ Packaged app icons and tiles (all required sizes) Assets/ Packaged app icons and tiles (all required sizes)
branding/ Source artwork the Assets/ tiles are generated from branding/ Source artwork the Assets/ tiles are generated from
Models/ Models/
AppSettings.cs Persisted user settings AppSettings.cs Persisted user settings (Video + Shorts defaults)
RenderSettings.cs Render job parameters RenderSettings.cs Video render job parameters
ShortSettings.cs Shorts render job parameters, ShortStyle enum
SupportedVideoFormats.cs Accepted input file extensions SupportedVideoFormats.cs Accepted input file extensions
Services/ Services/
RenderPipeline.cs Main render workflow (FFmpeg orchestration) RenderPipeline.cs Video render workflow (FFmpeg orchestration)
ShortsPipeline.cs Shorts render workflow; the 19 style filtergraphs live here
ProcessRunner.cs FFmpeg process execution and progress parsing ProcessRunner.cs FFmpeg process execution and progress parsing
FfmpegProgressParser.cs Parses time=/frame=/fps=/speed= from FFmpeg output lines
FfmpegOutputFilter.cs Shared FFmpeg banner/boilerplate line filtering
ToolExtractor.cs FFmpeg resolution, extraction, and fallback logic ToolExtractor.cs FFmpeg resolution, extraction, and fallback logic
MediaProbe.cs Duration/audio-stream probing via ffprobe MediaProbe.cs Duration/audio-stream probing via ffprobe
FfmpegLibraryProbe.cs NVENC capability probing FfmpegLibraryProbe.cs NVENC capability probing
AppSettingsStore.cs Settings load/save (JSON in %LOCALAPPDATA%) AppSettingsStore.cs Settings load/save (JSON in %LOCALAPPDATA%)
UserFacingErrors.cs Exception-to-message translation UserFacingErrors.cs Exception-to-message translation
Properties/
AssemblyInfo.cs InternalsVisibleTo AmiReel.Tests
launchSettings.json
PublishProfiles/ win-x64/x86/arm64 publish profiles
AmiReel.Tests/ Unit tests (MSTest), mirrors Models/ and Services/
amigadb-short.sh Original bash/FFmpeg script that ShortsPipeline.cs is a C# port of
ThirdParty/ Embedded ffmpeg.exe / ffprobe.exe (not checked in, see below) ThirdParty/ Embedded ffmpeg.exe / ffprobe.exe (not checked in, see below)
publish-win-x64.ps1 Publish script publish-win-x64.ps1 Publish script
``` ```
@@ -74,6 +100,20 @@ dotnet build .\AmiReel.csproj
dotnet run --project .\AmiReel.csproj dotnet run --project .\AmiReel.csproj
``` ```
## Run Tests
Unit tests live in `AmiReel.Tests/` (MSTest), covering the pure logic in `Models/` and
`Services/` — settings normalization, supported-format detection, FFmpeg output parsing/
filtering, render-settings validation, moving source files into `originals/`, and the Shorts
filtergraph/escaping/validation logic.
```powershell
dotnet test .\AmiReel.Tests\AmiReel.Tests.csproj
```
UI code-behind (`App`, `MainWindow`, `MainPage`) and anything that spawns an actual FFmpeg
process are intentionally left to manual/integration testing rather than unit tests.
## Publish ## Publish
```powershell ```powershell
@@ -131,8 +171,9 @@ User settings are stored in:
``` ```
This includes: output folder, end-card path, FFmpeg/preview-player paths, theme, encoder This includes: output folder, end-card path, FFmpeg/preview-player paths, theme, encoder
selection, timing settings, and whether source videos are moved into `originals/` after a selection, timing settings, whether source videos are moved into `originals/` after a
successful render. successful render, and the Shorts tab's sticky defaults (style, font, background image, CRF,
preset, hook, and website text).
## Notes for Distribution ## Notes for Distribution
@@ -141,6 +182,10 @@ successful render.
attribution required by that distribution. attribution required by that distribution.
- Windows Explorer may cache executable icons. If a freshly published build still shows an old - Windows Explorer may cache executable icons. If a freshly published build still shows an old
icon, rename the file or refresh the icon cache before assuming the embed failed. icon, rename the file or refresh the icon cache before assuming the embed failed.
- `Package.appxmanifest` currently has a placeholder `Identity` (a random GUID `Name` and
`Publisher="CN=AppPublisher"`). That's fine for local unpackaged builds, but before signing
an MSIX for real distribution, replace them with a real publisher identity and generate a
matching signing certificate (`winapp cert generate`, see `AGENTS.md`).
## Troubleshooting ## Troubleshooting
+40
View File
@@ -0,0 +1,40 @@
namespace AmiReel.Services;
/// <summary>
/// Recognizes the FFmpeg/FFprobe startup banner and stream-info boilerplate that both the
/// live log filter (<see cref="ProcessRunner"/>) and the error summarizer
/// (<see cref="UserFacingErrors"/>) want to hide — the version/library banner, input/output
/// stream dumps, and the final size/duration summary line are never useful to a user.
/// </summary>
public static class FfmpegOutputFilter
{
private static readonly string[] BoilerplatePrefixes =
[
"ffmpeg version ",
"built with ",
"configuration:",
"libavutil",
"libavcodec",
"libavformat",
"libavdevice",
"libavfilter",
"libswscale",
"libswresample",
"Input #",
"Output #",
"Stream mapping:",
"Stream #",
"Metadata:",
"Duration:",
"Press [q] to stop",
"video:",
"audio:",
"subtitle:",
"other streams:",
"global headers:",
"muxing overhead:",
];
public static bool IsBoilerplateLine(string line) =>
BoilerplatePrefixes.Any(prefix => line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
+44
View File
@@ -0,0 +1,44 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace AmiReel.Services;
public sealed record FfmpegOutputLine(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame);
/// <summary>
/// Parses a single line of FFmpeg stdout/stderr for the `time=`/`frame=`/`fps=`/`speed=`
/// progress fields FFmpeg prints while encoding.
/// </summary>
public static partial class FfmpegProgressParser
{
public static FfmpegOutputLine Parse(string line)
{
TimeSpan? time = null;
double? fps = null;
double? speed = null;
int? frame = null;
Match timeMatch = TimeRegex().Match(line);
if (timeMatch.Success && TimeSpan.TryParse(timeMatch.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan parsedTime))
time = parsedTime;
Match frameMatch = FrameRegex().Match(line);
if (frameMatch.Success)
{
if (int.TryParse(frameMatch.Groups["frame"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedFrame))
frame = parsedFrame;
if (double.TryParse(frameMatch.Groups["fps"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedFps))
fps = parsedFps;
if (double.TryParse(frameMatch.Groups["speed"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSpeed))
speed = parsedSpeed;
}
return new FfmpegOutputLine(line, time, fps, speed, frame);
}
[GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex TimeRegex();
[GeneratedRegex(@"frame=\s*(?<frame>\d+).*?fps=\s*(?<fps>\d+(?:\.\d+)?).*?speed=\s*(?<speed>\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)]
private static partial Regex FrameRegex();
}
+7 -64
View File
@@ -1,15 +1,11 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.IO; using System.IO;
namespace AmiReel.Services; namespace AmiReel.Services;
public sealed partial class ProcessRunner public sealed class ProcessRunner
{ {
public sealed record ProcessOutput(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame);
public async Task<string> RunAsync( public async Task<string> RunAsync(
string executable, string executable,
IEnumerable<string> arguments, IEnumerable<string> arguments,
@@ -17,7 +13,7 @@ public sealed partial class ProcessRunner
Action<TimeSpan>? position, Action<TimeSpan>? position,
CancellationToken token, CancellationToken token,
bool allowFailure = false, bool allowFailure = false,
Action<ProcessOutput>? outputHandler = null) Action<FfmpegOutputLine>? outputHandler = null)
{ {
ProcessStartInfo start = new() ProcessStartInfo start = new()
{ {
@@ -51,12 +47,12 @@ public sealed partial class ProcessRunner
private static async Task PumpAsync( private static async Task PumpAsync(
StreamReader reader, StringBuilder output, Action<string>? log, StreamReader reader, StringBuilder output, Action<string>? log,
Action<TimeSpan>? position, CancellationToken token, Action<ProcessOutput>? outputHandler) Action<TimeSpan>? position, CancellationToken token, Action<FfmpegOutputLine>? outputHandler)
{ {
while (await reader.ReadLineAsync(token) is { } line) while (await reader.ReadLineAsync(token) is { } line)
{ {
output.AppendLine(line); output.AppendLine(line);
ProcessOutput parsed = ParseOutput(line); FfmpegOutputLine parsed = FfmpegProgressParser.Parse(line);
if (!IsNoise(parsed)) if (!IsNoise(parsed))
log?.Invoke(line); log?.Invoke(line);
if (parsed.Time is { } time) if (parsed.Time is { } time)
@@ -65,32 +61,7 @@ public sealed partial class ProcessRunner
} }
} }
private static ProcessOutput ParseOutput(string line) internal static bool IsNoise(FfmpegOutputLine output)
{
TimeSpan? time = null;
double? fps = null;
double? speed = null;
int? frame = null;
Match timeMatch = TimeRegex().Match(line);
if (timeMatch.Success && TimeSpan.TryParse(timeMatch.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan parsedTime))
time = parsedTime;
Match frameMatch = FrameRegex().Match(line);
if (frameMatch.Success)
{
if (int.TryParse(frameMatch.Groups["frame"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedFrame))
frame = parsedFrame;
if (double.TryParse(frameMatch.Groups["fps"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedFps))
fps = parsedFps;
if (double.TryParse(frameMatch.Groups["speed"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSpeed))
speed = parsedSpeed;
}
return new ProcessOutput(line, time, fps, speed, frame);
}
private static bool IsNoise(ProcessOutput output)
{ {
string line = output.Line.TrimStart(); string line = output.Line.TrimStart();
if (string.IsNullOrWhiteSpace(line)) if (string.IsNullOrWhiteSpace(line))
@@ -111,39 +82,11 @@ public sealed partial class ProcessRunner
|| line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase)) || line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase))
return false; return false;
return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase) return FfmpegOutputFilter.IsBoilerplateLine(line)
|| line.StartsWith("built with ", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("configuration:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavutil", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavcodec", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavformat", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavdevice", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavfilter", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libswscale", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libswresample", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Input #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Output #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Stream mapping:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Stream #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Metadata:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase) || line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("title :", StringComparison.OrdinalIgnoreCase) || line.StartsWith("title :", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("CPB properties:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("CPB properties:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase) || line.StartsWith("[", StringComparison.OrdinalIgnoreCase);
|| line.StartsWith("[", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Duration:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("video:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("audio:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("other streams:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("global headers:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("muxing overhead:", StringComparison.OrdinalIgnoreCase);
} }
[GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex TimeRegex();
[GeneratedRegex(@"frame=\s*(?<frame>\d+).*?fps=\s*(?<fps>\d+(?:\.\d+)?).*?speed=\s*(?<speed>\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)]
private static partial Regex FrameRegex();
} }
+2 -2
View File
@@ -255,7 +255,7 @@ public sealed class RenderPipeline
}); });
} }
private static string BuildProgressMessage(TimeSpan current, double durationSeconds, double? fps, double? speed, int? frame, int framesPerSecond) internal static string BuildProgressMessage(TimeSpan current, double durationSeconds, double? fps, double? speed, int? frame, int framesPerSecond)
{ {
TimeSpan total = TimeSpan.FromSeconds(durationSeconds); TimeSpan total = TimeSpan.FromSeconds(durationSeconds);
List<string> parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"]; List<string> parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"];
@@ -358,7 +358,7 @@ public sealed class RenderPipeline
} }
} }
private static void Validate(RenderSettings s) internal static void Validate(RenderSettings s)
{ {
if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one video input file."); 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 (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more input files no longer exist.");
+386
View File
@@ -0,0 +1,386 @@
using System.Globalization;
using System.IO;
using System.Text;
using AmiReel.Models;
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.
/// </summary>
public sealed class ShortsPipeline
{
private readonly ProcessRunner _runner = new();
public async Task RenderAsync(ShortSettings settings, IProgress<RenderProgress> progress, Action<string> log, CancellationToken token)
{
Validate(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
if (settings.Style == ShortStyle.Spectrum)
{
MediaProbe probe = new(_runner, log);
bool hasAudio = await probe.HasAudioAsync(tools, settings.InputFile, token);
if (!hasAudio)
throw new InvalidOperationException("The spectrum style requires an audio stream in the source video.");
}
string? outputDirectory = Path.GetDirectoryName(Path.GetFullPath(settings.OutputPath));
if (!string.IsNullOrEmpty(outputDirectory))
Directory.CreateDirectory(outputDirectory);
string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", "shorts", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
try
{
string hookFile = WriteTextFile(workDir, "hook.txt", settings.Hook);
string titleFile = WriteTextFile(workDir, "title.txt", settings.Title);
string metaFile = WriteTextFile(workDir, "meta.txt", BuildMeta(settings));
string websiteFile = WriteTextFile(workDir, "website.txt", settings.Website);
double duration = settings.DurationSeconds;
double infoStart = Math.Max(0, duration - 7);
double ctaStart = Math.Max(0, duration - 3.5);
double hookEnd = Math.Min(2.7, duration);
string filterComplex = BuildFilterComplex(
GetBaseFilter(settings.Style),
fontEsc: EscapeFfmpegPath(settings.FontFile),
hookEsc: EscapeFfmpegPath(hookFile),
titleEsc: EscapeFfmpegPath(titleFile),
metaEsc: EscapeFfmpegPath(metaFile),
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]);
args.AddRange([
"-filter_complex", filterComplex,
"-map", "[vout]",
"-map", "0:a?",
"-c:v", "libx264",
"-preset", settings.Preset,
"-crf", settings.Crf.ToString(CultureInfo.InvariantCulture),
"-profile:v", "high",
"-level:v", "4.2",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "320k",
"-ar", "48000",
"-movflags", "+faststart",
"-shortest",
settings.OutputPath,
]);
progress.Report(new(2, "Rendering short", $"Applying the '{settings.Style}' style"));
TimeSpan totalSpan = TimeSpan.FromSeconds(duration);
await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, outputHandler: output =>
{
if (output.Time is not { } time) return;
double percent = Math.Min(98, 2 + time.TotalSeconds / duration * 96);
progress.Report(new(percent, "Rendering short", $"{time:hh\\:mm\\:ss} / {totalSpan:hh\\:mm\\:ss}"));
});
progress.Report(new(100, "Complete", settings.OutputPath));
}
finally
{
try { Directory.Delete(workDir, true); } catch { }
}
}
/// <summary>Looks for a common bold system font to pre-fill the Shorts font field with.</summary>
public static string? FindDefaultFont()
{
string fontsDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
foreach (string name in new[] { "segoeuib.ttf", "seguisb.ttf", "arialbd.ttf", "calibrib.ttf" })
{
string candidate = Path.Combine(fontsDir, name);
if (File.Exists(candidate)) return candidate;
}
return null;
}
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.");
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.");
if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage)))
throw new FileNotFoundException("The brand style requires a background image.");
}
internal static string BuildMeta(ShortSettings s)
{
List<string> parts = [];
if (!string.IsNullOrWhiteSpace(s.Group)) parts.Add(s.Group.Trim());
if (!string.IsNullOrWhiteSpace(s.Year)) parts.Add(s.Year.Trim());
if (!string.IsNullOrWhiteSpace(s.Type)) parts.Add(s.Type.Trim());
return string.Join(" · ", parts);
}
internal static string EscapeFfmpegPath(string value) =>
value.Replace("\\", "\\\\").Replace(":", "\\:").Replace("'", "\\'");
private static string WriteTextFile(string directory, string fileName, string content)
{
string path = Path.Combine(directory, fileName);
File.WriteAllText(path, content + "\n", new UTF8Encoding(false));
return path;
}
internal static string BuildFilterComplex(
string baseFilter, string fontEsc, string hookEsc, string titleEsc, string metaEsc, string websiteEsc,
double hookEnd, double infoStart, double ctaStart)
{
string hookEndStr = F(hookEnd);
string infoStartStr = F(infoStart);
string ctaStartStr = F(ctaStart);
return baseFilter + ";" +
"[base]" +
$"drawbox=x=0:y=0:w=1080:h=245:color=black@0.50:t=fill:enable='between(t,0,{hookEndStr})'," +
$"drawtext=fontfile='{fontEsc}':textfile='{hookEsc}':fontcolor=white:fontsize=70:line_spacing=10:x=(w-text_w)/2:y=70:fix_bounds=true:shadowcolor=black@0.75:shadowx=4:shadowy=4:enable='between(t,0,{hookEndStr})'," +
$"drawbox=x=0:y=1490:w=1080:h=430:color=black@0.62:t=fill:enable='gte(t,{infoStartStr})'," +
$"drawtext=fontfile='{fontEsc}':textfile='{titleEsc}':fontcolor=white:fontsize=62:line_spacing=8:x=(w-text_w)/2:y=1560:fix_bounds=true:shadowcolor=black@0.80:shadowx=4:shadowy=4:enable='gte(t,{infoStartStr})'," +
$"drawtext=fontfile='{fontEsc}':textfile='{metaEsc}':fontcolor=white@0.92:fontsize=42:x=(w-text_w)/2:y=1655:fix_bounds=true:shadowcolor=black@0.80:shadowx=3:shadowy=3:enable='gte(t,{infoStartStr})'," +
$"drawtext=fontfile='{fontEsc}':textfile='{websiteEsc}':fontcolor=0x55c8ff:fontsize=52:x=(w-text_w)/2:y=1780:fix_bounds=true:shadowcolor=black@0.85:shadowx=4:shadowy=4:enable='gte(t,{ctaStartStr})'," +
"fps=50,format=yuv420p[vout]";
}
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
internal static string GetBaseFilter(ShortStyle style) => style switch
{
ShortStyle.Brand => """
[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=black@0.22:t=fill[bg2];
[bg2][fg]overlay=x=(W-w)/2:y=520:format=auto[base]
""",
ShortStyle.Pixel => """
[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=0x55c8ff@0.72:t=fill,
drawbox=x=0:y='1610+18*sin(t*1.35)':w=1080:h=8:color=0xff7020@0.72:t=fill[base]
""",
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]
""",
ShortStyle.Crop => """
[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,eq=brightness=-0.04:saturation=1.05[base]
""",
ShortStyle.Workbench => """
[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]
""",
ShortStyle.Blur => """
[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]
""",
ShortStyle.Crt => """
[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=black@0.18[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=white@0.08:t=fill[base]
""",
ShortStyle.Copper => """
[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=0x061a5f@0.55:t=fill,
drawbox=x=0:y='220+20*sin(t*1.00)':w=1080:h=140:color=0x123fcb@0.28:t=fill,
drawbox=x=0:y='420+24*sin(t*1.20)':w=1080:h=100:color=0x2b8cff@0.22:t=fill,
drawbox=x=0:y='660+16*sin(t*1.50)':w=1080:h=120:color=0x00b8ff@0.18:t=fill,
drawbox=x=0:y='1110+22*sin(t*1.10)':w=1080:h=150:color=0xff7a1f@0.20:t=fill,
drawbox=x=0:y='1360+18*sin(t*1.30)':w=1080:h=110:color=0xffc120@0.18:t=fill,
drawbox=x=0:y='1660+15*sin(t*1.70)':w=1080:h=130:color=0x58d4ff@0.18: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]
""",
ShortStyle.Stars => """
[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=0x05081b@0.75:t=fill,
drawbox=x='mod(100+80*t,1080)':y='mod(80+210*t,1920)':w=3:h=3:color=white@0.95:t=fill,
drawbox=x='mod(880-55*t,1080)':y='mod(220+170*t,1920)':w=2:h=2:color=white@0.85:t=fill,
drawbox=x='mod(300+120*t,1080)':y='mod(520+140*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
drawbox=x='mod(980-75*t,1080)':y='mod(710+160*t,1920)':w=2:h=2:color=white@0.80:t=fill,
drawbox=x='mod(420+65*t,1080)':y='mod(1040+200*t,1920)':w=4:h=4:color=0xffd46b@0.85:t=fill,
drawbox=x='mod(750-90*t,1080)':y='mod(1280+110*t,1920)':w=2:h=2:color=white@0.88:t=fill,
drawbox=x='mod(210+75*t,1080)':y='mod(1440+180*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
drawbox=x='mod(1030-60*t,1080)':y='mod(1680+140*t,1920)':w=2:h=2:color=white@0.82:t=fill,
drawbox=x='mod(560+95*t,1080)':y='mod(260+260*t,1920)':w=2:h=2:color=white@0.92:t=fill,
drawbox=x='mod(60+110*t,1080)':y='mod(860+120*t,1920)':w=3:h=3:color=0xffd46b@0.82: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]
""",
ShortStyle.Grid => """
[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=0x090b17@0.72:t=fill,
drawgrid=width=90:height=90:thickness=2:color=0x39d2ff@0.18,
drawbox=x=0:y=0:w=1080:h=1120:color=0x050814@0.80:t=fill,
drawbox=x=0:y=1120:w=1080:h=4:color=0xff54d9@0.70: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]
""",
ShortStyle.Tiles => """
[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]
""",
ShortStyle.Scan => """
[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=white@0.14:t=fill,
drawbox=x=0:y='280+20*sin(t*1.40)':w=1080:h=6:color=0x55c8ff@0.45:t=fill,
drawbox=x=0:y='1640+15*sin(t*1.20)':w=1080:h=6:color=0xff7020@0.45:t=fill[base]
""",
ShortStyle.Starfield => """
[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=0x020511@0.82:t=fill,
drawbox=x='mod(120+75*t,1080)':y='mod(80+520*t,1920)':w=3:h=18:color=white@0.92:t=fill,
drawbox=x='mod(970-55*t,1080)':y='mod(250+430*t,1920)':w=2:h=14:color=0xa4dcff@0.85:t=fill,
drawbox=x='mod(370+95*t,1080)':y='mod(430+600*t,1920)':w=4:h=24:color=white@0.90:t=fill,
drawbox=x='mod(810-80*t,1080)':y='mod(640+470*t,1920)':w=2:h=17:color=0xffdc83@0.85:t=fill,
drawbox=x='mod(230+65*t,1080)':y='mod(900+540*t,1920)':w=3:h=20:color=white@0.88:t=fill,
drawbox=x='mod(1020-100*t,1080)':y='mod(1110+650*t,1920)':w=4:h=26:color=0x8fd5ff@0.90:t=fill,
drawbox=x='mod(560+85*t,1080)':y='mod(1320+490*t,1920)':w=2:h=16:color=white@0.86:t=fill,
drawbox=x='mod(70+110*t,1080)':y='mod(1510+590*t,1920)':w=3:h=22:color=0xffdc83@0.82:t=fill,
drawbox=x='mod(720-60*t,1080)':y='mod(1720+450*t,1920)':w=2:h=15:color=white@0.88:t=fill,
drawbox=x='mod(440+105*t,1080)':y='mod(180+700*t,1920)':w=3:h=28:color=0xa4dcff@0.88:t=fill,
drawbox=x='mod(900-72*t,1080)':y='mod(780+560*t,1920)':w=2:h=18:color=white@0.84:t=fill,
drawbox=x='mod(310+90*t,1080)':y='mod(1240+620*t,1920)':w=4:h=24:color=white@0.90: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]
""",
ShortStyle.Plasma => """
[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]
""",
ShortStyle.Rasterbars => """
[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=0x05061b@0.80:t=fill,
drawbox=x=0:y='250+90*sin(t*1.25)':w=1080:h=30:color=0xff3b7f@0.78:t=fill,
drawbox=x=0:y='300+90*sin(t*1.25)':w=1080:h=24:color=0xff7b32@0.72:t=fill,
drawbox=x=0:y='345+90*sin(t*1.25)':w=1080:h=18:color=0xffd12b@0.66:t=fill,
drawbox=x=0:y='1510+85*sin(t*1.10+1.2)':w=1080:h=18:color=0x55e6ff@0.68:t=fill,
drawbox=x=0:y='1550+85*sin(t*1.10+1.2)':w=1080:h=24:color=0x4b8cff@0.72:t=fill,
drawbox=x=0:y='1600+85*sin(t*1.10+1.2)':w=1080:h=30:color=0x8b55ff@0.76: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]
""",
ShortStyle.Vhs => """
[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=black@0.13[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=white@0.08:t=fill,
drawbox=x=0:y='420+mod(t*320,1080)':w=1080:h=3:color=0x7bd7ff@0.10:t=fill[base]
""",
ShortStyle.Monitor => """
[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=0x11131a@0.78: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]
""",
ShortStyle.Split => """
[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]
""",
ShortStyle.Spectrum => """
[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=0x040717@0.68: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]
""",
_ => throw new ArgumentOutOfRangeException(nameof(style), style, "Unknown short style."),
};
}
+3 -25
View File
@@ -39,38 +39,16 @@ public static class UserFacingErrors
return string.Join(Environment.NewLine, summary); return string.Join(Environment.NewLine, summary);
} }
private static bool IsNoise(string line) internal static bool IsNoise(string line)
{ {
return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase) return FfmpegOutputFilter.IsBoilerplateLine(line)
|| line.StartsWith("built with ", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("configuration:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavutil", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavcodec", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavformat", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavdevice", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavfilter", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libswscale", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libswresample", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Input #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Output #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Stream mapping:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Stream #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Metadata:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("major_brand", StringComparison.OrdinalIgnoreCase) || line.StartsWith("major_brand", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("minor_version", StringComparison.OrdinalIgnoreCase) || line.StartsWith("minor_version", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("compatible_brands", StringComparison.OrdinalIgnoreCase) || line.StartsWith("compatible_brands", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("encoder", StringComparison.OrdinalIgnoreCase) || line.StartsWith("encoder", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Duration:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("[in#", StringComparison.OrdinalIgnoreCase) || line.StartsWith("[in#", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("[out#", StringComparison.OrdinalIgnoreCase) || line.StartsWith("[out#", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Last message repeated", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Last message repeated", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("frame=", StringComparison.OrdinalIgnoreCase) || line.StartsWith("frame=", StringComparison.OrdinalIgnoreCase);
|| line.StartsWith("video:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("audio:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("other streams:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("global headers:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("muxing overhead:", StringComparison.OrdinalIgnoreCase);
} }
} }
+674
View File
@@ -0,0 +1,674 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# AmigaDB YouTube Shorts generator
# Creates a 1080x1920, 50 FPS vertical MP4 from an existing Amiga video.
# V3 expanded style pack for 4:3 Amiga footage, with demoscene-inspired FX and optional branded background image.
SCRIPT_NAME="$(basename "$0")"
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"
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
default: brand
--background-image Background image for brand style
default: amigadb_short.png
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
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
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
;;
--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
;;
-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"
case "$STYLE" in
brand|pixel|mirror|crop|workbench|blur|crt|copper|stars|grid|tiles|scan|starfield|plasma|rasterbars|vhs|monitor|split|spectrum)
;;
*)
fail "Unknown style '$STYLE'. Use brand, pixel, mirror, crop, workbench, blur, crt, copper, stars, grid, tiles, scan, starfield, plasma, rasterbars, vhs, monitor, split, or spectrum"
;;
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
}
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
# 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=black@0.22: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=0x55c8ff@0.72:t=fill,
drawbox=x=0:y='1610+18*sin(t*1.35)':w=1080:h=8:color=0xff7020@0.72:t=fill[base]
FILTER
)
;;
mirror)
BASE_FILTER=$(cat <<'FILTER'
[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]
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=black@0.18[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=white@0.08: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=0x061a5f@0.55:t=fill,
drawbox=x=0:y='220+20*sin(t*1.00)':w=1080:h=140:color=0x123fcb@0.28:t=fill,
drawbox=x=0:y='420+24*sin(t*1.20)':w=1080:h=100:color=0x2b8cff@0.22:t=fill,
drawbox=x=0:y='660+16*sin(t*1.50)':w=1080:h=120:color=0x00b8ff@0.18:t=fill,
drawbox=x=0:y='1110+22*sin(t*1.10)':w=1080:h=150:color=0xff7a1f@0.20:t=fill,
drawbox=x=0:y='1360+18*sin(t*1.30)':w=1080:h=110:color=0xffc120@0.18:t=fill,
drawbox=x=0:y='1660+15*sin(t*1.70)':w=1080:h=130:color=0x58d4ff@0.18: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=0x05081b@0.75:t=fill,
drawbox=x='mod(100+80*t,1080)':y='mod(80+210*t,1920)':w=3:h=3:color=white@0.95:t=fill,
drawbox=x='mod(880-55*t,1080)':y='mod(220+170*t,1920)':w=2:h=2:color=white@0.85:t=fill,
drawbox=x='mod(300+120*t,1080)':y='mod(520+140*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
drawbox=x='mod(980-75*t,1080)':y='mod(710+160*t,1920)':w=2:h=2:color=white@0.80:t=fill,
drawbox=x='mod(420+65*t,1080)':y='mod(1040+200*t,1920)':w=4:h=4:color=0xffd46b@0.85:t=fill,
drawbox=x='mod(750-90*t,1080)':y='mod(1280+110*t,1920)':w=2:h=2:color=white@0.88:t=fill,
drawbox=x='mod(210+75*t,1080)':y='mod(1440+180*t,1920)':w=3:h=3:color=0x9ad8ff@0.90:t=fill,
drawbox=x='mod(1030-60*t,1080)':y='mod(1680+140*t,1920)':w=2:h=2:color=white@0.82:t=fill,
drawbox=x='mod(560+95*t,1080)':y='mod(260+260*t,1920)':w=2:h=2:color=white@0.92:t=fill,
drawbox=x='mod(60+110*t,1080)':y='mod(860+120*t,1920)':w=3:h=3:color=0xffd46b@0.82: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=0x090b17@0.72:t=fill,
drawgrid=width=90:height=90:thickness=2:color=0x39d2ff@0.18,
drawbox=x=0:y=0:w=1080:h=1120:color=0x050814@0.80:t=fill,
drawbox=x=0:y=1120:w=1080:h=4:color=0xff54d9@0.70: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=white@0.14:t=fill,
drawbox=x=0:y='280+20*sin(t*1.40)':w=1080:h=6:color=0x55c8ff@0.45:t=fill,
drawbox=x=0:y='1640+15*sin(t*1.20)':w=1080:h=6:color=0xff7020@0.45: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=0x020511@0.82:t=fill,
drawbox=x='mod(120+75*t,1080)':y='mod(80+520*t,1920)':w=3:h=18:color=white@0.92:t=fill,
drawbox=x='mod(970-55*t,1080)':y='mod(250+430*t,1920)':w=2:h=14:color=0xa4dcff@0.85:t=fill,
drawbox=x='mod(370+95*t,1080)':y='mod(430+600*t,1920)':w=4:h=24:color=white@0.90:t=fill,
drawbox=x='mod(810-80*t,1080)':y='mod(640+470*t,1920)':w=2:h=17:color=0xffdc83@0.85:t=fill,
drawbox=x='mod(230+65*t,1080)':y='mod(900+540*t,1920)':w=3:h=20:color=white@0.88:t=fill,
drawbox=x='mod(1020-100*t,1080)':y='mod(1110+650*t,1920)':w=4:h=26:color=0x8fd5ff@0.90:t=fill,
drawbox=x='mod(560+85*t,1080)':y='mod(1320+490*t,1920)':w=2:h=16:color=white@0.86:t=fill,
drawbox=x='mod(70+110*t,1080)':y='mod(1510+590*t,1920)':w=3:h=22:color=0xffdc83@0.82:t=fill,
drawbox=x='mod(720-60*t,1080)':y='mod(1720+450*t,1920)':w=2:h=15:color=white@0.88:t=fill,
drawbox=x='mod(440+105*t,1080)':y='mod(180+700*t,1920)':w=3:h=28:color=0xa4dcff@0.88:t=fill,
drawbox=x='mod(900-72*t,1080)':y='mod(780+560*t,1920)':w=2:h=18:color=white@0.84:t=fill,
drawbox=x='mod(310+90*t,1080)':y='mod(1240+620*t,1920)':w=4:h=24:color=white@0.90: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=0x05061b@0.80:t=fill,
drawbox=x=0:y='250+90*sin(t*1.25)':w=1080:h=30:color=0xff3b7f@0.78:t=fill,
drawbox=x=0:y='300+90*sin(t*1.25)':w=1080:h=24:color=0xff7b32@0.72:t=fill,
drawbox=x=0:y='345+90*sin(t*1.25)':w=1080:h=18:color=0xffd12b@0.66:t=fill,
drawbox=x=0:y='1510+85*sin(t*1.10+1.2)':w=1080:h=18:color=0x55e6ff@0.68:t=fill,
drawbox=x=0:y='1550+85*sin(t*1.10+1.2)':w=1080:h=24:color=0x4b8cff@0.72:t=fill,
drawbox=x=0:y='1600+85*sin(t*1.10+1.2)':w=1080:h=30:color=0x8b55ff@0.76: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=black@0.13[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=white@0.08:t=fill,
drawbox=x=0:y='420+mod(t*320,1080)':w=1080:h=3:color=0x7bd7ff@0.10: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=0x11131a@0.78: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=0x040717@0.68: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
)
;;
esac
FILTER_COMPLEX=$(cat <<FILTER
${BASE_FILTER};
[base]
drawbox=x=0:y=0:w=1080:h=245:color=black@0.50: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=black@0.75:shadowx=4:shadowy=4:enable='between(t,0,${HOOK_END})',
drawbox=x=0:y=1490:w=1080:h=430:color=black@0.62: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=black@0.80:shadowx=4:shadowy=4:enable='gte(t,${INFO_START})',
drawtext=fontfile='${FONT_ESC}':textfile='${META_ESC}':fontcolor=white@0.92:fontsize=42:x=(w-text_w)/2:y=1655:fix_bounds=true:shadowcolor=black@0.80: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=black@0.85: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 ' Output: %s\n' "$OUTPUT"
printf ' Font: %s\n' "$FONT_FILE"
if [[ "$STYLE" == "brand" ]]; then
printf ' Bg image: %s\n' "$BACKGROUND_IMAGE"
fi
FFMPEG_ARGS=(
-hide_banner -y
-ss "$START"
-t "$DURATION"
-i "$INPUT"
)
if [[ "$STYLE" == "brand" ]]; then
FFMPEG_ARGS+=( -loop 1 -i "$BACKGROUND_IMAGE" )
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
-shortest
"$OUTPUT"
)
ffmpeg "${FFMPEG_ARGS[@]}"
printf '\nDone: %s\n' "$OUTPUT"