Add durable character preset storage

This commit is contained in:
xmarre
2026-08-11 01:41:37 +02:00
parent b192803faf
commit b4ec7f45c2
+382 -8
View File
@@ -9,7 +9,8 @@ import stat as stat_module
import tempfile
import threading
import time
from contextlib import contextmanager
import uuid
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
@@ -39,6 +40,10 @@ class InvalidThumbnail(ValueError):
pass
class InvalidPreset(ValueError):
pass
def normalize_relative_path(value: Any, *, allow_empty: bool = False) -> str:
raw = str(value or "").strip().replace("\\", "/")
if not raw:
@@ -70,6 +75,239 @@ def normalize_filename(value: Any) -> str:
return raw
def normalize_reference_slot(value: Any, input_root: str, *, must_exist: bool = True) -> Optional[Dict[str, str]]:
if value is None:
return None
if not isinstance(value, dict):
raise InvalidPreset("Reference slots must be image records or null.")
storage_type = str(value.get("type", "input")).strip().lower() or "input"
if storage_type != "input":
raise InvalidPreset("Reference presets may only contain ComfyUI input images.")
annotated = str(value.get("annotated", "")).strip()
suffix = " [input]"
if not annotated.endswith(suffix):
raise InvalidPreset("Reference images must use an annotated ComfyUI input path.")
try:
relative_path = normalize_relative_path(annotated[:-len(suffix)])
except InvalidInputPath as exc:
raise InvalidPreset("A reference slot contains an invalid input path.") from exc
if Path(relative_path).suffix.lower() not in SUPPORTED_IMAGE_EXTENSIONS:
raise InvalidPreset("A reference slot has an unsupported image extension.")
try:
resolve_under_root(input_root, relative_path, must_exist=must_exist)
except (InvalidInputPath, FileNotFoundError) as exc:
raise InvalidPreset(f"Reference image '{annotated}' is outside input or missing.") from exc
relative = PurePosixPath(relative_path)
parent = "" if str(relative.parent) == "." else str(relative.parent)
return {
"annotated": f"{relative_path} [input]",
"filename": relative.name,
"subfolder": parent,
"type": "input",
}
class PresetStore:
VERSION = 1
SLOT_COUNT = 8
def __init__(self, path: str, input_root: str):
self.path = path
self.input_root = os.path.realpath(input_root)
self._lock = threading.RLock()
self._recovery_pending = False
@staticmethod
def _empty_document() -> Dict[str, Any]:
return {"version": PresetStore.VERSION, "presets": []}
def _normalize_name(self, value: Any) -> str:
name = " ".join(str(value or "").strip().split())
if not name or len(name) > 120 or any(ord(character) < 32 or ord(character) == 127 for character in name):
raise InvalidPreset("Preset names must contain 1 to 120 printable characters.")
return name
def _normalize_id(self, value: Any) -> str:
try:
return str(uuid.UUID(str(value or "").strip()))
except (ValueError, AttributeError) as exc:
raise InvalidPreset("The preset ID is invalid.") from exc
def _normalize_slots(self, value: Any, *, must_exist: bool = True) -> List[Optional[Dict[str, str]]]:
if not isinstance(value, list) or len(value) != self.SLOT_COUNT:
raise InvalidPreset("A reference preset must contain exactly 8 slots.")
return [
normalize_reference_slot(slot, self.input_root, must_exist=must_exist)
for slot in value
]
def _normalize_preset(self, value: Any, *, must_exist: bool = False) -> Dict[str, Any]:
if not isinstance(value, dict):
raise InvalidPreset("The preset document is malformed.")
return {
"id": self._normalize_id(value.get("id")),
"name": self._normalize_name(value.get("name")),
"slots": self._normalize_slots(value.get("slots"), must_exist=must_exist),
"created_at": max(0, int(value.get("created_at", 0) or 0)),
"updated_at": max(0, int(value.get("updated_at", 0) or 0)),
}
def _quarantine(self) -> None:
if not os.path.exists(self.path):
return
quarantine = f"{self.path}.corrupt-{int(time.time() * 1000)}"
try:
os.replace(self.path, quarantine)
except OSError:
LOGGER.exception("Image Conveyor could not quarantine malformed preset storage.")
def _load_unlocked(self) -> Dict[str, Any]:
if not os.path.isfile(self.path):
return self._empty_document()
try:
with open(self.path, "r", encoding="utf-8") as handle:
raw = json.load(handle)
if not isinstance(raw, dict) or raw.get("version") != self.VERSION:
raise InvalidPreset("Unsupported preset document version.")
values = raw.get("presets")
if not isinstance(values, list):
raise InvalidPreset("The preset document is malformed.")
presets = [self._normalize_preset(value, must_exist=False) for value in values]
ids = {preset["id"] for preset in presets}
names = {preset["name"].casefold() for preset in presets}
if len(ids) != len(presets) or len(names) != len(presets):
raise InvalidPreset("The preset document contains duplicate IDs or names.")
return {"version": self.VERSION, "presets": presets}
except (OSError, UnicodeError, json.JSONDecodeError, InvalidPreset, ValueError, TypeError):
LOGGER.warning("Image Conveyor preset storage was malformed and has been quarantined.", exc_info=True)
self._quarantine()
self._recovery_pending = True
return self._empty_document()
def _write_unlocked(self, document: Dict[str, Any]) -> None:
parent = os.path.dirname(self.path)
os.makedirs(parent, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=".reference-presets-", suffix=".tmp", dir=parent)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(document, handle, ensure_ascii=False, separators=(",", ":"))
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, self.path)
self._recovery_pending = False
try:
directory_fd = os.open(parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except OSError:
pass
finally:
try:
os.unlink(temporary)
except OSError:
pass
@staticmethod
def _ordered(presets: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
return sorted(presets, key=lambda preset: (preset["name"].casefold(), preset["name"], preset["id"]))
def list(self) -> List[Dict[str, Any]]:
with self._lock:
return json.loads(json.dumps(self._ordered(self._load_unlocked()["presets"])))
def create(self, name: Any, slots: Any) -> Dict[str, Any]:
with self._lock:
document = self._load_unlocked()
normalized_name = self._normalize_name(name)
if any(preset["name"].casefold() == normalized_name.casefold() for preset in document["presets"]):
raise InvalidPreset("A preset with that name already exists.")
now = int(time.time() * 1000)
preset = {
"id": str(uuid.uuid4()),
"name": normalized_name,
"slots": self._normalize_slots(slots),
"created_at": now,
"updated_at": now,
}
document["presets"].append(preset)
document["presets"] = self._ordered(document["presets"])
self._write_unlocked(document)
return json.loads(json.dumps(preset))
def update(self, preset_id: Any, *, name: Any = None, slots: Any = None) -> Dict[str, Any]:
with self._lock:
document = self._load_unlocked()
normalized_id = self._normalize_id(preset_id)
preset = next((entry for entry in document["presets"] if entry["id"] == normalized_id), None)
if preset is None:
raise KeyError(normalized_id)
if name is not None:
normalized_name = self._normalize_name(name)
if any(
entry["id"] != normalized_id and entry["name"].casefold() == normalized_name.casefold()
for entry in document["presets"]
):
raise InvalidPreset("A preset with that name already exists.")
preset["name"] = normalized_name
if slots is not None:
preset["slots"] = self._normalize_slots(slots)
if name is None and slots is None:
raise InvalidPreset("The preset update did not contain a name or slots.")
preset["updated_at"] = int(time.time() * 1000)
document["presets"] = self._ordered(document["presets"])
self._write_unlocked(document)
return json.loads(json.dumps(preset))
def delete(self, preset_id: Any) -> bool:
with self._lock:
document = self._load_unlocked()
normalized_id = self._normalize_id(preset_id)
kept = [entry for entry in document["presets"] if entry["id"] != normalized_id]
if len(kept) == len(document["presets"]):
return False
document["presets"] = kept
self._write_unlocked(document)
return True
def relink_paths(self, replacements: Sequence[Dict[str, Any]]) -> int:
mapping: Dict[str, str] = {}
for replacement in replacements:
if not isinstance(replacement, dict):
raise InvalidPreset("A preset relink entry is malformed.")
old_path = normalize_relative_path(replacement.get("relative_path"))
keep_path = normalize_relative_path(replacement.get("keep_path"))
resolve_under_root(self.input_root, keep_path, must_exist=True)
mapping[old_path] = keep_path
if not mapping:
return 0
with self._lock:
document = self._load_unlocked()
if self._recovery_pending:
raise InvalidPreset(
"Preset storage was malformed; duplicate cleanup is blocked until presets are repaired or saved again."
)
changed = 0
for preset in document["presets"]:
for index, slot in enumerate(preset["slots"]):
if slot is None:
continue
relative_path = slot["annotated"][:-len(" [input]")]
keep_path = mapping.get(relative_path)
if not keep_path:
continue
preset["slots"][index] = normalize_reference_slot(
{"annotated": f"{keep_path} [input]", "type": "input"},
self.input_root,
)
preset["updated_at"] = int(time.time() * 1000)
changed += 1
if changed:
self._write_unlocked(document)
return changed
def resolve_under_root(root: str, relative_path: str, *, must_exist: bool = False) -> str:
relative = normalize_relative_path(relative_path)
root_real = os.path.realpath(root)
@@ -290,13 +528,23 @@ class _KeyLockEntry:
class InputLibrary:
def __init__(self, input_root: str, cache_root: str, snapshot_ttl: float = SNAPSHOT_TTL_SECONDS):
def __init__(
self,
input_root: str,
cache_root: str,
snapshot_ttl: float = SNAPSHOT_TTL_SECONDS,
preset_path: Optional[str] = None,
):
self.input_root = os.path.realpath(input_root)
self.cache_root = cache_root
self.snapshot_ttl = snapshot_ttl
self.index = ContentIndex(os.path.join(cache_root, "content-index.sqlite3"))
self.thumbnail_root = os.path.join(cache_root, "thumbnails")
self.upload_temp_root = os.path.join(cache_root, "uploads")
self.preset_store = PresetStore(
preset_path or os.path.join(cache_root, "reference-presets.json"),
self.input_root,
)
self._snapshot: Tuple[FileRecord, ...] = ()
self._snapshot_at = 0.0
self._snapshot_wall_ms = 0
@@ -724,6 +972,7 @@ class InputLibrary:
plan = []
planned_duplicates = 0
planned_paths = set()
for group in groups:
if not isinstance(group, dict):
raise InvalidUpload("The duplicate cleanup plan is malformed.")
@@ -745,9 +994,14 @@ class InputLibrary:
relative_path = normalize_relative_path(duplicate.get("relative_path"))
if relative_path == keep_path or not self._is_managed_path(relative_path):
raise InvalidUpload("Duplicate cleanup is restricted to the image_conveyor folder.")
if relative_path in planned_paths:
raise InvalidUpload("The duplicate cleanup plan contains a repeated path.")
planned_paths.add(relative_path)
relative_paths.append(relative_path)
plan.append((digest, keep_path, relative_paths))
presets_relinked = 0
for digest, keep_path, relative_paths in plan:
with self._key_lock(self._digest_locks, digest):
keep = self._record_with_digest(FileRecord(keep_path, -1, -1))
@@ -761,6 +1015,7 @@ class InputLibrary:
)
continue
candidates = []
for relative_path in relative_paths:
if relative_path in protected:
skipped.append(
@@ -770,7 +1025,17 @@ class InputLibrary:
}
)
continue
with self._key_lock(self._destination_locks, relative_path.casefold()):
candidates.append(relative_path)
with ExitStack() as destination_locks:
lock_keys = sorted({relative_path.casefold() for relative_path in candidates})
for lock_key in lock_keys:
destination_locks.enter_context(
self._key_lock(self._destination_locks, lock_key)
)
validated = []
for relative_path in candidates:
managed = self._managed_regular_file(relative_path)
if managed is None or managed[1].content_hash != digest:
skipped.append(
@@ -780,7 +1045,21 @@ class InputLibrary:
}
)
continue
lexical_path, current, expected_identity = managed
validated.append(managed)
# The preset document is durable. Its atomic update must
# succeed while every validated destination is locked and
# before the first unlink in this digest group.
presets_relinked += self.preset_store.relink_paths([
{
"relative_path": current.relative_path,
"keep_path": keep_path,
}
for _lexical_path, current, _expected_identity in validated
])
for lexical_path, current, expected_identity in validated:
relative_path = current.relative_path
try:
identity = os.lstat(lexical_path)
if (
@@ -813,6 +1092,7 @@ class InputLibrary:
"deleted": deleted,
"skipped": skipped,
"reclaimed_bytes": sum(entry["size"] for entry in deleted),
"presets_relinked": presets_relinked,
}
def verify_image(self, path: str) -> None:
@@ -899,13 +1179,31 @@ def _cache_directory(folder_paths_module) -> str:
return os.path.join(root, "image_conveyor")
def _preset_path(folder_paths_module) -> str:
return os.path.join(
folder_paths_module.get_user_directory(),
"image_conveyor",
"reference-presets.json",
)
def get_service(folder_paths_module) -> InputLibrary:
global _SERVICE
input_root = os.path.realpath(folder_paths_module.get_input_directory())
cache_root = _cache_directory(folder_paths_module)
preset_path = _preset_path(folder_paths_module)
with _SERVICE_LOCK:
if _SERVICE is None or _SERVICE.input_root != input_root or _SERVICE.cache_root != cache_root:
_SERVICE = InputLibrary(input_root, cache_root)
if (
_SERVICE is None
or _SERVICE.input_root != input_root
or _SERVICE.cache_root != cache_root
or _SERVICE.preset_store.path != preset_path
):
_SERVICE = InputLibrary(
input_root,
cache_root,
preset_path=preset_path,
)
return _SERVICE
@@ -1055,14 +1353,90 @@ def register_routes() -> None:
raise InvalidUpload("The duplicate cleanup plan is malformed or too large.")
if not isinstance(protected_paths, list) or len(protected_paths) > 10000:
raise InvalidUpload("The duplicate cleanup reservation is malformed or too large.")
result = await asyncio.to_thread(service.delete_managed_duplicates, groups, protected_paths)
result = await asyncio.to_thread(
service.delete_managed_duplicates,
groups,
protected_paths,
)
return web.json_response(result)
except (InvalidUpload, InvalidInputPath, json.JSONDecodeError, ValueError) as exc:
except (InvalidUpload, InvalidInputPath, InvalidPreset, json.JSONDecodeError, ValueError) as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception:
LOGGER.exception("Image Conveyor failed to delete managed duplicates.")
return web.json_response({"error": "Unable to delete exact duplicates."}, status=500)
@routes.get("/image-conveyor/reference-presets")
async def image_conveyor_list_reference_presets(_request):
service = get_service(folder_paths)
try:
presets = await asyncio.to_thread(service.preset_store.list)
return web.json_response({"version": PresetStore.VERSION, "presets": presets})
except Exception:
LOGGER.exception("Image Conveyor failed to list reference presets.")
return web.json_response({"error": "Unable to read reference presets."}, status=500)
@routes.post("/image-conveyor/reference-presets")
async def image_conveyor_create_reference_preset(request):
service = get_service(folder_paths)
try:
payload = await request.json()
if not isinstance(payload, dict):
raise InvalidPreset("The preset request is malformed.")
preset = await asyncio.to_thread(
service.preset_store.create,
payload.get("name"),
payload.get("slots"),
)
return web.json_response({"preset": preset}, status=201)
except (InvalidPreset, InvalidInputPath, json.JSONDecodeError, ValueError) as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception:
LOGGER.exception("Image Conveyor failed to create a reference preset.")
return web.json_response({"error": "Unable to create the reference preset."}, status=500)
@routes.put("/image-conveyor/reference-presets/{preset_id}")
async def image_conveyor_update_reference_preset(request):
service = get_service(folder_paths)
try:
payload = await request.json()
if not isinstance(payload, dict):
raise InvalidPreset("The preset request is malformed.")
kwargs = {}
if "name" in payload:
kwargs["name"] = payload.get("name")
if "slots" in payload:
kwargs["slots"] = payload.get("slots")
preset = await asyncio.to_thread(
service.preset_store.update,
request.match_info["preset_id"],
**kwargs,
)
return web.json_response({"preset": preset})
except KeyError:
return web.json_response({"error": "Reference preset not found."}, status=404)
except (InvalidPreset, InvalidInputPath, json.JSONDecodeError, ValueError) as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception:
LOGGER.exception("Image Conveyor failed to update a reference preset.")
return web.json_response({"error": "Unable to update the reference preset."}, status=500)
@routes.delete("/image-conveyor/reference-presets/{preset_id}")
async def image_conveyor_delete_reference_preset(request):
service = get_service(folder_paths)
try:
deleted = await asyncio.to_thread(
service.preset_store.delete,
request.match_info["preset_id"],
)
if not deleted:
return web.json_response({"error": "Reference preset not found."}, status=404)
return web.json_response({"deleted": True})
except (InvalidPreset, ValueError) as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception:
LOGGER.exception("Image Conveyor failed to delete a reference preset.")
return web.json_response({"error": "Unable to delete the reference preset."}, status=500)
@routes.get("/image-conveyor/thumbnail")
async def image_conveyor_thumbnail(request):
service = get_service(folder_paths)