mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-29 23:14:08 -03:00
feat(recipes): preserve embedded ComfyUI workflow on remote imports
CivitAI serves a re-encoded, metadata-free optimized rendition as the recipe preview, so the ComfyUI workflow embedded in the original image was dropped: imported recipes reported has_workflow=false and never offered "Send Workflow to ComfyUI" even when the source image carried one. Recover the workflow from the original rendition and carry it to the save step as data, so the stored preview stays the small optimized image: - ExifUtils: embed a caller-supplied workflow during optimize_image's single encode pass, and add embed_workflow() to patch WebP EXIF in place (used by the verbatim skip_optimize branch and as a safety net). - RecipePersistenceService.save_recipe: embed metadata["workflow"] before detecting has_workflow. - analyze_remote_image: return the workflow recovered from the original rendition it already downloads for EXIF parsing. - RecipeManagementHandler: add _fetch_original_media() and workflow helpers; _do_import_from_url reuses them, and _do_import_remote_recipe fetches the original only when CivitAI reports a ComfyUI payload (meta.comfy) so workflow-less images pay no extra bandwidth. - Batch URL imports and the import modal forward the recovered workflow. Verified against the reported image: has_workflow flips from false to true and the recovered workflow matches the original (25 nodes, same graph id).
This commit is contained in:
@@ -117,6 +117,10 @@ class RecipeAnalysisService:
|
||||
image_info: Optional[dict[str, Any]] = None
|
||||
is_video = False
|
||||
extension = ".jpg" # Default
|
||||
# Workflow recovered from the image. CivitAI's optimized renditions are
|
||||
# re-encoded and carry no metadata, so for those the workflow only
|
||||
# exists in the original rendition, fetched below for EXIF extraction.
|
||||
recovered_workflow: Optional[str] = None
|
||||
# Diagnostics collected during analysis; surfaced in the payload so
|
||||
# callers can persist an import_info block explaining empty LoRA lists.
|
||||
diagnostics: dict[str, Any] = {"channel": "url"}
|
||||
@@ -238,6 +242,9 @@ class RecipeAnalysisService:
|
||||
exif_metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata, temp_path
|
||||
)
|
||||
recovered_workflow = await asyncio.to_thread(
|
||||
self._read_embedded_workflow, temp_path
|
||||
)
|
||||
|
||||
# Fallback: try the original (non-optimized) image for EXIF data
|
||||
if not exif_metadata and civitai_image_id and image_info:
|
||||
@@ -255,6 +262,16 @@ class RecipeAnalysisService:
|
||||
self._exif_utils.extract_image_metadata,
|
||||
orig_temp_path,
|
||||
)
|
||||
# The original is also the only place a ComfyUI
|
||||
# workflow survives; carry it so the save step can
|
||||
# embed it even though the stored preview stays the
|
||||
# small, metadata-free optimized rendition.
|
||||
recovered_workflow = (
|
||||
await asyncio.to_thread(
|
||||
self._read_embedded_workflow, orig_temp_path
|
||||
)
|
||||
or recovered_workflow
|
||||
)
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
@@ -358,6 +375,8 @@ class RecipeAnalysisService:
|
||||
|
||||
diagnostics["is_video"] = is_video
|
||||
result.payload["diagnostics"] = diagnostics
|
||||
if recovered_workflow:
|
||||
result.payload["workflow"] = recovered_workflow
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -545,6 +564,25 @@ class RecipeAnalysisService:
|
||||
if not success:
|
||||
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
|
||||
|
||||
def _read_embedded_workflow(self, image_path: Optional[str]) -> Optional[str]:
|
||||
"""Return a ComfyUI workflow embedded in ``image_path``, if any.
|
||||
|
||||
The raw metadata string extractor stops at the generation parameters
|
||||
(``prompt``/``parameters``), so the UI-format workflow has to be read
|
||||
through the structured metadata reader. Failures map to ``None``.
|
||||
"""
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return None
|
||||
try:
|
||||
metadata = self._exif_utils._load_structured_metadata(image_path)
|
||||
except Exception as exc:
|
||||
self._logger.debug(
|
||||
"Failed to read embedded workflow from %s: %s", image_path, exc
|
||||
)
|
||||
return None
|
||||
workflow = metadata.get("workflow") if isinstance(metadata, dict) else None
|
||||
return workflow if isinstance(workflow, str) and workflow else None
|
||||
|
||||
def _metadata_not_found_response(self, path: str) -> AnalysisResult:
|
||||
payload: dict[str, Any] = {
|
||||
"error": "No metadata found in this image",
|
||||
|
||||
@@ -73,6 +73,11 @@ class RecipePersistenceService:
|
||||
byte-level EXIF update that leaves the pixels untouched). Used
|
||||
by local re-import, where the source is the recipe's own
|
||||
already-optimized preview image.
|
||||
|
||||
``metadata`` may carry a ``workflow`` entry (JSON string, dict or
|
||||
list) recovered from the source's original rendition; it is embedded
|
||||
into the stored image so the recipe reports ``has_workflow`` and can
|
||||
send the workflow back to ComfyUI.
|
||||
"""
|
||||
|
||||
missing_fields = []
|
||||
@@ -87,6 +92,13 @@ class RecipePersistenceService:
|
||||
|
||||
assert metadata is not None
|
||||
|
||||
# A workflow recovered from a higher-fidelity source (CivitAI's
|
||||
# original rendition — its optimized preview is re-encoded and carries
|
||||
# no metadata) travels as data instead of as image bytes. It is
|
||||
# embedded below so ``has_workflow`` and the "send workflow to ComfyUI"
|
||||
# action work for imports whose preview pixels are metadata-free.
|
||||
workflow = metadata.get("workflow")
|
||||
|
||||
resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64)
|
||||
recipes_dir = target_dir or recipe_scanner.recipes_dir
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
@@ -108,6 +120,7 @@ class RecipePersistenceService:
|
||||
format="webp",
|
||||
quality=85,
|
||||
preserve_metadata=True,
|
||||
workflow=workflow,
|
||||
)
|
||||
|
||||
image_filename = f"{recipe_id}{extension}"
|
||||
@@ -116,6 +129,12 @@ class RecipePersistenceService:
|
||||
with open(normalized_image_path, "wb") as file_obj:
|
||||
file_obj.write(optimized_image)
|
||||
|
||||
# The optimization branch above embeds the workflow while re-encoding;
|
||||
# the verbatim (skip_optimize) branch still needs it added, and this is
|
||||
# also the safety net when re-encoding dropped it.
|
||||
if workflow and not is_video:
|
||||
self._exif_utils.embed_workflow(normalized_image_path, workflow)
|
||||
|
||||
current_time = time.time()
|
||||
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
||||
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
||||
|
||||
Reference in New Issue
Block a user