Refactor: Enhance native metadata extraction strategy and add settings toggle

This commit is contained in:
Your Name
2026-02-03 20:34:23 +01:00
parent c79181e82c
commit 16e9eaad37
5 changed files with 199 additions and 37 deletions
+93 -33
View File
@@ -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 = {
+40 -3
View File
@@ -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):
"""
+43
View File
@@ -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")
+4
View File
@@ -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");
}
+19 -1
View File
@@ -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}`,