Library entries are the original artifact bytes (SHA256 = CivitAI identity); container artifacts (.zip, .png, .webp) get their extracted workflow document materialized as a *.workflow.json sidecar that follows rename/move/delete. - workflow_extraction: json direct-read, zip candidate pick, png/webp metadata - scanner whitelist extended; non-workflow files rejected; adjust_metadata fills analysis fields for downloaded entries; image artifacts self-preview - download_manager: workflow branch end-to-end, no zip unpacking, file selection prefers primary then json/zip/image - lifecycle: delete/rename/pending-delete carry the derived document - send-to-comfy reads the derived document for container artifacts - fix: force_refresh with no cache falls back to full initialization (first scan before deferred background init no longer crashes)
203 lines
7.5 KiB
Python
203 lines
7.5 KiB
Python
"""Extraction of the ComfyUI workflow document from a library artifact.
|
|
|
|
The workflow library keeps the original uploaded file byte-identical — its
|
|
SHA256 is the identity CivitAI hashed (by-hash lookup and version tracking
|
|
depend on it). Consumers (send-to-ComfyUI, graph analysis) work on the
|
|
extracted workflow document instead:
|
|
|
|
- ``.json`` artifacts ARE the document; they are validated and read in place.
|
|
- ``.zip`` / ``.png`` / ``.webp`` containers get the document materialized
|
|
once into an LM-owned derived sidecar ``<name>.workflow.json`` inside the
|
|
artifact's sidecar directory. Scanners exclude the suffix from the library
|
|
walk; lifecycle operations (move/rename/delete) carry it like any other
|
|
sidecar.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import zipfile
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from .sidecar_paths import get_sidecar_dir
|
|
from .workflow_analysis import (
|
|
MAX_WORKFLOW_JSON_BYTES,
|
|
load_workflow_file,
|
|
parse_workflow_text,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WORKFLOW_DOCUMENT_SUFFIX = ".workflow.json"
|
|
|
|
# Image containers whose metadata chunks can embed a ComfyUI workflow.
|
|
IMAGE_CONTAINER_EXTENSIONS = frozenset({".png", ".webp"})
|
|
|
|
# Every file type the workflow library accepts.
|
|
WORKFLOW_ARTIFACT_EXTENSIONS = frozenset({".json", ".zip"} | IMAGE_CONTAINER_EXTENSIONS)
|
|
|
|
# Bounds for zip exploration: workflow archives are user uploads with no
|
|
# structural contract, so candidate discovery is capped rather than exhaustive.
|
|
_MAX_ZIP_JSON_CANDIDATES = 64
|
|
|
|
|
|
def get_workflow_document_path(artifact_path: str) -> str:
|
|
"""Return the path of the extracted workflow document for an artifact.
|
|
|
|
``.json`` artifacts are their own document; containers map to the
|
|
derived ``<name>.workflow.json`` in the artifact's sidecar directory
|
|
(alongside the artifact or in the centralized mirror).
|
|
"""
|
|
|
|
if os.path.splitext(artifact_path)[1].lower() == ".json":
|
|
return artifact_path
|
|
base_name = os.path.splitext(os.path.basename(artifact_path))[0]
|
|
return os.path.join(
|
|
get_sidecar_dir(artifact_path), base_name + WORKFLOW_DOCUMENT_SUFFIX
|
|
)
|
|
|
|
|
|
def _zip_json_candidates(archive: zipfile.ZipFile) -> List[zipfile.ZipInfo]:
|
|
"""Return the archive's plausible workflow documents, best first."""
|
|
|
|
candidates: List[zipfile.ZipInfo] = []
|
|
for info in archive.infolist():
|
|
if info.is_dir():
|
|
continue
|
|
parts = info.filename.replace("\\", "/").split("/")
|
|
# Skip macOS resource forks and hidden files/directories.
|
|
if any(part == "__MACOSX" or part.startswith(".") for part in parts):
|
|
continue
|
|
if not parts[-1].lower().endswith(".json"):
|
|
continue
|
|
if info.file_size > MAX_WORKFLOW_JSON_BYTES:
|
|
continue
|
|
candidates.append(info)
|
|
if len(candidates) >= _MAX_ZIP_JSON_CANDIDATES:
|
|
break
|
|
# Prefer shallow, small documents: the workflow is typically the
|
|
# archive's headline content, not a bundled config deep in a subfolder.
|
|
candidates.sort(key=lambda info: (len(info.filename.split("/")), info.file_size))
|
|
return candidates
|
|
|
|
|
|
def _extract_from_zip(archive_path: str) -> Optional[Dict[str, Any]]:
|
|
try:
|
|
with zipfile.ZipFile(archive_path, "r") as archive:
|
|
for info in _zip_json_candidates(archive):
|
|
try:
|
|
with archive.open(info) as handle:
|
|
raw = handle.read(MAX_WORKFLOW_JSON_BYTES + 1)
|
|
text = raw.decode("utf-8")
|
|
except (OSError, UnicodeDecodeError, zipfile.BadZipFile):
|
|
continue
|
|
document = parse_workflow_text(text)
|
|
if document is not None:
|
|
return document
|
|
except (OSError, zipfile.BadZipFile) as exc:
|
|
logger.debug("Workflow archive unreadable: %s (%s)", archive_path, exc)
|
|
return None
|
|
|
|
|
|
def _extract_from_image(image_path: str) -> Optional[Dict[str, Any]]:
|
|
# Local import: PIL/piexif are heavy and only needed for image artifacts.
|
|
from .exif_utils import ExifUtils
|
|
|
|
try:
|
|
metadata = ExifUtils.load_structured_metadata(image_path)
|
|
except Exception as exc:
|
|
logger.debug("Workflow image metadata unreadable: %s (%s)", image_path, exc)
|
|
return None
|
|
# The UI-format document ("workflow") is what ComfyUI's loadGraphData
|
|
# consumes; fall back to the API-format "prompt" payload.
|
|
for key in ("workflow", "prompt"):
|
|
text = metadata.get(key)
|
|
if not text:
|
|
continue
|
|
document = parse_workflow_text(text)
|
|
if document is not None:
|
|
return document
|
|
return None
|
|
|
|
|
|
def extract_workflow_document(artifact_path: str) -> Optional[Dict[str, Any]]:
|
|
"""Extract the ComfyUI workflow document from an artifact.
|
|
|
|
Returns the parsed document when the artifact carries a recognizable
|
|
workflow, ``None`` otherwise (unreadable file, unknown extension, no
|
|
workflow inside). Never raises.
|
|
"""
|
|
|
|
extension = os.path.splitext(artifact_path)[1].lower()
|
|
try:
|
|
if extension == ".json":
|
|
return load_workflow_file(artifact_path)
|
|
if extension == ".zip":
|
|
return _extract_from_zip(artifact_path)
|
|
if extension in IMAGE_CONTAINER_EXTENSIONS:
|
|
return _extract_from_image(artifact_path)
|
|
except Exception as exc: # defensive: extraction must never break a scan
|
|
logger.debug("Workflow extraction failed for %s: %s", artifact_path, exc)
|
|
return None
|
|
|
|
|
|
def materialize_workflow_document(
|
|
artifact_path: str, *, read_path: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""Ensure the extracted workflow document exists on disk.
|
|
|
|
Returns the document path, or ``None`` when the artifact carries no
|
|
recognizable workflow. ``.json`` artifacts are their own document (the
|
|
path is returned after validation; nothing is written). Containers get
|
|
the document written to the derived sidecar path; an up-to-date derived
|
|
file is reused without re-extracting.
|
|
|
|
``read_path`` overrides the path the artifact bytes are read from (the
|
|
scanner hashes/extracts via the real path while sidecar locations derive
|
|
from the business path).
|
|
"""
|
|
|
|
read_path = read_path or artifact_path
|
|
|
|
if os.path.splitext(artifact_path)[1].lower() == ".json":
|
|
return artifact_path if load_workflow_file(read_path) is not None else None
|
|
|
|
document_path = get_workflow_document_path(artifact_path)
|
|
try:
|
|
if os.path.exists(document_path) and os.path.getmtime(
|
|
document_path
|
|
) >= os.path.getmtime(read_path):
|
|
return document_path
|
|
except OSError:
|
|
pass
|
|
|
|
document = extract_workflow_document(read_path)
|
|
if document is None:
|
|
return None
|
|
|
|
try:
|
|
os.makedirs(os.path.dirname(document_path), exist_ok=True)
|
|
with open(document_path, "w", encoding="utf-8") as handle:
|
|
json.dump(document, handle, ensure_ascii=False)
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Failed to write workflow document %s: %s", document_path, exc
|
|
)
|
|
return None
|
|
return document_path
|
|
|
|
|
|
def load_artifact_workflow_document(artifact_path: str) -> Optional[Dict[str, Any]]:
|
|
"""Load the workflow document for a library artifact.
|
|
|
|
Reads the derived sidecar for containers (materializing it on demand
|
|
when missing or stale) and the artifact itself for bare JSON.
|
|
"""
|
|
|
|
document_path = materialize_workflow_document(artifact_path)
|
|
if document_path is None:
|
|
return None
|
|
return load_workflow_file(document_path)
|