"""Storage abstraction layer for garment images and data. Supports two backends controlled by STORAGE_BACKEND env var: - "local" (default): filesystem at data/garments/ - "s3": HuggingFace Spaces S3 bucket for persistent storage """ import io import logging import os from pathlib import Path logger = logging.getLogger(__name__) STORAGE_BACKEND = os.environ.get("STORAGE_BACKEND", "local") LOCAL_GARMENTS_DIR = Path(__file__).resolve().parent.parent / "data" / "garments" S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", "") S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", "") S3_PREFIX = os.environ.get("S3_PREFIX", "garments/") _s3_client = None def _get_s3_client(): global _s3_client if _s3_client is None: import boto3 kwargs = {} if S3_ENDPOINT_URL: kwargs["endpoint_url"] = S3_ENDPOINT_URL _s3_client = boto3.client("s3", **kwargs) return _s3_client def _ensure_local_dir(): LOCAL_GARMENTS_DIR.mkdir(parents=True, exist_ok=True) def save_image(garment_id: str, image_bytes: bytes, extension: str = "jpg") -> str: """Save a garment image and return its storage reference. The reference is backend-agnostic (just the filename). Use get_image_url() to resolve it to a serveable path/URL. """ filename = f"{garment_id}.{extension}" if STORAGE_BACKEND == "s3": _save_to_s3(filename, image_bytes) else: _save_to_local(filename, image_bytes) logger.info("Saved image: %s (backend: %s)", filename, STORAGE_BACKEND) return filename def _save_to_local(filename: str, image_bytes: bytes): _ensure_local_dir() path = LOCAL_GARMENTS_DIR / filename path.write_bytes(image_bytes) def _save_to_s3(filename: str, image_bytes: bytes): client = _get_s3_client() key = f"{S3_PREFIX}{filename}" client.put_object( Bucket=S3_BUCKET_NAME, Key=key, Body=image_bytes, ContentType="image/jpeg", ) def get_image_path(garment_id: str, extension: str = "jpg") -> str | None: """Return the local filesystem path for a garment image. For S3 backend, downloads to a local cache first. Returns None if the image doesn't exist. """ filename = f"{garment_id}.{extension}" if STORAGE_BACKEND == "s3": return _download_from_s3(filename) path = LOCAL_GARMENTS_DIR / filename if path.exists(): return str(path) return None def _download_from_s3(filename: str) -> str | None: _ensure_local_dir() local_path = LOCAL_GARMENTS_DIR / filename if local_path.exists(): return str(local_path) try: client = _get_s3_client() key = f"{S3_PREFIX}{filename}" response = client.get_object(Bucket=S3_BUCKET_NAME, Key=key) local_path.write_bytes(response["Body"].read()) return str(local_path) except Exception as e: logger.warning("Failed to download %s from S3: %s", filename, e) return None def delete_image(garment_id: str, extension: str = "jpg"): """Remove a garment image from storage.""" filename = f"{garment_id}.{extension}" if STORAGE_BACKEND == "s3": try: client = _get_s3_client() key = f"{S3_PREFIX}{filename}" client.delete_object(Bucket=S3_BUCKET_NAME, Key=key) except Exception as e: logger.warning("Failed to delete %s from S3: %s", filename, e) local_path = LOCAL_GARMENTS_DIR / filename if local_path.exists(): local_path.unlink() def image_exists(garment_id: str, extension: str = "jpg") -> bool: """Check if a garment image exists in storage.""" filename = f"{garment_id}.{extension}" if STORAGE_BACKEND == "s3": try: client = _get_s3_client() key = f"{S3_PREFIX}{filename}" client.head_object(Bucket=S3_BUCKET_NAME, Key=key) return True except Exception: return False return (LOCAL_GARMENTS_DIR / filename).exists()