Files
chrmc40 68cbef51ec Lineage subway tree view + viewer/modal polish
- Lineage "subway" lanes: branch columns, spine + S-curve connectors, per-node
  expand/fold, header expand-all toggle, right-click Copy Path
- Lineage fixes: show family when an image is opened by file path; new-gen banner
  moved off the lineage header to top-center; transparent scrollbar corner;
  hover-only branch controls; stale-refetch staleness guard + edge-keyed
  connectors + anomaly instrumentation
- Modal-setup persistence: 3D scene, inpaint mask, prompt, upscale dials restore
  across reopen
- Re-pose modal: auto-restore last setup, Re-Apply, pan/zoom + before/after Compare
- Upscale modal: before/after Compare wipe
- Pose Studio: name-your-file scene export; $name{} block rename mirroring
- docs: README wordmark hero
2026-06-18 22:06:57 -07:00

294 lines
11 KiB
Python

# Viewer "Upscale" support: hands the frontend everything it needs to rebuild
# an upscale workflow from a finished image — the embedded workflow JSON from
# the PNG's metadata, the pixel dimensions, and a stable content-addressed copy
# of the image in the input dir for LoadImage to consume.
import asyncio
import hashlib
import io as _io
import json
import os
import re
import folder_paths
import server
from aiohttp import web
from PIL import Image
import node_helpers
from .api_utils import error_response
from .browse_api import _get_scope_root, _validate_path
from .history_db import resolve_image_path
routes = server.PromptServer.instance.routes
# "source", not "upscale": the copy is the ORIGINAL image staged for LoadImage.
# A prefix containing "upscale" read as "this is the upscaled version" and made
# the graft look like it grabbed the wrong image.
_UPSCALE_PREFIX = "promptchain_source_"
def _resolve_request_path(request) -> tuple[str | None, str | None]:
"""(file_path, error) from either ?hash= (DB image) or ?scope=&path= (browse)."""
image_hash = request.query.get("hash", "").strip()
if image_hash:
path = resolve_image_path(image_hash)
if path and os.path.isfile(str(path)):
return str(path), None
return None, "image not found for hash"
scope = request.query.get("scope", "").strip()
rel_path = request.query.get("path", "").strip()
if not scope or not rel_path:
return None, "need hash or scope+path"
root = _get_scope_root(scope)
if not root or not root.is_dir():
return None, f"invalid scope: {scope}"
target = _validate_path(root, rel_path)
if not target or not target.is_file():
return None, "not found"
return str(target), None
def _read_image_payload(path: str) -> dict:
with open(path, "rb") as f:
raw = f.read()
# Content-addressed input copy: same image always lands on the same name,
# so repeat upscales of one image don't pile up files, and the name can
# never hold different pixels. The copy goes in the input ROOT — LoadImage's
# combo only lists top-level files (nodes.py os.listdir), so a subfolder
# path can never be a legal option and permanently flags the node red even
# though execution resolves it fine. Namespace lives in the filename.
digest = hashlib.sha256(raw).hexdigest()[:12]
ext = os.path.splitext(path)[1].lower() or ".png"
name = f"{_UPSCALE_PREFIX}{digest}{ext}"
dest = os.path.join(folder_paths.get_input_directory(), name)
if not os.path.exists(dest):
with open(dest, "wb") as f:
f.write(raw)
img = node_helpers.pillow(Image.open, _io.BytesIO(raw))
# PNG tEXt lands in .info, iTXt in .text — ComfyUI saves may use either
# depending on payload encoding, so merge both before reading.
chunks = dict(img.info or {})
chunks.update(getattr(img, "text", None) or {})
def parse(key):
try:
value = chunks.get(key)
if not isinstance(value, str) or not value:
return None
# ComfyUI embeds the prompt with is_changed values that can be NaN —
# Python's json writes the bare NaN literal, which the browser's
# strict JSON.parse rejects. Map NaN/Infinity to null on the way in
# so our re-serialization stays valid JSON.
return json.loads(value, parse_constant=lambda _: None)
except (json.JSONDecodeError, TypeError):
return None
workflow = parse("workflow")
return {
"workflow": workflow,
"prompt": parse("prompt"),
"width": img.width,
"height": img.height,
"input_ref": name,
"filename": os.path.basename(path),
# Background mode's queue gate: content-addressed pose files mean
# existence == proof the rendered maps match the workflow's pose_state,
# so the client can queue directly instead of waiting for a re-render.
"pose_files_ok": _pose_files_ok(workflow),
}
def _pose_files_ok(workflow) -> bool:
"""Every PoseStudio control map the workflow references exists on disk."""
try:
nodes = (workflow or {}).get("nodes") or []
input_dir = folder_paths.get_input_directory()
for n in nodes:
if n.get("type") != "PromptChain_PoseStudio":
continue
values = n.get("widgets_values") or []
ref = values[0] if values and isinstance(values[0], str) else ""
ref = re.sub(r"\s*\[\w+\]$", "", ref.strip())
if not ref or not os.path.isfile(os.path.join(input_dir, ref)):
return False
return True
except Exception:
return False
@routes.get("/promptchain/image-workflow")
async def image_workflow(request):
path, err = _resolve_request_path(request)
if err:
return error_response(err, 404 if "not found" in err else 400)
try:
payload = await asyncio.to_thread(_read_image_payload, path)
except (OSError, ValueError) as e:
return error_response(f"could not read image: {e}", 500)
return web.json_response(payload)
@routes.get("/promptchain/reveal-file")
async def reveal_file(request):
"""Open the OS file manager with the image selected (local tool affordance).
Loopback-only: revealing a folder opens a window on the SERVER's desktop,
which is only meaningful (and only safe) when the requester is that
machine. The frontend hides the button for non-loopback origins; this is
the backstop.
"""
if request.remote not in ("127.0.0.1", "::1"):
return error_response("reveal is only available from the server machine", 403)
# Files resolve like image-workflow does (hash or scope+path); the save-
# path field's folder chips also reveal DIRECTORIES via scope+path.
is_dir = False
image_hash = request.query.get("hash", "").strip()
if image_hash:
path, err = _resolve_request_path(request)
if err:
return error_response(err, 404)
else:
root = _get_scope_root(request.query.get("scope", "output").strip() or "output")
if not root or not root.is_dir():
return error_response("invalid scope")
target = _validate_path(root, request.query.get("path", "").strip())
if not target or not target.exists():
return error_response("not found", 404)
path = str(target)
is_dir = target.is_dir()
import subprocess
import sys
try:
if sys.platform == "win32":
# explorer exits non-zero even on success; don't check the code
if is_dir:
subprocess.Popen(["explorer", os.path.normpath(path)])
else:
subprocess.Popen(["explorer", "/select,", os.path.normpath(path)])
elif sys.platform == "darwin":
subprocess.Popen(["open", path] if is_dir else ["open", "-R", path])
else:
subprocess.Popen(["xdg-open", path if is_dir else os.path.dirname(path)])
except OSError as e:
return error_response(f"could not open folder: {e}", 500)
return web.json_response({"ok": True})
@routes.get("/promptchain/open-file")
async def open_file(request):
"""Open the image itself in the OS default application (local tool affordance).
Loopback-only for the same reason as reveal-file: this launches a program on
the SERVER's desktop, only meaningful when the requester is that machine.
"""
if request.remote not in ("127.0.0.1", "::1"):
return error_response("open is only available from the server machine", 403)
path, err = _resolve_request_path(request)
if err:
return error_response(err, 404)
import subprocess
import sys
try:
if sys.platform == "win32":
os.startfile(os.path.normpath(path)) # noqa: S606 — local desktop launch
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])
except OSError as e:
return error_response(f"could not open file: {e}", 500)
return web.json_response({"ok": True})
@routes.get("/promptchain/file-path")
async def file_path(request):
"""Return the resolved absolute disk path for an image so the viewer can copy
it to the clipboard.
Loopback-only: the path is a SERVER-machine filesystem path — meaningless on a
remote client and not worth leaking. The frontend only offers Copy Path to
local clients; this is the backstop.
"""
if request.remote not in ("127.0.0.1", "::1"):
return error_response("file path is only available from the server machine", 403)
path, err = _resolve_request_path(request)
if err:
return error_response(err, 404)
return web.json_response({"path": os.path.normpath(path)})
def _show_file_properties_win(path):
"""Show the native Windows file-properties sheet for `path`.
SHObjectProperties creates a MODELESS property sheet that lives on the
calling thread and needs that thread to pump messages, so this runs on its
own daemon thread with a self-terminating pump: once the sheet has appeared
and the thread no longer owns any window, the user has closed it and we exit
(a plain GetMessage loop would block forever — the sheet posts no WM_QUIT).
"""
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
shell32 = ctypes.windll.shell32
ole32 = ctypes.windll.ole32
kernel32 = ctypes.windll.kernel32
ole32.CoInitialize(None)
try:
SHOP_FILEPATH = 0x00000002
shell32.SHObjectProperties(None, SHOP_FILEPATH, ctypes.c_wchar_p(path), None)
thread_id = kernel32.GetCurrentThreadId()
def thread_owns_window():
found = ctypes.c_bool(False)
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def _cb(hwnd, lparam):
found.value = True
return False # one is enough; stop enumerating
user32.EnumThreadWindows(thread_id, _cb, 0)
return found.value
PM_REMOVE = 0x0001
QS_ALLINPUT = 0x04FF
msg = wintypes.MSG()
sheet_seen = False
while True:
user32.MsgWaitForMultipleObjectsEx(0, None, 200, QS_ALLINPUT, 0)
while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, PM_REMOVE):
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
if thread_owns_window():
sheet_seen = True
elif sheet_seen:
break
finally:
ole32.CoUninitialize()
@routes.get("/promptchain/file-properties")
async def file_properties(request):
"""Show the OS file-properties dialog (Windows only)."""
if request.remote not in ("127.0.0.1", "::1"):
return error_response("properties is only available from the server machine", 403)
import sys
if sys.platform != "win32":
return error_response("file properties is only available on Windows", 501)
path, err = _resolve_request_path(request)
if err:
return error_response(err, 404)
import threading
threading.Thread(
target=_show_file_properties_win,
args=(os.path.normpath(path),),
daemon=True,
).start()
return web.json_response({"ok": True})