Files
chrmc40 4a3eb29968 Per-image modal setup persistence (Re-pose / Inpaint / Upscale)
Render modals now remember and restore the user's last setup per image,
surviving close/reopen and restart, via a server-side sidecar keyed by image
content hash.

- New core/modal_setup_api.py sidecar (sibling of edit-docs): POST(merge per
  kind)/GET/DELETE, manifest + binary planes, LRU budget, tombstone-aware.
- Re-pose: AUTO-restores recipe/model/prompt/strength and the full 3D pose on
  open (seed-before-mount), lineage-aware so a re-posed result/edit resumes its
  source's setup. Plus a Re-Apply button.
- Inpaint: restores engine/mode/dials, the PuLID/Style-Reference cluster, the
  prompt, and the painted mask (alpha plane).
- Upscale: restores dials + prompt via an opt-in chip, doc-dims keyed.
- Also: before/after Compare wipe on Upscale + Re-pose; Fit/Compare controls
  relocated with click-fix + double-click-to-fit.
2026-06-18 16:14:29 -07:00

233 lines
7.6 KiB
Python

# Per-image modal-setup persistence: the last Upscale / Inpaint / Re-pose setup
# for an image, so reopening a render modal can restore the user's work and
# survive a restart. A SIBLING of edit-docs keyed by the SAME content hash
# (the viewer's displayedHash == the editor's editDocHash, which is edit-stable):
# {user}/PromptChain/modal-setup/<hash>/manifest.json + optional binary planes.
#
# manifest = {"version", "dims": {"w","h"}, "kinds": {"upscale": {...},
# "inpaint": {...}, "repose": {...}}}
#
# One POST MERGES a single kind, so the three tools never clobber each other's
# setup for the same image. Planes (e.g. the inpaint mask) are namespaced
# "<kind>__<name>.png". LRU size budget; touch-on-read survives eviction.
# Routes register at import time -> needs a server restart to take effect.
import asyncio
import json
import os
import re
import shutil
import time
import folder_paths
import server
from aiohttp import web
from .api_utils import error_response
routes = server.PromptServer.instance.routes
_BUDGET_BYTES = 512 * 1024 ** 2 # setups are tiny; mask planes dominate. 512 MB is plenty.
_SCHEMA_VERSION = 1
_HASH_RE = re.compile(r"[0-9a-f]{64}")
_KIND_RE = re.compile(r"[a-z]+")
_PLANE_RE = re.compile(r"[a-z]+__[a-z0-9_]+\.png") # e.g. inpaint__mask.png
def _root() -> str:
return os.path.join(folder_paths.get_user_directory(), "PromptChain", "modal-setup")
def _rmtree_robust(path: str, tries: int = 6) -> None:
for _ in range(tries):
shutil.rmtree(path, ignore_errors=True)
if not os.path.exists(path):
return
time.sleep(0.05)
def _dir_size(path: str) -> int:
total = 0
for root, _dirs, files in os.walk(path):
for name in files:
try:
total += os.path.getsize(os.path.join(root, name))
except OSError:
pass
return total
def _evict(keep_hash: str) -> None:
"""LRU by manifest mtime until under budget; never evict keep_hash."""
root = _root()
try:
names = os.listdir(root)
except OSError:
return
entries, total = [], 0
for name in names:
path = os.path.join(root, name)
if not os.path.isdir(path):
continue
size = _dir_size(path)
total += size
manifest = os.path.join(path, "manifest.json")
try:
mtime = os.path.getmtime(manifest if os.path.isfile(manifest) else path)
except OSError:
mtime = 0
entries.append((mtime, size, name, path))
if total <= _BUDGET_BYTES:
return
entries.sort() # oldest first
for _mtime, size, name, path in entries:
if total <= _BUDGET_BYTES:
break
if name == keep_hash:
continue
_rmtree_robust(path)
total -= size
def _manifest_path(h: str) -> str:
return os.path.join(_root(), h, "manifest.json")
def _read_manifest(h: str) -> dict:
try:
with open(_manifest_path(h), "r", encoding="utf-8") as fp:
data = json.load(fp)
if isinstance(data, dict):
data.setdefault("kinds", {})
return data
except (OSError, ValueError):
pass
return {"version": _SCHEMA_VERSION, "dims": None, "kinds": {}}
def _form_text(val) -> str | None:
"""A multipart field that may arrive as a str or a file part."""
if val is None:
return None
if isinstance(val, str):
return val
f = getattr(val, "file", None)
return f.read().decode("utf-8") if f is not None else None
@routes.post("/promptchain/modal-setup/{hash}")
async def save_modal_setup(request):
h = request.match_info.get("hash", "")
if not _HASH_RE.fullmatch(h):
return error_response("invalid hash")
form = await request.post()
kind = _form_text(form.get("kind")) or ""
if not _KIND_RE.fullmatch(kind):
return error_response("invalid kind")
try:
kind_data = json.loads(_form_text(form.get("data")) or "{}")
except ValueError:
return error_response("invalid data json")
dims = None
dims_text = _form_text(form.get("dims"))
if dims_text:
try:
dims = json.loads(dims_text)
except ValueError:
dims = None
planes = {}
for key, val in form.items():
if key in ("kind", "data", "dims") or not _PLANE_RE.fullmatch(key):
continue
f = getattr(val, "file", None)
if f is not None:
planes[key] = f.read()
def _write():
doc_dir = os.path.join(_root(), h)
os.makedirs(doc_dir, exist_ok=True)
manifest = _read_manifest(h)
manifest["version"] = _SCHEMA_VERSION
if dims is not None:
manifest["dims"] = dims
manifest["kinds"][kind] = kind_data
with open(os.path.join(doc_dir, "manifest.json"), "w", encoding="utf-8") as fp:
json.dump(manifest, fp)
for name, content in planes.items():
with open(os.path.join(doc_dir, name), "wb") as fp:
fp.write(content)
_evict(h)
try:
await asyncio.to_thread(_write)
except OSError as e:
return error_response(f"could not save modal setup: {e}", 500)
return web.json_response({"ok": True})
@routes.get("/promptchain/modal-setup/{hash}")
async def get_modal_setup(request):
h = request.match_info.get("hash", "")
if not _HASH_RE.fullmatch(h):
return error_response("invalid hash")
path = _manifest_path(h)
if not os.path.isfile(path):
return web.json_response({"exists": False}, status=404)
try:
os.utime(path, None) # LRU touch — reopened setups survive eviction
except OSError:
pass
return web.FileResponse(path)
@routes.get("/promptchain/modal-setup/{hash}/{file}")
async def get_modal_setup_file(request):
h = request.match_info.get("hash", "")
fname = request.match_info.get("file", "")
if not _HASH_RE.fullmatch(h) or not _PLANE_RE.fullmatch(fname):
return error_response("invalid request")
root = os.path.normpath(_root())
path = os.path.normpath(os.path.join(root, h, fname))
if not path.startswith(root) or not os.path.isfile(path):
return web.Response(status=404)
return web.FileResponse(path)
@routes.delete("/promptchain/modal-setup/{hash}")
async def delete_modal_setup(request):
# ?kind=<x> drops one tool's setup (+ its planes); no kind drops the whole
# image's sidecar. Idempotent: a missing dir/kind is fine. Used to invalidate
# a stale setup when content is unchanged (same hash), like edit-doc flatten.
h = request.match_info.get("hash", "")
if not _HASH_RE.fullmatch(h):
return error_response("invalid hash")
kind = request.rel_url.query.get("kind", "")
def _delete():
doc_dir = os.path.join(_root(), h)
if kind and _KIND_RE.fullmatch(kind):
manifest = _read_manifest(h)
manifest["kinds"].pop(kind, None)
try:
for name in os.listdir(doc_dir):
if name.startswith(f"{kind}__"):
try:
os.remove(os.path.join(doc_dir, name))
except OSError:
pass
except OSError:
pass
if manifest["kinds"]:
try:
with open(_manifest_path(h), "w", encoding="utf-8") as fp:
json.dump(manifest, fp)
except OSError:
pass
else:
_rmtree_robust(doc_dir)
else:
_rmtree_robust(doc_dir)
await asyncio.to_thread(_delete)
return web.json_response({"ok": True})