diff --git a/py/recipes/parsers/civitai_image.py b/py/recipes/parsers/civitai_image.py index 331568a3..b78e7c74 100644 --- a/py/recipes/parsers/civitai_image.py +++ b/py/recipes/parsers/civitai_image.py @@ -526,6 +526,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser): if version_id_str in added_loras: continue + # Skip if this version ID is already the recipe's checkpoint + # (resolved earlier from embedded resources/Model hash, + # avoiding a duplicate CivitAI API call). + existing_model = result.get("model") + if existing_model and str(existing_model.get("id")) == version_id_str: + continue + # Initialize lora entry with version ID lora_entry = { "id": version_id, @@ -559,7 +566,37 @@ class CivitaiApiMetadataParser(RecipeMetadataParser): ) if populated_entry is None: - continue # Skip invalid LoRA types + # Not a LoRA — try as checkpoint (only if we + # don't already have one). Reuses the same + # civitai_info from the API call above so no + # extra query is made. + if result["model"] is None: + checkpoint_entry = { + "id": version_id, + "modelId": 0, + "name": "Unknown Model", + "version": "", + "type": "checkpoint", + "existsLocally": False, + "localPath": None, + "file_name": "", + "hash": "", + "thumbnailUrl": ( + "/loras_static/images/no-preview.png" + ), + "baseModel": "", + "size": 0, + "downloadUrl": "", + "isDeleted": False, + } + cp_populated = await ( + self.populate_checkpoint_from_civitai( + checkpoint_entry, civitai_info + ) + ) + if cp_populated.get("id"): + result["model"] = cp_populated + continue # Not a LoRA, don't add to loras lora_entry = populated_entry except Exception as e: diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 210e8f69..2f1fa4f8 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -32,6 +32,7 @@ from ...utils.civitai_utils import ( extract_civitai_image_id_from_cdn_url, rewrite_preview_url, ) +from ...utils.constants import NSFW_LEVELS from ...utils.exif_utils import ExifUtils from ...recipes.merger import GenParamsMerger from ...recipes.enrichment import RecipeEnricher @@ -1120,6 +1121,13 @@ class RecipeManagementHandler: if parsed_embedded.get("base_model") and not metadata.get("base_model"): metadata["base_model"] = parsed_embedded["base_model"] + # Extract preview_nsfw_level from the CivitAI API response + # (injected into civitai_meta_raw by _download_remote_media). + if isinstance(civitai_meta_raw, dict): + bl = civitai_meta_raw.get("browsingLevel") + if isinstance(bl, int) and bl > 0: + metadata["preview_nsfw_level"] = bl + civitai_client = self._civitai_client_getter() await RecipeEnricher.enrich_recipe( recipe=metadata, @@ -1515,8 +1523,31 @@ class RecipeManagementHandler: # CivitAI API returns modelVersionIds at the root level of # the image response, NOT inside the meta object. mvids = image_info.get("modelVersionIds") - if mvids and isinstance(civitai_meta_raw, dict): - civitai_meta_raw["modelVersionIds"] = mvids + if mvids: + if isinstance(civitai_meta_raw, dict): + civitai_meta_raw["modelVersionIds"] = mvids + else: + # meta is null but modelVersionIds exists — create a + # minimal dict so downstream parsers can discover + # LoRAs and checkpoints from the API response. + civitai_meta_raw = {"modelVersionIds": mvids} + + # Inject browsingLevel (canonical integer) so the recipe's + # preview_nsfw_level can be set, enabling proper NSFW blur + # of the preview image. Fall back to nsfwLevel (string) + # when browsingLevel is absent. + if isinstance(civitai_meta_raw, dict): + browsing_level = image_info.get("browsingLevel") + nsfw_level_str = image_info.get("nsfwLevel") + if isinstance(browsing_level, int) and browsing_level > 0: + civitai_meta_raw["browsingLevel"] = browsing_level + elif ( + isinstance(nsfw_level_str, str) + and nsfw_level_str in NSFW_LEVELS + ): + civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[ + nsfw_level_str + ] original_url = ( image_info.get("url") if civitai_image_id and image_info else None @@ -1796,6 +1827,13 @@ class RecipeManagementHandler: "source_path": image_url, } + # Extract preview_nsfw_level from the CivitAI API response + # (injected into civitai_meta_raw by _download_remote_media). + if isinstance(civitai_meta_raw, dict): + bl = civitai_meta_raw.get("browsingLevel") + if isinstance(bl, int) and bl > 0: + metadata["preview_nsfw_level"] = bl + if civitai_parsed: civitai_loras = civitai_parsed.get("loras", []) if civitai_loras and not metadata.get("loras"): diff --git a/py/services/batch_import_service.py b/py/services/batch_import_service.py index 976c8490..e1d80bd7 100644 --- a/py/services/batch_import_service.py +++ b/py/services/batch_import_service.py @@ -523,6 +523,10 @@ class BatchImportService: if payload.get("checkpoint"): metadata["checkpoint"] = payload["checkpoint"] + nsfw = payload.get("preview_nsfw_level") + if isinstance(nsfw, int) and nsfw > 0: + metadata["preview_nsfw_level"] = nsfw + image_bytes = None image_base64 = payload.get("image_base64") diff --git a/py/services/recipes/analysis_service.py b/py/services/recipes/analysis_service.py index 5e503424..5f5da302 100644 --- a/py/services/recipes/analysis_service.py +++ b/py/services/recipes/analysis_service.py @@ -146,11 +146,38 @@ class RecipeAnalysisService: ): metadata = metadata["meta"] - # Include modelVersionIds from root level if available - # Civitai API returns modelVersionIds at root level, not in meta + # Include modelVersionIds from root level if available. + # CivitAI API returns modelVersionIds at root level, not in meta. + # When meta is null (None), create a minimal dict so downstream + # parsers can still discover LoRAs and checkpoints. model_version_ids = image_info.get("modelVersionIds") - if model_version_ids and isinstance(metadata, dict): - metadata["modelVersionIds"] = model_version_ids + if model_version_ids: + if isinstance(metadata, dict): + metadata["modelVersionIds"] = model_version_ids + else: + metadata = {"modelVersionIds": model_version_ids} + + # Inject browsingLevel (canonical integer) so the recipe's + # preview_nsfw_level can be set, enabling proper NSFW blur + # of the preview image. Fall back to nsfwLevel (string) + # when browsingLevel is absent. + if isinstance(metadata, dict): + browsing_level = image_info.get("browsingLevel") + nsfw_level_str = image_info.get("nsfwLevel") + if isinstance(browsing_level, int) and browsing_level > 0: + metadata["browsingLevel"] = browsing_level + elif ( + isinstance(nsfw_level_str, str) + and nsfw_level_str + in ( + "PG", "PG13", "R", "X", "XXX", "Blocked", + ) + ): + from ...utils.constants import NSFW_LEVELS + + metadata["browsingLevel"] = NSFW_LEVELS.get( + nsfw_level_str, 0 + ) # Validate that metadata contains meaningful recipe fields # If not, treat as None to trigger EXIF extraction from downloaded image @@ -171,12 +198,19 @@ class RecipeAnalysisService: temp_path = self._create_temp_path(suffix=extension) await self._download_image(url, temp_path) - if metadata is None and not is_video: - metadata = await asyncio.to_thread( + # Always extract EXIF from the downloaded image for generation + # params (prompt, negative prompt, sampler, steps, etc.). + # Previously this was gated on ``metadata is None``, but that + # skipped EXIF entirely when API metadata (modelVersionIds, + # browsingLevel) is present, losing all generation parameters. + exif_metadata = None + if not is_video: + exif_metadata = await asyncio.to_thread( self._exif_utils.extract_image_metadata, temp_path ) - if not metadata and civitai_image_id and image_info: + # Fallback: try the original (non-optimized) image for EXIF data + if not exif_metadata and civitai_image_id and image_info: original_url = image_info.get("url") if original_url: self._logger.debug( @@ -187,15 +221,38 @@ class RecipeAnalysisService: orig_temp_path = self._create_temp_path(suffix=".png") try: await self._download_image(original_url, orig_temp_path) - metadata = await asyncio.to_thread( + exif_metadata = await asyncio.to_thread( self._exif_utils.extract_image_metadata, orig_temp_path, ) finally: self._safe_cleanup(orig_temp_path) + # Parse EXIF data (typically a string like parameters/prompt/workflow) + # and API metadata (dict with modelVersionIds, browsingLevel) separately, + # then merge: API loras/checkpoint override, EXIF gen_params fill in gaps. + # This mirrors the two-pass approach in _do_import_from_url. + exif_parsed_result = None + if isinstance(exif_metadata, str): + exif_parser = self._recipe_parser_factory.create_parser(exif_metadata) + if exif_parser: + exif_data = await exif_parser.parse_metadata( + exif_metadata, recipe_scanner=recipe_scanner, + ) + if exif_data and not exif_data.get("error"): + exif_parsed_result = exif_data + + # Merge API metadata (dict) with EXIF data (if dict) for the + # CivitaiApiMetadataParser. If EXIF data is a string it was + # parsed above — don't try to merge a string into a dict. + merged = {} + if isinstance(exif_metadata, dict): + merged.update(exif_metadata) + if isinstance(metadata, dict): + merged.update(metadata) + result = await self._parse_metadata( - metadata or {}, + merged, recipe_scanner=recipe_scanner, image_path=temp_path, include_image_base64=True, @@ -203,13 +260,23 @@ class RecipeAnalysisService: extension=extension, ) - if civitai_image_id and image_info and not result.payload.get("error"): - mvid = image_info.get("modelVersionId") - if not mvid: - mvids = image_info.get("modelVersionIds") - if isinstance(mvids, list) and mvids: - mvid = mvids[0] + # Merge EXIF string-parsed gen_params into the API result. + # API gen_params take priority (they come later via update). + if exif_parsed_result and not result.payload.get("error"): + exif_gp = exif_parsed_result.get("gen_params") or {} + result_gp = result.payload.get("gen_params") or {} + merged_gp = {**exif_gp, **result_gp} + if merged_gp: + result.payload["gen_params"] = merged_gp + if civitai_image_id and image_info and not result.payload.get("error"): + # Use the metadata dict we built (may contain modelVersionIds + # and browsingLevel from the API root level). Do NOT pass + # image_info.get("meta") — it is null for images whose meta + # lives at the root level only. Also do NOT derive + # model_version_id from modelVersionIds[0] — that array mixes + # checkpoints, LoRAs, and other types without ordering + # guarantees; the parser already resolved them correctly. recipe_for_enrich = { "gen_params": result.payload.get("gen_params", {}), "loras": result.payload.get("loras", []), @@ -222,8 +289,10 @@ class RecipeAnalysisService: recipe=recipe_for_enrich, civitai_client=civitai_client, request_params=None, - prefetched_civitai_meta_raw=image_info.get("meta"), - prefetched_model_version_id=mvid, + prefetched_civitai_meta_raw=( + metadata if isinstance(metadata, dict) else None + ), + prefetched_model_version_id=None, ) result.payload["gen_params"] = recipe_for_enrich["gen_params"] @@ -232,6 +301,12 @@ class RecipeAnalysisService: if recipe_for_enrich.get("base_model"): result.payload["base_model"] = recipe_for_enrich["base_model"] + # Extract browsingLevel from our constructed metadata for NSFW blur + if isinstance(metadata, dict): + bl = metadata.get("browsingLevel") + if isinstance(bl, int) and bl > 0: + result.payload["preview_nsfw_level"] = bl + return result finally: if temp_path: @@ -314,6 +389,10 @@ class RecipeAnalysisService: "prompt_type", "positive", "negative", + # modelVersionIds is injected at the root level by CivitAI's image + # API when meta is null. It carries the version IDs of ALL models + # (checkpoint + LoRAs) used to generate the image. + "modelVersionIds", } return any(field in metadata for field in recipe_fields) diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js index e553fa26..7dbb51e1 100644 --- a/static/js/managers/import/DownloadManager.js +++ b/static/js/managers/import/DownloadManager.js @@ -57,9 +57,16 @@ export class DownloadManager { base_model: this.importManager.recipeData.base_model || "", loras: this.importManager.recipeData.loras || [], gen_params: this.importManager.recipeData.gen_params || {}, - raw_metadata: this.importManager.recipeData.raw_metadata || {} + raw_metadata: this.importManager.recipeData.raw_metadata || {}, }; + // Preserve preview_nsfw_level from analysis so the saved + // recipe applies the correct NSFW blur on the preview image. + const nsfwLevel = this.importManager.recipeData.preview_nsfw_level; + if (nsfwLevel !== undefined && nsfwLevel !== null) { + completeMetadata.preview_nsfw_level = nsfwLevel; + } + const checkpointMetadata = this.importManager.recipeData.checkpoint || this.importManager.recipeData.model ||