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:
Will Miao
2026-09-29 07:20:12 +08:00
parent 0dd8d74032
commit 69691b17a1
11 changed files with 1059 additions and 64 deletions
@@ -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))