Files
willmiao-ComfyUI-Lora-Manager/py/services/workflow_scanner.py
T
Will Miao 7d071ab3aa feat(workflows): accept zip/png/webp artifacts with derived workflow documents
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)
2026-10-28 16:47:11 +08:00

178 lines
6.6 KiB
Python

"""Scanner for ComfyUI workflow resources (.json documents and containers)."""
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import List, Optional
from ..config import config
from ..utils.file_utils import (
calculate_sha256,
find_preview_file,
normalize_path,
)
from ..utils.metadata_manager import MetadataManager
from ..utils.models import WorkflowMetadata
from ..utils.sidecar_paths import get_preview_dir
from ..utils.workflow_analysis import analyze_workflow_file
from ..utils.workflow_extraction import (
IMAGE_CONTAINER_EXTENSIONS,
WORKFLOW_ARTIFACT_EXTENSIONS,
WORKFLOW_DOCUMENT_SUFFIX,
materialize_workflow_document,
)
from .model_hash_index import ModelHashIndex
from .model_scanner import ModelScanner
logger = logging.getLogger(__name__)
# Sidecar files share the .json extension; they must never enter the library.
_SIDECAR_SUFFIXES = (".metadata.json", WORKFLOW_DOCUMENT_SUFFIX)
class WorkflowScanner(ModelScanner):
"""Scan and manage ComfyUI workflow artifacts.
Library entries are the original uploaded files, kept byte-identical so
their SHA256 stays the CivitAI identity (by-hash lookup and version
tracking depend on it). ``.json`` artifacts carry the graph directly;
``.zip``/``.png``/``.webp`` containers get the workflow document
extracted into a derived ``.workflow.json`` sidecar that every consumer
(graph analysis, send-to-ComfyUI) reads. Files from which no workflow
document can be extracted never enter the library — this keeps settings
JSON and plain preview images out.
"""
def __init__(self):
super().__init__(
model_type="workflow",
model_class=WorkflowMetadata,
file_extensions=set(WORKFLOW_ARTIFACT_EXTENSIONS),
hash_index=ModelHashIndex(),
)
def _is_indexable_filename(self, filename: str) -> bool:
lowered = filename.lower()
return not lowered.endswith(_SIDECAR_SUFFIXES)
def get_model_roots(self) -> List[str]:
"""Workflow roots (primary + extra), empty while the feature is off."""
try:
from .settings_manager import get_settings_manager
if not get_settings_manager().is_workflows_enabled():
return []
except Exception: # pragma: no cover - defensive; settings unavailable
return []
roots: List[str] = []
roots.extend(config.workflows_roots or [])
roots.extend(config.extra_workflows_roots or [])
seen: set[str] = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots
@staticmethod
def _is_image_artifact(file_path: str) -> bool:
return os.path.splitext(file_path)[1].lower() in IMAGE_CONTAINER_EXTENSIONS
def adjust_metadata(self, metadata, file_path, root_path):
"""Fill workflow analysis fields when they are missing.
Runs on every scan and on the post-download path. The extraction
result is cached in the derived ``.workflow.json`` sidecar, so the
steady-state cost is one small JSON parse per entry whose analysis
fields have not been populated yet.
"""
if not isinstance(metadata, WorkflowMetadata) or metadata.workflow_format:
return metadata
try:
document_path = materialize_workflow_document(file_path)
analysis = (
analyze_workflow_file(document_path) if document_path else None
)
except Exception as exc:
logger.debug(
"Workflow analysis refresh failed for %s: %s", file_path, exc
)
return metadata
if analysis is None:
return metadata
metadata.workflow_format = analysis.workflow_format
metadata.node_count = analysis.node_count
metadata.node_classes = analysis.node_classes
metadata.model_refs = analysis.model_refs
metadata.civitai_refs = analysis.civitai_refs
return metadata
async def _create_default_metadata(
self, file_path: str
) -> Optional[WorkflowMetadata]:
"""Create metadata by hashing the artifact and analyzing its workflow.
Returns None for files that carry no ComfyUI workflow document, so
they stay out of the library.
"""
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found: {file_path}")
return None
document_path = materialize_workflow_document(
file_path, read_path=real_path
)
analysis = (
analyze_workflow_file(document_path) if document_path else None
)
if analysis is None:
logger.debug(f"Skipping artifact without a workflow: {file_path}")
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
if not preview_url and self._is_image_artifact(file_path):
# An image artifact doubles as its own preview.
preview_url = file_path
sha256 = await calculate_sha256(real_path)
if not sha256:
logger.warning(
f"Skipping metadata creation for empty workflow file: {file_path}"
)
return None
metadata = WorkflowMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256=sha256,
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
from_civitai=False,
workflow_format=analysis.workflow_format,
node_count=analysis.node_count,
node_classes=analysis.node_classes,
model_refs=analysis.model_refs,
civitai_refs=analysis.civitai_refs,
)
logger.info(f"Creating workflow metadata for {file_path}")
await MetadataManager.save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(
f"Error creating default workflow metadata for {file_path}: {e}"
)
return None