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:
parent
e3de7f0dab
commit
f778b100b7
3 changed files with 133 additions and 24 deletions
|
|
@ -67,6 +67,34 @@ public sealed unsafe class PanelFbo : IDisposable
|
|||
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) ──────
|
||||
|
||||
private void EnsureFramebuffer(int width, int height)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ public sealed record StudioOptions(
|
|||
string DatDir,
|
||||
uint? LayoutId,
|
||||
string? MarkupPath,
|
||||
string? DumpSlug = null,
|
||||
string? DumpFile = null)
|
||||
string? DumpSlug = null,
|
||||
string? DumpFile = null,
|
||||
string? ScreenshotPath = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <path></c>: override the default dump file path
|
||||
/// (<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>
|
||||
/// <para><c>--screenshot <path></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
|
||||
/// default layout <c>0x2100006C</c> (vitals) is used.</para>
|
||||
/// </summary>
|
||||
public static StudioOptions Parse(string[] args)
|
||||
{
|
||||
string? datDir = null;
|
||||
uint? layoutId = null;
|
||||
string? markupPath = null;
|
||||
string? dumpSlug = null;
|
||||
string? dumpFile = null;
|
||||
string? datDir = null;
|
||||
uint? layoutId = null;
|
||||
string? markupPath = null;
|
||||
string? dumpSlug = null;
|
||||
string? dumpFile = null;
|
||||
string? screenshotPath = null;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
|
|
@ -58,6 +63,10 @@ public sealed record StudioOptions(
|
|||
{
|
||||
dumpFile = args[++i];
|
||||
}
|
||||
else if (args[i] == "--screenshot" && i + 1 < args.Length)
|
||||
{
|
||||
screenshotPath = args[++i];
|
||||
}
|
||||
else if (!args[i].StartsWith('-'))
|
||||
{
|
||||
datDir ??= args[i];
|
||||
|
|
@ -75,7 +84,7 @@ public sealed record StudioOptions(
|
|||
if (layoutId is null && markupPath is null && dumpSlug is null)
|
||||
layoutId = 0x2100006Cu;
|
||||
|
||||
return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile);
|
||||
return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue