Full source for the-third-rev: Discord bot (discord.py), FastAPI web UI (React/TS/Vite/Tailwind), ComfyUI integration, generation history DB, preset manager, workflow inspector, and all supporting modules. Excluded from tracking: .env, invite_tokens.json, *.db (SQLite), current-workflow-changes.json, user_settings/, presets/, logs/, web-static/ (build output), frontend/node_modules/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
6.5 KiB
Python
207 lines
6.5 KiB
Python
"""
|
|
preset_manager.py
|
|
=================
|
|
|
|
Preset management for the Discord ComfyUI bot.
|
|
|
|
A preset is a named snapshot of the current workflow template and runtime
|
|
state (prompt, negative_prompt, input_image, seed). Presets are stored as
|
|
individual JSON files inside a ``presets/`` directory so they can be
|
|
inspected, backed up, or shared manually.
|
|
|
|
Usage via bot commands:
|
|
ttr!preset save <name> — capture current workflow + state
|
|
ttr!preset load <name> — restore workflow + state from snapshot
|
|
ttr!preset list — list all saved presets
|
|
ttr!preset delete <name> — permanently remove a preset
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Only allow alphanumeric characters, hyphens, and underscores in preset names.
|
|
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
|
|
|
|
|
class PresetManager:
|
|
"""
|
|
Manages named workflow presets on disk.
|
|
|
|
Each preset is stored as ``<presets_dir>/<name>.json`` and contains:
|
|
- ``name``: the preset name
|
|
- ``workflow``: the full ComfyUI workflow template dict (may be null)
|
|
- ``state``: the runtime state changes (prompt, negative_prompt,
|
|
input_image, seed)
|
|
|
|
Parameters
|
|
----------
|
|
presets_dir : str
|
|
Directory where preset files are stored. Created automatically if
|
|
it does not exist.
|
|
"""
|
|
|
|
def __init__(self, presets_dir: str = "presets") -> None:
|
|
self.presets_dir = Path(presets_dir)
|
|
self.presets_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def is_valid_name(name: str) -> bool:
|
|
"""Return True if the name only contains safe characters."""
|
|
return bool(_SAFE_NAME_RE.match(name))
|
|
|
|
def _path(self, name: str) -> Path:
|
|
"""Return the file path for a preset by name (no validation)."""
|
|
return self.presets_dir / f"{name}.json"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public API
|
|
# ------------------------------------------------------------------
|
|
|
|
def save(
|
|
self,
|
|
name: str,
|
|
workflow_template: Optional[dict[str, Any]],
|
|
state: dict[str, Any],
|
|
owner: Optional[str] = None,
|
|
description: Optional[str] = None,
|
|
) -> None:
|
|
"""
|
|
Save a preset to disk.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The preset name (alphanumeric, hyphens, underscores only).
|
|
workflow_template : Optional[dict]
|
|
The current workflow template, or None if none is loaded.
|
|
state : dict
|
|
The current runtime state from WorkflowStateManager.get_changes().
|
|
owner : Optional[str]
|
|
The user label of the preset creator. Stored for access control.
|
|
description : Optional[str]
|
|
A human-readable description of what this preset does.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If the name contains invalid characters.
|
|
OSError
|
|
If the file cannot be written.
|
|
"""
|
|
if not self.is_valid_name(name):
|
|
raise ValueError(
|
|
f"Invalid preset name '{name}'. "
|
|
"Use only letters, digits, hyphens, and underscores (max 64 chars)."
|
|
)
|
|
data: dict[str, Any] = {"name": name, "workflow": workflow_template, "state": state}
|
|
if owner is not None:
|
|
data["owner"] = owner
|
|
if description is not None:
|
|
data["description"] = description
|
|
path = self._path(name)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2)
|
|
logger.info("Saved preset '%s' to %s", name, path)
|
|
|
|
def load(self, name: str) -> Optional[dict[str, Any]]:
|
|
"""
|
|
Load a preset from disk.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The preset name.
|
|
|
|
Returns
|
|
-------
|
|
Optional[dict]
|
|
The preset data dict, or None if the preset does not exist.
|
|
"""
|
|
if not self.is_valid_name(name):
|
|
return None
|
|
path = self._path(name)
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception as exc:
|
|
logger.warning("Failed to load preset '%s': %s", name, exc)
|
|
return None
|
|
|
|
def delete(self, name: str) -> bool:
|
|
"""
|
|
Delete a preset file.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
The preset name.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
True if the preset existed and was deleted, False otherwise.
|
|
"""
|
|
if not self.is_valid_name(name):
|
|
return False
|
|
path = self._path(name)
|
|
if path.exists():
|
|
path.unlink()
|
|
logger.info("Deleted preset '%s'", name)
|
|
return True
|
|
return False
|
|
|
|
def list_presets(self) -> list[str]:
|
|
"""
|
|
List all saved preset names, sorted alphabetically.
|
|
|
|
Returns
|
|
-------
|
|
list[str]
|
|
Sorted list of preset names (without the .json extension).
|
|
"""
|
|
return sorted(p.stem for p in self.presets_dir.glob("*.json"))
|
|
|
|
def list_preset_details(self) -> list[dict[str, Any]]:
|
|
"""
|
|
List all presets with their metadata, sorted alphabetically by name.
|
|
|
|
Returns
|
|
-------
|
|
list[dict]
|
|
Each entry has ``"name"``, ``"owner"`` (may be None), and
|
|
``"description"`` (may be None).
|
|
"""
|
|
result = []
|
|
for p in sorted(self.presets_dir.glob("*.json"), key=lambda x: x.stem):
|
|
owner = None
|
|
description = None
|
|
try:
|
|
with open(p, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
owner = data.get("owner")
|
|
description = data.get("description")
|
|
except Exception:
|
|
pass
|
|
result.append({"name": p.stem, "owner": owner, "description": description})
|
|
return result
|
|
|
|
def exists(self, name: str) -> bool:
|
|
"""Return True if a preset with this name exists on disk."""
|
|
if not self.is_valid_name(name):
|
|
return False
|
|
return self._path(name).exists()
|