Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

454 lines
17 KiB
Python

# History API — image history, workflow tracking, thumbnail serving,
# lineage queries, orphan detection, and image prompt extraction.
import asyncio
import logging
import os
from aiohttp import web
import server
from .api_utils import error_response, ok_response, parse_json, validate_content_path
from .shared import HASH_RE
logger = logging.getLogger("promptchain.history_api")
routes = server.PromptServer.instance.routes
# ── image prompt extraction ──────────────────────────────────────
@routes.post("/promptchain/extract-image-prompts")
async def _api_extract_image_prompts(request):
"""Extract pos/neg prompts from a ComfyUI-generated PNG's metadata."""
from .load_image_prompts import extract_prompts_from_file
data, err = await parse_json(request)
if err: return err
image_path = data.get("image_path", "")
if not image_path:
return error_response("missing image_path")
# Reject path-traversal attempts before attempting any resolution.
# validate_content_path is the final authority, but an up-front check
# keeps a single bug in folder_paths.get_annotated_filepath from
# turning into an arbitrary read.
if ".." in image_path.replace("\\", "/").split("/"):
return error_response("invalid image_path", 400)
if not os.path.isabs(image_path):
try:
import folder_paths as fp
image_path = fp.get_annotated_filepath(image_path)
except Exception:
logger.debug("failed to resolve image path %s", image_path, exc_info=True)
image_path = validate_content_path(image_path)
if not image_path or not os.path.isfile(image_path):
return error_response("file not found or not in allowed directory", 403)
try:
positive, negative = extract_prompts_from_file(image_path)
return web.json_response({"positive": positive, "negative": negative})
except Exception:
logger.warning("prompt extraction failed for %s", image_path, exc_info=True)
return error_response("prompt extraction failed", 500)
# 100 MB is well past any legitimate PNG/JPEG workflow image while still
# bounding memory for the in-RAM PIL.Image.open path.
_MAX_EXTRACT_BYTES = 100 * 1024 * 1024
@routes.post("/promptchain/extract-prompts-upload")
async def _api_extract_prompts_upload(request):
"""Extract pos/neg prompts from an uploaded image's embedded ComfyUI metadata."""
from .load_image_prompts import extract_prompts_from_bytes
reader = await request.multipart()
raw = None
while True:
part = await reader.next()
if part is None:
break
if part.name != "file":
continue
buf = bytearray()
while True:
chunk = await part.read_chunk(64 * 1024)
if not chunk:
break
buf.extend(chunk)
if len(buf) > _MAX_EXTRACT_BYTES:
return error_response("file too large", 413)
raw = bytes(buf)
break
if not raw:
return error_response("missing file")
try:
positive, negative = extract_prompts_from_bytes(raw)
return web.json_response({"positive": positive, "negative": negative})
except Exception:
logger.warning("uploaded prompt extraction failed", exc_info=True)
return error_response("prompt extraction failed", 500)
# ── workflow management ──────────────────────────────────────────
@routes.post("/promptchain/workflow-check")
async def _api_workflow_check(request):
data, err = await parse_json(request)
if err: return err
workflow_id = data.get("workflow_id", "")
filepath = data.get("filepath", "")
if not workflow_id or not filepath:
return error_response("missing workflow_id or filepath")
from .history_db import try_register_workflow_atomic, clone_and_register_workflow_atomic, register_workflow
# resolve path
user_dir = os.path.join(server.PromptServer.instance.web_root or "", "user", "default")
try:
import folder_paths as fp
user_dir = fp.get_user_directory()
except Exception:
logger.debug("failed to resolve user directory", exc_info=True)
if not os.path.isabs(filepath):
filepath = os.path.normpath(os.path.join(user_dir, filepath))
registered, existing_path = try_register_workflow_atomic(workflow_id, filepath)
if registered:
return ok_response({"action": "ok", "workflow_id": workflow_id})
# different path — check if old file still exists
if not os.path.exists(existing_path):
register_workflow(workflow_id, filepath)
return ok_response({"action": "update_path", "workflow_id": workflow_id})
# both files exist — duplicate. generate new UUID, clone attachments
import uuid as _uuid
new_uuid = str(_uuid.uuid4())
success, cloned = clone_and_register_workflow_atomic(workflow_id, new_uuid, filepath)
if not success:
return error_response("clone failed", 500)
return ok_response({
"action": "clone",
"workflow_id": workflow_id,
"new_uuid": new_uuid,
"cloned_count": cloned,
})
@routes.post("/promptchain/client-error")
async def _api_client_error(request):
"""Relay frontend failures into the server log — browser consoles are
invisible during remote debugging, and graph-build errors otherwise die
client-side with no trace here."""
data, err = await parse_json(request)
if err: return err
where = str(data.get("where", ""))[:200]
message = str(data.get("message", ""))[:2000]
stack = str(data.get("stack", ""))[:8000]
logger.error("CLIENT ERROR [%s]: %s\n%s", where, message, stack)
return ok_response({})
@routes.post("/promptchain/workflow-clone")
async def _api_workflow_clone(request):
"""Clone image associations from one workflow UUID to another."""
data, err = await parse_json(request)
if err: return err
from_id = data.get("from_id", "")
to_id = data.get("to_id", "")
filepath = data.get("filepath", "")
if not from_id or not to_id:
return error_response("missing from_id or to_id")
from .history_db import clone_and_register_workflow_atomic
success, cloned = clone_and_register_workflow_atomic(from_id, to_id, filepath)
if not success:
return error_response("clone failed", 500)
return ok_response({"cloned_count": cloned})
# ── generation recording ─────────────────────────────────────────
@routes.post("/promptchain/generation/{workflow_id}")
async def _api_record_generation(request):
workflow_id = request.match_info.get("workflow_id", "")
if not workflow_id:
return error_response("missing workflow_id")
data, err = await parse_json(request)
if err: return err
filename = data.get("filename", "")
if not filename:
return error_response("missing filename")
from .history_db import record_image
from .thumbs import get_or_create_thumbnail
result = record_image(
filename=filename,
subfolder=data.get("subfolder", ""),
source_type=data.get("source_type", "output"),
workflow_id=workflow_id,
metadata={
"prompt": data.get("prompt"),
"negative": data.get("negative"),
"seed": data.get("seed"),
"model": data.get("model"),
"steps": data.get("steps"),
"cfg": data.get("cfg"),
"sampler": data.get("sampler"),
"scheduler": data.get("scheduler"),
"parent_filename": data.get("parent_filename"),
"denoise": data.get("denoise"),
"regions": data.get("regions"),
},
)
if result is None:
return error_response("file not found", 404)
# generate thumbnail immediately while source file exists. PIL
# resize is CPU-bound and synchronous, so push it off the event loop.
if result.get("hash"):
import asyncio
await asyncio.to_thread(get_or_create_thumbnail, result["hash"])
return web.json_response(result)
# ── image/thumbnail serving ──────────────────────────────────────
@routes.get("/promptchain/thumb/{hash}")
async def _api_serve_thumb(request):
image_hash = request.match_info.get("hash", "")
if not HASH_RE.match(image_hash):
return error_response("invalid hash")
from .thumbs import get_or_create_thumbnail
import asyncio
thumb_path = await asyncio.to_thread(get_or_create_thumbnail, image_hash)
if not thumb_path:
# no-store so a transient miss (thumb requested a beat before the record
# commits it) is never cached by the browser — otherwise the <img> stays
# broken even after the thumb lands. The gallery also retries on error.
return web.Response(status=404, headers={"Cache-Control": "no-store"})
return web.FileResponse(thumb_path, headers={
"Content-Type": "image/webp",
"Cache-Control": "public, max-age=86400",
})
@routes.get("/promptchain/image/{hash}")
async def _api_serve_image(request):
image_hash = request.match_info.get("hash", "")
if not HASH_RE.match(image_hash):
return error_response("invalid hash")
from .history_db import resolve_image_path
file_path = resolve_image_path(image_hash)
if not file_path or not file_path.is_file():
return error_response("not found", 404)
import mimetypes as mt
mime, _ = mt.guess_type(str(file_path))
return web.FileResponse(file_path, headers={
"Content-Type": mime or "application/octet-stream",
"Cache-Control": "public, max-age=31536000",
})
# ── workflow image queries ───────────────────────────────────────
@routes.get("/promptchain/workflow/{workflow_id}")
async def _api_workflow_images(request):
workflow_id = request.match_info.get("workflow_id", "")
if not workflow_id:
return error_response("missing workflow_id")
try:
limit = int(request.query.get("limit", "100"))
offset = int(request.query.get("offset", "0"))
except (ValueError, TypeError):
return error_response("invalid limit/offset")
from .history_db import get_workflow_images, get_workflow_image_count
images = get_workflow_images(workflow_id, limit=limit, offset=offset)
total = get_workflow_image_count(workflow_id)
return web.json_response({"images": images, "total": total})
@routes.get("/promptchain/count/{workflow_id}")
async def _api_workflow_count(request):
workflow_id = request.match_info.get("workflow_id", "")
if not workflow_id:
return error_response("missing workflow_id")
from .history_db import get_workflow_image_count
return web.json_response({"count": get_workflow_image_count(workflow_id)})
@routes.post("/promptchain/workflow/{workflow_id}/clear")
async def _api_clear_workflow(request):
workflow_id = request.match_info.get("workflow_id", "")
if not workflow_id:
return error_response("missing workflow_id")
if request.content_length:
data, err = await parse_json(request)
if err: return err
else:
data = {}
hashes = data.get("hashes", None)
from .history_db import detach_workflow_images, detach_images
if hashes and isinstance(hashes, list):
count = detach_images(workflow_id, hashes)
else:
count = detach_workflow_images(workflow_id)
return web.json_response({"cleared": count})
@routes.post("/promptchain/image-delete")
async def _api_image_delete(request):
"""Permanently delete an image by content hash. The same bytes can live at
several paths at once (output/input/temp + a cached copy + the
promptchain_source_<hash12> staging copies LoadImage uses for lineage), and
resolve_image_path heals a stale record onto any surviving copy — so deleting
one file let the image reappear on the next read. delete_image purges every
replica and tombstones the record so heal-at-read can't resurrect it. Returns
the primary scope + relative path so callers can broadcast the removal."""
data, err = await parse_json(request)
if err: return err
image_hash = data.get("hash", "")
if not HASH_RE.match(image_hash):
return error_response("invalid hash")
from .history_db import delete_image, get_image_meta
# read meta BEFORE deleting — get_image_meta filters tombstoned rows, so the
# broadcast path has to be captured while the record is still live
meta = get_image_meta(image_hash) or {}
result = delete_image(image_hash)
if not result.get("deleted"):
return web.json_response(result, status=404)
sub = meta.get("subfolder") or ""
fn = meta.get("filename") or ""
return web.json_response({
"deleted": True,
"scope": meta.get("source_type") or "output",
"path": (f"{sub}/{fn}" if sub else fn),
})
# ── lineage + metadata ───────────────────────────────────────────
@routes.get("/promptchain/lineage/{hash}")
async def _api_lineage(request):
image_hash = request.match_info.get("hash", "")
if not HASH_RE.match(image_hash):
return error_response("invalid hash")
from .history_db import get_lineage
data = get_lineage(image_hash)
if not data["image"]:
return error_response("not found", 404)
return web.json_response(data)
@routes.get("/promptchain/image-meta/{hash}")
async def _api_image_meta(request):
image_hash = request.match_info.get("hash", "")
if not HASH_RE.match(image_hash):
return error_response("invalid hash")
from .history_db import get_image_meta
meta = get_image_meta(image_hash)
if not meta:
return error_response("not found", 404)
return web.json_response(meta)
@routes.post("/promptchain/check-orphans")
async def _api_check_orphans(request):
data, err = await parse_json(request)
if err: return err
hashes = data.get("hashes", [])
if not hashes or not isinstance(hashes, list):
return web.json_response({"orphaned": []})
from .history_db import check_orphans
orphaned = check_orphans(hashes)
return web.json_response({"orphaned": orphaned})
@routes.post("/promptchain/reattach")
async def _api_reattach_image(request):
"""Re-attach an orphaned image record to a user-supplied file. The record's
key is the sha256 of its content, so the upload is accepted only when its
digest matches — picking the wrong file cannot corrupt lineage. The
verified bytes are staged as a content-addressed input copy (same
convention image-workflow uses) and the record re-pointed there."""
import hashlib
import re
# Stream the multipart body with a hard byte cap rather than buffering the
# whole upload via request.post() — a re-attach only needs to digest-match
# an existing record, so an oversize payload is never legitimate.
reader = await request.multipart()
image_hash = ""
raw = None
upload_name = ""
while True:
part = await reader.next()
if part is None:
break
if part.name == "hash":
image_hash = (await part.text()).strip()
elif part.name == "image":
upload_name = part.filename or ""
buf = bytearray()
while True:
chunk = await part.read_chunk(64 * 1024)
if not chunk:
break
buf.extend(chunk)
if len(buf) > _MAX_EXTRACT_BYTES:
return error_response("file too large", 413)
raw = bytes(buf)
if not HASH_RE.match(image_hash):
return error_response("invalid hash")
if not raw:
return error_response("missing image upload")
from .history_db import get_image_meta, reattach_record
meta = get_image_meta(image_hash)
if not meta:
return error_response("not found", 404)
digest = await asyncio.to_thread(lambda: hashlib.sha256(raw).hexdigest())
if digest != image_hash:
return error_response("file content does not match this image", 409)
ext = os.path.splitext(upload_name)[1].lower()
if not re.fullmatch(r"\.[a-z0-9]{1,5}", ext):
fmt = (meta.get("format") or "").lower()
ext = ".jpg" if fmt == "jpeg" else f".{fmt}" if fmt else ".png"
import folder_paths
name = f"promptchain_source_{image_hash[:12]}{ext}"
dest = os.path.join(folder_paths.get_input_directory(), name)
try:
await asyncio.to_thread(_write_bytes, dest, raw)
except OSError as e:
return error_response(f"could not stage file: {e}", 500)
reattach_record(image_hash, name, "", "input")
return web.json_response(get_image_meta(image_hash))
def _write_bytes(dest: str, raw: bytes) -> None:
with open(dest, "wb") as f:
f.write(raw)