fix(import): discover LoRA + checkpoint from modelVersionIds when API meta is null

When CivitAI image API returns meta=null and modelVersionIds at root
level, the import flow now:

- Injects modelVersionIds + browsingLevel into a minimal metadata dict
  so the parser can discover LoRAs and checkpoints (both import-from-url
  and analyze-image paths)
- Adds checkpoint dedup + fallback in the parser's modelVersionIds
  handler to avoid duplicate API calls
- Runs EXIF extraction unconditionally in analyze-image path, then
  merges with API metadata (fixes gen params loss)
- Propagates preview_nsfw_level through all three import paths:
  import-from-url, analyze-image (UI Import), and batch-import,
  plus the frontend save flow
This commit is contained in:
Will Miao
2026-06-27 17:05:38 +08:00
parent 20417797e8
commit 283730cf38
5 changed files with 186 additions and 21 deletions

View File

@@ -526,6 +526,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if version_id_str in added_loras: if version_id_str in added_loras:
continue 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 # Initialize lora entry with version ID
lora_entry = { lora_entry = {
"id": version_id, "id": version_id,
@@ -559,7 +566,37 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
) )
if populated_entry is None: 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 lora_entry = populated_entry
except Exception as e: except Exception as e:

View File

@@ -32,6 +32,7 @@ from ...utils.civitai_utils import (
extract_civitai_image_id_from_cdn_url, extract_civitai_image_id_from_cdn_url,
rewrite_preview_url, rewrite_preview_url,
) )
from ...utils.constants import NSFW_LEVELS
from ...utils.exif_utils import ExifUtils from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
@@ -1120,6 +1121,13 @@ class RecipeManagementHandler:
if parsed_embedded.get("base_model") and not metadata.get("base_model"): if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["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() civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(
recipe=metadata, recipe=metadata,
@@ -1515,8 +1523,31 @@ class RecipeManagementHandler:
# CivitAI API returns modelVersionIds at the root level of # CivitAI API returns modelVersionIds at the root level of
# the image response, NOT inside the meta object. # the image response, NOT inside the meta object.
mvids = image_info.get("modelVersionIds") mvids = image_info.get("modelVersionIds")
if mvids and isinstance(civitai_meta_raw, dict): if mvids:
civitai_meta_raw["modelVersionIds"] = 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 = ( original_url = (
image_info.get("url") if civitai_image_id and image_info else None image_info.get("url") if civitai_image_id and image_info else None
@@ -1796,6 +1827,13 @@ class RecipeManagementHandler:
"source_path": image_url, "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: if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", []) civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"): if civitai_loras and not metadata.get("loras"):

View File

@@ -523,6 +523,10 @@ class BatchImportService:
if payload.get("checkpoint"): if payload.get("checkpoint"):
metadata["checkpoint"] = payload["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_bytes = None
image_base64 = payload.get("image_base64") image_base64 = payload.get("image_base64")

View File

@@ -146,11 +146,38 @@ class RecipeAnalysisService:
): ):
metadata = metadata["meta"] metadata = metadata["meta"]
# Include modelVersionIds from root level if available # Include modelVersionIds from root level if available.
# Civitai API returns modelVersionIds at root level, not in meta # 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") model_version_ids = image_info.get("modelVersionIds")
if model_version_ids and isinstance(metadata, dict): if model_version_ids:
metadata["modelVersionIds"] = 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 # Validate that metadata contains meaningful recipe fields
# If not, treat as None to trigger EXIF extraction from downloaded image # 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) temp_path = self._create_temp_path(suffix=extension)
await self._download_image(url, temp_path) await self._download_image(url, temp_path)
if metadata is None and not is_video: # Always extract EXIF from the downloaded image for generation
metadata = await asyncio.to_thread( # 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 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") original_url = image_info.get("url")
if original_url: if original_url:
self._logger.debug( self._logger.debug(
@@ -187,15 +221,38 @@ class RecipeAnalysisService:
orig_temp_path = self._create_temp_path(suffix=".png") orig_temp_path = self._create_temp_path(suffix=".png")
try: try:
await self._download_image(original_url, orig_temp_path) 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, self._exif_utils.extract_image_metadata,
orig_temp_path, orig_temp_path,
) )
finally: finally:
self._safe_cleanup(orig_temp_path) 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( result = await self._parse_metadata(
metadata or {}, merged,
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
image_path=temp_path, image_path=temp_path,
include_image_base64=True, include_image_base64=True,
@@ -203,13 +260,23 @@ class RecipeAnalysisService:
extension=extension, extension=extension,
) )
if civitai_image_id and image_info and not result.payload.get("error"): # Merge EXIF string-parsed gen_params into the API result.
mvid = image_info.get("modelVersionId") # API gen_params take priority (they come later via update).
if not mvid: if exif_parsed_result and not result.payload.get("error"):
mvids = image_info.get("modelVersionIds") exif_gp = exif_parsed_result.get("gen_params") or {}
if isinstance(mvids, list) and mvids: result_gp = result.payload.get("gen_params") or {}
mvid = mvids[0] 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 = { recipe_for_enrich = {
"gen_params": result.payload.get("gen_params", {}), "gen_params": result.payload.get("gen_params", {}),
"loras": result.payload.get("loras", []), "loras": result.payload.get("loras", []),
@@ -222,8 +289,10 @@ class RecipeAnalysisService:
recipe=recipe_for_enrich, recipe=recipe_for_enrich,
civitai_client=civitai_client, civitai_client=civitai_client,
request_params=None, request_params=None,
prefetched_civitai_meta_raw=image_info.get("meta"), prefetched_civitai_meta_raw=(
prefetched_model_version_id=mvid, metadata if isinstance(metadata, dict) else None
),
prefetched_model_version_id=None,
) )
result.payload["gen_params"] = recipe_for_enrich["gen_params"] result.payload["gen_params"] = recipe_for_enrich["gen_params"]
@@ -232,6 +301,12 @@ class RecipeAnalysisService:
if recipe_for_enrich.get("base_model"): if recipe_for_enrich.get("base_model"):
result.payload["base_model"] = recipe_for_enrich["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 return result
finally: finally:
if temp_path: if temp_path:
@@ -314,6 +389,10 @@ class RecipeAnalysisService:
"prompt_type", "prompt_type",
"positive", "positive",
"negative", "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) return any(field in metadata for field in recipe_fields)

View File

@@ -57,9 +57,16 @@ export class DownloadManager {
base_model: this.importManager.recipeData.base_model || "", base_model: this.importManager.recipeData.base_model || "",
loras: this.importManager.recipeData.loras || [], loras: this.importManager.recipeData.loras || [],
gen_params: this.importManager.recipeData.gen_params || {}, 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 = const checkpointMetadata =
this.importManager.recipeData.checkpoint || this.importManager.recipeData.checkpoint ||
this.importManager.recipeData.model || this.importManager.recipeData.model ||