← back to Wallco Ai

scripts/build-mural-master.py

526 lines

#!/usr/bin/env python3
"""
build-mural-master.py — room-size scenic-mural master + vertical-panel slicer.

Steve's brief (2026-06-03): recreate the Monterey scenic murals as ONE
continuous 12ft-wide x 11ft-tall scene ("the entire design is all the panels —
create room size to fit"). The wall is sliced into vertical PANELS the customer
picks the width of (24" / 36" / 54"). Assemble the panels side-by-side → the
complete scene.

Geometry @ 150 DPI:
  master   = 144" x 132"  = 21,600 x 19,800 px   (12ft W x 11ft H)
  focal    = 144" x 120"  = 21,600 x 18,000 px   (the protected scene)
  bleed    = top 6" + bottom 6" (900px each) of FORGIVING content (sky/clouds
             up top, grass/ground at the bottom) so the installer can shift the
             design up/down and trim WITHIN those bands without losing the scene.

Method (DTD verdict 2026-06-03, Steve's pick): UPSCALE existing curated art via
the Real-ESRGAN chain in build-tif.py, COVER-fit to the focal box (no stretch —
scale-to-cover + center-crop), then synthesize the 6" sky/grass bleed bands.
v1 bleed = deterministic edge-band mirror+fade (free, no model, never trips the
settlement foliage detectors). Generative outpaint (SDXL inpaint) is the
documented upgrade — drop it into make_bleed() behind --bleed=generative later.

Usage:
  python3 scripts/build-mural-master.py <design_id>                  # full real build (ESRGAN, ~$0.12, multi-GB)
  python3 scripts/build-mural-master.py <design_id> --panels 24,36   # only these panel widths
  python3 scripts/build-mural-master.py --self-test                  # zero-cost geometry proof (no DB/net)
  python3 scripts/build-mural-master.py <design_id> --print-ft 12 --height-ft 11 --bleed-in 6

Outputs (under WALLCO_MURAL_DIR or data/mural-masters/<id>/):
  master_12x11ft_150dpi.tif
  panels_24in/panel_01.tif ... panel_06.tif   (+ manifest.json per width)
"""
import argparse
import importlib.util
import io
import json
import os
import sys
import time
from pathlib import Path

from PIL import Image

Image.MAX_IMAGE_PIXELS = None  # a 21,600 x 19,800 master is ~428 MP — intentional.

ROOT = Path(__file__).resolve().parents[1]

# Reuse the ESRGAN upscaler + TIF writer from build-tif.py (hyphenated filename
# → load via importlib rather than import). Single source of truth for the
# Replicate mechanism, DPI, and the LZW-TIF writer.
_bt_spec = importlib.util.spec_from_file_location(
    "buildtif", str(ROOT / "scripts" / "build-tif.py"))
_bt = importlib.util.module_from_spec(_bt_spec)
_bt_spec.loader.exec_module(_bt)

# Replicate's Cloudflare edge 403s the default `Python-urllib/x` User-Agent on
# some endpoints (files upload, model GET). Install a browser-ish UA process-
# wide so every urllib call (here AND in build-tif's _esrgan_x4) gets through.
import urllib.request as _ur
_op = _ur.build_opener()
_op.addheaders = [("User-Agent", "Mozilla/5.0 (Macintosh) wallco-mural/1.0")]
_ur.install_opener(_op)

DPI = _bt.DPI  # 150

_HENRY = Path("/Volumes/Henry/wallco-ai-archive/mural-masters")
MURAL_DIR = Path(os.environ.get("WALLCO_MURAL_DIR")
                 or (_HENRY if _HENRY.parent.is_dir() else ROOT / "data" / "mural-masters"))


# ── geometry ────────────────────────────────────────────────────────────────
def cover_fit(img, box_w, box_h):
    """Scale-to-cover box_w x box_h then center-crop. No aspect distortion."""
    img = img.convert("RGB")
    w, h = img.size
    scale = max(box_w / w, box_h / h)
    nw, nh = max(box_w, round(w * scale)), max(box_h, round(h * scale))
    img = img.resize((nw, nh), Image.LANCZOS)
    left, top = (nw - box_w) // 2, (nh - box_h) // 2
    return img.crop((left, top, left + box_w, top + box_h))


def make_bleed(focal, bleed_px, edge):
    """
    Synthesize a forgiving bleed band of height bleed_px by extending the
    focal's edge band. edge='top' → sky/clouds extended upward; edge='bottom'
    → grass/ground extended downward. Deterministic mirror+fade: sample a band
    off the focal edge, mirror it, and fade toward the edge's mean tone so the
    extension reads as "more sky"/"more ground", not a hard seam.
    """
    w, _ = focal.size
    sample_h = min(bleed_px, max(64, bleed_px // 2))
    if edge == "top":
        band = focal.crop((0, 0, w, sample_h))
    else:
        band = focal.crop((0, focal.size[1] - sample_h, w, focal.size[1]))
    band = band.transpose(Image.FLIP_TOP_BOTTOM)  # mirror so the seam matches
    # Tile the mirrored band up to bleed_px tall.
    out = Image.new("RGB", (w, bleed_px))
    y = 0
    while y < bleed_px:
        out.paste(band, (0, y))
        y += sample_h
    out = out.crop((0, 0, w, bleed_px))
    # Fade the far edge toward the band's mean tone for a soft, forgiving end.
    from PIL import ImageStat
    mean = tuple(int(c) for c in ImageStat.Stat(band).mean[:3])
    solid = Image.new("RGB", (w, bleed_px), mean)
    mask = Image.new("L", (w, bleed_px))
    px = mask.load()
    for yy in range(bleed_px):
        # 0 at the focal-adjacent edge → 255 at the far (trim) edge.
        t = yy / max(1, bleed_px - 1)
        a = int(255 * (t ** 1.4))
        row = a
        for xx in range(0, w, 8):  # coarse fill; mask is low-freq, 8px step is fine
            for k in range(xx, min(xx + 8, w)):
                px[k, yy] = row
    if edge == "top":
        mask = mask.transpose(Image.FLIP_TOP_BOTTOM)  # far edge = the very top
    return Image.composite(solid, out, mask)


def assemble_master(focal, bleed_px):
    """Stack top-bleed + focal + bottom-bleed into the full master."""
    w, h = focal.size
    top = make_bleed(focal, bleed_px, "top")
    bot = make_bleed(focal, bleed_px, "bottom")
    master = Image.new("RGB", (w, h + 2 * bleed_px))
    master.paste(top, (0, 0))
    master.paste(focal, (0, bleed_px))
    master.paste(bot, (0, bleed_px + h))
    return master


_ESRGAN_MAX_IN = 1448   # Replicate real-esrgan GPU cap (~2.09M px input)


def _esrgan_x4_retry(img, token, tries=4):
    """real-esrgan x4 with retries — a single transient Replicate error (400/
    429/5xx/timeout) on any one of a mural's ~17 tile calls otherwise crashes
    the whole build. Retry with backoff; re-raise only if all attempts fail."""
    last = None
    for i in range(tries):
        try:
            return _bt._esrgan_x4(img, token)
        except Exception as e:
            last = e
            print(f"      esrgan tile attempt {i+1}/{tries} failed ({str(e)[:70]}); retrying",
                  file=sys.stderr)
            time.sleep(3 + i * 4)
    raise last


def _replicate_run_latest(model, inp, token, poll_max=600):
    """Run a Replicate model's latest version via the version-pinned
    /v1/predictions path (the model-predictions endpoint 404s for some models;
    this resolves latest_version then POSTs like the working real-esrgan call)."""
    import urllib.request, json as _json
    greq = urllib.request.Request(
        f"https://api.replicate.com/v1/models/{model}",
        headers={"Authorization": "Bearer " + token})
    ver = (_json.loads(urllib.request.urlopen(greq, timeout=30).read())
           .get("latest_version") or {}).get("id")
    if not ver:
        raise RuntimeError(f"{model}: no latest_version")
    req = urllib.request.Request(
        "https://api.replicate.com/v1/predictions",
        data=_json.dumps({"version": ver, "input": inp}).encode(),
        headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
    pred = _json.loads(urllib.request.urlopen(req, timeout=90).read())
    t0 = time.time()
    while pred.get("status") not in ("succeeded", "failed", "canceled"):
        if time.time() - t0 > poll_max:
            raise RuntimeError("inpaint timeout")
        time.sleep(3)
        pred = _json.loads(urllib.request.urlopen(urllib.request.Request(
            pred["urls"]["get"], headers={"Authorization": "Bearer " + token}), timeout=60).read())
    if pred["status"] != "succeeded":
        raise RuntimeError(f"{model} {pred['status']}: {str(pred.get('error'))[:160]}")
    return pred["output"]


def _fetch_img(url):
    import urllib.request
    return Image.open(io.BytesIO(urllib.request.urlopen(url, timeout=180).read())).convert("RGB")


def _replicate_upload(im, token, name="img.png"):
    """Upload a PIL image to the Replicate files API → served URL. Large inline
    base64 in the prediction body gets 403'd by Replicate's edge; uploaded URLs
    don't. Returns the get-URL to pass as a model input."""
    import urllib.request, json as _json, uuid
    buf = io.BytesIO(); im.save(buf, format="PNG"); data = buf.getvalue()
    boundary = "----wallco" + uuid.uuid4().hex
    body = (f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="content"; filename="{name}"\r\n'
            f"Content-Type: image/png\r\n\r\n").encode() + data + \
        f"\r\n--{boundary}--\r\n".encode()
    req = urllib.request.Request(
        "https://api.replicate.com/v1/files", data=body,
        headers={"Authorization": "Bearer " + token,
                 "Content-Type": f"multipart/form-data; boundary={boundary}"})
    r = _json.loads(urllib.request.urlopen(req, timeout=120).read())
    url = (r.get("urls") or {}).get("get")
    if not url:
        raise RuntimeError("file upload: no url in " + str(r)[:160])
    return url


def esrgan_tiled(img, token, target_long):
    """
    Upscale to target_long honouring the 1448px ESRGAN input cap: at each x4
    level, tile the input into <=1448px tiles (with overlap), ESRGAN each, and
    feather-blend the seams. Repeat levels until >= target, then exact LANCZOS.
    Pure-LANCZOS fallback when no token.
    """
    img = img.convert("RGB")
    if not token:
        s = target_long / max(img.size)
        return img.resize((round(img.size[0]*s), round(img.size[1]*s)), Image.LANCZOS), "lanczos-only"
    # Only run an x4 level while we're still under half target — a 4x jump then
    # lands <=~2x target; LANCZOS the small remainder. Prevents huge overshoot
    # (e.g. 1080->4320->17280, then LANCZOS->21600 — never 69120).
    levels = 0
    while max(img.size) * 2 < target_long and levels < 4:
        if max(img.size) <= _ESRGAN_MAX_IN:
            img = _esrgan_x4_retry(img, token); levels += 1; continue
        img = _esrgan_x4_tiled(img, token); levels += 1
    cw, ch = img.size
    s = target_long / max(cw, ch)
    if abs(s - 1) > 1e-3:
        img = img.resize((round(cw*s), round(ch*s)), Image.LANCZOS)
    return img, f"esrgan-tiled x{levels} + lanczos"


def _esrgan_x4_tiled(img, token, tile=1200, overlap=128):
    """One x4 level via overlapping tiles, feather-blended (no visible seams)."""
    from PIL import ImageDraw
    W, H = img.size
    out = Image.new("RGB", (W*4, H*4))
    acc = Image.new("L", (W*4, H*4), 0)  # coverage for feather normalisation
    step = tile - overlap
    xs = list(range(0, max(1, W - overlap), step))
    ys = list(range(0, max(1, H - overlap), step))
    for ty in ys:
        for tx in xs:
            x0, y0 = tx, ty
            x1, y1 = min(tx + tile, W), min(ty + tile, H)
            piece = img.crop((x0, y0, x1, y1))
            up = _esrgan_x4_retry(piece, token)  # piece <= 1200 < 1448 cap
            pw, ph = up.size
            # feather mask: ramp in over the overlap on each interior edge
            m = Image.new("L", (pw, ph), 255); d = ImageDraw.Draw(m)
            ov = overlap * 4
            for i in range(ov):
                a = int(255 * (i / max(1, ov)))
                if x0 > 0:  d.line((i, 0, i, ph), fill=a)
                if y0 > 0:  d.line((0, i, pw, i), fill=a)
            out.paste(up, (x0*4, y0*4), m)
    return out


def outpaint_bleed(focal, bleed_px, top_prompt, bottom_prompt, token):
    """
    Extend the focal by bleed_px top+bottom using SDXL inpainting (lucataco/
    sdxl-inpainting, latest). Builds a canvas with empty bands + a white mask
    over them and lets the model paint sky (top) / ground (bottom). Falls back
    to make_bleed() mirror+fade if no token or the call fails. Runs at LOW res
    (model size limit) — caller upscales the whole composition afterwards.
    """
    w, h = focal.size
    canvas = Image.new("RGB", (w, h + 2*bleed_px), (128, 128, 128))
    canvas.paste(focal, (0, bleed_px))
    # seed the bands with the focal edge colour so the model has context
    from PIL import ImageStat
    top_c = tuple(int(c) for c in ImageStat.Stat(focal.crop((0, 0, w, max(8, bleed_px//3)))).mean[:3])
    bot_c = tuple(int(c) for c in ImageStat.Stat(focal.crop((0, h-max(8, bleed_px//3), w, h))).mean[:3])
    canvas.paste(Image.new("RGB", (w, bleed_px), top_c), (0, 0))
    canvas.paste(Image.new("RGB", (w, bleed_px), bot_c), (0, h + bleed_px))
    mask = Image.new("L", (w, h + 2*bleed_px), 0)
    mask.paste(Image.new("L", (w, bleed_px), 255), (0, 0))
    mask.paste(Image.new("L", (w, bleed_px), 255), (0, h + bleed_px))
    if not token:
        return assemble_master(focal, bleed_px)
    try:
        img_url = _replicate_upload(canvas, token, "canvas.png")
        mask_url = _replicate_upload(mask.convert("L"), token, "mask.png")
        out = _replicate_run_latest("lucataco/sdxl-inpainting", {
            "image": img_url, "mask": mask_url,
            "prompt": f"{top_prompt} above, {bottom_prompt} below, soft natural "
                      "continuation of the scene, muted screen-print tones, no animals, no text",
            "negative_prompt": "birds, butterflies, people, text, watermark, frame, border",
            "num_inference_steps": 25, "guidance_scale": 7,
        }, token)
        url = out[0] if isinstance(out, list) else out
        painted = _fetch_img(url).resize(canvas.size, Image.LANCZOS)
        # keep the original focal crisp; only take the painted bands
        painted.paste(focal, (0, bleed_px))
        return painted
    except Exception as e:
        print(f"    outpaint failed ({e}); mirror+fade fallback", file=sys.stderr)
        return assemble_master(focal, bleed_px)


def slice_panels(master, panel_in, dpi=DPI):
    """
    Slice the master into vertical panels panel_in inches wide (full height).
    Returns a list of (index, x0, x1, width_px, width_in) — the last panel is
    the remainder when panel_in doesn't divide the wall evenly.
    """
    W, H = master.size
    pw = int(round(panel_in * dpi))
    panels, x, i = [], 0, 0
    while x < W:
        x1 = min(x + pw, W)
        i += 1
        panels.append((i, x, x1, x1 - x, round((x1 - x) / dpi, 2)))
        x = x1
    return panels


# ── full real build ─────────────────────────────────────────────────────────
def build(design_id, print_ft, height_ft, bleed_in, panel_widths,
          bleed_method="outpaint", upscale_method="tiled", force=False):
    master_w = int(round(print_ft * 12 * DPI))      # 21,600
    master_h = int(round(height_ft * 12 * DPI))     # 19,800
    bleed_px = int(round(bleed_in * DPI))           # 900
    focal_w, focal_h = master_w, master_h - 2 * bleed_px  # 21,600 x 18,000

    conn = _bt.db()
    d = _bt.fetch_design(conn, design_id)
    conn.close()
    if not d:
        print(f"design {design_id} not found", file=sys.stderr); return 3
    src = _bt.resolve_source(d)
    if not src:
        print(f"design {design_id}: source PNG not found", file=sys.stderr); return 4
    token = _bt._replicate_token()

    out_dir = MURAL_DIR / str(design_id)
    out_dir.mkdir(parents=True, exist_ok=True)
    master_path = out_dir / f"master_{print_ft:g}x{height_ft:g}ft_{DPI}dpi.tif"
    if master_path.exists() and not force:
        print(f"master exists {master_path} — use --force to rebuild");
    else:
        t0 = time.time()
        img = Image.open(src)
        print(f"design {design_id} ({d.get('kind')}) src {img.size[0]}x{img.size[1]} "
              f"→ master {master_w}x{master_h}px ({print_ft:g}x{height_ft:g}ft @ {DPI}dpi)")
        # Order is forced by model size limits: OUTPAINT must run at low res
        # (SDXL inpaint can't do 21k px), so compose at low res then upscale the
        # whole focal+bleed together for consistent detail.
        LR_W = 1080
        lr_fw, lr_fh = LR_W, round(LR_W * focal_h / focal_w)        # 1080 x 900 (6:5)
        lr_bleed = max(8, round(bleed_px * LR_W / master_w))        # 900*1080/21600 = 45
        focal_lr = cover_fit(img, lr_fw, lr_fh)
        if bleed_method == "outpaint":
            master_lr = outpaint_bleed(focal_lr, lr_bleed,
                                       "clear soft sky with gentle clouds",
                                       "grassy ground meadow", token)
            bmeth = "sdxl-outpaint" if token else "mirror(no-token)"
        elif bleed_method == "mirror":
            master_lr = assemble_master(focal_lr, lr_bleed); bmeth = "mirror+fade"
        else:
            master_lr = assemble_master(focal_lr, lr_bleed); bmeth = "mirror+fade"
        print(f"  low-res compose {master_lr.size[0]}x{master_lr.size[1]} "
              f"(focal {lr_fw}x{lr_fh} + {bleed_in}\" bleed via {bmeth})")
        if upscale_method == "tiled":
            master, umeth = esrgan_tiled(master_lr, token, master_w)
        else:
            master, umeth = _bt.upscale_mural(master_lr, master_w)
        if master.size != (master_w, master_h):
            master = master.resize((master_w, master_h), Image.LANCZOS)
        print(f"  upscaled via {umeth} → master {master.size[0]}x{master.size[1]}")
        _bt.write_tif(master, master_path)
        print(f"  ✓ master {master_path} ({master_path.stat().st_size/1e6:.0f} MB) "
              f"in {time.time()-t0:.0f}s")

    master = Image.open(master_path)
    summary = {"design_id": design_id, "master": str(master_path),
               "master_px": list(master.size), "dpi": DPI,
               "print_ft": print_ft, "height_ft": height_ft, "bleed_in": bleed_in,
               "panels": {}}
    for pw_in in panel_widths:
        pdir = out_dir / f"panels_{pw_in:g}in"
        pdir.mkdir(exist_ok=True)
        panels = slice_panels(master, pw_in)
        man = []
        for (i, x0, x1, wpx, win) in panels:
            ppath = pdir / f"panel_{i:02d}.tif"
            master.crop((x0, 0, x1, master.size[1])).save(
                ppath, format="TIFF", compression="tiff_lzw", dpi=(DPI, DPI))
            man.append({"index": i, "file": ppath.name, "x0": x0, "x1": x1,
                        "width_px": wpx, "width_in": win, "height_px": master.size[1]})
        (pdir / "manifest.json").write_text(json.dumps(man, indent=2))
        summary["panels"][f"{pw_in:g}in"] = {"count": len(man), "dir": str(pdir),
                                             "panel_px": [m["width_px"] for m in man]}
        print(f"  ✓ {pw_in:g}\" → {len(man)} panels "
              f"({', '.join(str(m['width_px']) for m in man)} px)")
    (out_dir / "summary.json").write_text(json.dumps(summary, indent=2))
    print(f"\nDONE → {out_dir}/summary.json")
    return 0


# ── zero-spend cost estimator ────────────────────────────────────────────────
def _tiles_1d(n, tile=1200, overlap=128):
    """Tile count along one axis — mirrors _esrgan_x4_tiled's xs/ys."""
    step = tile - overlap
    return len(range(0, max(1, n - overlap), step))


def count_esrgan_calls(lr_long, target_long):
    """Mirror esrgan_tiled's level loop EXACTLY → number of real-esrgan calls."""
    calls, cur, levels = 0, lr_long, 0
    while cur * 2 < target_long and levels < 4:
        calls += 1 if cur <= _ESRGAN_MAX_IN else _tiles_1d(cur) ** 2
        cur *= 4
        levels += 1
    return calls


def estimate(print_ft, height_ft, bleed_in, panel_widths, n_murals,
             esrgan_usd=0.015, inpaint_usd=0.02):
    """Print a no-spend cost + work estimate for a batch of n_murals."""
    master_w = int(round(print_ft * 12 * DPI))
    master_h = int(round(height_ft * 12 * DPI))
    LR_W = 1080
    eg = count_esrgan_calls(LR_W, master_w)
    per_calls = eg + 1                       # +1 SDXL outpaint
    per_usd = eg * esrgan_usd + inpaint_usd
    mp = master_w * master_h / 1e6
    print(f"=== mural-master batch estimate (NO SPEND — arithmetic only) ===")
    print(f"spec        : {print_ft:g}ft x {height_ft:g}ft @ {DPI} DPI = "
          f"{master_w:,} x {master_h:,} px ({mp:.0f} MP/master)")
    print(f"bleed       : {bleed_in:g}\" sky+grass top/bottom (SDXL outpaint, low-res)")
    print(f"upscale path: 1080 -> {master_w:,} via tiled ESRGAN "
          f"({eg} real-esrgan calls) + LANCZOS finish")
    for pw in panel_widths:
        n = len(slice_panels(Image.new('RGB', (master_w, 8), (0,0,0)), pw))
        print(f"  panels {pw:g}\" : {n} per mural")
    print(f"\nper mural   : ~{per_calls} Replicate calls  ~${per_usd:.2f}  "
          f"(esrgan ${esrgan_usd}/call, inpaint ${inpaint_usd}/call)")
    print(f"batch x{n_murals} : ~{per_calls*n_murals} calls  ~${per_usd*n_murals:.2f}")
    print(f"\n(estimates are upper-ish; real-esrgan often <$0.015/call. NO calls made.)")
    return 0


# ── zero-cost geometry proof ─────────────────────────────────────────────────
def self_test():
    """Validate cover-fit + bleed + slicer with no DB, no network, no $ — at a
    1/10-scale geometry, then assert the real 12x11ft panel math arithmetically."""
    print("SELF-TEST — geometry only, no ESRGAN/DB/disk-master\n")
    ok = True

    # 1/10 scale: 2160 x 1980 master, 90px bleed, focal 2160 x 1800.
    sc = 10
    mw, mh, bp = 21600 // sc, 19800 // sc, 900 // sc
    fw, fh = mw, mh - 2 * bp
    src = Image.new("RGB", (102, 102))
    for y in range(102):  # vertical gradient so cover-fit/bleed are visible
        for x in range(0, 102, 6):
            for k in range(x, min(x + 6, 102)):
                src.putpixel((k, y), (120, 150 + y // 3, 90))
    focal = cover_fit(src, fw, fh)
    assert focal.size == (fw, fh), focal.size
    print(f"  cover_fit  square 102² → focal {focal.size} (expect {(fw, fh)}) ✓")
    master = assemble_master(focal, bp)
    assert master.size == (mw, mh), master.size
    print(f"  assemble   focal+2*{bp}px bleed → master {master.size} (expect {(mw, mh)}) ✓")

    # Real 12x11ft @150dpi panel math.
    REAL_W = 12 * 12 * DPI  # 21,600
    cases = {24: 6, 36: 4, 54: 3}
    for pw_in, expect_n in cases.items():
        panels = slice_panels(Image.new("RGB", (REAL_W, 100)), pw_in)
        widths = [p[3] for p in panels]
        total = sum(widths)
        good = len(panels) == expect_n and total == REAL_W
        ok &= good
        print(f"  slice {pw_in}\" → {len(panels)} panels {widths}px "
              f"(Σ={total}, expect {expect_n} panels / Σ{REAL_W}) {'✓' if good else '✗'}")

    # Bleed band must be the right size and not throw.
    tb = make_bleed(focal, bp, "top"); bb = make_bleed(focal, bp, "bottom")
    assert tb.size == (fw, bp) and bb.size == (fw, bp)
    print(f"  bleed      top/bottom bands {tb.size} ✓")

    print("\n" + ("ALL GEOMETRY ASSERTIONS PASS ✓" if ok else "FAIL ✗"))
    return 0 if ok else 1


def main():
    p = argparse.ArgumentParser()
    p.add_argument("design_id", type=int, nargs="?")
    p.add_argument("--print-ft", type=float, default=12.0)
    p.add_argument("--height-ft", type=float, default=11.0)
    p.add_argument("--bleed-in", type=float, default=6.0)
    p.add_argument("--panels", default="24,36,54",
                   help="comma-separated panel widths in inches (default 24,36,54)")
    p.add_argument("--bleed", choices=("outpaint", "mirror", "none"), default="outpaint",
                   help="bleed-band fill: SDXL outpaint (default), mirror+fade, or none")
    p.add_argument("--upscale", choices=("tiled", "lanczos"), default="tiled",
                   help="tiled ESRGAN (default, true detail) or single-pass+lanczos")
    p.add_argument("--force", action="store_true")
    p.add_argument("--self-test", action="store_true")
    p.add_argument("--estimate", type=int, metavar="N",
                   help="print a no-spend cost estimate for an N-mural batch, then exit")
    a = p.parse_args()
    if a.self_test:
        sys.exit(self_test())
    if a.estimate is not None:
        widths = [float(x) for x in a.panels.split(",") if x.strip()]
        sys.exit(estimate(a.print_ft, a.height_ft, a.bleed_in, widths, a.estimate))
    if a.design_id is None:
        p.error("design_id is required (or pass --self-test)")
    widths = [float(x) for x in a.panels.split(",") if x.strip()]
    sys.exit(build(a.design_id, a.print_ft, a.height_ft, a.bleed_in, widths,
                   bleed_method=a.bleed, upscale_method=a.upscale, force=a.force))


if __name__ == "__main__":
    main()