Skip to content

tools: snap-to-png.py — headless widget-tree rendering for review/iteration (no display needed) - #190

Open
kkkkikun wants to merge 2 commits into
OctoSense-org:mainfrom
kkkkikun:add/snap-to-png-tool
Open

kkkkikun wants to merge 2 commits into
OctoSense-org:mainfrom
kkkkikun:add/snap-to-png-tool

Conversation

@kkkkikun

@kkkkikun kkkkikun commented Oct 11, 2026 •

Copy link
Copy Markdown

What

Add tools/snap-to-png.py: render a running script app's live /snap widget tree to a PNG — for review and iteration on machines without a graphical session (headless CI, WSL2, remote boxes), where tools/octo shot cannot read back host pixels.

Why

octo shot needs the render backend to produce frames; on headless machines it times out ("grab timeout"), so a working app cannot show any picture of itself. The widget tree (/snap) is fully available headless — this tool draws it honestly: labels, buttons and text inputs at their real positions with their real text.

Honest limitation (stated in the header, the output line and the README row): this is a redraw of the live widget tree, not host pixels — the palette is neutral, and anything a virtualized list has scrolled out of the viewport is (correctly) absent, which makes the tool double as a "what is on screen right now" probe.

Usage

python3 tools/snap-to-png.py --port 8141 --out shot.png [--width 412] [--height 892] [--note "caption"]

Requires Pillow. CJK-capable fonts are picked from common system paths when present.

Battle-tested

Developed and used through a full GOSIM Agentic App Hackathon project (kkkkikun/qianxian-guardian): an app-specific ancestor of this tool produced all submission screenshots and ~30 regression rounds' worth of "what changed on screen" evidence, including on WSL2 where host-pixel grabs are unavailable. This PR generalizes it: neutral palette (no app-specific theming), size flags, and a caption option.

Checklist

  • python3 tools/snap-to-png.py --port <port> --out x.png against a running card-host (verified live)
  • README tools table row added
  • Honest redraw-vs-pixels labeling in tool header and output

…without a display

On headless machines (CI, WSL2, remote boxes), tools/octo shot cannot grab
host pixels. This tool renders the live /snap widget tree (labels, buttons,
text inputs at real positions with real text) to a PNG for review and
iteration — honestly labeled as a redraw, not host pixels.

Battle-tested through a full hackathon project: ~30 regression rounds and all
submission screenshots were produced with an earlier app-specific version.

Usage: python3 tools/snap-to-png.py --port 8141 --out shot.png

@BH3GEI BH3GEI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together — the headless gap is real (/g answers "grab timeout" when there is no window server to render), and building the picture from the live widget tree instead of host pixels is the right shape for it.

I verified the tool end to end: built card-host from App Hub main against the locked runtime (6cf2d90a), ran a fresh octo new app with --hidden --detach, and compared the tool's PNG against a real octo shot of the same frame — title, input, button and list positions and text all match; typing into the input and clicking Add both re-render correctly; the card-studio /snap fixtures (clipped-phone, digest-glance, clipped-glance) render correctly. The Splash filter is doing real work (the card container carries the whole script source as its t), and so is the caption filter (with the caption visible, the "Card host [remote]" label has a live rect at [158,5,119,22]). python -m unittest discover -s tools (48 tests) and tools/check-links.py still pass on the branch.

Three concerns before this could be approved:

  1. Password inputs render their cleartext value — tools/snap-to-png.py:101-103. In the /snap payload, val is the widget's raw text (widget_tree.rs snapshot: value = Some(widget.text()), which for TextInput is the unobfuscated content), while t is display_text() (makepad text_input.rs:921: password inputs come back as •). shown = val if val else text prefers the raw value, so a password field draws its cleartext into a PNG whose purpose is sharing. Note that t alone already carries everything this branch needs — non-empty input renders the text itself, empty renders the placeholder — so the val preference has no upside and this one downside. Reproduced with a payload shaped exactly as the serializer emits it (password TextInput with t: "••••••••", val: "hunter2-super-secret"): the output PNG shows the cleartext.

  2. The ancestor app's text heuristics are still in the "generalized" tool — tools/snap-to-png.py:105. text.startswith(("AI", "已", "把", "(")) and wid in ("hint",) demote matching labels to the small grey style. These look like qianxian-guardian's status-line prefixes ("hint" is not a platform id — its only other appearance in the repo is an unrelated example app's widget). On any other app they hit ordinary content: I rendered AI regulation news, 已完成的任务清单, 把数据同步打开, (注释)说明文字 — all four come out demoted, while Normal body text renders normally. That contradicts the PR's "no app-specific theming" claim; could this branch come out (or move behind a flag)?

  3. The redraw is labelled everywhere except in the PNG itself. The honest "not host pixels" framing lives in the docstring, the console line and the README row — all outside the artifact. The PNG defaults to 412x892 (the card-host window size), styled credibly, and --note is optional and arbitrary. docs/PUBLISHING.md is explicit that "the gate does not judge whether the screenshots are real captures … Reviewers do" — and at 1x a redraw is indistinguishable from an octo shot capture. Since screenshots are review evidence in this repo's flow, could the tool mark the artifact itself (e.g. a small permanent corner caption like widget-tree redraw)? That would let the honesty claim travel with the file.

Smaller notes, none blocking on their own:

  • README.zh-CN.md has the mirrored tools table (lines 365-367) but no row for the new tool — the EN and zh tables have been kept in sync so far.
  • FONT_PATHS lists Linux paths only, so on macOS/Windows CJK falls back to the bitmap font and renders as tofu (confirmed on macOS). /System/Library/Fonts/PingFang.ttc and C:\Windows\Fonts\msyh.ttc would cover the other platforms the repo runs CI on.
  • Failure modes print raw tracebacks (nothing listening on the port, non-JSON reply, missing "s" key) and --out into a missing directory raises; the octo family prints a one-line actionable hint and mkdirs the output parent.
  • The output line counted 34 widgets for the clipped-phone fixture while 13 actually get drawn (the count is taken before the rect/text draw filters).
  • Text truncation ([:24]/[:40]/[:60]) has no ellipsis, and a label clipped to a narrow rect still draws its full text (the 12-px-wide label in clipped-glance paints its whole headline), which sits a little oddly next to the "correctly absent" framing.

The core path — payload contract, live refresh, ordering, the Splash/caption filters — checks out against the serializer source and real captures; with the three concerns above addressed this would be a welcome addition.

…ling)

Three review concerns:

1. Password inputs no longer leak cleartext: the raw snapshot value
   (val) is never painted — text inputs draw the snapshot's display
   text (t), which is the • mask for passwords. val now serves only
   as an is-it-empty flag for ink color.
2. App-specific text heuristics removed: no more startswith prefixes
   or widget-id demotion carried over from the ancestor project;
   styling is uniform (title rule + body), per the neutral-palette claim.
3. The artifact labels itself: a permanent bottom band reads
   "widget-tree redraw · not host pixels", drawn last so content
   cannot overpaint it — the honesty claim now travels with the PNG.

Smaller notes:

- README.zh-CN.md tools table gained the mirrored row (kept in sync).
- FONT_PATHS covers macOS (PingFang/STHeiti) and Windows (msyh/simhei)
  so CJK no longer falls back to tofu there.
- Failure modes print one-line actionable hints (unreachable port /
  unexpected payload) and --out parents are mkdir'd.
- The output line now counts drawn widgets ("N drawn of M").
- Truncation is width-based with an ellipsis, and clipped narrow
  labels no longer paint their full text.

Verified: password-shaped payload renders the mask (not the secret),
the four formerly-demoted prefixes render as normal body text,
python -m unittest discover -s tools (48) and tools/check-links.py
pass, and a live card-host render redraws correctly.
@kkkkikun

Copy link
Copy Markdown
Author

Thanks for the thorough review — all three concerns are addressed in fcb4e2a:

  1. Raw input values are never painted anymore; text inputs draw only the snapshot's display text (the • mask for passwords), with val reduced to an is-it-empty flag. Reproduced your password payload: the PNG shows the mask, not the secret.
  2. The ancestor app's heuristics are gone entirely — no prefix/id demotion; your four counterexamples render as normal body text now.
  3. The PNG now carries a permanent bottom band, "widget-tree redraw · not host pixels", drawn last so it can't be overpainted. Smaller notes are in too: zh README row, macOS/Windows font paths, one-line actionable errors + output-dir mkdir, post-filter draw count, and width-based ellipsis truncation (narrow clipped labels no longer paint full text). 48 unittests + check-links still green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants