Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,23 @@ non-zero with the error printed. `--scale <n>` sets the output PNG's
pixel scale factor (default 8). `--size` / `--width` / `--height` pick
the panel size, same as `matrixbox app`.

`--style` picks how the frame is drawn:

- `pixels` (default): the raw frame, each pixel an NxN block.
- `panel`: glowing round LEDs in a dark housing, on a transparent
background, exactly like the browser renderer's panel view.
- `device`: the browser renderer's device mockup, cropped to the device.

```sh
matrixbox screenshot /path/to/matrixbox/apps/clock --style device -o clock.png
```

`panel` and `device` render through the real web page in a headless
browser, so they need [Playwright][playwright]
(`pip install 'matrixbox-simulator[screenshot]'`). An installed Chrome is
used if there is one, otherwise run `playwright install chromium` once.
For these, `--scale` is the size of one LED in pixels.

## Sandbox

Every `matrixbox app`/`screenshot` run stages a fresh copy of the
Expand Down Expand Up @@ -274,3 +291,4 @@ https://github.com/user-attachments/assets/d0655003-ce85-4c79-b668-5602e9f14155
[matrixbox-source]: https://github.com/MatrixBOX-dev/matrixbox
[circuitpython]: https://circuitpython.org/
[uv]: https://docs.astral.sh/uv/
[playwright]: https://playwright.dev/python/
57 changes: 54 additions & 3 deletions matrixbox_simulator/device/run_screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import io
import json
import os
import subprocess
import sys
import threading
import time
Expand All @@ -26,6 +27,8 @@
from matrixbox_simulator.device import frame_bridge, run_app
from matrixbox_simulator.sizes import ROTATION_OVERRIDES, SIZE_PRESETS

SCREENSHOT_STYLES = ("pixels", "panel", "device")


def build_parser(
parser: argparse.ArgumentParser | None = None,
Expand Down Expand Up @@ -84,7 +87,16 @@ def build_parser(
type=int,
default=8,
help="pixel scale factor for the output PNG: each device pixel is "
"drawn as an NxN block (default 8)",
"drawn as an NxN block, or an N pixel LED for --style panel/device "
"(default 8)",
)
parser.add_argument(
"--style",
choices=SCREENSHOT_STYLES,
default="pixels",
help="pixels: the raw frame, scaled up (default). panel: glowing "
"LEDs in a dark housing, like the web renderer. device: the web "
"renderer's 3D device mockup. panel and device need Playwright",
)
parser.add_argument(
"-o",
Expand Down Expand Up @@ -224,6 +236,38 @@ def _write_screenshot(
path.write_bytes(buffer.getvalue())


def _write_web_screenshot(
path: Path, width: int, height: int, rgb: bytes, *, style: str, scale: int
) -> None:
# A separate, unsandboxed process: this one has open()/stat() and
# friends redirected into the app's sandbox, which a browser driver
# can't run under. -P keeps the staged checkout's own modules (it has
# a code.py, for one) from shadowing the standard library there.
result = subprocess.run(
[
sys.executable,
"-P",
"-m",
"matrixbox_simulator.web.capture",
"--style",
style,
"--width",
str(width),
"--height",
str(height),
"--led-size",
str(scale),
"--output",
str(path),
],
input=rgb,
cwd=path.parent,
check=False,
)
if result.returncode != 0:
raise SystemExit(f"couldn't render the {style} screenshot")


def run(args: argparse.Namespace) -> None:
args.geometry_explicit = (
args.size is not None or args.width is not None or args.height is not None
Expand Down Expand Up @@ -355,10 +399,17 @@ def run_app_code() -> None:
f"{app_dir.name} never drew a frame within {args.timeout:.1f}s"
)

_write_screenshot(output, width, height, rgb, scale=args.scale)
if args.style == "pixels":
_write_screenshot(output, width, height, rgb, scale=args.scale)
else:
_write_web_screenshot(
output, width, height, rgb, style=args.style, scale=args.scale
)

print(
f"matrixbox-simulator: wrote {output} ({width}x{height}, "
f"{count} frame{'s' if count != 1 else ''} drawn, {args.scale}x scale)"
f"{count} frame{'s' if count != 1 else ''} drawn, {args.style} style, "
f"{args.scale}x scale)"
)


Expand Down
128 changes: 128 additions & 0 deletions matrixbox_simulator/web/capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Renders one frame through the web renderer's own page in a headless
browser and saves it as a PNG, so screenshots look exactly like the live
view. Needs Playwright (`pip install 'matrixbox-simulator[screenshot]'`).

Run as its own process by `matrixbox screenshot --style panel|device`,
with the frame's raw RGB bytes on stdin: the screenshot process itself
has file access patched into the app's sandbox, which a browser driver
can't run under.
"""

import argparse
import sys
from pathlib import Path

from matrixbox_simulator.sizes import panel_count_for
from matrixbox_simulator.term import wire
from matrixbox_simulator.web.server import WebServer

try:
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
except ImportError:
# Optional: only the panel and device screenshot styles need it.
PlaywrightError = Exception # ty: ignore[invalid-assignment]
sync_playwright = None

STYLES = ("panel", "device")
READY_TIMEOUT_MS = 15000

# Room around the panel or device; the page crops to it anyway, this
# only has to be big enough to never cut into it.
_VIEWPORT_MARGIN_LEDS = {"panel": 16, "device": 90}


class WebCapture:
def __init__(self, frame: wire.Frame, style: str, led_size: int) -> None:
self.frame = frame
self.style = style
self.led_size = led_size

def save(self, output: Path) -> None:
server = WebServer("127.0.0.1", 0)
server.start()
server.publish(wire.encode_frame(self.frame))
server.publish_status(connected=True, message="screenshot")

try:
self._screenshot(f"http://127.0.0.1:{server.port}/", output)
finally:
server.stop()

def viewport_size(self) -> tuple[int, int]:
margin = _VIEWPORT_MARGIN_LEDS[self.style]

return (
(self.frame.width + margin) * self.led_size,
(self.frame.height + margin) * self.led_size,
)

def _screenshot(self, base_url: str, output: Path) -> None:
if sync_playwright is None:
raise SystemExit(
"--style panel/device needs Playwright: "
"pip install 'matrixbox-simulator[screenshot]'"
)

with sync_playwright() as playwright:
try:
# The installed Chrome first, so nothing extra has to be
# downloaded, then Playwright's own Chromium.
browser = playwright.chromium.launch(channel="chrome")
except PlaywrightError:
try:
browser = playwright.chromium.launch()
except PlaywrightError as err:
raise SystemExit(
"no browser for --style panel/device: install Chrome, "
"or run `playwright install chromium`"
) from err

try:
width, height = self.viewport_size()
page = browser.new_page(
viewport={"width": width, "height": height}, color_scheme="dark"
)
page.goto(f"{base_url}?view={self.style}&capture=1&led={self.led_size}")
page.wait_for_selector("body[data-ready]", timeout=READY_TIMEOUT_MS)
clip = page.evaluate("window.matrixboxCaptureBounds()")
output.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(
path=str(output),
clip=clip,
omit_background=self.style == "panel",
)
finally:
browser.close()


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--style", choices=STYLES, required=True)
parser.add_argument("--width", type=int, required=True)
parser.add_argument("--height", type=int, required=True)
parser.add_argument("--led-size", type=int, default=8)
parser.add_argument("-o", "--output", required=True)

return parser


def main() -> None:
args = build_parser().parse_args()
rgb = sys.stdin.buffer.read()
if len(rgb) != args.width * args.height * 3:
raise SystemExit(
f"expected {args.width * args.height * 3} bytes of RGB, got {len(rgb)}"
)

frame = wire.Frame(
width=args.width,
height=args.height,
pixels=rgb,
tiles=panel_count_for(args.width, args.height),
)
WebCapture(frame, args.style, args.led_size).save(Path(args.output))


if __name__ == "__main__":
main()
68 changes: 65 additions & 3 deletions matrixbox_simulator/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,10 @@ class CableRope {
this.restingFrames = 0;
}

settled() {
return this.points.length > 0 && !this.running;
}

start() {
this.points = [];
this.endY = 0;
Expand Down Expand Up @@ -701,15 +705,68 @@ class SimulatorPage {
this.viewButtons = document.querySelectorAll("[data-view]");
this.drawPending = false;
this.lastFrameSize = "";
this.view =
new URLSearchParams(window.location.search).get("view") ?? this.preferences.read("view", "panel");
const params = new URLSearchParams(window.location.search);
this.view = params.get("view") ?? this.preferences.read("view", "panel");
// Screenshot mode: just the panel or device at a fixed LED size, no
// page chrome, flagging when it's safe to capture.
this.capture = params.get("capture") === "1" ? { ledCssSize: Number(params.get("led")) || 8 } : null;

this.renderer.showSeams = this.preferences.read("seams", "off") === "on";
this.seamsToggle.checked = this.renderer.showSeams;
this.renderAppControls();
this.bindControls();
this.setView(this.view);
this.connect();
if (this.capture) {
this.prepareCapture();
}
}

prepareCapture() {
document.body.classList.add("capture", `capture-${this.view}`);
window.matrixboxCaptureBounds = () => this.captureBounds();

const markReadyOnceSettled = () => {
const drawn = this.renderer.frame && this.renderer.ledSize;
if (drawn && (this.view !== "device" || this.cable.settled())) {
document.body.dataset.ready = "true";
return;
}

window.requestAnimationFrame(markReadyOnceSettled);
};
window.requestAnimationFrame(markReadyOnceSettled);
}

// The region worth keeping: the panel itself, or the device with a
// little room around it, leaving the cable to run off the edge.
captureBounds() {
const elements =
this.view === "device"
? document.querySelectorAll("#box .face, #plug .plug-side, #device-button .cylinder-top")
: [this.renderer.canvas];
const margin = this.view === "device" ? this.capture.ledCssSize * 5 : 0;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const element of elements) {
const rect = element.getBoundingClientRect();
left = Math.min(left, rect.left);
top = Math.min(top, rect.top);
right = Math.max(right, rect.right);
bottom = Math.max(bottom, rect.bottom);
}

const x = Math.max(0, Math.floor(left - margin));
const y = Math.max(0, Math.floor(top - margin));

return {
x,
y,
width: Math.min(window.innerWidth, Math.ceil(right + margin)) - x,
height: Math.min(window.innerHeight, Math.ceil(bottom + margin)) - y,
};
}

renderAppControls() {
Expand Down Expand Up @@ -784,7 +841,8 @@ class SimulatorPage {
const enclosureLeds = DEVICE_BEZEL_LEDS * 2 + DEVICE_DEPTH_LEDS;
const ledsWide = frame.width + enclosureLeds;
const ledsHigh = frame.height + enclosureLeds;
const ledCssSize = Math.min((bounds.width * 0.86) / ledsWide, (bounds.height * 0.8) / ledsHigh);
const ledCssSize =
this.capture?.ledCssSize ?? Math.min((bounds.width * 0.86) / ledsWide, (bounds.height * 0.8) / ledsHigh);
// A hair over, so rounding down to whole device pixels lands on it.
this.renderer.layout(frame.width * ledCssSize + 0.01, frame.height * ledCssSize + 0.01);
const canvas = this.renderer.canvas;
Expand All @@ -794,6 +852,10 @@ class SimulatorPage {
this.renderer.cssLedSize(),
);
this.cable.start();
} else if (this.capture) {
const housing = PANEL_HOUSING_LEDS * 2;
const led = this.capture.ledCssSize;
this.renderer.layout((frame.width + housing) * led + 0.01, (frame.height + housing) * led + 0.01);
} else {
this.renderer.layout(bounds.width - 32, bounds.height - 32);
}
Expand Down
17 changes: 17 additions & 0 deletions matrixbox_simulator/web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ body {
font-size: 14px;
}

body.capture {
grid-template-rows: 1fr;
}

body.capture-panel {
background: transparent;
}

.capture .toolbar,
.capture .footer {
display: none;
}

.capture .panel-view .leds {
filter: none;
}

[hidden] {
display: none !important;
}
Expand Down
Loading
Loading