diff --git a/.github/workflows/autorelease.yml b/.github/workflows/autorelease.yml
index d134915..ca3b621 100644
--- a/.github/workflows/autorelease.yml
+++ b/.github/workflows/autorelease.yml
@@ -3,7 +3,7 @@ name: "pre-release"
on:
push:
branches:
- - "master"
+ - "main"
jobs:
pre-release:
@@ -11,33 +11,38 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- - uses: actions/checkout@v1
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: '10.0.x'
-
- - name: Install zip
- uses: montudor/action-zip@v1
-
- - name: Build with dotnet
- working-directory: ./
- run: |
- dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishReadyToRun=true -p:PublishSingleFile=true -o linux
- dotnet publish -c Release -r win-x64 --self-contained true -p:PublishReadyToRun=true -p:PublishSingleFile=true -o windows
- dotnet publish -c Release -r osx-x64 --self-contained true -p:PublishReadyToRun=true -p:PublishSingleFile=true -o macos
-
- - name: Zip output
- run: zip -qq -r Release.zip Release
- working-directory: ./
-
- - name: Publish GitHub Release
- uses: "marvinpinto/action-automatic-releases@latest"
- with:
- repo_token: "${{ secrets.GITHUB_TOKEN }}"
- automatic_release_tag: "latest"
- prerelease: true
- title: "Development Build"
- files: |
- ./bin/Release.zip
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Install zip
+ uses: montudor/action-zip@v1
+
+ - name: Build with dotnet
+ working-directory: ./
+ run: |
+ dotnet publish logo.csproj -c Release -r linux-x64 --self-contained true -p:PublishReadyToRun=true -p:PublishSingleFile=true -o artifacts/release/linux-x64
+ dotnet publish logo.csproj -c Release -r win-x64 --self-contained true -p:PublishReadyToRun=true -p:PublishSingleFile=true -o artifacts/release/win-x64
+ dotnet publish logo.csproj -c Release -r osx-x64 --self-contained true -p:PublishReadyToRun=true -p:PublishSingleFile=true -o artifacts/release/osx-x64
+
+ - name: Zip outputs
+ run: |
+ zip -qq -r logo-linux-x64.zip artifacts/release/linux-x64
+ zip -qq -r logo-win-x64.zip artifacts/release/win-x64
+ zip -qq -r logo-osx-x64.zip artifacts/release/osx-x64
+ working-directory: ./
+
+ - name: Publish GitHub Release
+ uses: "marvinpinto/action-automatic-releases@latest"
+ with:
+ repo_token: "${{ secrets.GITHUB_TOKEN }}"
+ automatic_release_tag: "latest"
+ prerelease: true
+ title: "Development Build"
+ files: |
+ ./logo-linux-x64.zip
+ ./logo-win-x64.zip
+ ./logo-osx-x64.zip
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 10c2791..6ae919a 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -1,26 +1,44 @@
name: .NET Build
-# Trigger the workflow on push or pull request
-on: [push]
+on:
+ push:
+ branches:
+ - main
+ - encoder
+ pull_request:
+ branches:
+ - main
jobs:
build:
-
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
fail-fast: false
-
+
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v1
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: '10.0.x'
-
- - name: Build with dotnet
- working-directory: ./
- run: dotnet build --configuration Release
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Restore
+ working-directory: ./
+ run: |
+ dotnet restore tools/Hnm0Encoder/Hnm0Encoder.csproj
+ dotnet restore HnmPlayer/HnmPlayer.csproj
+ dotnet restore tests/HnmEndToEndTests/HnmEndToEndTests.csproj
+
+ - name: Build encoder and player
+ working-directory: ./
+ run: |
+ dotnet build tools/Hnm0Encoder/Hnm0Encoder.csproj --configuration Release --no-restore
+ dotnet build HnmPlayer/HnmPlayer.csproj --configuration Release --no-restore
+
+ - name: Test
+ working-directory: ./
+ run: dotnet test tests/HnmEndToEndTests/HnmEndToEndTests.csproj --configuration Release
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..176560c
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,5 @@
+
+
+ $(NoWarn);NU1902;NU1903
+
+
diff --git a/HnmPlayer/App.cs b/HnmPlayer/App.cs
index 0b9f239..796940c 100644
--- a/HnmPlayer/App.cs
+++ b/HnmPlayer/App.cs
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Themes.Fluent;
+using System.Runtime.Versioning;
namespace logo;
@@ -11,11 +12,14 @@ public override void Initialize()
Styles.Add(new FluentTheme());
}
+ [SupportedOSPlatform("windows")]
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
- desktop.MainWindow = new MainWindow();
+ desktop.MainWindow = PlayerLaunchContext.Current.Headless
+ ? new HeadlessCaptureWindow()
+ : new MainWindow();
}
base.OnFrameworkInitializationCompleted();
diff --git a/HnmPlayer/HeadlessCaptureWindow.cs b/HnmPlayer/HeadlessCaptureWindow.cs
new file mode 100644
index 0000000..a1aee4b
--- /dev/null
+++ b/HnmPlayer/HeadlessCaptureWindow.cs
@@ -0,0 +1,34 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using System.Diagnostics;
+
+namespace logo;
+
+public sealed class HeadlessCaptureWindow : Window
+{
+ public HeadlessCaptureWindow()
+ {
+ Opened += OnOpened;
+ }
+
+ private async void OnOpened(object? sender, EventArgs e)
+ {
+ Debug.Assert(PlayerLaunchContext.Current.Headless, "Headless capture window should only be used in headless mode.");
+ if (string.IsNullOrWhiteSpace(PlayerLaunchContext.Current.InputPath))
+ {
+ throw new InvalidOperationException("Headless mode requires an input path.");
+ }
+
+ if (string.IsNullOrWhiteSpace(PlayerLaunchContext.Current.ScreenshotDirectory))
+ {
+ throw new InvalidOperationException("Headless mode requires a screenshot directory.");
+ }
+
+ string inputPath = PlayerLaunchContext.Current.InputPath;
+ string screenshotDirectory = PlayerLaunchContext.Current.ScreenshotDirectory;
+
+ await HeadlessPlayerRunner.RunAsync(inputPath, screenshotDirectory);
+ Close();
+ }
+}
\ No newline at end of file
diff --git a/HnmPlayer/HeadlessPlayerRunner.cs b/HnmPlayer/HeadlessPlayerRunner.cs
new file mode 100644
index 0000000..f0ca8ec
--- /dev/null
+++ b/HnmPlayer/HeadlessPlayerRunner.cs
@@ -0,0 +1,60 @@
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using System.Diagnostics;
+
+namespace logo;
+
+internal static class HeadlessPlayerRunner
+{
+ public static async Task RunAsync(string inputPath, string screenshotDirectory)
+ {
+ Debug.Assert(!string.IsNullOrWhiteSpace(inputPath), "Input path must not be empty.");
+ Debug.Assert(!string.IsNullOrWhiteSpace(screenshotDirectory), "Screenshot directory must not be empty.");
+
+ Directory.CreateDirectory(screenshotDirectory);
+
+ var engine = new HnmPlaybackEngine();
+ await engine.LoadAsync(inputPath);
+
+ using WriteableBitmap bitmap = new(
+ new PixelSize(HnmPlaybackEngine.FrameWidth, HnmPlaybackEngine.FrameHeight),
+ new Vector(96, 96),
+ Avalonia.Platform.PixelFormat.Bgra8888,
+ AlphaFormat.Opaque);
+
+ int screenshotIndex = 0;
+ SaveScreenshot(engine, bitmap, Path.Combine(screenshotDirectory, FormatScreenshotName(screenshotIndex++)));
+
+ while (true)
+ {
+ HnmStepResult stepResult = engine.Step();
+ if (!stepResult.Advanced)
+ {
+ break;
+ }
+
+ if (stepResult.VisualChanged)
+ {
+ SaveScreenshot(engine, bitmap, Path.Combine(screenshotDirectory, FormatScreenshotName(screenshotIndex++)));
+ }
+ }
+ }
+
+ private static void SaveScreenshot(HnmPlaybackEngine engine, WriteableBitmap bitmap, string path)
+ {
+ engine.RenderCurrentFrame(bitmap);
+ WriteableBitmapToPng(bitmap, path);
+ }
+
+ private static void WriteableBitmapToPng(WriteableBitmap bitmap, string path)
+ {
+ using FileStream stream = File.Create(path);
+ bitmap.Save(stream);
+ }
+
+ private static string FormatScreenshotName(int index)
+ {
+ return $"frame{index:0000}.png";
+ }
+}
\ No newline at end of file
diff --git a/HnmPlayer/HnmPlayer.csproj b/HnmPlayer/HnmPlayer.csproj
index f715a53..40bb312 100644
--- a/HnmPlayer/HnmPlayer.csproj
+++ b/HnmPlayer/HnmPlayer.csproj
@@ -14,11 +14,15 @@
+
+
+
-
-
-
+
+
+
+
\ No newline at end of file
diff --git a/HnmPlayer/PlayerLaunchContext.cs b/HnmPlayer/PlayerLaunchContext.cs
new file mode 100644
index 0000000..7d1eb91
--- /dev/null
+++ b/HnmPlayer/PlayerLaunchContext.cs
@@ -0,0 +1,77 @@
+namespace logo;
+
+internal static class PlayerLaunchContext
+{
+ public static PlayerLaunchOptions Current { get; set; } = PlayerLaunchOptions.UiDefault;
+}
+
+public sealed record PlayerLaunchOptions(bool Headless, string? InputPath, string? ScreenshotDirectory)
+{
+ public static PlayerLaunchOptions UiDefault { get; } = new(false, null, null);
+}
+
+internal static class PlayerCommandLineParser
+{
+ public static PlayerLaunchOptions Parse(string[] args)
+ {
+ bool headless = false;
+ string? inputPath = null;
+ string? screenshotDirectory = null;
+
+ for (int i = 0; i < args.Length; i++)
+ {
+ string arg = args[i];
+ switch (arg)
+ {
+ case "--headless":
+ headless = true;
+ break;
+ case "--input":
+ inputPath = ReadValue(args, ref i, arg);
+ break;
+ case "--screenshot-dir":
+ screenshotDirectory = ReadValue(args, ref i, arg);
+ break;
+ default:
+ throw new InvalidOperationException($"Unknown argument '{arg}'.");
+ }
+ }
+
+ if (!headless)
+ {
+ return PlayerLaunchOptions.UiDefault;
+ }
+
+ if (string.IsNullOrWhiteSpace(inputPath))
+ {
+ throw new InvalidOperationException("Missing required argument --input for headless mode.");
+ }
+
+ if (string.IsNullOrWhiteSpace(screenshotDirectory))
+ {
+ throw new InvalidOperationException("Missing required argument --screenshot-dir for headless mode.");
+ }
+
+ return new PlayerLaunchOptions(
+ true,
+ Path.GetFullPath(inputPath),
+ Path.GetFullPath(screenshotDirectory));
+ }
+
+ private static string ReadValue(string[] args, ref int index, string argName)
+ {
+ if (index + 1 >= args.Length)
+ {
+ throw new InvalidOperationException($"Missing value after {argName}.");
+ }
+
+ index++;
+ string value = args[index];
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new InvalidOperationException($"Argument {argName} requires a non-empty value.");
+ }
+
+ return value;
+ }
+}
\ No newline at end of file
diff --git a/HnmPlayer/Program.cs b/HnmPlayer/Program.cs
index c3c3b70..e291acf 100644
--- a/HnmPlayer/Program.cs
+++ b/HnmPlayer/Program.cs
@@ -1,4 +1,5 @@
using Avalonia;
+using Avalonia.Headless;
namespace logo;
@@ -7,10 +8,23 @@ public static class Program
[STAThread]
public static void Main(string[] args)
{
- BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ PlayerLaunchOptions launchOptions = PlayerCommandLineParser.Parse(args);
+ PlayerLaunchContext.Current = launchOptions;
+ BuildAvaloniaApp(launchOptions).StartWithClassicDesktopLifetime(args);
}
- public static AppBuilder BuildAvaloniaApp()
- => AppBuilder.Configure()
- .UsePlatformDetect();
+ public static AppBuilder BuildAvaloniaApp(PlayerLaunchOptions launchOptions)
+ {
+ AppBuilder builder = AppBuilder.Configure();
+ if (launchOptions.Headless)
+ {
+ builder = builder.UseHeadless(new Avalonia.Headless.AvaloniaHeadlessPlatformOptions());
+ }
+ else
+ {
+ builder = builder.UsePlatformDetect();
+ }
+
+ return builder;
+ }
}
\ No newline at end of file
diff --git a/logo.csproj b/logo.csproj
index feb0939..7174913 100644
--- a/logo.csproj
+++ b/logo.csproj
@@ -10,6 +10,7 @@
+
@@ -20,9 +21,9 @@
-
-
-
+
+
+