Refactor: Enhance native metadata extraction strategy and add settings toggle
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user