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 @@ - - - + + +