Files
willmiao-ComfyUI-Lora-Manager/py/services/workflow_scanner.py
T
Will Miao 749c71976a feat(workflows): resolve base model when creating library entries
Newly created workflow entries used to be hardcoded to "Unknown". The
base model is now resolved at creation time only (Create LM-managed
workflow and first-time scans); write-back never re-resolves it.

Resolution order:
1. generation-time hint: on create with a prompt context, the primary
   checkpoint from the metadata collector (primary-sampler tracing plus
   lm_marker_role marks) outranks static analysis when it resolves in
   the local checkpoint library
2. static votes: majority base model of the referenced checkpoints in
   the local library, falling back to the referenced LoRAs
3. CivitAI URN fallback for one-off user actions (never scans)

Workflow analysis now extracts structured loader model references from
both API-format and UI-format graphs, including loaders whose widget
values are serialized as arrays.
2026-10-31 07:57:29 +08:00

236 lines
9.4 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.constants import PREVIEW_EXTENSIONS
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)
# LM-generated preview sidecars (``<workflow>.preview.webp``) share the image
# artifact extensions, so they must be filtered by name before the workflow
# extraction step. Without this, a preview that embeds a workflow (e.g. one
# written with preserve_metadata) would land in the library as a second entry.
_PREVIEW_SUFFIXES = tuple(
ext for ext in PREVIEW_EXTENSIONS if ext.startswith(".preview.")
)
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()
if lowered.endswith(_SIDECAR_SUFFIXES):
return False
# Preview assets share the artifact image extensions (.webp/.png), so
# a preview sidecar named "<workflow>.preview.webp" must never be
# indexed as an image artifact of its own.
if lowered.endswith(_PREVIEW_SUFFIXES):
return False
return True
def get_model_roots(self) -> List[str]:
"""Return the configured workflow library root (empty while disabled)."""
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 []
return [root for root in (config.workflows_roots or []) if root]
@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
metadata.primary_model_refs = analysis.primary_model_refs
return metadata
async def _create_default_metadata(
self,
file_path: str,
*,
include_remote: bool = False,
checkpoint_name_hint: Optional[str] = None,
) -> 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. The base model is resolved from the
workflow's referenced models against the local library;
``include_remote`` (one-off user actions only, never scans) also
allows the CivitAI URN fallback. ``checkpoint_name_hint`` names the
primary checkpoint observed at generation time and outranks the
static votes when it resolves locally.
"""
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
try:
from .workflow_base_model import resolve_workflow_base_model
base_model = await resolve_workflow_base_model(
primary_model_refs=analysis.primary_model_refs,
model_refs=analysis.model_refs,
civitai_refs=analysis.civitai_refs,
include_remote=include_remote,
checkpoint_name_hint=checkpoint_name_hint,
)
except Exception:
logger.debug(
"Base model resolution failed for %s", file_path, exc_info=True
)
base_model = 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=base_model or "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,
primary_model_refs=analysis.primary_model_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
async def register_local_artifact(
self,
file_path: str,
*,
checkpoint_name_hint: Optional[str] = None,
) -> Optional[WorkflowMetadata]:
"""Create metadata and cache entry for a workflow written by LM.
Used by the "Create LM-managed workflow" flow: the file is already on
disk, so this reuses the scanner's metadata builder (analysis fields,
preview lookup, sidecar write) and then inserts/updates the cache entry
without forcing a full rescan. Being a one-off user action, base model
resolution here may also consult CivitAI URNs embedded in the graph.
``checkpoint_name_hint`` names the primary checkpoint observed at
generation time and outranks the static votes when it resolves locally.
"""
metadata = await self._create_default_metadata(
file_path, include_remote=True, checkpoint_name_hint=checkpoint_name_hint
)
if metadata is None:
return None
await self.sync_cache_from_metadata(file_path, metadata.to_dict())
return metadata