diff --git a/backend/features/metadata/service.py b/backend/features/metadata/service.py index e15da599..493817ad 100644 --- a/backend/features/metadata/service.py +++ b/backend/features/metadata/service.py @@ -273,6 +273,20 @@ class MetadataService: ext = os.path.splitext(file_path)[1].lower() + # 1. Native Extraction (Fast Path for Drop) + if kind == "image" and await self._settings.get_native_extraction_enabled(): + try: + native_data = await extract_png_metadata_native(file_path) + # If we found workflow/prompt in native, return immediately (fastest) + if native_data and (native_data.get("workflow") or native_data.get("prompt")): + return Result.Ok({ + "workflow": native_data.get("workflow"), + "prompt": native_data.get("prompt"), + "quality": "full" + }, quality="full") + except Exception: + pass + exif_start = time.perf_counter() exif_result = await asyncio.to_thread(self.exiftool.read, file_path) exif_duration = time.perf_counter() - exif_start @@ -360,18 +374,39 @@ class MetadataService: "quality": "partial" if exif_data else "none" }) - # Ensure dimensions are present even if ExifTool missed them or didn't map them correctly - # We can call the native extractor cheaply here for dims. - try: - native_dims = await extract_png_metadata_native(file_path) - if native_dims and metadata_result.ok: - data = metadata_result.data or {} - if not data.get("width") and native_dims.get("width"): - data["width"] = native_dims["width"] - if not data.get("height") and native_dims.get("height"): - data["height"] = native_dims["height"] - except Exception: - pass + # Optional Native Extraction (Fallback/Augmentation) + # Driven by 'Enable Native Extraction' setting + if await self._settings.get_native_extraction_enabled(): + try: + native_data = await extract_png_metadata_native(file_path) + if native_data: + # Initialize data if extraction failed or is empty + if not metadata_result.ok or not metadata_result.data: + metadata_result = Result.Ok({ + "workflow": None, + "prompt": None, + "quality": "none" + }) + + res_data = metadata_result.data or {} + + # 1. Dimensions + if not res_data.get("width") and native_data.get("width"): + res_data["width"] = native_data["width"] + if not res_data.get("height") and native_data.get("height"): + res_data["height"] = native_data["height"] + + # 2. Workflow/GenInfo/Prompt + if not res_data.get("workflow") and native_data.get("workflow"): + res_data["workflow"] = native_data["workflow"] + if not res_data.get("prompt") and native_data.get("prompt"): + res_data["prompt"] = native_data["prompt"] + if not res_data.get("geninfo") and native_data.get("geninfo"): + res_data["geninfo"] = native_data["geninfo"] + + metadata_result.data = res_data + except Exception: + pass if not metadata_result.ok: self._log_metadata_issue( @@ -586,34 +621,30 @@ class MetadataService: # For PNGs, if full metadata is found, we might skip ExifTool. native_cache: Dict[str, Dict[str, Any]] = {} - for path in images: - if probe_mode == "exiftool": # Forced check - continue - - native_res = await extract_png_metadata_native(path) # Supports all images (dimensions) now - if native_res: - native_cache[path] = native_res - - # Decision: Is this result "complete" enough to skip ExifTool? - # For PNG: If we have workflow/prompt OR just dimensions, we often trust Pillow for PNGs in ComfyUI ecosystem. - # NOTE: Only skip ExifTool if it is actually a PNG, other formats might need ExifTool for metadata hidden in EXIF tags. - if path.lower().endswith(".png"): - combined = { - "file_info": self._get_file_info(path), - "exif": None, - **native_res - } - await self._enrich_with_geninfo_async(combined) - # If we have workflow/prompt, it's definitely full. Result handles partial too. - results[path] = Result.Ok(combined, quality="full") - seen_exif.add(path) + # Check setting before running native extraction + if await self._settings.get_native_extraction_enabled(): + for path in images: + if probe_mode == "exiftool": # Forced check + continue + + native_res = await extract_png_metadata_native(path) # Supports all images (dimensions) now + if native_res: + native_cache[path] = native_res + + # NOTE: We previously skipped ExifTool if native extraction found geninfo/workflow. + # However, user feedback indicates not all PNGs are created equal (e.g. some might miss text chunks but have XMP). + # To ensure maximum coverage, we now ALWAYS let it fall through to ExifTool, using Native data as a fallback/augment. + pass # 2. Schedule remaining files for tools for path in [*images, *videos, *audios]: + # NOTE: We do not check 'if path in results' here anymore for images, because we forced them to fall through. + # But technically 'results' is empty for images now. if path in results: continue + backends = pick_probe_backend(path, settings_override=probe_mode) if "exiftool" in backends and path not in seen_exif: seen_exif.add(path) @@ -651,6 +682,35 @@ class MetadataService: "prompt": None, "quality": "partial" if exif_data else "none" }) + + # --- MERGE NATIVE EXTRACTION (Fallback/Augmentation) --- + native_data = native_cache.get(path) + if native_data: + # Initialize data if extraction failed or is empty + if not metadata_result.ok or not metadata_result.data: + metadata_result = Result.Ok({ + "workflow": None, + "prompt": None, + "quality": "none" + }) + + res_data = metadata_result.data or {} + + # 1. Dimensions (Native is usually reliable for PNG) + if not res_data.get("width") and native_data.get("width"): + res_data["width"] = native_data["width"] + if not res_data.get("height") and native_data.get("height"): + res_data["height"] = native_data["height"] + + # 2. Workflow/GenInfo (If ExifTool missed it, but Native found it) + if not res_data.get("workflow") and native_data.get("workflow"): + res_data["workflow"] = native_data["workflow"] + if not res_data.get("prompt") and native_data.get("prompt"): + res_data["prompt"] = native_data["prompt"] + if not res_data.get("geninfo") and native_data.get("geninfo"): + res_data["geninfo"] = native_data["geninfo"] + + metadata_result.data = res_data if metadata_result.ok: combined = { diff --git a/backend/routes/handlers/health.py b/backend/routes/handlers/health.py index 8600c3a2..02dcdcfb 100644 --- a/backend/routes/handlers/health.py +++ b/backend/routes/handlers/health.py @@ -97,20 +97,57 @@ def register_health_routes(routes: web.RouteTableDef) -> None: Get configuration (output directory, etc.). """ svc, _ = await _require_services() + probe_mode = MEDIA_PROBE_BACKEND + native_enabled = False # Default off + if svc: settings_service = svc.get("settings") if settings_service: try: - probe_mode = settings_service.get_probe_backend() + # FIX: await the async method + probe_mode = await settings_service.get_probe_backend() + native_enabled = await settings_service.get_native_extraction_enabled() except Exception: - probe_mode = MEDIA_PROBE_BACKEND + # fallback to defaults + pass + return _json_response(Result.Ok({ "output_directory": OUTPUT_ROOT, "tool_paths": get_tool_paths(), - "media_probe_backend": probe_mode + "media_probe_backend": probe_mode, + "native_extraction_enabled": native_enabled, })) + @routes.post("/mjr/am/settings/native-extraction") + async def update_native_extraction(request): + """ + Update native extraction setting (Pillow). + """ + csrf = _csrf_error(request) + if csrf: + return _json_response(Result.Err(ErrorCode.CSRF, csrf)) + + svc, error_result = await _require_services() + if error_result: + return _json_response(error_result) + + settings_service = svc.get("settings") + if not settings_service: + return _json_response(Result.Err(ErrorCode.SERVICE_UNAVAILABLE, "Settings service unavailable")) + + body_res = await _read_json(request) + if not body_res.ok: + return _json_response(body_res) + body = body_res.data or {} + + enabled = parse_bool(body.get("enabled"), False) + + result = await settings_service.set_native_extraction_enabled(enabled) + if result.ok: + return _json_response(Result.Ok({"native_extraction_enabled": result.data})) + return _json_response(result) + @routes.post("/mjr/am/settings/probe-backend") async def update_probe_backend(request): """ diff --git a/backend/settings.py b/backend/settings.py index bd0da347..2ad174c7 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -15,6 +15,7 @@ from .utils import env_bool, parse_bool logger = get_logger(__name__) _PROBE_BACKEND_KEY = "media_probe_backend" +_NATIVE_EXTRACTION_KEY = "native_extraction_enabled" _SETTINGS_VERSION_KEY = "__settings_version" _VALID_PROBE_MODES = {"auto", "exiftool", "ffprobe", "both"} _SECURITY_PREFS_INFO: Mapping[str, dict[str, bool | str]] = { @@ -182,3 +183,45 @@ class AppSettings: logger.info("Media probe backend set to %s", normalized) return Result.Ok(normalized) return Result.Err("DB_ERROR", result.error or "Failed to persist probe backend") + + async def get_native_extraction_enabled(self) -> bool: + """Return whether native extraction (Pillow) is enabled.""" + async with self._lock: + current_version = await self._get_settings_version() + cached = self._cache.get(_NATIVE_EXTRACTION_KEY) + if cached is not None: + try: + ts = float(self._cache_at.get(_NATIVE_EXTRACTION_KEY) or 0.0) + except Exception: + ts = 0.0 + cached_ver = int(self._cache_version.get(_NATIVE_EXTRACTION_KEY) or 0) + if cached_ver == int(current_version or 0) and ts and (time.monotonic() - ts) < self._cache_ttl_s: + return parse_bool(cached, False) + + # Default is False (disabled by default as requested) + val = await self._read_setting(_NATIVE_EXTRACTION_KEY) + is_enabled = parse_bool(val, False) + + self._cache[_NATIVE_EXTRACTION_KEY] = str(is_enabled).lower() + self._cache_at[_NATIVE_EXTRACTION_KEY] = time.monotonic() + self._cache_version[_NATIVE_EXTRACTION_KEY] = int(current_version or 0) + return is_enabled + + async def set_native_extraction_enabled(self, enabled: bool) -> Result[bool]: + """Persist the native extraction setting and bump the settings version.""" + val = "true" if enabled else "false" + async with self._lock: + result = await self._write_setting(_NATIVE_EXTRACTION_KEY, val) + if result.ok: + bump = await self._bump_settings_version_locked() + if not bump.ok: + try: + logger.warning("Failed to bump settings version: %s", bump.error) + except Exception: + pass + self._cache[_NATIVE_EXTRACTION_KEY] = val + self._cache_at[_NATIVE_EXTRACTION_KEY] = time.monotonic() + self._cache_version[_NATIVE_EXTRACTION_KEY] = int(bump.data or await self._get_settings_version() or 0) + logger.info("Native extraction set to %s", val) + return Result.Ok(enabled) + return Result.Err("DB_ERROR", result.error or "Failed to persist native extraction setting") diff --git a/js/api/client.js b/js/api/client.js index 11fa5d59..8747515f 100644 --- a/js/api/client.js +++ b/js/api/client.js @@ -432,6 +432,10 @@ export async function setProbeBackendMode(mode) { return post("/mjr/am/settings/probe-backend", { mode }); } +export async function setNativeExtractionEnabled(enabled) { + return post("/mjr/am/settings/native-extraction", { enabled: !!enabled }); +} + export async function getSecuritySettings() { return get("/mjr/am/settings/security"); } diff --git a/js/app/settings.js b/js/app/settings.js index 0c0e49a2..c583ce6b 100644 --- a/js/app/settings.js +++ b/js/app/settings.js @@ -3,7 +3,7 @@ */ import { APP_CONFIG, APP_DEFAULTS } from "./config.js"; -import { getSecuritySettings, setSecuritySettings, setProbeBackendMode, resetIndex } from "../api/client.js"; +import { getSecuritySettings, setSecuritySettings, setProbeBackendMode, setNativeExtractionEnabled, resetIndex } from "../api/client.js"; import { comfyToast } from "./toast.js"; import { safeDispatchCustomEvent } from "../utils/events.js"; import { t, initI18n } from "./i18n.js"; @@ -72,6 +72,7 @@ const DEFAULT_SETTINGS = { }, probeBackend: { mode: "auto", + nativeExtraction: false, }, ratingTagsSync: { enabled: true, @@ -652,6 +653,23 @@ export const registerMajoorSettings = (app, onApplied) => { }, }); + safeAddSetting({ + id: `${SETTINGS_PREFIX}.ProbeBackend.NativeExtraction`, + category: cat(t("cat.tools"), "Enable Native Extraction (Pillow)"), + name: "Use Pillow for PNGs", + tooltip: "Enable Python Pillow extraction for PNG metadata (Faster, but less accurate than ExifTool). Disabled by default.", + type: "boolean", + defaultValue: !!settings.probeBackend?.nativeExtraction, + onChange: (value) => { + settings.probeBackend = settings.probeBackend || {}; + settings.probeBackend.nativeExtraction = !!value; + saveMajoorSettings(settings); + applySettingsToConfig(settings); + notifyApplied("probeBackend.nativeExtraction"); + setNativeExtractionEnabled(!!value).catch(() => {}); + }, + }); + const registerMinimapToggle = (idKey, stateKey, nameKey, descKey) => { safeAddSetting({ id: `${SETTINGS_PREFIX}.WorkflowMinimap.${idKey}`,