Files
DemonGatanjieu-Anomalous_Mo…/api/recipe_schema.py
T
DemonGatanjieuandClaude Opus 5.5 b323d82220 Focus values: mark what matters most in a workflow
- Every node value under the details' "every node's values" has a star; marked values show at
  the top of the workflow's own values and of each set as tiles (node, value, a note, and on a
  set the default when it differs). Set rows list them first; the comparison window leads its
  key values with them and their note.
- Kept in the recipe's params as focusParams [{node, type, widget, note}]; the server refuses
  any other shape (at most 12, short strings, notes up to 120 characters). Kept while the node
  of that id and type is there, also when the workflow is updated from the canvas.
- compareParams' isKey now also receives the node ids, so a mark holds for one node only.
- Tests: tests/recipe_focus.mjs, focus values in test_recipe_roundtrip.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:03:40 +08:00

416 lines
17 KiB
Python

"""Workflow Recipe record normalization, model references, and enrichment."""
import hashlib
import json
import os
import re
import time
import folder_paths
from .metadata import get_metadata
from .recipe_constants import *
from .path_utils import _model_roots, _resolve_exact_model_reference
from .recipe_images import _attach_preview_snapshots
from .utils import require_filename, resolve_within
from .workflow_schema import (
_node_title, _node_type, _parameter_signature, _validate_workflow,
_widget_values, _workflow_fingerprint,
)
# All-in-one loaders with a verified serialized widget layout. Must stay in sync
# with ALL_IN_ONE_LOADER_SPECS in web/modules/recipe_identity.js.
ALL_IN_ONE_LOADER_SPECS = {
# ComfyUI-Easy-Use: ckpt_name, vae_name, clip_skip, lora_name, ...
"easy a1111loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
# ComfyUI-Easy-Use: ckpt_name, config_name, vae_name, clip_skip, lora_name, ...
"easy fullloader": ((0, "checkpoint", "ckpt_name"), (2, "vae", "vae_name"), (4, "lora", "lora_name")),
# efficiency-nodes-comfyui: ckpt_name, vae_name, clip_skip, lora_name, ...
"efficient loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
}
PLACEHOLDER_MODEL_VALUES = {"none", "baked vae"}
def _model_reference_specs(node):
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
node_type = _node_type(node)
lowered = node_type.lower().strip()
specs = []
if lowered in ALL_IN_ONE_LOADER_SPECS:
specs.extend(ALL_IN_ONE_LOADER_SPECS[lowered])
elif re.search(r"checkpointloader(simple)?$", lowered):
specs.append((0, "checkpoint", "checkpoint"))
elif lowered.endswith("unetloader"):
specs.append((0, "unet", "unet"))
elif re.search(r"loraloader", lowered):
specs.append((0, "lora", "lora"))
elif lowered.endswith("vaeloader"):
specs.append((0, "vae", "vae"))
elif lowered.endswith("clipvisionloader"):
specs.append((0, "clip_vision", "clip_vision"))
elif lowered.endswith("controlnetloader"):
specs.append((0, "controlnet", "controlnet"))
elif re.search(r"(?:^|[^a-z])(?:dual|triple)?cliploader$", lowered):
specs.append((0, "text_encoder", "clip"))
if "dualclip" in lowered or "tripleclip" in lowered:
specs.append((1, "text_encoder", "clip"))
if "tripleclip" in lowered:
specs.append((2, "text_encoder", "clip"))
return specs
def _identity_for_reference(saved_value):
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return {"status": "unavailable"}, None
identity = {"status": "unverified", "provenance": "local cached metadata"}
origin = None
try:
identity["size"] = os.path.getsize(resolved["path"])
except OSError:
pass
try:
metadata = get_metadata(resolved["path"])
candidate_hash = str(metadata.get("hash") or "").strip()
if SHA256_PATTERN.fullmatch(candidate_hash):
identity["status"] = "verified"
identity["sha256"] = candidate_hash.lower()
identity["provenance"] = metadata.get("hash_source") or "sidecar file SHA-256"
civitai_url = metadata.get("civitai_url")
if civitai_url:
origin = {
"provider": "civitai",
"model_name": metadata.get("name") or "",
"model_url": civitai_url,
"model_id": metadata.get("model_id") or "",
"version_id": metadata.get("version_id") or ""
}
except Exception:
pass
return identity, origin
def _computed_identity_for_reference(saved_value):
"""Compute an exact SHA-256 only after an explicit save-time opt-in."""
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
return {"status": "unavailable"}
digest = hashlib.sha256()
try:
with open(resolved["path"], "rb") as model_file:
for block in iter(lambda: model_file.read(4 * 1024 * 1024), b""):
digest.update(block)
return {
"status": "verified",
"sha256": digest.hexdigest(),
"size": os.path.getsize(resolved["path"]),
"provenance": "computed during recipe save",
}
except OSError:
return {"status": "unverified"}
def _workflow_identity_for_reference(workflow, node_id, saved_value):
extra = workflow.get("extra") if isinstance(workflow, dict) else None
hashes = extra.get("anomalous_hashes") if isinstance(extra, dict) else None
if not isinstance(hashes, dict) or not isinstance(saved_value, str):
return None
normalized = saved_value.replace("\\", "/")
windows_path = saved_value.replace("/", "\\")
keys = (
f"{node_id}_{saved_value}",
f"{node_id}_{normalized}",
f"{node_id}_{windows_path}",
saved_value,
normalized,
windows_path,
)
record = next((hashes[key] for key in keys if key in hashes), None)
candidate_hash = record if isinstance(record, str) else record.get("hash") if isinstance(record, dict) else None
if not isinstance(candidate_hash, str) or not SHA256_PATTERN.fullmatch(candidate_hash.strip()):
return None
identity = {
"status": "verified",
"sha256": candidate_hash.strip().lower(),
"provenance": "workflow snapshot",
}
if isinstance(record, dict):
try:
identity["size"] = int(record.get("size"))
except (TypeError, ValueError):
pass
return identity
def _build_model_references(recipe, verify_identities=False):
workflow = recipe.get("workflow") if isinstance(recipe, dict) else None
params = recipe.get("params") if isinstance(recipe, dict) else None
base_model = params.get("baseModel") if isinstance(params, dict) else None
references = []
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
if not isinstance(node, dict):
continue
values = _widget_values(node)
for widget_index, category, widget_name in _model_reference_specs(node):
saved_value = values[widget_index] if widget_index < len(values) else None
if not isinstance(saved_value, str) or not saved_value.strip():
continue
if saved_value.strip().lower() in PLACEHOLDER_MODEL_VALUES:
continue
identity_result = _identity_for_reference(saved_value)
if isinstance(identity_result, tuple):
identity, origin = (identity_result + (None,))[:2]
else:
# Keep compatibility with older callers/tests that provide the
# pre-origin helper contract and return identity only.
identity, origin = identity_result, None
workflow_identity = _workflow_identity_for_reference(workflow, node.get("id"), saved_value)
if workflow_identity:
identity = workflow_identity
elif (
verify_identities
and category in VERIFIABLE_RECIPE_MODEL_CATEGORIES
and identity.get("status") != "verified"
):
identity = _computed_identity_for_reference(saved_value)
ref_dict = {
"node_id": node.get("id"),
"node_type": _node_type(node) or "Unknown",
"node_title": _node_title(node),
"widget_index": widget_index,
"widget_name": widget_name,
"saved_value": saved_value,
"category": category,
"base_model": base_model,
"identity": identity,
}
if origin:
ref_dict["origin"] = origin
references.append(ref_dict)
return references
def _model_reference_key(reference):
if not isinstance(reference, dict):
return None
return (
reference.get("node_id"),
reference.get("widget_index"),
reference.get("category"),
reference.get("saved_value"),
)
def _preserve_model_reference_fields(previous_references, references, preserve_identity=True):
"""Carry package/history presentation and identity without trusting names."""
previous = {
_model_reference_key(reference): reference
for reference in previous_references or []
if _model_reference_key(reference) is not None
and isinstance(reference, dict)
}
for reference in references:
prior = previous.get(_model_reference_key(reference))
if prior is None:
continue
for field in ("identity", "origin", "preview"):
if field == "preview" or (field in ("identity", "origin") and preserve_identity):
if isinstance(prior.get(field), dict):
reference[field] = json.loads(json.dumps(prior[field], ensure_ascii=False))
user_note = prior.get("user_note")
if isinstance(user_note, str) and user_note.strip():
reference["user_note"] = user_note.strip()
def _enrich_recipe(
recipe,
recipes_dir=None,
filename=None,
recapture_previews=True,
refresh_identities=False,
verify_identities=False,
):
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
# Keep the exact parameter-match identity alongside the structural
# fingerprint. The detail panel can use this value for result discovery
# without asking the browser to recreate or upload a legacy notebook.
recipe["parameter_signature"] = _parameter_signature(recipe["workflow"])
params = dict(recipe.get("params") or {})
previous_references = params.get("model_references", [])
references = _build_model_references(recipe, verify_identities=verify_identities)
_preserve_model_reference_fields(
previous_references,
references,
preserve_identity=not (refresh_identities or verify_identities),
)
if recipes_dir and filename and recapture_previews:
_attach_preview_snapshots(recipe, recipes_dir, filename, references)
params["model_references"] = references
recipe["params"] = params
if recipe.get("workflow_scope") not in {"partial", "complete"}:
recipe["workflow_scope"] = "complete"
recipe["schema_version"] = 7
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
return recipe
def _normalise_source_image(value):
if value is None:
return None
if not isinstance(value, dict) or value.get("type") != "output":
raise ValueError("Invalid source image")
filename = require_filename(value.get("filename", ""))
subfolder = value.get("subfolder", "")
if not isinstance(subfolder, str) or len(subfolder) > MAX_SOURCE_SUBFOLDER_LENGTH:
raise ValueError("Invalid source image")
output_dir = folder_paths.get_output_directory()
resolve_within(output_dir, subfolder)
return {
"filename": filename,
"subfolder": subfolder,
"type": "output",
}
def _normalise_focus_params(entries):
"""A workflow's focus values (重点参数): [{node, type, widget, note}], all short text; anything else is refused."""
if not isinstance(entries, list) or len(entries) > MAX_FOCUS_PARAMS:
raise ValueError("Invalid recipe focus values")
kept = []
seen = set()
for entry in entries:
if not isinstance(entry, dict):
raise ValueError("Invalid recipe focus value")
node, node_type, widget, note = (entry.get(key, "") for key in ("node", "type", "widget", "note"))
if not all(isinstance(value, str) for value in (node, node_type, widget, note)):
raise ValueError("Invalid recipe focus value")
if (
not node or not widget or len(node) > 32
or max(len(node_type), len(widget)) > MAX_FOCUS_NAME_LENGTH
or len(note) > MAX_FOCUS_NOTE_LENGTH
):
raise ValueError("Invalid recipe focus value")
if (node, widget) in seen:
continue
seen.add((node, widget))
kept.append({"node": node, "type": node_type, "widget": widget, "note": note.strip()})
return kept
def _normalise_recipe(payload):
if not isinstance(payload, dict):
raise ValueError("Invalid recipe")
name = payload.get("name", "")
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_NAME_LENGTH:
raise ValueError("Invalid recipe name")
raw_tags = payload.get("tags", [])
if not isinstance(raw_tags, list):
raise ValueError("Invalid recipe tags")
tags = []
for tag in raw_tags:
if not isinstance(tag, str):
raise ValueError("Invalid recipe tag")
tag = tag.strip()
if not tag or len(tag) > MAX_TAG_LENGTH:
continue
if tag not in tags:
tags.append(tag)
if len(tags) >= MAX_TAGS:
break
notes = payload.get("notes", "")
if not isinstance(notes, str) or len(notes) > MAX_NOTES_LENGTH:
raise ValueError("Invalid recipe notes")
params = payload.get("params", {})
workflow = payload.get("workflow")
if not isinstance(params, dict) or not isinstance(workflow, dict):
raise ValueError("Invalid recipe workflow")
model_references = params.get("model_references")
if model_references is not None:
if not isinstance(model_references, list):
raise ValueError("Invalid recipe model references")
for reference in model_references:
if not isinstance(reference, dict):
raise ValueError("Invalid recipe model reference")
user_note = reference.get("user_note")
if user_note is None:
continue
if not isinstance(user_note, str) or len(user_note) > MAX_MODEL_NOTE_LENGTH:
raise ValueError("Invalid recipe model note")
if user_note.strip():
reference["user_note"] = user_note.strip()
else:
reference.pop("user_note", None)
if "focusParams" in params:
params["focusParams"] = _normalise_focus_params(params["focusParams"])
_validate_workflow(workflow)
workflow_scope = payload.get("workflow_scope", "complete")
if workflow_scope not in {"partial", "complete"}:
raise ValueError("Invalid recipe workflow scope")
thumbnail = payload.get("thumbnail")
if thumbnail is not None:
if (
not isinstance(thumbnail, str)
or len(thumbnail) > MAX_THUMBNAIL_LENGTH
or not thumbnail.lower().startswith(SAFE_THUMBNAIL_PREFIXES)
):
thumbnail = None
presentation = payload.get("presentation", {})
if presentation is None:
presentation = {}
if not isinstance(presentation, dict):
raise ValueError("Invalid recipe presentation")
save_model_preview_snapshots = presentation.get("save_model_preview_snapshots", True)
if not isinstance(save_model_preview_snapshots, bool):
raise ValueError("Invalid recipe presentation")
cover_asset_id = presentation.get("cover_asset_id")
if cover_asset_id is not None:
cover_asset_id = require_filename(cover_asset_id)
if not cover_asset_id.startswith("cover-") or not cover_asset_id.endswith(".webp"):
raise ValueError("Invalid recipe presentation")
recipe = {
"schema_version": 1,
"name": name,
"tags": tags,
"notes": notes.strip(),
"params": params,
"workflow": workflow,
"workflow_scope": workflow_scope,
"thumbnail": thumbnail,
"source_image": _normalise_source_image(payload.get("source_image")),
"presentation": {
"save_model_preview_snapshots": save_model_preview_snapshots,
**({"cover_asset_id": cover_asset_id} if cover_asset_id else {}),
},
"timestamp": int(time.time() * 1000),
}
encoded = json.dumps(recipe, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
return recipe
def _updated_recipe(payload, existing):
recipe = _normalise_recipe(payload)
existing_presentation = existing.get("presentation") if isinstance(existing.get("presentation"), dict) else {}
existing_cover = existing_presentation.get("cover_asset_id")
source_unchanged = recipe.get("source_image") == existing.get("source_image")
if existing_cover and source_unchanged and not recipe["presentation"].get("cover_asset_id"):
recipe["presentation"]["cover_asset_id"] = existing_cover
created_timestamp = existing.get("created_timestamp", existing.get("timestamp"))
if isinstance(created_timestamp, int):
recipe["created_timestamp"] = created_timestamp
recipe["updated_timestamp"] = recipe["timestamp"]
return recipe