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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ jobs:
- run: uv run ruff format --check .
- run: uv run ruff check .
- run: uv run ty check .
- run: uv run pytest
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,31 @@ full brightness would be blinding in person; a color that's already
it. Start around 5.0 for a similarly low "should read as white" value
and adjust live with `[`/`]` for anything not listed.

## Screenshots

For CI, PR previews, or anywhere else a terminal isn't available:

```sh
uv run matrixbox screenshot clock -o clock.png
```

Boots one app headlessly (no terminal, no button listener), waits for it
to draw, writes the result to a PNG, and exits — no second terminal or
`--connect` needed. `--settings <name>` seeds it with a settings file
before boot, resolved inside the app's own directory (e.g. `--settings
ci.json` for `apps/clock/ci.json`); omit it to boot with plain defaults.
Always starts from a clean, reset state, regardless of whatever an
earlier `uv run matrixbox app` run against the same app may have saved.

By default it captures as soon as one frame is drawn, waiting up to 5
seconds; `--after-frames <n>` and `--timeout <seconds>` adjust both,
whichever is reached first. An app that never draws in time, or raises
while starting up, exits non-zero with the error printed, so a CI job
fails loudly instead of shipping a blank or stale image. `--scale <n>`
sets the output PNG's pixel scale factor (default 8, so a 128x32 panel
becomes a 1024x256 image). `--size` / `--width` / `--height` pick the
panel size, same as `matrixbox app` (see "Panel sizes" above).

## Useful flags

`uv run matrixbox app`: `--size` or `--width` / `--height` for panel
Expand All @@ -248,6 +273,8 @@ and start fresh, `--refresh-fps` / `--gamma` (see above).
`--device` or `--width` / `--height` for the demo/placeholder size (see
"Panel sizes" above), `--fps` (demo mode only).

`uv run matrixbox screenshot`: see "Screenshots" above.

## Limitations

- `Group(scale=...)` is accepted but not honored: nothing renders
Expand Down
18 changes: 15 additions & 3 deletions matrixbox_simulator/cli.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Single `matrixbox` entrypoint, with `app` and `simulator` as subcommands."""
"""Single `matrixbox` entrypoint, with `app`, `simulator`, and `screenshot`
as subcommands."""

import argparse

from matrixbox_simulator.device import run_app
from matrixbox_simulator.device import run_app, run_screenshot
from matrixbox_simulator.term import run_simulator


Expand All @@ -24,12 +25,23 @@ def main() -> None:
description=run_simulator.__doc__,
)
)
run_screenshot.build_parser(
subparsers.add_parser(
"screenshot",
help="boot an app headlessly and save one rendered frame to a PNG",
description=run_screenshot.__doc__,
)
)

args = parser.parse_args()
if args.command == "app":
run_app.run(args)
else:
elif args.command == "screenshot":
run_screenshot.run(args)
elif args.command == "simulator":
run_simulator.run(args)
else:
raise AssertionError(f"unhandled command: {args.command!r}")


if __name__ == "__main__":
Expand Down
9 changes: 9 additions & 0 deletions matrixbox_simulator/device/frame_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import struct
import sys
import time
from collections.abc import Callable

try:
import resource
Expand Down Expand Up @@ -40,6 +41,11 @@ def __init__(self) -> None:
self._last_rgb: bytes | None = None
self._smoothed_interval: float | None = None

# Set by headless callers (screenshot mode) that need a composited
# frame directly, without standing up a real renderer to decode it
# back off the wire.
self.on_publish: Callable[[int, int, bytes], None] | None = None

def start(self, host: str = "127.0.0.1", port: int = 9191) -> wsserver.FrameServer:
if self._server is None:
self._server = wsserver.FrameServer(host, port)
Expand Down Expand Up @@ -97,6 +103,9 @@ def publish(self, width: int, height: int, rgb: bytes) -> None:
header = struct.pack("<BBHHBB", 0xF3, 1, width, height, 0, self._tiles)
self._server.broadcast(header + bytes(rgb), kind="frame")

if self.on_publish is not None:
self.on_publish(width, height, rgb)

self._publish_stats()

def _publish_stats(self) -> None:
Expand Down
Loading