Download ComfyUI-MiniMaxH3-Context-Loop/project_assets.py from dzmdd/Fast_deploy: direct link, hf CLI and curl.
- Browser
- Download file 68.9 kB
-
https://huggingface.co/dzmdd/Fast_deploy/resolve/main/ComfyUI-MiniMaxH3-Context-Loop/project_assets.py
- Command line
-
hf download hf://dzmdd/Fast_deploy/ComfyUI-MiniMaxH3-Context-Loop/project_assets.py
-
curl -L -o project_assets.py https://huggingface.co/dzmdd/Fast_deploy/resolve/main/ComfyUI-MiniMaxH3-Context-Loop/project_assets.py
68.9 kB
| """Project-owned media catalog for the H3 Asset Carousel node. | |
| The catalog lives below ComfyUI's input directory so ordinary loader nodes can | |
| open every imported file without this pack. A second, content-identical copy | |
| is retained below the matching H3 chain output directory for recovery. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import hashlib | |
| import json | |
| import mimetypes | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import uuid | |
| from datetime import datetime, timezone | |
| from fractions import Fraction | |
| from typing import Any | |
| if __package__: | |
| from .audio_track_contract import audio_track_bindings | |
| else: # Standalone catalog tools and storage tests. | |
| from audio_track_contract import audio_track_bindings | |
| try: | |
| import av | |
| except ImportError: # ComfyUI normally includes PyAV. | |
| av = None | |
| try: | |
| from PIL import Image, ImageOps | |
| except ImportError: # ComfyUI normally includes Pillow. | |
| Image = ImageOps = None | |
| PROJECT_ASSET_FORMAT = "h3_project_assets_v1" | |
| PROJECT_ASSET_VERSION = 1 | |
| PROJECT_ASSET_ROLES = ( | |
| "picture", "semantic_anchor", "video", "motion", | |
| "audio_reference", "source_track", | |
| ) | |
| IMAGE_EXTENSIONS = frozenset(( | |
| ".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tif", | |
| ".tiff", ".webp", | |
| )) | |
| VIDEO_EXTENSIONS = frozenset(( | |
| ".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", | |
| ".webm", | |
| )) | |
| AUDIO_EXTENSIONS = frozenset(( | |
| ".aac", ".flac", ".m4a", ".mp3", ".oga", ".ogg", ".opus", | |
| ".wav", | |
| )) | |
| ALL_MEDIA_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS | AUDIO_EXTENSIONS | |
| MAX_CATALOG_ASSETS = 512 | |
| MAX_REFERENCE_SLOTS = 512 | |
| MAX_ASSET_FOLDERS = 128 | |
| MAX_ASSET_LYRICS_CHARACTERS = 100_000 | |
| MAX_DERIVED_IMAGE_PIXELS = 268_435_456 | |
| MAX_INPUT_RESULTS = 2000 | |
| INPUT_BROWSER_EXCLUDED_DIRECTORIES = frozenset(( | |
| "clipspace", "h3_projects", | |
| )) | |
| _TAG_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}") | |
| def _utc_now() -> str: | |
| return datetime.now(timezone.utc).isoformat( | |
| timespec="seconds").replace("+00:00", "Z") | |
| def _safe_name(value: Any, fallback: str = "asset", limit: int = 128) -> str: | |
| text = re.sub(r"[^A-Za-z0-9._-]+", "_", str(value or "").strip()) | |
| text = text.strip("._-") | |
| return (text or fallback)[:limit] | |
| def _safe_project(value: Any) -> str: | |
| project = _safe_name(value, "", 96) | |
| if not project: | |
| raise ValueError("Run name cannot be blank.") | |
| return project | |
| def _safe_tag(value: Any, fallback: str = "asset") -> str: | |
| tag = str(value or "").strip().lstrip("@#") | |
| if not tag: | |
| tag = _safe_name(fallback, "asset", 64) | |
| if not tag[:1].isalpha(): | |
| tag = "asset_" + tag | |
| if _TAG_RE.fullmatch(tag) is None: | |
| tag = re.sub(r"[^A-Za-z0-9_-]+", "_", tag).strip("_-")[:64] | |
| if not tag or not tag[0].isalpha(): | |
| tag = "asset_" + (tag or uuid.uuid4().hex[:8]) | |
| tag = tag[:64] | |
| if _TAG_RE.fullmatch(tag) is None: | |
| raise ValueError("Asset tag must begin with a letter and contain only letters, numbers, _ or -.") | |
| return tag | |
| def _safe_folder_name(value: Any) -> str: | |
| name = re.sub(r"[\x00-\x1f\x7f]+", " ", str(value or "")).strip() | |
| name = re.sub(r"\s+", " ", name) | |
| if not name: | |
| raise ValueError("Asset folder name cannot be blank.") | |
| return name[:80] | |
| def _unique_tag(catalog: dict[str, Any], value: Any, fallback: str, | |
| *, exclude_id: str = "") -> str: | |
| tag = _safe_tag(value, fallback) | |
| used = { | |
| str(item.get("tag") or "") for item in catalog.get("assets", []) | |
| if str(item.get("id") or "") != exclude_id | |
| and bool(item.get("enabled", True)) | |
| } | |
| if tag not in used: | |
| return tag | |
| base = tag | |
| ordinal = 2 | |
| while tag in used: | |
| suffix = "_%d" % ordinal | |
| tag = base[:64 - len(suffix)] + suffix | |
| ordinal += 1 | |
| return tag | |
| def _image_resampling(value: Any): | |
| if Image is None: | |
| raise RuntimeError("Pillow is required for project image variants.") | |
| name = str(value or "lanczos").strip().lower() | |
| modes = { | |
| "nearest": Image.Resampling.NEAREST, | |
| "box": Image.Resampling.BOX, | |
| "bilinear": Image.Resampling.BILINEAR, | |
| "hamming": Image.Resampling.HAMMING, | |
| "bicubic": Image.Resampling.BICUBIC, | |
| "lanczos": Image.Resampling.LANCZOS, | |
| } | |
| if name not in modes: | |
| raise ValueError("Image resampling must be nearest, box, bilinear, hamming, bicubic, or lanczos.") | |
| return name, modes[name] | |
| def _image_operation_geometry(image: Any, crop: Any, target: Any) -> tuple[int, int, int, int, int, int]: | |
| if not isinstance(crop, dict): | |
| raise ValueError("Image crop must be a JSON object.") | |
| if not isinstance(target, dict): | |
| raise ValueError("Image target size must be a JSON object.") | |
| try: | |
| x = int(crop.get("x", 0)) | |
| y = int(crop.get("y", 0)) | |
| width = int(crop.get("width", image.width)) | |
| height = int(crop.get("height", image.height)) | |
| target_width = int(target.get("width", width)) | |
| target_height = int(target.get("height", height)) | |
| except (TypeError, ValueError) as exc: | |
| raise ValueError("Crop and target dimensions must be whole pixels.") from exc | |
| if x < 0 or y < 0 or width < 1 or height < 1: | |
| raise ValueError("Crop dimensions must describe a positive area inside the source image.") | |
| if x + width > image.width or y + height > image.height: | |
| raise ValueError( | |
| "Crop %d,%d %dx%d exceeds the oriented source image %dx%d." % | |
| (x, y, width, height, image.width, image.height)) | |
| if target_width < 1 or target_height < 1: | |
| raise ValueError("Target width and height must be positive.") | |
| if target_width * target_height > MAX_DERIVED_IMAGE_PIXELS: | |
| raise ValueError("Derived image target exceeds %d pixels." % MAX_DERIVED_IMAGE_PIXELS) | |
| return x, y, width, height, target_width, target_height | |
| def _inside(root: str, path: str) -> bool: | |
| try: | |
| return os.path.commonpath( | |
| (os.path.realpath(root), os.path.realpath(path))) == os.path.realpath(root) | |
| except ValueError: | |
| return False | |
| def _atomic_json(path: str, value: Any) -> None: | |
| os.makedirs(os.path.dirname(path), exist_ok=True) | |
| temporary = "%s.%s.tmp" % (path, uuid.uuid4().hex) | |
| try: | |
| with open(temporary, "w", encoding="utf-8") as handle: | |
| json.dump(value, handle, ensure_ascii=False, indent=2, sort_keys=True) | |
| handle.write("\n") | |
| os.replace(temporary, path) | |
| finally: | |
| try: | |
| os.unlink(temporary) | |
| except FileNotFoundError: | |
| pass | |
| def _file_sha256(path: str) -> str: | |
| digest = hashlib.sha256() | |
| with open(path, "rb") as handle: | |
| while True: | |
| chunk = handle.read(1024 * 1024) | |
| if not chunk: | |
| break | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| def _canonical_fingerprint(value: Any) -> str: | |
| payload = json.dumps( | |
| value, ensure_ascii=False, sort_keys=True, | |
| separators=(",", ":")).encode("utf-8") | |
| return hashlib.sha256(payload).hexdigest() | |
| def _media_kind(path: str) -> str: | |
| suffix = os.path.splitext(path)[1].lower() | |
| if suffix in IMAGE_EXTENSIONS: | |
| return "image" | |
| if suffix in VIDEO_EXTENSIONS: | |
| return "video" | |
| if suffix in AUDIO_EXTENSIONS: | |
| return "audio" | |
| raise ValueError("Unsupported media extension %r." % suffix) | |
| def _role_for_kind(kind: str, role: Any) -> str: | |
| role = str(role or "").strip().lower() | |
| defaults = {"image": "picture", "video": "video", "audio": "audio_reference"} | |
| role = role or defaults[kind] | |
| allowed = { | |
| "image": {"picture", "semantic_anchor"}, | |
| "video": {"video", "motion", "source_track"}, | |
| "audio": {"audio_reference", "source_track"}, | |
| } | |
| if role not in allowed[kind]: | |
| raise ValueError("Role %r is not valid for a %s asset." % (role, kind)) | |
| return role | |
| def _stream_duration(stream: Any, container: Any) -> float: | |
| if stream.duration is not None and stream.time_base is not None: | |
| return max(0.0, float(stream.duration * stream.time_base)) | |
| if container.duration is not None: | |
| return max(0.0, float(container.duration) / 1_000_000.0) | |
| return 0.0 | |
| def _fraction_parts(value: Any, fallback: int = 24) -> tuple[int, int]: | |
| try: | |
| rate = Fraction(value) | |
| except (TypeError, ValueError, ZeroDivisionError): | |
| rate = Fraction(fallback, 1) | |
| if rate <= 0: | |
| rate = Fraction(fallback, 1) | |
| return int(rate.numerator), int(rate.denominator) | |
| def _probe_media(path: str, kind: str) -> dict[str, Any]: | |
| if kind == "image": | |
| if Image is None: | |
| return {} | |
| with Image.open(path) as image: | |
| return { | |
| "width": int(image.width), | |
| "height": int(image.height), | |
| "format": str(image.format or "").lower(), | |
| "frames": int(getattr(image, "n_frames", 1)), | |
| } | |
| if av is None: | |
| return {} | |
| with av.open(path, mode="r") as container: | |
| if kind == "video": | |
| videos = list(container.streams.video) | |
| if not videos: | |
| raise ValueError("Video asset contains no video stream.") | |
| video = videos[0] | |
| rate = video.average_rate or video.guessed_rate or video.base_rate | |
| numerator, denominator = _fraction_parts(rate) | |
| duration = _stream_duration(video, container) | |
| frames = int(video.frames or 0) | |
| if frames <= 0 and duration > 0: | |
| frames = int(round(duration * numerator / denominator)) | |
| audio_metadata = {} | |
| audios = list(container.streams.audio) | |
| if audios: | |
| audio = audios[0] | |
| audio_metadata = { | |
| "audio_sample_rate": int( | |
| audio.codec_context.sample_rate or audio.rate or 0), | |
| "audio_channels": len(audio.codec_context.layout.channels), | |
| "audio_start_seconds": float( | |
| audio.start_time * audio.time_base) | |
| if audio.start_time is not None | |
| and audio.time_base is not None else 0.0, | |
| } | |
| return { | |
| "width": int(video.codec_context.width or video.width or 0), | |
| "height": int(video.codec_context.height or video.height or 0), | |
| "duration_seconds": duration, | |
| "frame_count": frames, | |
| "source_rate_numerator": numerator, | |
| "source_rate_denominator": denominator, | |
| "fps": numerator / float(denominator), | |
| "codec": str(video.codec_context.name or ""), | |
| "video_start_seconds": float( | |
| video.start_time * video.time_base) | |
| if video.start_time is not None and video.time_base is not None | |
| else 0.0, | |
| "has_audio": bool(audios), | |
| **audio_metadata, | |
| } | |
| audios = list(container.streams.audio) | |
| if not audios: | |
| raise ValueError("Audio asset contains no audio stream.") | |
| audio = audios[0] | |
| sample_rate = int(audio.codec_context.sample_rate or audio.rate or 0) | |
| channels = len(audio.codec_context.layout.channels) | |
| return { | |
| "duration_seconds": _stream_duration(audio, container), | |
| "sample_rate": sample_rate, | |
| "channels": channels, | |
| "codec": str(audio.codec_context.name or ""), | |
| } | |
| def _entry_contract(entry: dict[str, Any]) -> dict[str, Any]: | |
| options = entry.get("options") if isinstance(entry.get("options"), dict) else {} | |
| return { | |
| "id": str(entry.get("id") or ""), | |
| "kind": str(entry.get("kind") or ""), | |
| "role": str(entry.get("role") or ""), | |
| "tag": str(entry.get("tag") or ""), | |
| "sha256": str(entry.get("sha256") or ""), | |
| "enabled": bool(entry.get("enabled", True)), | |
| "options": options, | |
| } | |
| def _slot_contract(slot: dict[str, Any]) -> dict[str, Any]: | |
| options = slot.get("options") if isinstance(slot.get("options"), dict) else {} | |
| return { | |
| "id": str(slot.get("id") or ""), | |
| "kind": str(slot.get("kind") or ""), | |
| "role": str(slot.get("role") or ""), | |
| "tag": str(slot.get("tag") or ""), | |
| "content_hash": str(slot.get("content_hash") or ""), | |
| "available": bool(slot.get("available", True)), | |
| "options": options, | |
| } | |
| class ProjectAssetStore: | |
| """Own project media below input/h3_projects and mirror it to a run.""" | |
| def __init__(self, input_root: str, output_root: str): | |
| self.input_root = os.path.realpath(os.path.abspath(input_root)) | |
| self.output_root = os.path.realpath(os.path.abspath(output_root)) | |
| self.projects_root = os.path.join(self.input_root, "h3_projects") | |
| self.chains_root = os.path.join(self.output_root, "h3_chains") | |
| def _project_dir(self, project: Any) -> tuple[str, str]: | |
| name = _safe_project(project) | |
| path = os.path.realpath(os.path.join(self.projects_root, name)) | |
| if not _inside(self.input_root, path): | |
| raise ValueError("Project asset path escapes the ComfyUI input directory.") | |
| return path, name | |
| def _backup_dir(self, project: Any) -> tuple[str, str]: | |
| name = _safe_project(project) | |
| path = os.path.realpath(os.path.join( | |
| self.chains_root, name, "project_assets")) | |
| if not _inside(self.output_root, path): | |
| raise ValueError("Project backup path escapes the ComfyUI output directory.") | |
| return path, name | |
| def _empty_catalog(project: str) -> dict[str, Any]: | |
| return { | |
| "format": PROJECT_ASSET_FORMAT, | |
| "version": PROJECT_ASSET_VERSION, | |
| "project": project, | |
| "updated_at": _utc_now(), | |
| "revision": "", | |
| "assets": [], | |
| "reference_slots": [], | |
| "folders": [], | |
| } | |
| def load(self, project: Any, *, create: bool = False) -> dict[str, Any]: | |
| directory, name = self._project_dir(project) | |
| path = os.path.join(directory, "catalog.json") | |
| if not os.path.isfile(path): | |
| catalog = self._empty_catalog(name) | |
| if create: | |
| return self._save_catalog(catalog) | |
| return catalog | |
| with open(path, "r", encoding="utf-8") as handle: | |
| catalog = json.load(handle) | |
| if (not isinstance(catalog, dict) | |
| or catalog.get("format") != PROJECT_ASSET_FORMAT | |
| or int(catalog.get("version", -1)) != PROJECT_ASSET_VERSION | |
| or not isinstance(catalog.get("assets"), list)): | |
| raise ValueError("Project asset catalog has an unsupported format.") | |
| catalog["project"] = name | |
| slots = catalog.get("reference_slots") | |
| catalog["reference_slots"] = ( | |
| slots if isinstance(slots, list) else []) | |
| folders = catalog.get("folders") | |
| catalog["folders"] = folders if isinstance(folders, list) else [] | |
| return catalog | |
| def _save_catalog(self, catalog: dict[str, Any]) -> dict[str, Any]: | |
| directory, name = self._project_dir(catalog.get("project")) | |
| assets = [dict(item) for item in catalog.get("assets", []) | |
| if isinstance(item, dict)] | |
| slots = [dict(item) for item in catalog.get("reference_slots", []) | |
| if isinstance(item, dict)] | |
| folders = [dict(item) for item in catalog.get("folders", []) | |
| if isinstance(item, dict)] | |
| if len(assets) > MAX_CATALOG_ASSETS: | |
| raise ValueError("Project asset catalog supports at most %d assets." % MAX_CATALOG_ASSETS) | |
| if len(slots) > MAX_REFERENCE_SLOTS: | |
| raise ValueError( | |
| "Project asset catalog supports at most %d unresolved " | |
| "reference slots." % MAX_REFERENCE_SLOTS) | |
| if len(folders) > MAX_ASSET_FOLDERS: | |
| raise ValueError( | |
| "Project asset catalog supports at most %d folders." % | |
| MAX_ASSET_FOLDERS) | |
| folder_ids = [str(item.get("id") or "") for item in folders] | |
| if (any(not item for item in folder_ids) | |
| or len(folder_ids) != len(set(folder_ids))): | |
| raise ValueError("Every asset folder must have a unique ID.") | |
| known_folders = set(folder_ids) | |
| for asset in assets: | |
| options = asset.get("options") or {} | |
| if options.get("audio_tracks") is not None: | |
| if asset.get("kind") != "audio": | |
| raise ValueError("Only audio assets can group audio tracks.") | |
| asset["options"] = { | |
| **options, "audio_tracks": audio_track_bindings( | |
| options["audio_tracks"], assets), | |
| } | |
| folder_id = str(asset.get("folder_id") or "") | |
| if folder_id and folder_id not in known_folders: | |
| asset["folder_id"] = "" | |
| document = { | |
| "format": PROJECT_ASSET_FORMAT, | |
| "version": PROJECT_ASSET_VERSION, | |
| "project": name, | |
| "updated_at": _utc_now(), | |
| "assets": assets, | |
| "reference_slots": slots, | |
| "folders": folders, | |
| } | |
| document["revision"] = _canonical_fingerprint({ | |
| "assets": [_entry_contract(item) for item in assets], | |
| "reference_slots": [_slot_contract(item) for item in slots], | |
| }) | |
| for group in ("images", "videos", "audio", "previews", ".uploads"): | |
| os.makedirs(os.path.join(directory, group), exist_ok=True) | |
| _atomic_json(os.path.join(directory, "catalog.json"), document) | |
| backup, _name = self._backup_dir(name) | |
| os.makedirs(backup, exist_ok=True) | |
| _atomic_json(os.path.join(backup, "catalog.json"), document) | |
| return document | |
| def public_catalog(self, project: Any) -> dict[str, Any]: | |
| catalog = self.load(project, create=True) | |
| return { | |
| **catalog, | |
| "assets": [dict(item) for item in catalog["assets"]], | |
| "reference_slots": [ | |
| dict(item) for item in catalog.get("reference_slots", [])], | |
| "folders": [dict(item) for item in catalog.get("folders", [])], | |
| } | |
| def duplicate_project( | |
| self, project: Any, new_project: Any) -> dict[str, Any]: | |
| """Clone one complete asset project without copying generated output. | |
| Only catalog-referenced media below ``input/h3_projects`` and its | |
| ``project_assets`` recovery mirror are copied. The source run's | |
| checkpoints, generated clips, assembled videos, and other output | |
| files are never traversed. | |
| """ | |
| source_directory, source_name = self._project_dir(project) | |
| target_directory, target_name = self._project_dir(new_project) | |
| if source_name == target_name: | |
| raise ValueError( | |
| "The duplicated asset project needs a different Run name.") | |
| source_catalog_path = os.path.join(source_directory, "catalog.json") | |
| if not os.path.isfile(source_catalog_path): | |
| raise FileNotFoundError( | |
| "Asset project %s does not exist." % source_name) | |
| source_catalog = self.load(source_name) | |
| media: dict[str, tuple[str, str, int]] = {} | |
| for entry in source_catalog.get("assets", []): | |
| source_path = self._asset_path(source_name, entry) | |
| relative = str(entry.get("relative_path") or "").replace( | |
| "/", os.sep) | |
| normalized = os.path.normpath(relative) | |
| if (not relative or normalized in ("", ".") | |
| or os.path.isabs(normalized) | |
| or normalized == os.pardir | |
| or normalized.startswith(os.pardir + os.sep)): | |
| raise ValueError( | |
| "Project asset media path escapes its store: %s" % | |
| relative) | |
| digest = _file_sha256(source_path) | |
| expected = str(entry.get("sha256") or "") | |
| if expected and digest != expected: | |
| raise ValueError( | |
| "Project asset %s no longer matches its catalog SHA-256." | |
| % entry.get("tag", entry.get("id", "asset"))) | |
| existing = media.get(normalized) | |
| if existing is not None and existing[1] != digest: | |
| raise ValueError( | |
| "Project asset catalog maps one media path to different " | |
| "content.") | |
| media[normalized] = ( | |
| source_path, digest, int(os.path.getsize(source_path))) | |
| target_backup, _name = self._backup_dir(target_name) | |
| target_run_directory = os.path.dirname(target_backup) | |
| if os.path.exists(target_directory): | |
| raise FileExistsError( | |
| "An asset project named %s already exists. Choose a new Run " | |
| "name." % target_name) | |
| if os.path.exists(target_run_directory): | |
| raise FileExistsError( | |
| "An H3 output Run named %s already exists. Choose a new Run " | |
| "name so its renders and checkpoints remain separate." % | |
| target_name) | |
| os.makedirs(self.projects_root, exist_ok=True) | |
| os.makedirs(self.chains_root, exist_ok=True) | |
| created_project = False | |
| created_run = False | |
| try: | |
| os.mkdir(target_directory) | |
| created_project = True | |
| os.mkdir(target_run_directory) | |
| created_run = True | |
| os.makedirs(target_backup) | |
| for group in ("images", "videos", "audio", "previews", ".uploads"): | |
| os.makedirs(os.path.join(target_directory, group)) | |
| for group in ("images", "videos", "audio"): | |
| os.makedirs(os.path.join(target_backup, group)) | |
| for relative, (source_path, digest, _size) in media.items(): | |
| for root in (target_directory, target_backup): | |
| destination = os.path.realpath(os.path.join(root, relative)) | |
| if not _inside(root, destination): | |
| raise ValueError( | |
| "Project asset media path escapes its duplicate.") | |
| os.makedirs(os.path.dirname(destination), exist_ok=True) | |
| shutil.copy2(source_path, destination) | |
| if _file_sha256(destination) != digest: | |
| raise ValueError( | |
| "Duplicated project asset failed its SHA-256 check.") | |
| now = _utc_now() | |
| cloned_catalog = copy.deepcopy(source_catalog) | |
| cloned_catalog["project"] = target_name | |
| cloned_catalog["updated_at"] = now | |
| for entry in cloned_catalog.get("assets", []): | |
| relative = str(entry.get("relative_path") or "") | |
| entry["input_path"] = os.path.join( | |
| "h3_projects", target_name, relative).replace(os.sep, "/") | |
| entry["updated_at"] = now | |
| saved = self._save_catalog(cloned_catalog) | |
| return { | |
| "catalog": saved, | |
| "source_project": source_name, | |
| "target_project": target_name, | |
| "asset_count": len(saved.get("assets", [])), | |
| "media_file_count": len(media), | |
| "copied_bytes": sum(item[2] for item in media.values()), | |
| "render_outputs_copied": False, | |
| } | |
| except Exception: | |
| if created_run: | |
| shutil.rmtree(target_run_directory, ignore_errors=True) | |
| if created_project: | |
| shutil.rmtree(target_directory, ignore_errors=True) | |
| raise | |
| def _asset_path(self, project: Any, entry: dict[str, Any]) -> str: | |
| directory, _name = self._project_dir(project) | |
| relative = str(entry.get("relative_path") or "") | |
| path = os.path.realpath(os.path.join(directory, relative)) | |
| if not relative or not _inside(directory, path) or not os.path.isfile(path): | |
| raise FileNotFoundError("Project asset file is missing: %s" % relative) | |
| return path | |
| def asset(self, project: Any, asset_id: Any) -> tuple[dict[str, Any], str]: | |
| catalog = self.load(project) | |
| wanted = str(asset_id or "") | |
| entry = next((item for item in catalog["assets"] | |
| if str(item.get("id") or "") == wanted), None) | |
| if entry is None: | |
| raise FileNotFoundError("Project asset %s was not found." % wanted) | |
| return dict(entry), self._asset_path(project, entry) | |
| def upload_path(self, project: Any, filename: Any) -> str: | |
| directory, _name = self._project_dir(project) | |
| os.makedirs(os.path.join(directory, ".uploads"), exist_ok=True) | |
| basename = _safe_name(os.path.basename(str(filename or "asset")), "asset") | |
| suffix = os.path.splitext(basename)[1].lower() | |
| if suffix not in ALL_MEDIA_EXTENSIONS: | |
| raise ValueError("Unsupported upload extension %r." % suffix) | |
| return os.path.join(directory, ".uploads", "%s_%s" % ( | |
| uuid.uuid4().hex, basename)) | |
| def sync_reference_slots( | |
| self, project: Any, templates: Any) -> dict[str, Any]: | |
| """Mirror reference metadata without copying or serializing media.""" | |
| if not isinstance(templates, list): | |
| raise ValueError("Reference templates must be a list.") | |
| catalog = self.load(project, create=True) | |
| resolved_tags = { | |
| str(item.get("tag") or "") for item in catalog["assets"]} | |
| current = { | |
| str(item.get("tag") or ""): dict(item) | |
| for item in catalog.get("reference_slots", []) | |
| if isinstance(item, dict) and str(item.get("tag") or "") | |
| } | |
| incoming_tags = set() | |
| slots = [] | |
| now = _utc_now() | |
| for raw in templates: | |
| if not isinstance(raw, dict): | |
| raise ValueError("Every reference template must be an object.") | |
| kind = str(raw.get("kind") or "").strip().lower() | |
| if kind not in ("image", "video", "audio"): | |
| raise ValueError( | |
| "Reference template kind must be image, video, or audio.") | |
| tag = _safe_tag(raw.get("tag"), "%s_reference" % kind) | |
| if tag in incoming_tags: | |
| raise ValueError("Reference template @%s is duplicated." % tag) | |
| incoming_tags.add(tag) | |
| if tag in resolved_tags: | |
| # A project asset with this tag is already the authoritative | |
| # binding. Synchronization never overwrites it. | |
| continue | |
| role = _role_for_kind(kind, raw.get("role")) | |
| options = raw.get("options") | |
| options = dict(options) if isinstance(options, dict) else {} | |
| previous = current.get(tag) | |
| created_at = str( | |
| (previous or {}).get("created_at") or now) | |
| slot = { | |
| "id": str((previous or {}).get("id") or | |
| ("slot_" + uuid.uuid4().hex)), | |
| "kind": kind, | |
| "role": role, | |
| "tag": tag, | |
| "content_hash": str(raw.get("content_hash") or ""), | |
| "available": True, | |
| "source": "tagged_references", | |
| "created_at": created_at, | |
| "updated_at": now, | |
| "options": options, | |
| } | |
| slots.append(slot) | |
| for tag, previous in current.items(): | |
| if tag in incoming_tags or tag in resolved_tags: | |
| continue | |
| stale = dict(previous) | |
| stale["available"] = False | |
| stale["updated_at"] = now | |
| slots.append(stale) | |
| old_contract = [ | |
| _slot_contract(item) | |
| for item in catalog.get("reference_slots", [])] | |
| new_contract = [_slot_contract(item) for item in slots] | |
| if old_contract == new_contract: | |
| return self.public_catalog(project) | |
| catalog["reference_slots"] = slots | |
| return self._save_catalog(catalog) | |
| def bind_reference_slot( | |
| self, project: Any, slot_id: Any, source_path: Any, *, | |
| role: Any = "", tag: Any = "", original_name: Any = "", | |
| source_kind: str = "path", | |
| options: dict[str, Any] | None = None) -> dict[str, Any]: | |
| """Bind explicitly selected media to one unresolved reference slot.""" | |
| catalog = self.load(project) | |
| wanted = str(slot_id or "") | |
| slot = next((item for item in catalog.get("reference_slots", []) | |
| if str(item.get("id") or "") == wanted), None) | |
| if slot is None: | |
| raise FileNotFoundError( | |
| "Unresolved reference slot %s was not found." % wanted) | |
| merged_options = dict(slot.get("options") or {}) | |
| if isinstance(options, dict): | |
| merged_options.update(options) | |
| result = self.import_file( | |
| project, source_path, | |
| role=role or slot.get("role"), tag=tag or slot.get("tag"), | |
| original_name=original_name, source_kind=source_kind, | |
| options=merged_options) | |
| bound_catalog = result["catalog"] | |
| bound_catalog["reference_slots"] = [ | |
| item for item in bound_catalog.get("reference_slots", []) | |
| if str(item.get("id") or "") != wanted] | |
| result["catalog"] = self._save_catalog(bound_catalog) | |
| result["bound_slot_id"] = wanted | |
| return result | |
| def import_file(self, project: Any, source_path: Any, *, role: Any = "", | |
| tag: Any = "", original_name: Any = "", | |
| source_kind: str = "path", | |
| options: dict[str, Any] | None = None) -> dict[str, Any]: | |
| source = os.path.realpath(os.path.abspath(os.path.expanduser( | |
| str(source_path or "").strip()))) | |
| if not source or not os.path.isfile(source): | |
| raise FileNotFoundError("Asset source does not exist: %s" % source) | |
| kind = _media_kind(source) | |
| role = _role_for_kind(kind, role) | |
| basename = os.path.basename(str(original_name or source)) | |
| stem = os.path.splitext(basename)[0] | |
| tag = _safe_tag(tag, stem) | |
| digest = _file_sha256(source) | |
| size = int(os.path.getsize(source)) | |
| directory, name = self._project_dir(project) | |
| catalog = self.load(name, create=True) | |
| if role == "source_track" and any( | |
| item.get("role") == "source_track" | |
| and bool(item.get("enabled", True)) | |
| for item in catalog["assets"]): | |
| raise ValueError( | |
| "This project already has an enabled Source track. Disable " | |
| "or reassign it before importing another one.") | |
| group = {"image": "images", "video": "videos", "audio": "audio"}[kind] | |
| suffix = os.path.splitext(source)[1].lower() | |
| filename = "%s_%s%s" % ( | |
| digest[:16], _safe_name(stem, kind, 80), suffix) | |
| destination = os.path.join(directory, group, filename) | |
| os.makedirs(os.path.dirname(destination), exist_ok=True) | |
| if not os.path.isfile(destination) or _file_sha256(destination) != digest: | |
| temporary = "%s.%s.tmp" % (destination, uuid.uuid4().hex) | |
| try: | |
| shutil.copy2(source, temporary) | |
| if _file_sha256(temporary) != digest: | |
| raise ValueError("Imported asset failed its SHA-256 check.") | |
| os.replace(temporary, destination) | |
| finally: | |
| try: | |
| os.unlink(temporary) | |
| except FileNotFoundError: | |
| pass | |
| metadata = _probe_media(destination, kind) | |
| tag = _unique_tag(catalog, tag, stem) | |
| now = _utc_now() | |
| entry = { | |
| "id": uuid.uuid4().hex, | |
| "kind": kind, | |
| "role": role, | |
| "tag": tag, | |
| "enabled": True, | |
| "relative_path": os.path.relpath(destination, directory).replace(os.sep, "/"), | |
| "input_path": os.path.relpath(destination, self.input_root).replace(os.sep, "/"), | |
| "sha256": digest, | |
| "size": size, | |
| "mime_type": mimetypes.guess_type(destination)[0] or "application/octet-stream", | |
| "original_name": basename, | |
| "source_kind": str(source_kind or "path")[:40], | |
| "created_at": now, | |
| "updated_at": now, | |
| "metadata": metadata, | |
| "options": dict(options or {}), | |
| } | |
| catalog["assets"].append(entry) | |
| catalog = self._save_catalog(catalog) | |
| backup, _name = self._backup_dir(name) | |
| backup_path = os.path.join(backup, group, filename) | |
| os.makedirs(os.path.dirname(backup_path), exist_ok=True) | |
| if not os.path.isfile(backup_path) or _file_sha256(backup_path) != digest: | |
| temporary = "%s.%s.tmp" % (backup_path, uuid.uuid4().hex) | |
| try: | |
| shutil.copy2(destination, temporary) | |
| os.replace(temporary, backup_path) | |
| finally: | |
| try: | |
| os.unlink(temporary) | |
| except FileNotFoundError: | |
| pass | |
| return {"catalog": catalog, "asset": entry} | |
| def create_folder(self, project: Any, name: Any, *, color: Any = "") -> dict[str, Any]: | |
| catalog = self.load(project, create=True) | |
| if len(catalog.get("folders", [])) >= MAX_ASSET_FOLDERS: | |
| raise ValueError( | |
| "Project asset catalog supports at most %d folders." % | |
| MAX_ASSET_FOLDERS) | |
| folder_name = _safe_folder_name(name) | |
| if any(str(item.get("name") or "").casefold() == folder_name.casefold() | |
| for item in catalog.get("folders", [])): | |
| raise ValueError("An asset folder named %r already exists." % folder_name) | |
| now = _utc_now() | |
| folder = { | |
| "id": "folder_" + uuid.uuid4().hex, | |
| "name": folder_name, | |
| "color": str(color or "")[:32], | |
| "created_at": now, | |
| "updated_at": now, | |
| } | |
| catalog.setdefault("folders", []).append(folder) | |
| return {"catalog": self._save_catalog(catalog), "folder": dict(folder)} | |
| def update_folder(self, project: Any, folder_id: Any, | |
| changes: Any) -> dict[str, Any]: | |
| if not isinstance(changes, dict): | |
| raise ValueError("Asset folder changes must be a JSON object.") | |
| catalog = self.load(project) | |
| wanted = str(folder_id or "") | |
| folder = next((item for item in catalog.get("folders", []) | |
| if str(item.get("id") or "") == wanted), None) | |
| if folder is None: | |
| raise FileNotFoundError("Asset folder %s was not found." % wanted) | |
| if "name" in changes: | |
| name = _safe_folder_name(changes["name"]) | |
| if any(str(item.get("name") or "").casefold() == name.casefold() | |
| and str(item.get("id") or "") != wanted | |
| for item in catalog.get("folders", [])): | |
| raise ValueError("An asset folder named %r already exists." % name) | |
| folder["name"] = name | |
| if "color" in changes: | |
| folder["color"] = str(changes["color"] or "")[:32] | |
| folder["updated_at"] = _utc_now() | |
| return {"catalog": self._save_catalog(catalog), "folder": dict(folder)} | |
| def delete_folder(self, project: Any, folder_id: Any) -> dict[str, Any]: | |
| catalog = self.load(project) | |
| wanted = str(folder_id or "") | |
| folder = next((item for item in catalog.get("folders", []) | |
| if str(item.get("id") or "") == wanted), None) | |
| if folder is None: | |
| raise FileNotFoundError("Asset folder %s was not found." % wanted) | |
| catalog["folders"] = [ | |
| item for item in catalog.get("folders", []) | |
| if str(item.get("id") or "") != wanted] | |
| moved = 0 | |
| for asset in catalog.get("assets", []): | |
| if str(asset.get("folder_id") or "") == wanted: | |
| asset["folder_id"] = "" | |
| asset["updated_at"] = _utc_now() | |
| moved += 1 | |
| return { | |
| "catalog": self._save_catalog(catalog), | |
| "folder": dict(folder), | |
| "assets_unfiled": moved, | |
| } | |
| def reorder_folders(self, project: Any, folder_ids: Any) -> dict[str, Any]: | |
| if not isinstance(folder_ids, list): | |
| raise ValueError("Folder order must be a JSON list of folder IDs.") | |
| catalog = self.load(project) | |
| current = [str(item.get("id") or "") | |
| for item in catalog.get("folders", [])] | |
| requested = [str(item or "") for item in folder_ids] | |
| if (len(requested) != len(set(requested)) | |
| or set(requested) != set(current)): | |
| raise ValueError( | |
| "Folder order must contain every current folder ID exactly once.") | |
| by_id = {str(item.get("id") or ""): item | |
| for item in catalog.get("folders", [])} | |
| catalog["folders"] = [by_id[item] for item in requested] | |
| return {"catalog": self._save_catalog(catalog)} | |
| def duplicate(self, project: Any, asset_id: Any, *, tag: Any = "", | |
| folder_id: Any = None) -> dict[str, Any]: | |
| """Create a second catalog card without copying its media bytes.""" | |
| catalog = self.load(project) | |
| wanted = str(asset_id or "") | |
| source = next((item for item in catalog["assets"] | |
| if str(item.get("id") or "") == wanted), None) | |
| if source is None: | |
| raise FileNotFoundError("Project asset %s was not found." % wanted) | |
| target_folder = (str(source.get("folder_id") or "") | |
| if folder_id is None else str(folder_id or "")) | |
| known_folders = {str(item.get("id") or "") | |
| for item in catalog.get("folders", [])} | |
| if target_folder and target_folder not in known_folders: | |
| raise FileNotFoundError("Asset folder %s was not found." % target_folder) | |
| now = _utc_now() | |
| clone = dict(source) | |
| clone.update({ | |
| "id": uuid.uuid4().hex, | |
| "tag": _unique_tag( | |
| catalog, tag or "%s_copy" % source.get("tag", "asset"), | |
| "%s_copy" % source.get("tag", "asset")), | |
| "folder_id": target_folder, | |
| "parent_asset_id": wanted, | |
| "source_kind": "catalog_duplicate", | |
| "created_at": now, | |
| "updated_at": now, | |
| }) | |
| if source.get("role") == "source_track": | |
| clone["enabled"] = False | |
| clone["options"] = dict(source.get("options") or {}) | |
| if clone["options"].get("audio_tracks") is not None: | |
| clone["options"]["audio_tracks"] = { | |
| role: clone["id"] if value == wanted else value | |
| for role, value in clone["options"]["audio_tracks"].items()} | |
| clone["metadata"] = dict(source.get("metadata") or {}) | |
| catalog["assets"].append(clone) | |
| return {"catalog": self._save_catalog(catalog), "asset": dict(clone)} | |
| def operation_asset(self, project: Any, operation_id: Any) -> dict[str, Any] | None: | |
| wanted = str(operation_id or "")[:128] | |
| if not wanted: | |
| return None | |
| catalog = self.load(project) | |
| existing = next((item for item in catalog["assets"] | |
| if str((item.get("transform") or {}).get( | |
| "operation_id") or "") == wanted), None) | |
| if existing is None: | |
| return None | |
| return {"catalog": catalog, "asset": dict(existing), "reused": True} | |
| def prepare_image_crop(self, project: Any, asset_id: Any, | |
| crop: Any, target: Any) -> tuple[dict[str, Any], Any, dict[str, int]]: | |
| if Image is None or ImageOps is None: | |
| raise RuntimeError("Pillow is required for project image variants.") | |
| entry, path = self.asset(project, asset_id) | |
| if entry.get("kind") != "image": | |
| raise ValueError("Only image assets can create cropped variants.") | |
| with Image.open(path) as opened: | |
| oriented = ImageOps.exif_transpose(opened).copy() | |
| geometry = _image_operation_geometry(oriented, crop, target) | |
| x, y, width, height, target_width, target_height = geometry | |
| cropped = oriented.crop((x, y, x + width, y + height)) | |
| values = { | |
| "x": x, "y": y, "width": width, "height": height, | |
| "target_width": target_width, "target_height": target_height, | |
| "source_width": int(oriented.width), | |
| "source_height": int(oriented.height), | |
| } | |
| return entry, cropped, values | |
| def register_derived_image( | |
| self, project: Any, parent_asset_id: Any, rendered_path: Any, *, | |
| tag: Any = "", folder_id: Any = None, | |
| transform: dict[str, Any] | None = None, | |
| operation_id: Any = "") -> dict[str, Any]: | |
| parent, _path = self.asset(project, parent_asset_id) | |
| if parent.get("kind") != "image": | |
| raise ValueError("Only image assets can own image variants.") | |
| catalog = self.load(project) | |
| operation_id = str(operation_id or "")[:128] | |
| if operation_id: | |
| existing = next((item for item in catalog["assets"] | |
| if str((item.get("transform") or {}).get( | |
| "operation_id") or "") == operation_id), None) | |
| if existing is not None: | |
| return {"catalog": catalog, "asset": dict(existing), "reused": True} | |
| target_folder = (str(parent.get("folder_id") or "") | |
| if folder_id is None else str(folder_id or "")) | |
| result = self.import_file( | |
| project, rendered_path, | |
| role=parent.get("role"), | |
| tag=tag or "%s_variant" % parent.get("tag", "asset"), | |
| original_name="%s_variant.png" % parent.get("tag", "asset"), | |
| source_kind="derived_image", | |
| options=dict(parent.get("options") or {})) | |
| derived_id = str(result["asset"]["id"]) | |
| catalog = result["catalog"] | |
| derived = next(item for item in catalog["assets"] | |
| if str(item.get("id") or "") == derived_id) | |
| derived["folder_id"] = target_folder | |
| derived["parent_asset_id"] = str(parent_asset_id or "") | |
| lineage = dict(transform or {}) | |
| if operation_id: | |
| lineage["operation_id"] = operation_id | |
| derived["transform"] = lineage | |
| derived["updated_at"] = _utc_now() | |
| saved = self._save_catalog(catalog) | |
| return {"catalog": saved, "asset": dict(derived), "reused": False} | |
| def derive_image( | |
| self, project: Any, asset_id: Any, *, crop: Any, target: Any, | |
| resample: Any = "lanczos", tag: Any = "", folder_id: Any = None, | |
| operation_id: Any = "") -> dict[str, Any]: | |
| existing = self.operation_asset(project, operation_id) | |
| if existing is not None: | |
| return existing | |
| _entry, cropped, geometry = self.prepare_image_crop( | |
| project, asset_id, crop, target) | |
| resample_name, resample_mode = _image_resampling(resample) | |
| if "A" in cropped.getbands() or "transparency" in cropped.info: | |
| cropped = cropped.convert("RGBA") | |
| output_size = (geometry["target_width"], geometry["target_height"]) | |
| if cropped.size != output_size: | |
| cropped = cropped.resize(output_size, resample=resample_mode) | |
| if cropped.mode not in ("RGB", "RGBA", "L", "LA"): | |
| cropped = cropped.convert("RGBA" if "A" in cropped.getbands() else "RGB") | |
| temporary = self.upload_path(project, "%s_variant.png" % asset_id) | |
| try: | |
| cropped.save(temporary, format="PNG") | |
| return self.register_derived_image( | |
| project, asset_id, temporary, tag=tag, | |
| folder_id=folder_id, | |
| operation_id=operation_id, | |
| transform={ | |
| "kind": "crop_resize", | |
| "crop": {key: geometry[key] | |
| for key in ("x", "y", "width", "height")}, | |
| "target": { | |
| "width": geometry["target_width"], | |
| "height": geometry["target_height"], | |
| }, | |
| "source": { | |
| "width": geometry["source_width"], | |
| "height": geometry["source_height"], | |
| }, | |
| "resample": resample_name, | |
| }) | |
| finally: | |
| try: | |
| os.unlink(temporary) | |
| except FileNotFoundError: | |
| pass | |
| def update(self, project: Any, asset_id: Any, changes: Any) -> dict[str, Any]: | |
| if not isinstance(changes, dict): | |
| raise ValueError("Asset changes must be a JSON object.") | |
| catalog = self.load(project) | |
| wanted = str(asset_id or "") | |
| entry = next((item for item in catalog["assets"] | |
| if str(item.get("id") or "") == wanted), None) | |
| if entry is None: | |
| raise FileNotFoundError("Project asset %s was not found." % wanted) | |
| kind = str(entry["kind"]) | |
| if "role" in changes: | |
| entry["role"] = _role_for_kind(kind, changes["role"]) | |
| if "tag" in changes: | |
| tag = _safe_tag(changes["tag"], entry.get("tag") or "asset") | |
| if any(str(item.get("tag") or "") == tag | |
| and str(item.get("id") or "") != wanted | |
| and bool(item.get("enabled", True)) | |
| for item in catalog["assets"]): | |
| raise ValueError("Another enabled project asset already uses @%s." % tag) | |
| entry["tag"] = tag | |
| if "enabled" in changes: | |
| entry["enabled"] = bool(changes["enabled"]) | |
| if "folder_id" in changes: | |
| folder_id = str(changes["folder_id"] or "") | |
| known_folders = {str(item.get("id") or "") | |
| for item in catalog.get("folders", [])} | |
| if folder_id and folder_id not in known_folders: | |
| raise FileNotFoundError( | |
| "Asset folder %s was not found." % folder_id) | |
| entry["folder_id"] = folder_id | |
| if "lyrics" in changes: | |
| if kind != "audio": | |
| raise ValueError("Lyrics can only be attached to audio assets.") | |
| lyrics = str(changes["lyrics"] or "").replace( | |
| "\r\n", "\n").replace("\r", "\n") | |
| if len(lyrics) > MAX_ASSET_LYRICS_CHARACTERS: | |
| raise ValueError( | |
| "Asset lyrics cannot exceed %d characters." % | |
| MAX_ASSET_LYRICS_CHARACTERS) | |
| if lyrics: | |
| entry["lyrics"] = lyrics | |
| else: | |
| entry.pop("lyrics", None) | |
| if "options" in changes: | |
| if not isinstance(changes["options"], dict): | |
| raise ValueError("Asset options must be a JSON object.") | |
| options = dict(entry.get("options") or {}) | |
| options.update(changes["options"]) | |
| entry["options"] = options | |
| if bool(entry.get("enabled", True)): | |
| if any(str(item.get("tag") or "") == str(entry.get("tag") or "") | |
| and str(item.get("id") or "") != wanted | |
| and bool(item.get("enabled", True)) | |
| for item in catalog["assets"]): | |
| raise ValueError( | |
| "Another enabled project asset already uses @%s." % | |
| entry.get("tag")) | |
| if (entry.get("role") == "source_track" and any( | |
| item.get("role") == "source_track" | |
| and str(item.get("id") or "") != wanted | |
| and bool(item.get("enabled", True)) | |
| for item in catalog["assets"])): | |
| raise ValueError( | |
| "This project already has an enabled Source track.") | |
| entry["updated_at"] = _utc_now() | |
| catalog = self._save_catalog(catalog) | |
| return {"catalog": catalog, "asset": dict(entry)} | |
| def reorder(self, project: Any, asset_ids: Any) -> dict[str, Any]: | |
| """Persist one exact permutation of the project's asset cards.""" | |
| if not isinstance(asset_ids, list): | |
| raise ValueError("Asset order must be a JSON list of asset IDs.") | |
| catalog = self.load(project) | |
| current_ids = [str(item.get("id") or "") | |
| for item in catalog["assets"]] | |
| requested = [str(item or "") for item in asset_ids] | |
| if (len(requested) != len(set(requested)) | |
| or set(requested) != set(current_ids)): | |
| raise ValueError( | |
| "Asset order must contain every current asset ID exactly once.") | |
| by_id = { | |
| str(item.get("id") or ""): item for item in catalog["assets"]} | |
| catalog["assets"] = [by_id[asset_id] for asset_id in requested] | |
| return {"catalog": self._save_catalog(catalog)} | |
| def delete(self, project: Any, asset_id: Any) -> dict[str, Any]: | |
| """Remove a catalog asset and its project-owned media copies.""" | |
| catalog = self.load(project) | |
| wanted = str(asset_id or "") | |
| entry = next((item for item in catalog["assets"] | |
| if str(item.get("id") or "") == wanted), None) | |
| if entry is None: | |
| raise FileNotFoundError("Project asset %s was not found." % wanted) | |
| removed = dict(entry) | |
| for owner in catalog["assets"]: | |
| bindings = (owner.get("options") or {}).get("audio_tracks") or {} | |
| if str(owner.get("id") or "") != wanted and wanted in bindings.values(): | |
| raise ValueError( | |
| "This audio is part of %s's track group. Detach it there " | |
| "before deleting it." % (owner.get("tag") or owner["id"])) | |
| catalog["assets"] = [ | |
| item for item in catalog["assets"] | |
| if str(item.get("id") or "") != wanted] | |
| remaining_paths = { | |
| str(item.get("relative_path") or "") | |
| for item in catalog["assets"]} | |
| saved = self._save_catalog(catalog) | |
| deleted_files = 0 | |
| relative = str(removed.get("relative_path") or "") | |
| if relative and relative not in remaining_paths: | |
| project_dir, name = self._project_dir(project) | |
| backup_dir, _name = self._backup_dir(name) | |
| for root in (project_dir, backup_dir): | |
| path = os.path.realpath(os.path.join(root, relative)) | |
| if not _inside(root, path): | |
| raise ValueError("Project asset media path escapes its store.") | |
| try: | |
| os.unlink(path) | |
| deleted_files += 1 | |
| except FileNotFoundError: | |
| pass | |
| project_dir, _name = self._project_dir(project) | |
| preview_dir = os.path.join(project_dir, "previews") | |
| preview_prefix = wanted + "_" | |
| if os.path.isdir(preview_dir): | |
| with os.scandir(preview_dir) as previews: | |
| for preview in previews: | |
| if (preview.is_file(follow_symlinks=False) | |
| and preview.name.startswith(preview_prefix)): | |
| os.unlink(preview.path) | |
| deleted_files += 1 | |
| return { | |
| "catalog": saved, | |
| "asset": removed, | |
| "deleted_files": deleted_files, | |
| } | |
| def input_media(self, query: Any = "") -> list[dict[str, Any]]: | |
| needle = str(query or "").strip().lower() | |
| result = [] | |
| if not os.path.isdir(self.input_root): | |
| return result | |
| for root, directories, files in os.walk(self.input_root): | |
| # Walk normal input subfolders recursively, but omit storage that | |
| # would either duplicate this catalog or flood the picker with | |
| # transient clipboard/Clipspace images. | |
| directories[:] = [ | |
| name for name in directories | |
| if name.casefold() not in INPUT_BROWSER_EXCLUDED_DIRECTORIES | |
| ] | |
| for filename in files: | |
| suffix = os.path.splitext(filename)[1].lower() | |
| if suffix not in ALL_MEDIA_EXTENSIONS: | |
| continue | |
| path = os.path.join(root, filename) | |
| relative = os.path.relpath(path, self.input_root).replace(os.sep, "/") | |
| if needle and needle not in relative.lower(): | |
| continue | |
| try: | |
| stat = os.stat(path) | |
| except OSError: | |
| continue | |
| result.append({ | |
| "path": relative, | |
| "kind": _media_kind(path), | |
| "size": int(stat.st_size), | |
| "modified": float(stat.st_mtime), | |
| }) | |
| if len(result) >= MAX_INPUT_RESULTS: | |
| break | |
| if len(result) >= MAX_INPUT_RESULTS: | |
| break | |
| return sorted(result, key=lambda item: item["path"].lower()) | |
| def input_path(self, relative: Any) -> str: | |
| value = str(relative or "").strip() | |
| path = os.path.realpath(os.path.join(self.input_root, value)) | |
| if not value or not _inside(self.input_root, path) or not os.path.isfile(path): | |
| raise FileNotFoundError("ComfyUI input asset was not found: %s" % value) | |
| return path | |
| def project_catalogs(self, query: Any = "") -> list[dict[str, Any]]: | |
| """List existing live Carousel projects, including empty catalogs.""" | |
| needle = str(query or "").strip().lower() | |
| result = [] | |
| if not os.path.isdir(self.projects_root): | |
| return result | |
| with os.scandir(self.projects_root) as projects: | |
| for project in projects: | |
| if (not project.is_dir(follow_symlinks=False) | |
| or needle and needle not in project.name.lower() | |
| or not os.path.isfile(os.path.join( | |
| project.path, "catalog.json"))): | |
| continue | |
| try: | |
| catalog = self.load(project.name) | |
| except (OSError, TypeError, ValueError, json.JSONDecodeError): | |
| continue | |
| result.append({ | |
| "project": project.name, | |
| "asset_count": len(catalog.get("assets", [])), | |
| "unassigned_count": len( | |
| catalog.get("reference_slots", [])), | |
| "folder_count": len(catalog.get("folders", [])), | |
| "updated_at": str(catalog.get("updated_at") or ""), | |
| "revision": str(catalog.get("revision") or ""), | |
| }) | |
| result.sort(key=lambda item: item["project"].lower()) | |
| result.sort(key=lambda item: item["updated_at"], reverse=True) | |
| return result[:MAX_INPUT_RESULTS] | |
| def projects(self, query: Any = "", *, exclude_project: Any = "") -> list[dict[str, Any]]: | |
| """List live Carousel catalogs without exposing arbitrary input files.""" | |
| needle = str(query or "").strip().lower() | |
| excluded = (_safe_project(exclude_project) | |
| if str(exclude_project or "").strip() else "") | |
| result = [] | |
| remaining = MAX_INPUT_RESULTS | |
| if not os.path.isdir(self.projects_root): | |
| return result | |
| with os.scandir(self.projects_root) as projects: | |
| for project in projects: | |
| if remaining <= 0: | |
| break | |
| if (not project.is_dir(follow_symlinks=False) | |
| or project.name == excluded): | |
| continue | |
| try: | |
| catalog = self.load(project.name) | |
| except (OSError, TypeError, ValueError, json.JSONDecodeError): | |
| continue | |
| assets = [] | |
| for raw in catalog.get("assets", []): | |
| if not isinstance(raw, dict): | |
| continue | |
| searchable = " ".join(( | |
| project.name, | |
| str(raw.get("tag") or ""), | |
| str(raw.get("original_name") or ""), | |
| str(raw.get("role") or ""), | |
| )).lower() | |
| if needle and needle not in searchable: | |
| continue | |
| try: | |
| self._asset_path(project.name, raw) | |
| except (OSError, TypeError, ValueError): | |
| continue | |
| assets.append(dict(raw)) | |
| remaining -= 1 | |
| if remaining <= 0: | |
| break | |
| if assets: | |
| result.append({ | |
| "project": project.name, | |
| "revision": str(catalog.get("revision") or ""), | |
| "assets": assets, | |
| }) | |
| return sorted(result, key=lambda item: item["project"].lower()) | |
| def import_project_asset( | |
| self, project: Any, source_project: Any, asset_id: Any, *, | |
| slot_id: Any = "") -> dict[str, Any]: | |
| """Copy one asset from another live Carousel into this project.""" | |
| target_name = _safe_project(project) | |
| source_name = _safe_project(source_project) | |
| if target_name == source_name: | |
| raise ValueError( | |
| "Choose another Run. Use Duplicate for an asset already in " | |
| "this Carousel.") | |
| source_entry, source_path = self.asset(source_name, asset_id) | |
| wanted_slot = str(slot_id or "") | |
| options = dict(source_entry.get("options") or {}) | |
| bindings = audio_track_bindings( | |
| options.pop("audio_tracks", None), self.load(source_name)["assets"]) | |
| # Resolve every dependency before copying anything. Track bindings are | |
| # project-local IDs; they cannot be copied verbatim to another Run. | |
| dependencies = { | |
| value: self.asset(source_name, value) | |
| for value in (bindings or {}).values() | |
| if value and value != str(source_entry["id"])} if not wanted_slot else {} | |
| if wanted_slot: | |
| result = self.bind_reference_slot( | |
| target_name, wanted_slot, source_path, | |
| original_name=source_entry.get("original_name", ""), | |
| source_kind="project") | |
| else: | |
| result = self.import_file( | |
| target_name, source_path, | |
| role=source_entry.get("role", ""), | |
| tag=source_entry.get("tag", ""), | |
| original_name=source_entry.get("original_name", ""), | |
| source_kind="project", | |
| options=options) | |
| if bindings and not wanted_slot: | |
| mapped = {str(source_entry["id"]): result["asset"]["id"]} | |
| for value, (entry, path) in dependencies.items(): | |
| track_options = dict(entry.get("options") or {}) | |
| track_options.pop("audio_tracks", None) | |
| imported = self.import_file( | |
| target_name, path, role="audio_reference", | |
| tag=entry.get("tag", ""), | |
| original_name=entry.get("original_name", ""), | |
| source_kind="project", options=track_options) | |
| mapped[value] = imported["asset"]["id"] | |
| self.update(target_name, mapped[value], { | |
| "enabled": False, "lyrics": str(entry.get("lyrics") or "")}) | |
| result = self.update(target_name, result["asset"]["id"], { | |
| "options": {"audio_tracks": { | |
| role: mapped[value] if value else "" | |
| for role, value in bindings.items()}}}) | |
| lyrics = str(source_entry.get("lyrics") or "") | |
| if lyrics and result.get("asset", {}).get("kind") == "audio": | |
| bound_slot_id = result.get("bound_slot_id") | |
| result = self.update( | |
| target_name, result["asset"]["id"], {"lyrics": lyrics}) | |
| if bound_slot_id: | |
| result["bound_slot_id"] = bound_slot_id | |
| result["source_project"] = source_name | |
| result["source_asset_id"] = str(source_entry.get("id") or "") | |
| return result | |
| def backups(self) -> list[dict[str, Any]]: | |
| result = [] | |
| if not os.path.isdir(self.chains_root): | |
| return result | |
| with os.scandir(self.chains_root) as runs: | |
| for run in runs: | |
| if not run.is_dir(follow_symlinks=False): | |
| continue | |
| path = os.path.join(run.path, "project_assets", "catalog.json") | |
| if not os.path.isfile(path): | |
| continue | |
| try: | |
| with open(path, "r", encoding="utf-8") as handle: | |
| catalog = json.load(handle) | |
| assets = catalog.get("assets") if isinstance(catalog, dict) else None | |
| if not isinstance(assets, list): | |
| continue | |
| result.append({ | |
| "run_name": run.name, | |
| "revision": str(catalog.get("revision") or ""), | |
| "assets": [dict(item) for item in assets if isinstance(item, dict)], | |
| }) | |
| except (OSError, ValueError, json.JSONDecodeError): | |
| continue | |
| return sorted(result, key=lambda item: item["run_name"].lower()) | |
| def backup_asset_path(self, run_name: Any, asset_id: Any) -> tuple[dict[str, Any], str]: | |
| backup, name = self._backup_dir(run_name) | |
| path = os.path.join(backup, "catalog.json") | |
| if not os.path.isfile(path): | |
| raise FileNotFoundError("H3 chain %s has no project asset backup." % name) | |
| with open(path, "r", encoding="utf-8") as handle: | |
| catalog = json.load(handle) | |
| entry = next((item for item in catalog.get("assets", []) | |
| if str(item.get("id") or "") == str(asset_id or "")), None) | |
| if entry is None: | |
| raise FileNotFoundError("Backup asset %s was not found." % asset_id) | |
| relative = str(entry.get("relative_path") or "") | |
| source = os.path.realpath(os.path.join(backup, relative)) | |
| if not _inside(backup, source) or not os.path.isfile(source): | |
| raise FileNotFoundError("Backup media is missing: %s" % relative) | |
| return dict(entry), source | |
| def _preview_path(self, project: Any, entry: dict[str, Any], suffix: str) -> str: | |
| directory, _name = self._project_dir(project) | |
| return os.path.join(directory, "previews", "%s_%s%s" % ( | |
| entry["id"], str(entry.get("sha256") or "")[:12], suffix)) | |
| def ensure_poster(self, project: Any, asset_id: Any) -> str: | |
| return self._ensure_still_preview( | |
| project, asset_id, ".jpg", (960, 540), 86) | |
| def ensure_thumbnail(self, project: Any, asset_id: Any) -> str: | |
| """Return a small cached still used by large carousel collections.""" | |
| return self._ensure_still_preview( | |
| project, asset_id, ".thumb.jpg", (320, 180), 78) | |
| def _ensure_still_preview( | |
| self, project: Any, asset_id: Any, suffix: str, | |
| bounds: tuple[int, int], quality: int) -> str: | |
| entry, source = self.asset(project, asset_id) | |
| if entry["kind"] == "audio": | |
| raise ValueError("Audio assets do not have poster frames.") | |
| target = self._preview_path(project, entry, suffix) | |
| os.makedirs(os.path.dirname(target), exist_ok=True) | |
| if os.path.isfile(target) and os.path.getsize(target) > 0: | |
| return target | |
| temporary = "%s.%s.tmp.jpg" % (target, uuid.uuid4().hex) | |
| try: | |
| if entry["kind"] == "image" and Image is not None: | |
| with Image.open(source) as image: | |
| frame = image.convert("RGB") | |
| frame.thumbnail(bounds) | |
| frame.save( | |
| temporary, format="JPEG", quality=quality, | |
| optimize=True) | |
| else: | |
| ffmpeg = shutil.which("ffmpeg") | |
| if ffmpeg: | |
| width, height = bounds | |
| command = [ | |
| ffmpeg, "-hide_banner", "-loglevel", "error", "-y", | |
| "-ss", "0", "-i", source, "-frames:v", "1", | |
| "-vf", "scale='min(%d,iw)':'min(%d,ih)':" | |
| "force_original_aspect_ratio=decrease" % ( | |
| width, height), | |
| "-q:v", "3", temporary, | |
| ] | |
| completed = subprocess.run( | |
| command, stdout=subprocess.DEVNULL, | |
| stderr=subprocess.PIPE, timeout=60, check=False) | |
| if completed.returncode: | |
| raise RuntimeError(completed.stderr.decode( | |
| "utf-8", errors="replace")[-1200:]) | |
| elif av is not None and Image is not None: | |
| with av.open(source, mode="r") as container: | |
| video = next(iter(container.streams.video), None) | |
| frame = next(container.decode(video), None) if video else None | |
| if frame is None: | |
| raise ValueError("Video contains no decodable frame.") | |
| image = frame.to_image().convert("RGB") | |
| image.thumbnail(bounds) | |
| image.save( | |
| temporary, format="JPEG", quality=quality) | |
| else: | |
| raise RuntimeError("Video poster generation requires ffmpeg or PyAV/Pillow.") | |
| if not os.path.isfile(temporary) or os.path.getsize(temporary) <= 0: | |
| raise RuntimeError("Preview generator produced an empty poster.") | |
| os.replace(temporary, target) | |
| finally: | |
| try: | |
| os.unlink(temporary) | |
| except FileNotFoundError: | |
| pass | |
| return target | |
| def _browser_video(entry: dict[str, Any], path: str) -> bool: | |
| suffix = os.path.splitext(path)[1].lower() | |
| codec = str((entry.get("metadata") or {}).get("codec") or "").lower() | |
| return ((suffix in (".mp4", ".m4v", ".mov") and codec in ( | |
| "h264", "av1", "hevc")) or | |
| (suffix == ".webm" and codec in ("vp8", "vp9", "av1"))) | |
| def ensure_browser_media(self, project: Any, asset_id: Any) -> str: | |
| entry, source = self.asset(project, asset_id) | |
| if entry["kind"] != "video" or self._browser_video(entry, source): | |
| return source | |
| target = self._preview_path(project, entry, ".mp4") | |
| os.makedirs(os.path.dirname(target), exist_ok=True) | |
| if os.path.isfile(target) and os.path.getsize(target) > 0: | |
| return target | |
| ffmpeg = shutil.which("ffmpeg") | |
| if not ffmpeg: | |
| return source | |
| temporary = "%s.%s.tmp.mp4" % (target, uuid.uuid4().hex) | |
| try: | |
| command = [ | |
| ffmpeg, "-hide_banner", "-loglevel", "error", "-y", | |
| "-i", source, "-vf", "scale='min(1280,iw)':-2", | |
| "-c:v", "libx264", "-preset", "veryfast", "-crf", "25", | |
| "-pix_fmt", "yuv420p", "-movflags", "+faststart", | |
| "-c:a", "aac", "-b:a", "128k", temporary, | |
| ] | |
| completed = subprocess.run( | |
| command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, | |
| timeout=max(120, int(float((entry.get("metadata") or {}).get( | |
| "duration_seconds") or 0) * 8)), check=False) | |
| if completed.returncode: | |
| raise RuntimeError(completed.stderr.decode( | |
| "utf-8", errors="replace")[-1600:]) | |
| if not os.path.isfile(temporary) or os.path.getsize(temporary) <= 0: | |
| raise RuntimeError("Preview proxy generator produced an empty video.") | |
| os.replace(temporary, target) | |
| finally: | |
| try: | |
| os.unlink(temporary) | |
| except FileNotFoundError: | |
| pass | |
| return target | |