feat(example-images): namespace storage by library

This commit is contained in:
pixelpaws
2025-10-04 20:29:49 +08:00
parent 16b611cb7e
commit d43d992362
8 changed files with 445 additions and 95 deletions

View File

@@ -8,6 +8,7 @@ import time
from typing import Any, Dict
from ..services.service_registry import ServiceRegistry
from ..utils.example_images_paths import ensure_library_root_exists
from ..utils.metadata_manager import MetadataManager
from .example_images_processor import ExampleImagesProcessor
from .example_images_metadata import MetadataUpdater
@@ -84,6 +85,13 @@ class DownloadManager:
self._ws_manager = ws_manager
self._state_lock = state_lock or asyncio.Lock()
def _resolve_output_dir(self) -> str:
base_path = settings.get('example_images_path')
if not base_path:
return ''
library_name = settings.get_active_library_name()
return ensure_library_root_exists(library_name)
async def start_download(self, options: dict):
"""Start downloading example images for models."""
@@ -98,9 +106,9 @@ class DownloadManager:
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2))
output_dir = settings.get('example_images_path')
base_path = settings.get('example_images_path')
if not output_dir:
if not base_path:
error_msg = 'Example images path not configured in settings'
if auto_mode:
logger.debug(error_msg)
@@ -110,7 +118,9 @@ class DownloadManager:
}
raise DownloadConfigurationError(error_msg)
os.makedirs(output_dir, exist_ok=True)
output_dir = self._resolve_output_dir()
if not output_dir:
raise DownloadConfigurationError('Example images path not configured in settings')
self._progress.reset()
self._progress['status'] = 'running'
@@ -458,13 +468,14 @@ class DownloadManager:
if not model_hashes:
raise DownloadConfigurationError('Missing model_hashes parameter')
output_dir = settings.get('example_images_path')
base_path = settings.get('example_images_path')
if not base_path:
raise DownloadConfigurationError('Example images path not configured in settings')
output_dir = self._resolve_output_dir()
if not output_dir:
raise DownloadConfigurationError('Example images path not configured in settings')
os.makedirs(output_dir, exist_ok=True)
self._progress.reset()
self._progress['total'] = len(model_hashes)
self._progress['status'] = 'running'

View File

@@ -4,6 +4,10 @@ import sys
import subprocess
from aiohttp import web
from ..services.settings_manager import settings
from ..utils.example_images_paths import (
get_model_folder,
get_model_relative_path,
)
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__)
@@ -41,8 +45,12 @@ class ExampleImagesFileManager:
}, status=400)
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
model_folder = os.path.abspath(model_folder) # Get absolute path
model_folder = get_model_folder(model_hash)
if not model_folder:
return web.json_response({
'success': False,
'error': 'Failed to resolve example images folder for this model.'
}, status=500)
# Path validation: ensure model_folder is under example_images_path
if not model_folder.startswith(os.path.abspath(example_images_path)):
@@ -109,8 +117,13 @@ class ExampleImagesFileManager:
}, status=400)
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
model_folder = get_model_folder(model_hash)
if not model_folder:
return web.json_response({
'success': False,
'error': 'Failed to resolve example images folder for this model'
}, status=500)
# Check if folder exists
if not os.path.exists(model_folder):
return web.json_response({
@@ -128,9 +141,10 @@ class ExampleImagesFileManager:
file_ext = os.path.splitext(file)[1].lower()
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
relative_path = get_model_relative_path(model_hash)
files.append({
'name': file,
'path': f'/example_images_static/{model_hash}/{file}',
'path': f'/example_images_static/{relative_path}/{file}',
'extension': file_ext,
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
})
@@ -176,8 +190,13 @@ class ExampleImagesFileManager:
})
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
model_folder = get_model_folder(model_hash)
if not model_folder:
return web.json_response({
'has_images': False,
'error': 'Failed to resolve example images folder for this model'
})
# Check if folder exists
if not os.path.exists(model_folder) or not os.path.isdir(model_folder):
return web.json_response({

View File

@@ -5,6 +5,7 @@ import re
import json
from ..services.settings_manager import settings
from ..services.service_registry import ServiceRegistry
from ..utils.example_images_paths import iter_library_roots
from ..utils.metadata_manager import MetadataManager
from ..utils.example_images_processor import ExampleImagesProcessor
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
@@ -19,29 +20,35 @@ class ExampleImagesMigration:
@staticmethod
async def check_and_run_migrations():
"""Check if migrations are needed and run them in background"""
example_images_path = settings.get('example_images_path')
if not example_images_path or not os.path.exists(example_images_path):
root = settings.get('example_images_path')
if not root or not os.path.exists(root):
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
return
# Check current version from progress file
current_version = 0
progress_file = os.path.join(example_images_path, '.download_progress.json')
if os.path.exists(progress_file):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
progress_data = json.load(f)
current_version = progress_data.get('naming_version', 0)
except Exception as e:
logger.error(f"Failed to load progress file for migration check: {e}")
# If current version is less than target version, start migration
if current_version < CURRENT_NAMING_VERSION:
logger.info(f"Starting example images naming migration from v{current_version} to v{CURRENT_NAMING_VERSION}")
# Start migration in background task
asyncio.create_task(
ExampleImagesMigration.run_migrations(example_images_path, current_version, CURRENT_NAMING_VERSION)
)
for library_name, library_path in iter_library_roots():
if not library_path or not os.path.exists(library_path):
continue
current_version = 0
progress_file = os.path.join(library_path, '.download_progress.json')
if os.path.exists(progress_file):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
progress_data = json.load(f)
current_version = progress_data.get('naming_version', 0)
except Exception as e:
logger.error(f"Failed to load progress file for migration check: {e}")
if current_version < CURRENT_NAMING_VERSION:
logger.info(
"Starting example images naming migration from v%s to v%s for library '%s'",
current_version,
CURRENT_NAMING_VERSION,
library_name,
)
asyncio.create_task(
ExampleImagesMigration.run_migrations(library_path, current_version, CURRENT_NAMING_VERSION)
)
@staticmethod
async def run_migrations(example_images_path, from_version, to_version):

View File

@@ -0,0 +1,193 @@
"""Utility helpers for resolving example image storage paths."""
from __future__ import annotations
import logging
import os
import re
import shutil
from typing import Iterable, List, Optional, Tuple
from ..services.settings_manager import settings
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
logger = logging.getLogger(__name__)
def _get_configured_libraries() -> List[str]:
"""Return configured library names if multi-library support is enabled."""
libraries = settings.get("libraries")
if isinstance(libraries, dict) and libraries:
return list(libraries.keys())
return []
def get_example_images_root() -> str:
"""Return the root directory configured for example images."""
root = settings.get("example_images_path") or ""
return os.path.abspath(root) if root else ""
def uses_library_scoped_folders() -> bool:
"""Return True when example images should be separated per library."""
libraries = _get_configured_libraries()
return len(libraries) > 1
def sanitize_library_name(library_name: Optional[str]) -> str:
"""Return a filesystem safe library name."""
name = library_name or settings.get_active_library_name() or "default"
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
return safe_name or "default"
def get_library_root(library_name: Optional[str] = None) -> str:
"""Return the directory where a library's example images should live."""
root = get_example_images_root()
if not root:
return ""
if uses_library_scoped_folders():
return os.path.join(root, sanitize_library_name(library_name))
return root
def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
"""Ensure the example image directory for a library exists and return it."""
library_root = get_library_root(library_name)
if library_root:
os.makedirs(library_root, exist_ok=True)
return library_root
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
"""Return the folder path for a model's example images."""
if not model_hash:
return ""
library_root = ensure_library_root_exists(library_name)
if not library_root:
return ""
normalized_hash = (model_hash or "").lower()
resolved_folder = os.path.join(library_root, normalized_hash)
if uses_library_scoped_folders():
legacy_root = get_example_images_root()
legacy_folder = os.path.join(legacy_root, normalized_hash)
if os.path.exists(legacy_folder) and not os.path.exists(resolved_folder):
try:
os.makedirs(library_root, exist_ok=True)
shutil.move(legacy_folder, resolved_folder)
logger.info(
"Migrated legacy example images folder '%s' to '%s'", legacy_folder, resolved_folder
)
except OSError as exc:
logger.error(
"Failed to migrate example images from '%s' to '%s': %s",
legacy_folder,
resolved_folder,
exc,
)
return legacy_folder
return resolved_folder
def get_model_relative_path(model_hash: str, library_name: Optional[str] = None) -> str:
"""Return the relative URL path from the static mount to a model folder."""
root = get_example_images_root()
folder = get_model_folder(model_hash, library_name)
if not root or not folder:
return ""
try:
relative = os.path.relpath(folder, root)
except ValueError:
return ""
return relative.replace("\\", "/")
def iter_library_roots() -> Iterable[Tuple[str, str]]:
"""Yield configured library names and their resolved filesystem roots."""
root = get_example_images_root()
if not root:
return []
libraries = _get_configured_libraries()
if uses_library_scoped_folders():
results: List[Tuple[str, str]] = []
if libraries:
for library in libraries:
results.append((library, get_library_root(library)))
else:
# Fall back to the active library to avoid skipping migrations/cleanup
active = settings.get_active_library_name() or "default"
results.append((active, get_library_root(active)))
return results
active = settings.get_active_library_name() or "default"
return [(active, root)]
def is_hash_folder(name: str) -> bool:
"""Return True if the provided name looks like a model hash folder."""
return bool(_HEX_PATTERN.fullmatch(name or ""))
def is_valid_example_images_root(folder_path: str) -> bool:
"""Check whether a folder looks like a dedicated example images root."""
try:
items = os.listdir(folder_path)
except OSError:
return False
for item in items:
item_path = os.path.join(folder_path, item)
if item == ".download_progress.json" and os.path.isfile(item_path):
continue
if os.path.isdir(item_path):
if is_hash_folder(item):
continue
if item == "_deleted":
# Allow cleanup staging folders
continue
# When multi-library mode is active we expect nested hash folders
if uses_library_scoped_folders():
if _library_folder_has_only_hash_dirs(item_path):
continue
return False
return True
def _library_folder_has_only_hash_dirs(path: str) -> bool:
"""Return True when a library subfolder only contains hash folders or metadata files."""
try:
for entry in os.listdir(path):
entry_path = os.path.join(path, entry)
if entry == ".download_progress.json" and os.path.isfile(entry_path):
continue
if entry == "_deleted" and os.path.isdir(entry_path):
continue
if not os.path.isdir(entry_path) or not is_hash_folder(entry):
return False
except OSError:
return False
return True

View File

@@ -7,6 +7,7 @@ from aiohttp import web
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import settings
from ..utils.example_images_paths import get_model_folder, get_model_relative_path
from .example_images_metadata import MetadataUpdater
from ..utils.metadata_manager import MetadataManager
@@ -346,7 +347,9 @@ class ExampleImagesProcessor:
)
# Create model folder
model_folder = os.path.join(example_images_path, model_hash)
model_folder = get_model_folder(model_hash)
if not model_folder:
raise ExampleImagesImportError('Failed to resolve model folder for example images')
os.makedirs(model_folder, exist_ok=True)
imported_files = []
@@ -383,7 +386,7 @@ class ExampleImagesProcessor:
# Add to imported files list
imported_files.append({
'name': new_filename,
'path': f'/example_images_static/{model_hash}/{new_filename}',
'path': f'/example_images_static/{get_model_relative_path(model_hash)}/{new_filename}',
'extension': file_ext,
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
})
@@ -497,7 +500,12 @@ class ExampleImagesProcessor:
}, status=404)
# Find and delete the actual file
model_folder = os.path.join(example_images_path, model_hash)
model_folder = get_model_folder(model_hash)
if not model_folder:
return web.json_response({
'success': False,
'error': 'Failed to resolve model folder for example images'
}, status=500)
file_deleted = False
if os.path.exists(model_folder):