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

@ -7,6 +7,8 @@ using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using static Silk.NET.OpenGL.ClearBufferMask;
namespace AcDream.App.Studio;
@ -51,6 +53,10 @@ public sealed class StudioWindow : IDisposable
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
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)
{
_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.
Samples = startupQuality.MsaaSamples,
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);
@ -96,7 +106,6 @@ public sealed class StudioWindow : IDisposable
private void OnLoad()
{
var gl = GL.GetApi(_window!);
var input = _window!.CreateInput();
_dats = new DatCollection(_opts.DatDir, DatAccessType.Read);
@ -110,12 +119,6 @@ public sealed class StudioWindow : IDisposable
_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.
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
// 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.
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
_panelFbo = new PanelFbo(gl);
_inspector = new StudioInspector();
// Task 3: ImGui IDE — interactive mode only.
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
_panelFbo = new PanelFbo(gl);
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 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;
}
// ── INTERACTIVE PATH ──────────────────────────────────────────────────────
if (_imgui is null || _inspector is null) return;
var gl = _stack.Gl;
int w = _window!.Size.X;
int h = _window!.Size.Y;
int iw = _window!.Size.X;
int ih = _window!.Size.Y;
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
_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.
// 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.
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).
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.
(int x, int y)? click = null;
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
// Z-order + modal exclusivity) and select the topmost element. The canvas click coord is