Skip to content

Commit adfd442

Browse files
authored
Merge pull request #29 from comfygit-ai/dev
Release 0.3.7: Multi-model extraction and export --allow-issues fix
2 parents 5cfedb7 + 796ce53 commit adfd442

12 files changed

Lines changed: 461 additions & 58 deletions

File tree

packages/cli/comfygit_cli/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def _add_global_commands(subparsers: argparse._SubParsersAction) -> None:
197197
# export - Export ComfyGit environment
198198
export_parser = subparsers.add_parser("export", help="Export ComfyGit environment (include relevant files from .cec)")
199199
export_parser.add_argument("path", type=Path, nargs="?", help="Path to output file")
200-
export_parser.add_argument("--allow-issues", action="store_true", help="Skip confirmation if models are missing source URLs")
200+
export_parser.add_argument("--allow-issues", action="store_true", help="Export even with unresolved workflows or models without source URLs")
201201
export_parser.set_defaults(func=global_cmds.export_env)
202202

203203
# Model management subcommands

packages/cli/comfygit_cli/global_commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,7 @@ def on_models_without_sources(self, models: list):
749749
callbacks = CLIExportCallbacks()
750750

751751
try:
752-
tarball_path = env.export_environment(output_path, callbacks=callbacks)
752+
tarball_path = env.export_environment(output_path, callbacks=callbacks, allow_issues=args.allow_issues)
753753

754754
# Check if we need user confirmation
755755
if callbacks.models_without_sources and not args.allow_issues:

packages/cli/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[project]
22
name = "comfygit"
3-
version = "0.3.6"
3+
version = "0.3.7"
44
description = "ComfyGit - Git-based environment management for ComfyUI"
55
readme = "README.md"
66
requires-python = ">=3.10"
77
dependencies = [
8-
"comfygit-core==0.3.6",
8+
"comfygit-core==0.3.7",
99
"argcomplete>=3.5.0",
1010
]
1111

packages/core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "comfygit-core"
3-
version = "0.3.6"
3+
version = "0.3.7"
44
description = "ComfyGit Core - Git-based ComfyUI environment manager"
55
readme = "README.md"
66
requires-python = ">=3.10"

packages/core/src/comfygit_core/analyzers/workflow_dependency_parser.py

Lines changed: 130 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ..logging.logging_config import get_logger
1010
from .node_classifier import NodeClassifier
1111
from ..configs.model_config import ModelConfig
12+
from ..configs.comfyui_models import MULTI_MODEL_WIDGET_CONFIGS
1213
from ..models.workflow import (
1314
WorkflowNodeWidgetRef,
1415
WorkflowNode,
@@ -88,57 +89,151 @@ def analyze_dependencies(self) -> WorkflowDependencies:
8889
def _extract_model_node_refs(self, node_id: str, node_info: WorkflowNode) -> List["WorkflowNodeWidgetRef"]:
8990
"""Extract possible model references from a single node.
9091
92+
Uses a two-pronged approach:
93+
1. Extract from properties.models (preferred - has URLs for auto-download)
94+
2. Fall back to widget extraction using MULTI_MODEL_WIDGET_CONFIGS
95+
9196
Args:
9297
node_id: Scoped node ID from workflow.nodes dict key (e.g., "uuid:12" for subgraph nodes)
9398
node_info: WorkflowNode object containing node data
9499
"""
100+
refs: list[WorkflowNodeWidgetRef] = []
95101

96-
refs = []
102+
# Strategy 1: Extract from properties.models (preferred - has URLs)
103+
property_models = node_info.properties.get('models', [])
104+
if property_models:
105+
refs.extend(self._extract_from_properties_models(node_id, node_info, property_models))
97106

98-
# Handle multi-model nodes specially
99-
if node_info.type == "CheckpointLoader":
100-
# Index 0: checkpoint, Index 1: config
101-
widgets = node_info.widgets_values or []
102-
if len(widgets) > 0 and widgets[0]:
103-
refs.append(WorkflowNodeWidgetRef(
104-
node_id=node_id, # Use scoped ID from dict key
105-
node_type=node_info.type,
106-
widget_index=0,
107-
widget_value=widgets[0]
108-
))
109-
if len(widgets) > 1 and widgets[1]:
110-
refs.append(WorkflowNodeWidgetRef(
111-
node_id=node_id, # Use scoped ID from dict key
112-
node_type=node_info.type,
113-
widget_index=1,
114-
widget_value=widgets[1]
115-
))
107+
# Strategy 2: Multi-model nodes (explicit widget indices from config)
108+
if node_info.type in MULTI_MODEL_WIDGET_CONFIGS:
109+
widget_refs = self._extract_multi_model_widgets(node_id, node_info)
110+
refs = self._merge_model_refs(refs, widget_refs)
116111

117-
# Standard single-model loaders
112+
# Strategy 3: Standard single-model loaders
118113
elif self.model_config.is_model_loader_node(node_info.type):
119-
widget_idx = self.model_config.get_widget_index_for_node(node_info.type)
120-
widgets = node_info.widgets_values or []
121-
if widget_idx < len(widgets) and widgets[widget_idx]:
122-
refs.append(WorkflowNodeWidgetRef(
123-
node_id=node_id, # Use scoped ID from dict key
124-
node_type=node_info.type,
125-
widget_index=widget_idx,
126-
widget_value=widgets[widget_idx]
127-
))
114+
widget_refs = self._extract_single_model_widget(node_id, node_info)
115+
refs = self._merge_model_refs(refs, widget_refs)
128116

129-
# Pattern match all widgets for custom nodes
117+
# Strategy 4: Pattern match all widgets for custom nodes
130118
else:
131-
widgets = node_info.widgets_values or []
132-
for idx, value in enumerate(widgets):
133-
if self._looks_like_model(value):
119+
widget_refs = self._extract_by_pattern(node_id, node_info)
120+
refs = self._merge_model_refs(refs, widget_refs)
121+
122+
return refs
123+
124+
def _extract_from_properties_models(
125+
self,
126+
node_id: str,
127+
node_info: WorkflowNode,
128+
property_models: list[dict]
129+
) -> list[WorkflowNodeWidgetRef]:
130+
"""Extract model refs from node.properties.models array.
131+
132+
Properties models have structure:
133+
{"name": "model.safetensors", "url": "https://...", "directory": "text_encoders"}
134+
"""
135+
refs = []
136+
for idx, model_entry in enumerate(property_models):
137+
if not isinstance(model_entry, dict):
138+
continue
139+
name = model_entry.get('name', '')
140+
if not name:
141+
continue
142+
143+
# Find corresponding widget index by matching name to widgets_values
144+
widget_idx = self._find_widget_index_for_name(node_info, name)
145+
146+
refs.append(WorkflowNodeWidgetRef(
147+
node_id=node_id,
148+
node_type=node_info.type,
149+
widget_index=widget_idx if widget_idx is not None else idx,
150+
widget_value=name,
151+
property_url=model_entry.get('url'),
152+
property_directory=model_entry.get('directory')
153+
))
154+
return refs
155+
156+
def _find_widget_index_for_name(self, node_info: WorkflowNode, name: str) -> int | None:
157+
"""Find widget index that contains the given model name."""
158+
widgets = node_info.widgets_values or []
159+
for idx, value in enumerate(widgets):
160+
if isinstance(value, str) and value == name:
161+
return idx
162+
return None
163+
164+
def _extract_multi_model_widgets(self, node_id: str, node_info: WorkflowNode) -> list[WorkflowNodeWidgetRef]:
165+
"""Extract models from multi-model nodes using MULTI_MODEL_WIDGET_CONFIGS.
166+
167+
Note: Unlike pattern matching, multi-model configs explicitly define which
168+
widgets contain models, so we trust them without extension filtering.
169+
This allows CheckpointLoader to capture both .safetensors and .yaml configs.
170+
"""
171+
refs = []
172+
widget_indices = MULTI_MODEL_WIDGET_CONFIGS.get(node_info.type, [])
173+
widgets = node_info.widgets_values or []
174+
175+
for widget_idx in widget_indices:
176+
if widget_idx < len(widgets) and widgets[widget_idx]:
177+
value = widgets[widget_idx]
178+
if isinstance(value, str) and value.strip():
134179
refs.append(WorkflowNodeWidgetRef(
135-
node_id=node_id, # Use scoped ID from dict key
180+
node_id=node_id,
136181
node_type=node_info.type,
137-
widget_index=idx,
182+
widget_index=widget_idx,
138183
widget_value=value
139184
))
185+
return refs
186+
187+
def _extract_single_model_widget(self, node_id: str, node_info: WorkflowNode) -> list[WorkflowNodeWidgetRef]:
188+
"""Extract model from standard single-model loader nodes."""
189+
refs = []
190+
widget_idx = self.model_config.get_widget_index_for_node(node_info.type)
191+
widgets = node_info.widgets_values or []
192+
193+
if widget_idx < len(widgets) and widgets[widget_idx]:
194+
refs.append(WorkflowNodeWidgetRef(
195+
node_id=node_id,
196+
node_type=node_info.type,
197+
widget_index=widget_idx,
198+
widget_value=widgets[widget_idx]
199+
))
200+
return refs
201+
202+
def _extract_by_pattern(self, node_id: str, node_info: WorkflowNode) -> list[WorkflowNodeWidgetRef]:
203+
"""Extract models by pattern matching widget values (for custom nodes)."""
204+
refs = []
205+
widgets = node_info.widgets_values or []
140206

207+
for idx, value in enumerate(widgets):
208+
if self._looks_like_model(value):
209+
refs.append(WorkflowNodeWidgetRef(
210+
node_id=node_id,
211+
node_type=node_info.type,
212+
widget_index=idx,
213+
widget_value=value
214+
))
141215
return refs
216+
217+
def _merge_model_refs(
218+
self,
219+
property_refs: list[WorkflowNodeWidgetRef],
220+
widget_refs: list[WorkflowNodeWidgetRef]
221+
) -> list[WorkflowNodeWidgetRef]:
222+
"""Merge property refs with widget refs, preserving property metadata.
223+
224+
Property refs take precedence when both have the same widget_value,
225+
since they may contain URL metadata for auto-download.
226+
"""
227+
# Build set of values already in property_refs
228+
property_values = {ref.widget_value for ref in property_refs}
229+
230+
# Add widget refs that aren't already covered by property refs
231+
merged = list(property_refs)
232+
for ref in widget_refs:
233+
if ref.widget_value not in property_values:
234+
merged.append(ref)
235+
236+
return merged
142237

143238
def _looks_like_model(self, value: Any) -> bool:
144239
"""Check if value looks like a model path"""

packages/core/src/comfygit_core/configs/comfyui_models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
"""Default ComfyUI models configuration"""
22

3+
# Multi-model widget configurations: node_type -> list of widget indices containing models
4+
# These nodes load multiple models from different widget positions
5+
MULTI_MODEL_WIDGET_CONFIGS: dict[str, list[int]] = {
6+
"CheckpointLoader": [0, 1], # checkpoint, config
7+
"DualCLIPLoader": [0, 1], # clip_name1, clip_name2
8+
"TripleCLIPLoader": [0, 1, 2], # clip_name1, clip_name2, clip_name3
9+
"QuadrupleCLIPLoader": [0, 1, 2, 3], # clip_name1, clip_name2, clip_name3, clip_name4
10+
}
11+
312
COMFYUI_MODELS_CONFIG = {
413
"version": "2024.1",
514
"default_extensions": [

packages/core/src/comfygit_core/core/environment.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,19 +1474,21 @@ def list_dependencies(self, all: bool = False) -> dict[str, list[str]]:
14741474
def export_environment(
14751475
self,
14761476
output_path: Path,
1477-
callbacks: ExportCallbacks | None = None
1477+
callbacks: ExportCallbacks | None = None,
1478+
allow_issues: bool = False
14781479
) -> Path:
14791480
"""Export environment as .tar.gz bundle.
14801481
14811482
Args:
14821483
output_path: Path for output tarball
14831484
callbacks: Optional callbacks for warnings/progress
1485+
allow_issues: Allow export even with unresolved workflow issues
14841486
14851487
Returns:
14861488
Path to created tarball
14871489
14881490
Raises:
1489-
CDExportError: If environment has uncommitted changes or unresolved issues
1491+
CDExportError: If environment has uncommitted changes or unresolved issues (unless allow_issues)
14901492
"""
14911493
from ..managers.export_import_manager import ExportImportManager
14921494
from ..models.exceptions import CDExportError, ExportErrorContext
@@ -1516,8 +1518,8 @@ def export_environment(
15161518
context=context
15171519
)
15181520

1519-
# Validation: Check all workflows are resolved
1520-
if not status.is_commit_safe:
1521+
# Validation: Check all workflows are resolved (unless allow_issues)
1522+
if not status.is_commit_safe and not allow_issues:
15211523
context = ExportErrorContext(has_unresolved_issues=True)
15221524
raise CDExportError(
15231525
"Cannot export - workflows have unresolved issues",

packages/core/src/comfygit_core/models/workflow.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -621,24 +621,35 @@ def version_mismatch(self) -> bool:
621621
return bool(self.suggested_version and
622622
self.installed_version != self.suggested_version)
623623

624-
@dataclass(frozen=True)
624+
@dataclass
625625
class WorkflowNodeWidgetRef:
626-
"""Reference to a widget value in a workflow node."""
626+
"""Reference to a widget value in a workflow node.
627+
628+
Core identity fields: node_id, node_type, widget_index, widget_value
629+
Optional metadata fields: property_url, property_directory (from properties.models)
630+
631+
Hash/eq are based only on core identity fields for deduplication.
632+
"""
627633
node_id: str
628634
node_type: str
629635
widget_index: int
630636
widget_value: str # Original value from workflow
631-
637+
638+
# Optional metadata from properties.models (for download intent creation)
639+
property_url: str | None = None
640+
property_directory: str | None = None
641+
632642
def __eq__(self, value: object) -> bool:
643+
"""Compare based on core identity fields only (excludes property metadata)."""
633644
if isinstance(value, WorkflowNodeWidgetRef):
634-
return self.node_id == value.node_id and \
635-
self.node_type == value.node_type and \
636-
self.widget_index == value.widget_index and \
637-
self.widget_value == value.widget_value
645+
return (self.node_id == value.node_id and
646+
self.node_type == value.node_type and
647+
self.widget_index == value.widget_index and
648+
self.widget_value == value.widget_value)
638649
return False
639-
650+
640651
def __hash__(self) -> int:
641-
"""Hash based on all fields for proper dict/set lookups."""
652+
"""Hash based on core identity fields only for proper dict/set lookups."""
642653
return hash((self.node_id, self.node_type, self.widget_index, self.widget_value))
643654

644655
@dataclass

packages/core/src/comfygit_core/resolvers/model_resolver.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,41 @@ def resolve_model(
152152
for model in candidates
153153
]
154154

155+
# Strategy 5: Auto-create download intent from properties.models metadata
156+
if ref.property_url:
157+
target_directory = ref.property_directory or self._infer_directory_for_node(ref.node_type)
158+
target_path = Path(target_directory) / Path(ref.widget_value).name
159+
logger.debug(
160+
f"Creating property-based download intent for {ref.widget_value} "
161+
f"from URL: {ref.property_url} -> {target_path}"
162+
)
163+
return [
164+
ResolvedModel(
165+
workflow=workflow_name,
166+
reference=ref,
167+
resolved_model=None,
168+
model_source=ref.property_url,
169+
target_path=target_path,
170+
match_type="property_download_intent",
171+
match_confidence=1.0,
172+
)
173+
]
174+
155175
# No matches found
156176
logger.debug(f"No matches found in pyproject or model index for {ref}")
157177
return None
158178

179+
def _infer_directory_for_node(self, node_type: str) -> str:
180+
"""Infer target model directory from node type.
181+
182+
Uses model_config mappings to determine appropriate directory.
183+
Falls back to 'models' if node type is unknown.
184+
"""
185+
directories = self.model_config.get_directories_for_node(node_type)
186+
if directories:
187+
return directories[0] # Use first directory as default
188+
return "models"
189+
159190
def _try_exact_match(self, path: str, all_models: list[ModelWithLocation] | None =None) -> list["ModelWithLocation"]:
160191
"""Try exact path match"""
161192
if all_models is None:

0 commit comments

Comments
 (0)