diff --git a/docs/headless.md b/docs/headless.md index 75e83384..3ca6da92 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -83,9 +83,9 @@ builder.UseDefaultRendering(new WindowConfig(Size: (1280, 720), Title: "Hotbar") An agent usually needs no code change for this: `PIXELY_HEADLESS=1` switches any app to headless mode, see [Environment variables](#environment-variables). -`screenshot ` writes the last rendered frame as a PNG; the file exists once the command's frame has run. A path that cannot be written, or a screenshot before the first frame has rendered, throws out of the frame loop. Commands run before that frame's render, so a screenshot right after the commands it should show is one frame too early; put `wait 1;` before it. The capture waits for the GPU, so that frame takes longer. +`screenshot ` writes the last rendered frame as a PNG; the file exists once the command's frame has run. Missing directories of the path are created. A path that still cannot be written, such as one whose parent is a file, throws out of the frame loop. So does a screenshot before the first frame has rendered. Commands run before that frame's render, so a screenshot right after the commands it should show is one frame too early; put `wait 1;` before it. The capture waits for the GPU, so that frame takes longer. -The frame is also available to code through `OffscreenWindow.CaptureLastFrame()`, and `IImageWriter` saves a tightly packed, non-planar, non-indexed `Image` as a PNG; `Build()` registers the SDL one unless the app registered its own. +The frame is also available to code through `OffscreenWindow.CaptureLastFrame()`, and `IImageWriter` saves a tightly packed, non-planar, non-indexed `Image` as a PNG, creating missing directories; `Build()` registers the SDL one unless the app registered its own. ### Environment variables diff --git a/src/Pixely/Content/SdlImageWriter.cs b/src/Pixely/Content/SdlImageWriter.cs index 120589aa..836582f5 100644 --- a/src/Pixely/Content/SdlImageWriter.cs +++ b/src/Pixely/Content/SdlImageWriter.cs @@ -20,6 +20,12 @@ public unsafe void SavePng(Image image, string path) throw new NotSupportedException($"Only tightly packed images in a non-planar, non-indexed pixel format can be saved, not {image.PixelFormat} with {pixels.Length} bytes for {width}x{height}."); } + string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (directory != null) + { + Directory.CreateDirectory(directory); + } + fixed (byte* pixelsPointer = pixels) { Pointer surface = SDL3.SDL_CreateSurfaceFrom(width, height, format, (IntPtr)pixelsPointer, pitch); diff --git a/tests/Pixely.Tests/SdlImageWriterTests.cs b/tests/Pixely.Tests/SdlImageWriterTests.cs new file mode 100644 index 00000000..2308a35e --- /dev/null +++ b/tests/Pixely.Tests/SdlImageWriterTests.cs @@ -0,0 +1,42 @@ +using Pixely.Content; +using Pixely.Gpu; + +namespace Pixely.Tests; + +public class SdlImageWriterTests +{ + private DirectoryInfo _temporaryDirectory = null!; + + [SetUp] + public void SetUp() + { + _temporaryDirectory = Directory.CreateTempSubdirectory("Pixely.Tests-"); + } + + [TearDown] + public void TearDown() + { + _temporaryDirectory.Delete(recursive: true); + } + + [Test] + public void SavePng_CreatesMissingDirectories() + { + string path = Path.Combine(_temporaryDirectory.FullName, "frames", "run", "shot.png"); + + new SdlImageWriter().SavePng(CreateImage(), path); + + Assert.That(File.Exists(path), Is.True); + } + + [Test] + public void SavePng_WithParentThatIsAFile_Throws() + { + string parent = Path.Combine(_temporaryDirectory.FullName, "frames"); + File.WriteAllBytes(parent, []); + + Assert.That(() => new SdlImageWriter().SavePng(CreateImage(), Path.Combine(parent, "shot.png")), Throws.InstanceOf()); + } + + private static RawImage CreateImage() => new(new byte[2 * 2 * 4], new ShortSize(2, 2), PixelFormat.Rgba8888); +}