feat(studio): headless --screenshot mode (render panel to PNG + exit)

Add `--screenshot <path>` to the UI Studio: when set the window is
created hidden (WindowOptions.IsVisible = false), the panel is loaded
and rendered into PanelFbo on the first OnRender tick, pixels are read
back with the new PanelFbo.ReadColorRgba(), rows are flipped (GL
bottom-left → PNG top-left), and the result is saved via ImageSharp
SaveAsPng before calling _window.Close().

Render size is derived from the loaded root's Width/Height (clamped
256–2048 px); falls back to 1280×720 when the root has no explicit
size.  In headless mode ImGui, the inspector, and input wiring are
all skipped — only PanelFbo is created.  Interactive path is
unchanged.  SixLabors.ImageSharp 3.1.12 was already a direct dep of
AcDream.App (Phase O-T7); no new PackageReference needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 16:36:10 +02:00
parent e3de7f0dab
commit f778b100b7
3 changed files with 133 additions and 24 deletions

View file

@ -67,6 +67,34 @@ public sealed unsafe class PanelFbo : IDisposable
return _colorTex; return _colorTex;
} }
/// <summary>
/// Read the FBO color attachment back to CPU as a flat RGBA8 byte array.
/// Must be called AFTER <see cref="Render"/> for the same <paramref name="width"/> and
/// <paramref name="height"/> (so the FBO exists and is the right size).
///
/// <para>FBO origin is bottom-left (GL convention). The caller is responsible for
/// flipping rows vertically before saving as a top-left-origin image format (PNG).</para>
///
/// <para>Returns an empty array when the FBO is not ready.</para>
/// </summary>
public unsafe byte[] ReadColorRgba(int width, int height)
{
if (_fbo == 0 || width <= 0 || height <= 0) return Array.Empty<byte>();
int byteCount = width * height * 4;
var buf = new byte[byteCount];
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
fixed (byte* p = buf)
{
_gl.ReadPixels(0, 0, (uint)width, (uint)height,
PixelFormat.Rgba, PixelType.UnsignedByte, p);
}
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
return buf;
}
// ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ────── // ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ──────
private void EnsureFramebuffer(int width, int height) private void EnsureFramebuffer(int width, int height)

View file

@ -9,8 +9,9 @@ public sealed record StudioOptions(
string DatDir, string DatDir,
uint? LayoutId, uint? LayoutId,
string? MarkupPath, string? MarkupPath,
string? DumpSlug = null, string? DumpSlug = null,
string? DumpFile = null) string? DumpFile = null,
string? ScreenshotPath = null)
{ {
/// <summary> /// <summary>
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token. /// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
@ -24,16 +25,20 @@ public sealed record StudioOptions(
/// <para><c>--dump-file &lt;path&gt;</c>: override the default dump file path /// <para><c>--dump-file &lt;path&gt;</c>: override the default dump file path
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c> from the solution root). /// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c> from the solution root).
/// Only meaningful when <c>--dump</c> is also given.</para> /// Only meaningful when <c>--dump</c> is also given.</para>
/// <para><c>--screenshot &lt;path&gt;</c>: headless mode — render the loaded panel to a PNG
/// at <paramref name="path"/> and exit without showing an interactive window.
/// Combines with <c>--dump</c> or <c>--layout</c>.</para>
/// <para>When neither <c>--layout</c>, <c>--markup</c>, nor <c>--dump</c> is given the /// <para>When neither <c>--layout</c>, <c>--markup</c>, nor <c>--dump</c> is given the
/// default layout <c>0x2100006C</c> (vitals) is used.</para> /// default layout <c>0x2100006C</c> (vitals) is used.</para>
/// </summary> /// </summary>
public static StudioOptions Parse(string[] args) public static StudioOptions Parse(string[] args)
{ {
string? datDir = null; string? datDir = null;
uint? layoutId = null; uint? layoutId = null;
string? markupPath = null; string? markupPath = null;
string? dumpSlug = null; string? dumpSlug = null;
string? dumpFile = null; string? dumpFile = null;
string? screenshotPath = null;
for (int i = 0; i < args.Length; i++) for (int i = 0; i < args.Length; i++)
{ {
@ -58,6 +63,10 @@ public sealed record StudioOptions(
{ {
dumpFile = args[++i]; dumpFile = args[++i];
} }
else if (args[i] == "--screenshot" && i + 1 < args.Length)
{
screenshotPath = args[++i];
}
else if (!args[i].StartsWith('-')) else if (!args[i].StartsWith('-'))
{ {
datDir ??= args[i]; datDir ??= args[i];
@ -75,7 +84,7 @@ public sealed record StudioOptions(
if (layoutId is null && markupPath is null && dumpSlug is null) if (layoutId is null && markupPath is null && dumpSlug is null)
layoutId = 0x2100006Cu; layoutId = 0x2100006Cu;
return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile); return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath);
} }
/// <summary> /// <summary>

View file

@ -7,6 +7,8 @@ using Silk.NET.Input;
using Silk.NET.Maths; using Silk.NET.Maths;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
using Silk.NET.Windowing; using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using static Silk.NET.OpenGL.ClearBufferMask; using static Silk.NET.OpenGL.ClearBufferMask;
namespace AcDream.App.Studio; namespace AcDream.App.Studio;
@ -51,6 +53,10 @@ public sealed class StudioWindow : IDisposable
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly. // so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
private AcDream.Core.Items.ClientObjectTable? _objects; private AcDream.Core.Items.ClientObjectTable? _objects;
// Headless screenshot mode: set when --screenshot was passed.
// True after the first OnRender fires the screenshot (guard against repeat).
private bool _screenshotDone;
public StudioWindow(StudioOptions opts) public StudioWindow(StudioOptions opts)
{ {
_opts = opts ?? throw new ArgumentNullException(nameof(opts)); _opts = opts ?? throw new ArgumentNullException(nameof(opts));
@ -83,6 +89,10 @@ public sealed class StudioWindow : IDisposable
// MSAA from quality preset — must be baked into the GL context at creation. // MSAA from quality preset — must be baked into the GL context at creation.
Samples = startupQuality.MsaaSamples, Samples = startupQuality.MsaaSamples,
PreferredStencilBufferBits = 8, PreferredStencilBufferBits = 8,
// Headless screenshot mode: hide the window so no desktop flash occurs.
// The GL context is still fully valid on a hidden window; FBO rendering
// is off-screen and independent of window visibility.
IsVisible = _opts.ScreenshotPath is null,
}; };
_window = Window.Create(options); _window = Window.Create(options);
@ -96,7 +106,6 @@ public sealed class StudioWindow : IDisposable
private void OnLoad() private void OnLoad()
{ {
var gl = GL.GetApi(_window!); var gl = GL.GetApi(_window!);
var input = _window!.CreateInput();
_dats = new DatCollection(_opts.DatDir, DatAccessType.Read); _dats = new DatCollection(_opts.DatDir, DatAccessType.Read);
@ -110,12 +119,6 @@ public sealed class StudioWindow : IDisposable
_stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality)); _stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality));
// Wire input into UiHost.
foreach (var mouse in input.Mice)
_stack.UiHost.WireMouse(mouse);
foreach (var kb in input.Keyboards)
_stack.UiHost.WireKeyboard(kb);
// Load the panel described by options and add it to the UI tree. // Load the panel described by options and add it to the UI tree.
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers). // Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
// All other modes use LayoutSource + FixtureProvider (production path). // All other modes use LayoutSource + FixtureProvider (production path).
@ -161,22 +164,91 @@ public sealed class StudioWindow : IDisposable
} }
} }
// Task 3: ImGui IDE. The studio is always "devtools" — no gate flag needed. // Task 3: ImGui IDE — interactive mode only.
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input); // Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
_panelFbo = new PanelFbo(gl); _panelFbo = new PanelFbo(gl);
_inspector = new StudioInspector(); if (_opts.ScreenshotPath is null)
{
var input = _window!.CreateInput();
// Wire input into UiHost (interactive only).
foreach (var mouse in input.Mice)
_stack.UiHost.WireMouse(mouse);
foreach (var kb in input.Keyboards)
_stack.UiHost.WireKeyboard(kb);
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
_inspector = new StudioInspector();
}
} }
private void OnUpdate(double dt) { } private void OnUpdate(double dt) { }
private void OnRender(double dt) private void OnRender(double dt)
{ {
if (_stack is null || _imgui is null || _panelFbo is null || _inspector is null) if (_stack is null || _panelFbo is null) return;
// ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
if (_opts.ScreenshotPath is not null)
{
if (_screenshotDone) return; // fire exactly once
_screenshotDone = true;
// Pick render size from the loaded root's bounds (clamped to sane limits).
// Fall back to 1280×720 when the root has no explicit size.
int w = 1280, h = 720;
if (_panelRoot is not null)
{
float rw = _panelRoot.Width;
float rh = _panelRoot.Height;
if (rw >= 1f && rh >= 1f)
{
w = Math.Clamp((int)rw, 256, 2048);
h = Math.Clamp((int)rh, 256, 2048);
}
}
// Tick once so widget state is initialised (e.g. bar fills).
_stack.UiHost.Tick(dt);
// Render the panel into the FBO.
_panelFbo.Render(w, h, _stack.UiHost);
// Read back RGBA pixels (FBO origin = bottom-left).
byte[] pixels = _panelFbo.ReadColorRgba(w, h);
if (pixels.Length == 0)
{
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
_window?.Close();
return;
}
// Flip rows vertically: FBO bottom-left → PNG top-left.
int stride = w * 4;
byte[] flipped = new byte[pixels.Length];
for (int row = 0; row < h; row++)
{
System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride);
}
// Build ImageSharp image and save as PNG.
var path = _opts.ScreenshotPath;
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
using var img = Image.LoadPixelData<Rgba32>(flipped, w, h);
img.SaveAsPng(path);
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
_window?.Close();
return; return;
}
// ── INTERACTIVE PATH ──────────────────────────────────────────────────────
if (_imgui is null || _inspector is null) return;
var gl = _stack.Gl; var gl = _stack.Gl;
int w = _window!.Size.X; int iw = _window!.Size.X;
int h = _window!.Size.Y; int ih = _window!.Size.Y;
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta). // 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
_stack.UiHost.Tick(dt); _stack.UiHost.Tick(dt);
@ -184,7 +256,7 @@ public sealed class StudioWindow : IDisposable
// 2. Render the panel into the off-screen FBO; get the color texture. // 2. Render the panel into the off-screen FBO; get the color texture.
// The FBO is the same logical size as the window, so element rects map 1:1 to // The FBO is the same logical size as the window, so element rects map 1:1 to
// FBO pixels — no scale factor needed when displaying the canvas at full size. // FBO pixels — no scale factor needed when displaying the canvas at full size.
uint panelTex = _panelFbo.Render(w, h, _stack.UiHost); uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost);
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes). // 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f); gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
@ -196,7 +268,7 @@ public sealed class StudioWindow : IDisposable
// 5. Canvas pane — show the FBO texture; detect clicks. // 5. Canvas pane — show the FBO texture; detect clicks.
(int x, int y)? click = null; (int x, int y)? click = null;
if (panelTex != 0) if (panelTex != 0)
click = _inspector.DrawCanvas((nint)panelTex, w, h); click = _inspector.DrawCanvas((nint)panelTex, iw, ih);
// 6. If the user clicked inside the canvas, hit-test the whole UI tree (UiRoot.Pick honors // 6. If the user clicked inside the canvas, hit-test the whole UI tree (UiRoot.Pick honors
// Z-order + modal exclusivity) and select the topmost element. The canvas click coord is // Z-order + modal exclusivity) and select the topmost element. The canvas click coord is