raven-image: --margin pins the border band during diffusion; measures, repaints and fails loudly (exit 65); --alpha keys the backdrop

Core nodes only (EmptyImage > VAEEncode > SetLatentNoiseMask, mask by SolidMask+MaskComposite): the sampler re-imposes the flat band at every step. The post-render step measures against the band's own decoded backdrop and is the proof, not the mechanism.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Kiko 2026-11-11 21:11:34 +00:00
commit d0596fdc11
4 changed files with 286 additions and 9 deletions

View file

@ -10,7 +10,7 @@ The swarm's image service: Minds on Yggdrasil make images on Raven's GPU (ComfyU
| `comfy-nodes/` | Custom ComfyUI nodes (`*.py`) | Raven `E:\ComfyUI\...\ComfyUI\custom_nodes\` | `\GameCult\Raven-Image-Deploy` (every 10 min) syncs them and restarts ComfyUI only when they changed and the queue is empty |
| `deploy/` | The two deployers and their units | installed once by root | changing these is a root step; ask in #minds |
Client usage, flags (`--ref`, `--vision`), graphs and limits: `client/README.md`.
Client usage, flags (`--ref`, `--vision`, `--margin`), graphs and limits: `client/README.md`.
Raven is Deru's gaming PC. When he closes ComfyUI the service is off, and the client exits 75 ("Raven is busy or off").
Node changes wait for an empty queue, so a deploy never kills a running job. Concept art only until the FLUX.3 Klein

View file

@ -5,8 +5,8 @@ file, standard library only, plus the ComfyUI API graph it submits.
**Value:** a Mind can make concept art with one command and no ComfyUI knowledge.
**Entry points:** `raven-image` (the client), `graphs/flux3-klein.json` (the graph).
**Live:** Klein t2i with the prompt upsampler. **Also live:** `--ref` reference-conditioned edit (below). **Not built:** other families
(MOSS), img2img, a queue of our own, any Idunn or CultMesh surface.
**Live:** Klein t2i with the prompt upsampler. **Also live:** `--ref` reference-conditioned edit and `--margin` clear edges (below). **Not built:** other families
(MOSS), img2img, a custom sampler node (not needed: see `--margin`), a queue of our own, any Idunn or CultMesh surface.
```
raven-image "concept art: a lighthouse on a basalt cliff at dusk" -o out.png
@ -39,7 +39,47 @@ Test 2026-11-11, Kiko's r1a 12-cell pose sheet as the only reference, same seed:
but also pasted three of the sheet's cells into the frame; use one cropped cell as the reference. Images and verdict:
`/opt/commons/handoff/raven-ref-edit-20261111/`.
Offline test (fake ComfyUI on 127.0.0.1): `python3 scripts/test-raven-image.py` in gamecult-ops.
## `--margin PCT`: clear edges by construction
```
raven-image "a goldfish mascot on a plain pale cream background" --no-enhance --margin 6 --bg fcf7ee -o fish.png
raven-image ... --margin 6 --bg 12fa5f --alpha -o sticker.png # RGBA, backdrop keyed out
```
The border band (PCT of each axis, rounded up to the 16 px latent grid, 6% of 1024 is 64 px) is held flat during
diffusion, not cleaned afterwards. The client replaces the empty latent with a `--bg`-coloured image run through the
graph's own VAE (`EmptyImage` > `VAEEncode`) and puts a `SetLatentNoiseMask` on it that is 0 on the band and 1 inside
(`SolidMask` + `MaskComposite`, nodes 30-35; node 6 goes). ComfyUI's sampler re-imposes the masked-out latent at every
step at that step's noise level (RePaint-style), so the model composes the interior around a frame it cannot change.
Core nodes only; no custom node, no Raven deploy. Denoise stays 1. Works on `flux3-klein` and `flux3-klein-edit`
(with or without `--vision`). Put the same backdrop in the prompt ("plain pale cream background"); the pin sets the band,
the prompt sets what the interior tries to match.
After the render the client measures the band against its own backdrop (the per-channel median of the band: the VAE
shifts a saturated `--bg` by tens of levels, `#12fa5f` came back `#1fef6d`; cream stayed within 2), repaints the whole
band with that colour, and prints it with the per-side count of pixels that were content:
`margin 6.0%: backdrop #fef9ec, cleared 802 of 245760 band px (0.33%; left 646 top 0 right 156 bottom 0)`.
If more than `--max-clip` percent of the band (default 0.5) was content the file is still written, already cleared, and
the client exits **65**: the subject ran into the frame, so it is cut flat at the seam. Re-seed or ask for more room.
`-n` renders all images, then exits 65 if any was dirty. `--alpha` floods from the band through backdrop-coloured pixels
and makes them transparent (hard key; pure Python, about 1 s). The output is RGBA only with `--alpha`.
Measured 2026-11-11 (1024x1024, 6%, one render each, same seed and ref as the unpinned image):
| case | unpinned: band that was content | pinned |
|---|---|---|
| Aqua's facelift seed 2203 (`--ref --vision`, seaweed and bubbles in the corners) | 33% | 0.33% (corners clean; character unchanged) |
| Kiko r1a at a desk, seed 11 (green) | 31% (the desk ran off the bottom) | 0.43% (the desk now ends inside the frame) |
Pinned and unpinned images and the comparison: `/opt/commons/handoff/raven-ref-edit-20261111/clear-edge/`. The pin
did not visibly degrade either render. The residue is subject touching the seam (fin, tail, desk corner), not clutter.
Limits: a hard key leaves a faint backdrop-coloured fringe on antialiased outlines (visible on saturated green, faint on
cream); backdrop enclosed by the subject (the gap between a headband and the head) stays opaque; a saturated key colour
the subject also contains (green in a green-eyed character) is untouched except where connected to the band; the band is
a rectangle, so the pin cannot ask for a vignette or a round sticker. A subject cannot be asked to stay off the seam: only
the exit code tells you it did not.
Offline test (fake ComfyUI on 127.0.0.1, 127 checks): `python3 tests/test-raven-image.py`.
## What it does

View file

@ -5,10 +5,12 @@ Talks to ComfyUI's own API: POST /prompt, poll GET /history/<id>, GET /view,
POST /free when the queue is empty. No retry loop: an unreachable Raven exits 75
with "Raven may be busy or off". See README.md next to this file.
"""
import argparse, json, math, os, random, stat, sys, time, urllib.error, urllib.parse, urllib.request
import argparse, json, math, os, random, re, stat, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
BASE = os.environ.get("RAVEN_URL", "http://10.77.0.4:8190")
EX_BUSY = 75 # EX_TEMPFAIL: no answer from Raven (off, busy, a read timeout mid-render, or a lock screen after a reboot); try again later, by hand
EX_DIRTY = 65 # EX_DATAERR: --margin was asked for and the render put subject in the band; the file is written, already cleared
TOL = 12 # per-channel distance from the band's backdrop beyond which a pixel counts as content (VAE noise on a flat fill is a few levels)
MAX_REF = 20 * 2 ** 20 # bytes; refs persist in Raven's input folder, so cap what we put there
MAGIC = ((b"\x89PNG\r\n\x1a\n", None), (b"\xff\xd8\xff", None), (b"RIFF", b"WEBP")) # png, jpeg, webp (bytes 8-12)
@ -118,6 +120,133 @@ def build(name, text, w, h, seed, enhance, refs=(), vision=False):
return g
def margin_px(pct, w, h):
"""Band width on each axis in pixels: pct of that axis, rounded up to the 16-px latent grid so the mask edge is exact."""
return tuple(-(-math.ceil(pct / 100 * d) // 16) * 16 for d in (w, h))
def pin_edges(g, w, h, pct, bg):
"""Native pin: sample from a flat --bg latent with a noise mask that is 0 on the border band, so the band is
re-imposed at every step and the model composes the interior around it. Replaces node 6 (the empty latent)."""
mx, my = margin_px(pct, w, h)
g["30"] = {"class_type": "EmptyImage", "inputs": {"width": w, "height": h, "batch_size": 1, "color": bg}}
g["31"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["30", 0], "vae": ["3", 0]}}
g["32"] = {"class_type": "SolidMask", "inputs": {"value": 0.0, "width": w, "height": h}} # 0 = keep, 1 = sample
g["33"] = {"class_type": "SolidMask", "inputs": {"value": 1.0, "width": w - 2 * mx, "height": h - 2 * my}}
g["34"] = {"class_type": "MaskComposite", "inputs": {"destination": ["32", 0], "source": ["33", 0], "x": mx, "y": my, "operation": "add"}}
g["35"] = {"class_type": "SetLatentNoiseMask", "inputs": {"samples": ["31", 0], "mask": ["34", 0]}}
g["7"]["inputs"]["latent_image"] = ["35", 0]
del g["6"]
def png_decode(data):
"""8-bit non-interlaced RGB/RGBA PNG (what ComfyUI writes) -> (w, h, bpp, rows, ancillary chunks). Stdlib only."""
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValueError("not a png")
pos, idat, keep, hdr = 8, [], [], None
while pos < len(data):
n, typ = struct.unpack(">I4s", data[pos:pos + 8])
body = data[pos + 8:pos + 8 + n]
pos += 12 + n
if typ == b"IHDR":
hdr = struct.unpack(">IIBBBBB", body)
elif typ == b"IDAT":
idat.append(body)
elif typ != b"IEND":
keep.append((typ, body))
if hdr is None or not idat:
raise ValueError("no IHDR or no image data")
w, h, depth, ctype, _, _, interlace = hdr
if depth != 8 or ctype not in (2, 6) or interlace:
raise ValueError(f"only 8-bit non-interlaced RGB/RGBA is handled (got depth {depth}, colour type {ctype}, interlace {interlace})")
bpp = 3 if ctype == 2 else 4
stride, rows, prev = w * bpp, [], bytearray(w * bpp)
raw = zlib.decompressobj().decompress(b"".join(idat), h * (stride + 1) + 1) # bounded: a bomb cannot outgrow its header
if len(raw) != h * (stride + 1):
raise ValueError("image data does not match the header size")
for y in range(h):
f, cur = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
if f == 1:
for i in range(bpp, stride): cur[i] = (cur[i] + cur[i - bpp]) & 255
elif f == 2:
for i in range(stride): cur[i] = (cur[i] + prev[i]) & 255
elif f == 3:
for i in range(stride): cur[i] = (cur[i] + (((cur[i - bpp] if i >= bpp else 0) + prev[i]) >> 1)) & 255
elif f == 4:
for i in range(stride):
a, b, c = (cur[i - bpp] if i >= bpp else 0), prev[i], (prev[i - bpp] if i >= bpp else 0)
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
cur[i] = (cur[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
elif f != 0:
raise ValueError(f"bad PNG filter {f}")
rows.append(cur); prev = cur
return w, h, bpp, rows, keep
def png_encode(w, h, bpp, rows, keep):
def chunk(t, b): return struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
raw = b"".join(b"\0" + bytes(r) for r in rows)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2 if bpp == 3 else 6, 0, 0, 0))
+ b"".join(chunk(t, b) for t, b in keep) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
def key_alpha(w, h, bpp, rows, back):
"""Make the backdrop transparent: flood fill from every border pixel through pixels within TOL of `back`, so only
backdrop connected to the (cleared) band goes; enclosed holes and same-coloured subject parts stay opaque.
Returns RGBA rows. Hard key, no feathering: antialiased outlines keep a faint backdrop-coloured fringe."""
out = []
for r in rows:
o = bytearray(w * 4)
for c in range(3): o[c::4] = r[c::bpp]
o[3::4] = b"\xff" * w
out.append(o)
near = lambda o, x: all(abs(o[x * 4 + c] - back[c]) <= TOL for c in range(3))
stack = [(x, y) for x in range(w) for y in (0, h - 1)] + [(x, y) for y in range(h) for x in (0, w - 1)]
while stack:
x, y = stack.pop()
if 0 <= x < w and 0 <= y < h and out[y][x * 4 + 3] and near(out[y], x):
out[y][x * 4 + 3] = 0
stack += ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))
return out
def clear_edges(png, pct, alpha=False):
"""Measure the border band of a rendered PNG against its own backdrop (the per-channel median of the band: the VAE
shifts a saturated --bg by tens of levels, so the asked-for colour is not what a clean band decodes to), paint the
whole band with that backdrop, and report what was content. Returns (png bytes, backdrop (r, g, b), clipped pixels per
side {left, top, right, bottom}, band pixel count). With alpha the PNG is RGBA with the backdrop keyed out."""
w, h, bpp, rows, keep = png_decode(png)
mx, my = margin_px(pct, w, h)
spans = [(y, 0, w) if y < my or y >= h - my else None for y in range(h)]
sample = [[], [], []]
for y in range(0, h, 3):
for x0, x1 in ([(0, w)] if spans[y] else [(0, mx), (w - mx, w)]):
for x in range(x0, x1, 3):
for c in range(3): sample[c].append(rows[y][x * bpp + c])
back = tuple(sorted(v)[len(v) // 2] for v in sample)
want = bytes(back + ((255,) if bpp == 4 else ()))
clip = dict.fromkeys(("left", "top", "right", "bottom"), 0)
def dirty(row, x0, x1):
n = 0
for x in range(x0, x1):
o = x * bpp
if abs(row[o] - back[0]) > TOL or abs(row[o + 1] - back[1]) > TOL or abs(row[o + 2] - back[2]) > TOL: n += 1
return n
for y in range(h):
row, edge = rows[y], "top" if y < my else "bottom" if y >= h - my else None
if edge: # whole row is band; the corners belong to top/bottom
clip[edge] += dirty(row, 0, w)
else:
clip["left"] += dirty(row, 0, mx); clip["right"] += dirty(row, w - mx, w)
for x0, x1 in ((0, w),) if edge else ((0, mx), (w - mx, w)):
row[x0 * bpp:x1 * bpp] = want * (x1 - x0)
band = w * h - (w - 2 * mx) * (h - 2 * my)
if alpha:
rows, bpp = key_alpha(w, h, bpp, rows, back), 4
return png_encode(w, h, bpp, rows, keep), back, clip, band
def render(g, timeout):
try:
r = json.loads(call("POST", "/prompt", {"prompt": g, "client_id": "raven-image"}))
@ -164,6 +293,12 @@ def main():
ap.add_argument("--vision", action="store_true",
help="with --ref: also feed the first 3 refs to the text encoder (TextEncodeQwenImageEditPlus, no vae)")
ap.add_argument("--graph", help="graphs/<name>.json (default flux3-klein, or flux3-klein-edit with --ref)")
ap.add_argument("--margin", type=float, metavar="PCT",
help="keep a PCT%% border band of --bg on every side (rounded up to 16 px): the sampler is pinned to it, "
"then the band is measured and repainted; exit 65 if more than --max-clip %% of it had content")
ap.add_argument("--bg", default="ffffff", metavar="RRGGBB", help="backdrop colour for --margin (the prompt must ask for the same one)")
ap.add_argument("--alpha", action="store_true", help="--margin: key the backdrop connected to the band to transparent (RGBA output)")
ap.add_argument("--max-clip", type=float, default=0.5, metavar="PCT", help="--margin: largest %% of the band that may need clearing (default 0.5)")
ap.add_argument("--no-enhance", action="store_true", help="send the prompt as the caption, skip the upsampler")
ap.add_argument("--timeout", type=int, default=900, help="seconds to wait per image")
ap.add_argument("--keep-loaded", action="store_true", help="skip POST /free at the end")
@ -172,24 +307,48 @@ def main():
ap.error("--vision needs at least one --ref")
if a.graph and (a.graph == "flux3-klein-edit") != bool(a.ref):
ap.error("--ref and graph flux3-klein-edit go together (the edit graph needs refs, other graphs cannot take them)")
if a.margin is not None and not 0 < a.margin <= 40:
ap.error("--margin is a percentage above 0 and at most 40")
if a.alpha and not a.margin:
ap.error("--alpha needs --margin")
if not re.fullmatch(r"#?[0-9a-fA-F]{6}", a.bg):
ap.error("--bg is six hex digits, RRGGBB")
bg = int(a.bg.lstrip("#"), 16)
a.graph = a.graph or ("flux3-klein-edit" if a.ref else "flux3-klein")
ref_data = [load_ref(r) for r in a.ref] # everything refusable is refused before the first request
w, h = (int(v) for v in a.size.lower().split("x"))
seed0 = a.seed if a.seed is not None else random.randrange(2 ** 48)
root, ext = os.path.splitext(a.out)
dirty = 0
try:
call("GET", "/system_stats", timeout=5)
refs = [upload(r, d) for r, d in zip(a.ref, ref_data)]
for i in range(a.n):
seed = seed0 + i
t = time.time()
png = render(build(a.graph, a.prompt, w, h, seed, not a.no_enhance, refs, a.vision), a.timeout)
g = build(a.graph, a.prompt, w, h, seed, not a.no_enhance, refs, a.vision)
if a.margin:
pin_edges(g, w, h, a.margin, bg)
png = render(g, a.timeout)
note = ""
if a.margin:
try:
png, back, clip, band = clear_edges(png, a.margin, a.alpha)
except (ValueError, zlib.error, struct.error) as e:
sys.exit(f"raven-image: --margin: cannot read the render as a PNG ({e}); nothing written")
pct = 100 * sum(clip.values()) / band
note = " margin %s%%: backdrop #%02x%02x%02x, cleared %d of %d band px (%.2f%%; %s)" % (
a.margin, *back, sum(clip.values()), band, pct, " ".join(f"{k} {v}" for k, v in clip.items()))
if pct > a.max_clip:
dirty += 1; note += f" DIRTY: over --max-clip {a.max_clip}%"
path = a.out if a.n == 1 else f"{root}-{i + 1}{ext}"
with open(path, "wb") as f:
f.write(png)
print(f"{path} seed={seed} {w}x{h} {time.time() - t:.0f}s", flush=True)
print(f"{path} seed={seed} {w}x{h} {time.time() - t:.0f}s{note}", flush=True)
if not a.keep_loaded:
print("freed Raven's VRAM" if free_if_idle() else "queue not empty, left models loaded")
if dirty:
sys.exit(EX_DIRTY)
except Busy as e:
print(f"raven-image: Raven may be busy or off ({BASE}: {e}). Not retrying; try again later. "
"Most likely cause after a Raven reboot: the lock screen (the ComfyUI task needs someone logged in on Raven's desktop). "

View file

@ -7,7 +7,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
tool = os.path.join(root, "client", "raven-image")
PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 32
seen = {"uploads": [], "prompts": []}
seen = {"uploads": [], "prompts": [], "view": PNG}
class H(BaseHTTPRequestHandler):
@ -19,7 +19,7 @@ class H(BaseHTTPRequestHandler):
if self.path.startswith("/history/"):
self.send(json.dumps({self.path[9:]: {"status": {"completed": True}, "outputs": {"9": {"images": [
{"filename": "x.png", "subfolder": "", "type": "temp"}]}}}}).encode())
elif self.path.startswith("/view"): self.send(PNG, "image/png")
elif self.path.startswith("/view"): self.send(seen["view"], "image/png")
elif self.path == "/queue": self.send(b'{"queue_running":[],"queue_pending":[]}')
else: self.send(b"{}")
def do_POST(self):
@ -177,6 +177,84 @@ for nm in ('a"b\r\nX-Evil: 1\\c.png', "pass wd;$x.png", "é.png"):
p, _ = run("cap", url="http://127.0.0.1:1")
check(p.returncode == 75 and "may be busy or off" in p.stderr and "lock screen" in p.stderr, "unreachable: exit 75, hedged message names lock screen")
# --margin: the pin is in the graph, the band is measured and repainted, a dirty band is loud
import importlib.machinery, importlib.util, struct, zlib
mod2 = importlib.util.module_from_spec(importlib.util.spec_from_loader("ri2", importlib.machinery.SourceFileLoader("ri2", tool)))
mod2.__spec__.loader.exec_module(mod2)
def mkpng(w, h, px, bpp=3, filt=0):
"""px(x, y) -> tuple of bpp bytes. filt 0..4 applies that PNG filter to every row (the decoder must undo all five)."""
rows = [bytes(v for x in range(w) for v in px(x, y)) for y in range(h)]
raw, prev = b"", bytes(w * bpp)
for r in rows:
f = bytearray()
for i in range(w * bpp):
a, b, c = (r[i - bpp] if i >= bpp else 0), prev[i], (prev[i - bpp] if i >= bpp else 0)
pr = (0, a, b, (a + b) >> 1, (lambda pa, pb, pc: a if pa <= pb and pa <= pc else b if pb <= pc else c)(abs(b - c), abs(a - c), abs(a + b - 2 * c)))[filt]
f.append((r[i] - pr) & 255)
raw += bytes([filt]) + bytes(f); prev = r
ck = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
return (b"\x89PNG\r\n\x1a\n" + ck(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2 if bpp == 3 else 6, 0, 0, 0))
+ ck(b"tEXt", b"prompt\0keepme") + ck(b"IDAT", zlib.compress(raw)) + ck(b"IEND", b""))
BG = (30, 200, 90)
# codec: every filter type round-trips, ancillary chunks survive
for f in range(5):
for bpp in (3, 4):
pic = lambda x, y, bpp=bpp: ((x * 7 + y * 3) & 255, (x * y) & 255, (x ^ y) & 255) + ((200,) if bpp == 4 else ())
w_, h_, b_, rows_, keep_ = mod2.png_decode(mkpng(37, 23, pic, bpp, f))
check((w_, h_, b_) == (37, 23, bpp) and all(bytes(rows_[y][x * bpp:(x + 1) * bpp]) == bytes(pic(x, y)) for y in range(23) for x in range(37))
and keep_ == [(b"tEXt", b"prompt\0keepme")], f"png codec: filter {f}, {bpp} bytes/px")
check(mod2.png_decode(mod2.png_encode(w_, h_, b_, rows_, keep_))[3] == rows_, f"png codec: encode round-trips (filter {f}, {bpp})")
for bad in (b"nope", mkpng(8, 8, lambda x, y: BG)[:40]):
try: mod2.png_decode(bad); check(False, "png_decode refuses garbage")
except (ValueError, zlib.error, struct.error): check(True, "")
# margin_px: pct of each axis, up to the 16 px latent grid
check(mod2.margin_px(6, 1024, 1024) == (64, 64) and mod2.margin_px(5, 1536, 1024) == (80, 64) and mod2.margin_px(0.1, 1024, 1024) == (16, 16),
"margin_px: 16 px steps per axis")
# the pin graph: flat-bg latent, noise mask 0 on the band and 1 inside, sampler starts from it
for extra, base in (([], "flux3-klein.json"), (["--ref", refs[0]], "flux3-klein-edit.json")):
p, g = run(*extra, "--margin", "5", "--bg", "#12fa5f", "--size", "1536x1024", "--no-enhance", "cap")
check(g is not None and "6" not in g and g["7"]["inputs"]["latent_image"] == ["35", 0], f"margin {base}: sampler starts from the masked latent, empty latent gone")
if g:
check(g["30"]["inputs"] == {"width": 1536, "height": 1024, "batch_size": 1, "color": 0x12fa5f}, f"margin {base}: flat bg image at full size")
check(g["31"] == {"class_type": "VAEEncode", "inputs": {"pixels": ["30", 0], "vae": ["3", 0]}}, f"margin {base}: bg encoded with the graph's vae")
check(g["32"]["inputs"] == {"value": 0.0, "width": 1536, "height": 1024} and g["33"]["inputs"] == {"value": 1.0, "width": 1536 - 160, "height": 1024 - 128},
f"margin {base}: mask 0 outside, 1 inside by 80x64 px")
check(g["34"]["inputs"]["x"] == 80 and g["34"]["inputs"]["y"] == 64 and g["34"]["inputs"]["operation"] == "add"
and g["34"]["inputs"]["destination"] == ["32", 0] and g["34"]["inputs"]["source"] == ["33", 0], f"margin {base}: interior composited at the margin offset")
check(g["35"]["inputs"] == {"samples": ["31", 0], "mask": ["34", 0]} and g["7"]["inputs"]["denoise"] == 1, f"margin {base}: noise mask on the encoded bg, denoise stays 1")
p, g = run("--no-enhance", "cap")
check(g is not None and "6" in g and "35" not in g, "no --margin: graph unchanged")
for args, needle in ((["--margin", "0"], "--margin is"), (["--margin", "41"], "--margin is"), (["--margin", "5", "--bg", "green"], "--bg is"), (["--alpha"], "--alpha needs --margin")):
refused([*args, "cap"], needle, " ".join(args))
# the render is measured: clean band exits 0 and is exact, content in the band is cleared and exits 65, alpha keys only connected backdrop
W = 160 # 6% -> 16 px band
def scene(x, y, hole=False):
if x < 6 and y < 6 or (hole and 80 <= x < 90 and 80 <= y < 90): return (250, 20, 20) # corner speck in the band; an enclosed hole is separate below
return BG if not (50 <= x < 110 and 50 <= y < 110) else (250, 20, 20)
seen["view"] = mkpng(W, W, lambda x, y: (BG[0] + (x + y) % 3, BG[1], BG[2]) if not (50 <= x < 110 and 50 <= y < 110) else (250, 20, 20))
p, g = run("--margin", "6", "--size", "160x160", "--no-enhance", "cap")
out = open(os.path.join(tmp, "o.png"), "rb").read()
check(p.returncode == 0 and "cleared 0 of" in p.stdout and "backdrop #1ec85a" in p.stdout, f"clean band: exit 0, nothing cleared, backdrop reported ({p.stdout.strip()[-140:]})")
r_ = mod2.png_decode(out)[3]
check(all(bytes(r_[y][x * 3:x * 3 + 3]) == bytes(BG) for y in range(W) for x in range(W) if x < 16 or y < 16 or x >= W - 16 or y >= W - 16), "clean band: repainted exactly to the backdrop")
check(bytes(r_[80][80 * 3:80 * 3 + 3]) == bytes((250, 20, 20)), "clean band: interior untouched")
seen["view"] = mkpng(W, W, lambda x, y: (250, 20, 20) if (x < 12 and 30 <= y < 130) or (50 <= x < 110 and 50 <= y < 110) else BG) # a stub entering the left band
p, g = run("--margin", "6", "--size", "160x160", "--no-enhance", "cap")
check(p.returncode == 65 and "DIRTY" in p.stdout and "left %d" % (12 * 100) in p.stdout and "top 0" in p.stdout, f"dirty band: exit 65 naming the side ({p.returncode} {p.stdout.strip()[-160:]})")
r_ = mod2.png_decode(open(os.path.join(tmp, "o.png"), "rb").read())[3]
check(all(bytes(r_[y][x * 3:x * 3 + 3]) == bytes(BG) for y in range(W) for x in range(12)), "dirty band: file is written and the stub cleared")
p, g = run("--margin", "6", "--max-clip", "50", "--size", "160x160", "--no-enhance", "cap")
check(p.returncode == 0, "--max-clip raises the tolerance")
holes = lambda x, y: (250, 20, 20) if (50 <= x < 110 and 50 <= y < 110 and not (70 <= x < 90 and 70 <= y < 90)) else BG # ring around an enclosed backdrop hole
seen["view"] = mkpng(W, W, holes)
p, g = run("--margin", "6", "--alpha", "--size", "160x160", "--no-enhance", "cap")
w_, h_, b_, r_, k_ = mod2.png_decode(open(os.path.join(tmp, "o.png"), "rb").read())
check(p.returncode == 0 and b_ == 4 and r_[0][3] == 0 and r_[30][3 + 4 * 40] == 0 and r_[60][4 * 60 + 3] == 255, "--alpha: RGBA, outside keyed out, subject opaque")
check(r_[80][4 * 80 + 3] == 255, "--alpha: enclosed backdrop hole stays opaque (connected backdrop only)")
seen["view"] = PNG
p, g = run("--margin", "6", "--no-enhance", "cap")
check(p.returncode != 0 and "cannot read the render" in p.stderr and "Traceback" not in p.stderr, "undecodable render: clean error, no traceback")
srv.shutdown()
print(f"{count[0] - len(fails)}/{count[0]} checks passed")
sys.exit(1 if fails else 0)