Files
DemonGatanjieuandClaude Opus 5.5 ec81f8b230 Bring a save's pictures back onto the output nodes
Going back to a save left the last run's picture on the output node, so the canvas and the picture told different stories.

- api/workflow_saves.py keeps what each output node made, previews too, the newest 16, and read_group gives them as `outputs`.
- ui_snapshot_bar.js puts them on the nodes through the tab's changeTracker (store, restore): no `executed` event, so no plugin takes them for a new run. Several runs sit on the node as a batch does. A node the save has no picture for goes empty; Load Image's own picture stays (snapshot_plan.outputsAt).

The lit number went out every other time with a Preview Image on the canvas, and an empty undo step came with it: a loaded workflow serializes a little differently from its file, and a node gains an empty widget list when it shows a picture. The number now goes out only when the canvas really changed (withoutEmptyValues), and those differences take no step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:20:59 +08:00

337 lines
13 KiB
Python

"""Saves of a workflow (the bar under the canvas, web/modules/ui_snapshot_bar.js): whole workflows kept
while the user works on one, a group for each canvas. A group is a folder of the store, named by the key
the workflow carries in its extra: index.json says what its saves are and each save's workflow is a file of
its own, so keeping one writes that file and a small index. Saves outlive their canvas: a closed tab or a
deleted file leaves them here, and only the user removes them, to the Recycle Bin. The page names a save's
runs (prompt ids); reading the group finds their pictures in ComfyUI's history and keeps the names, so a
page reloaded during a run loses nothing. It keeps them by output node too, previews as well: going back to
a save puts them on those nodes. Pictures stay where ComfyUI saved them; one gone is not shown.
A save becomes something else only as a workflow card, made by the page from its workflow (AD-023)."""
import asyncio
import json
import os
import re
import threading
import time
import uuid
import folder_paths
from aiohttp import web
from .path_utils import atomic_write_json, resolve_within
from .recipe_constants import MAX_RECIPE_BYTES
from .trash import TrashUnavailable, move_to_trash, trash_failure
INDEX = "index.json"
MAX_SAVES = 50 # of a group: past that the user removes some, nothing goes by itself
MAX_GROUPS = 2000 # read for a list
MAX_PICTURES = 200 # kept of a save, the newest
MAX_ON_NODE = 16 # of an output node at a save, the newest: a full round of runs
MAX_CHANGES = 40
MAX_RUNS = 16 # of a save at a time; web/modules/snapshot_plan.js MAX_COUNT
MAX_WAITING = 200
KEY = re.compile(r"^[0-9a-f-]{8,40}$")
SAVE_ID = re.compile(r"^[0-9a-f]{12}$")
PICTURES = (".png", ".jpg", ".jpeg", ".webp")
FULL = f"这个工作流的存档满了(最多 {MAX_SAVES} 个),删掉一些再存 / This workflow has {MAX_SAVES} saves, the most it takes: remove some first."
_lock = threading.Lock()
class SavesFull(Exception):
pass
def saves_dir():
return os.path.join(folder_paths.get_user_directory(), "workflows", "anomalous_saves")
def _folder(key):
if not isinstance(key, str) or not KEY.match(key):
raise ValueError("Invalid key")
return os.path.join(saves_dir(), key)
def _workflow_file(key, save_id):
if not isinstance(save_id, str) or not SAVE_ID.match(save_id):
raise ValueError("Invalid save")
return os.path.join(_folder(key), f"{save_id}.json")
def _read(key):
try:
with open(os.path.join(_folder(key), INDEX), encoding="utf-8") as file:
return json.load(file)
except FileNotFoundError:
return None
def _write(group):
group["updated"] = time.time()
atomic_write_json(os.path.join(_folder(group["key"]), INDEX), group)
def _groups():
root = saves_dir()
if not os.path.isdir(root):
return []
found = []
for name in sorted(os.listdir(root))[:MAX_GROUPS]:
try:
group = _read(name) if KEY.match(name) else None
except (OSError, ValueError):
continue
if group:
found.append(group)
return found
def _save_of(group, save_id):
for save in (group or {}).get("saves") or []:
if save["id"] == save_id:
return save
raise LookupError("No such save")
def _queue():
from server import PromptServer
return PromptServer.instance.prompt_queue
def _run_outputs(record):
"""The pictures of a finished run by the node that shows them: {node: pictures}."""
found = {}
for node, output in (record.get("outputs") or {}).items():
pictures = [{"filename": image["filename"], "subfolder": image.get("subfolder") or "", "type": image.get("type") or "output"}
for image in output.get("images") or [] if str(image.get("filename") or "").lower().endswith(PICTURES)]
if pictures:
found[str(node)] = pictures
return found
def _run_pictures(record):
"""What a finished run made: the pictures it saved, or with none saved those it previewed."""
found = [picture for pictures in _run_outputs(record).values() for picture in pictures]
return [picture for picture in found if picture["type"] == "output"] or found
def _collect(group):
"""Moves the pictures of finished runs into their saves; whether anything changed. The queue is asked
first: a run leaves it straight into the history, so one in neither was taken off the queue or went
with a restart, and is forgotten."""
if not any(save["waiting"] for save in group["saves"]):
return False
running, pending = _queue().get_current_queue()
queued = {item[1] for item in [*running, *pending]}
changed = False
for save in group["saves"]:
for run in list(save["waiting"]):
if run["id"] in queued:
continue
record = _queue().get_history(prompt_id=run["id"]).get(run["id"])
save["pictures"].extend(picture for picture in _run_pictures(record or {}) if picture not in save["pictures"])
del save["pictures"][:-MAX_PICTURES]
for node, pictures in _run_outputs(record or {}).items():
on_node = save.setdefault("outputs", {}).setdefault(node, [])
on_node.extend(picture for picture in pictures if picture not in on_node)
del on_node[:-MAX_ON_NODE]
save["waiting"].remove(run)
changed = True
return changed
def _there(picture):
folder = folder_paths.get_directory_by_type(picture["type"])
if not folder:
return False
try:
return os.path.isfile(resolve_within(folder, picture["subfolder"], picture["filename"]))
except ValueError:
return False
def _view(group):
"""A group as the page shows it."""
saves = [{"id": save["id"], "at": save["at"], "count": save["count"], "changes": save["changes"],
"pictures": [picture for picture in save["pictures"] if _there(picture)], "running": len(save["waiting"])}
for save in group["saves"]]
return {"key": group["key"], "title": group.get("title") or "", "path": group.get("path") or "", "saves": saves}
def _outputs(save):
"""What a save's output nodes made, the newest first: no picture that is gone, no node left without one."""
shown = {node: [picture for picture in reversed(pictures) if _there(picture)] for node, pictures in (save.get("outputs") or {}).items()}
return {node: pictures for node, pictures in shown.items() if pictures}
def read_group(key, path=""):
"""The group of `key`, or the newest one of the workflow file `path`; None when there is none."""
with _lock:
group = _read(key) if key else None
if group is None and path:
group = max((item for item in _groups() if item.get("path") == path), key=lambda item: item["updated"], default=None)
if group is None:
return None
if _collect(group):
_write(group)
shown = _view(group)
for view, save in zip(shown["saves"], group["saves"]):
view["outputs"] = _outputs(save)
return shown
def list_groups():
"""Every group, the newest first: what it is called and its newest picture."""
with _lock:
groups = sorted(_groups(), key=lambda item: item["updated"], reverse=True)
listed = []
for group in groups:
shown = _view(group)
pictures = [picture for save in shown["saves"] for picture in save["pictures"]]
listed.append({"key": shown["key"], "title": shown["title"], "path": shown["path"], "updated": group["updated"],
"count": len(shown["saves"]), "cover": pictures[-1] if pictures else None})
return listed
def add_save(key, workflow, title, path, changes):
with _lock:
group = _read(key) or {"key": key, "created": time.time(), "saves": []}
if len(group["saves"]) >= MAX_SAVES:
raise SavesFull()
save_id = uuid.uuid4().hex[:12]
os.makedirs(_folder(key), exist_ok=True)
atomic_write_json(_workflow_file(key, save_id), workflow, MAX_RECIPE_BYTES)
group["saves"].append({"id": save_id, "at": time.time(), "count": 1, "changes": changes, "pictures": [], "waiting": []})
group.update(title=title, path=path)
_write(group)
return _view(group)
def read_workflow(key, save_id):
with open(_workflow_file(key, save_id), encoding="utf-8") as file:
return json.load(file)
def set_count(key, save_id, count):
with _lock:
group = _read(key)
_save_of(group, save_id)["count"] = max(1, min(MAX_RUNS, int(count)))
_write(group)
return _view(group)
def note_runs(key, save_id, prompt_ids):
with _lock:
group = _read(key)
save = _save_of(group, save_id)
save["waiting"].extend({"id": str(prompt_id)} for prompt_id in prompt_ids)
del save["waiting"][:-MAX_WAITING]
_write(group)
return _view(group)
def remove_save(key, save_id, next_changes=None):
"""Moves the save to the Recycle Bin; the group as it stands then, None when that was its last.
`next_changes`: what the save after it changes from the one it now follows."""
with _lock:
group = _read(key)
save = _save_of(group, save_id)
move_to_trash(_workflow_file(key, save_id))
at = group["saves"].index(save)
group["saves"].remove(save)
if next_changes is not None and at < len(group["saves"]):
group["saves"][at]["changes"] = next_changes
if group["saves"]:
_write(group)
return _view(group)
os.remove(os.path.join(_folder(key), INDEX)) # our own list of nothing
os.rmdir(_folder(key))
return None
def remove_group(key):
"""Moves a whole group to the Recycle Bin."""
with _lock:
if _read(key) is None:
raise LookupError("No such group")
move_to_trash(_folder(key))
def _changes(value):
"""What the page says changed from the save before, short strings only."""
if not isinstance(value, list):
return []
return [{str(name)[:40]: str(text)[:200] for name, text in item.items()} for item in value[:MAX_CHANGES] if isinstance(item, dict)]
async def _answer(work, *args):
"""`work` off the event loop, its answer as JSON; what went wrong as a line for the page."""
try:
return web.json_response(await asyncio.to_thread(work, *args))
except SavesFull:
return web.json_response({"message": FULL}, status=409)
except (TrashUnavailable, OSError) as error:
return web.json_response({"message": trash_failure(error)}, status=500)
except (LookupError, TypeError, ValueError) as error:
return web.json_response({"message": str(error)}, status=400)
async def _body(request):
try:
body = await request.json()
except ValueError:
body = None
return body if isinstance(body, dict) else {}
async def api_group(request):
"""GET /anomalous/saves/group?key&path - the open canvas's saves."""
query = request.query
return await _answer(lambda: {"group": read_group(query.get("key") or "", query.get("path") or "")})
async def api_list(request):
"""GET /anomalous/saves - every group."""
return await _answer(lambda: {"groups": list_groups()})
async def api_workflow(request):
"""GET /anomalous/saves/workflow?key&id - one save's whole workflow."""
query = request.query
return await _answer(lambda: {"workflow": read_workflow(query.get("key"), query.get("id"))})
async def api_add(request):
"""POST /anomalous/saves/add {key, workflow, title, path, changes}."""
body = await _body(request)
if not isinstance(body.get("workflow"), dict):
return web.json_response({"message": "Expected a workflow"}, status=400)
title, path = str(body.get("title") or "")[:200], str(body.get("path") or "")[:1024]
return await _answer(lambda: {"group": add_save(body.get("key"), body["workflow"], title, path, _changes(body.get("changes")))})
async def api_update(request):
"""POST /anomalous/saves/update {key, id, count}."""
body = await _body(request)
return await _answer(lambda: {"group": set_count(body.get("key"), body.get("id"), body.get("count"))})
async def api_ran(request):
"""POST /anomalous/saves/ran {key, id, prompt_ids}: runs of a save just queued."""
body = await _body(request)
return await _answer(lambda: {"group": note_runs(body.get("key"), body.get("id"), list(body.get("prompt_ids") or []))})
async def api_remove(request):
"""POST /anomalous/saves/remove {key, id, changes}."""
body = await _body(request)
changes = None if body.get("changes") is None else _changes(body["changes"])
return await _answer(lambda: {"group": remove_save(body.get("key"), body.get("id"), changes)})
async def api_remove_group(request):
"""POST /anomalous/saves/remove_group {key}."""
body = await _body(request)
return await _answer(lambda: remove_group(body.get("key")) or {"removed": True})