import logging import os import sys from datetime import datetime from pathlib import Path from typing import List logger = logging.getLogger(__name__) SUPPORTED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} def is_supported_image(path): """Return True if path has a supported image extension (case-insensitive).""" return Path(path).suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS # --------------------------------------------------------------------------- # Path helpers # --------------------------------------------------------------------------- def get_app_dir(): """Return the program root directory (read-only program files) as a Path. PyInstaller onedir: directory that contains the .exe. In the versioned install layout (docs/10-lan-update.md) this is versions//, which is replaced wholesale on update. Development: project root (three levels above this file: src/services/file_service.py -> src/services -> src -> project root). Resources live here; writable user data does not — use get_data_dir(). """ if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent return Path(__file__).resolve().parent.parent.parent def get_data_dir(): """Return the writable data root (config, templates, logs, output) as a Path. Kept separate from the program root so that replacing the program on update never touches user data (docs/10-lan-update.md §5). Resolution order: 1. CMBOT_DATA_DIR environment variable, when set. The launcher points this at the install-wide data/ folder in the versioned layout. 2. Fallback to get_app_dir() — the flat layout used in development and in the current onedir release, where data sits next to the program. """ env = os.environ.get("CMBOT_DATA_DIR", "").strip() if env: return Path(env) return get_app_dir() def get_resource_path(relative_path): """Return absolute Path for a resource file under the program root. Development resources live under src/resources/. In a PyInstaller onedir build, resources are copied next to the executable under resources/. """ if getattr(sys, "frozen", False): return get_app_dir() / "resources" / relative_path return get_app_dir() / "src" / "resources" / relative_path def get_config_path(relative_path): """Return absolute Path for a file under /config/.""" return get_data_dir() / "config" / relative_path def get_log_dir(): """Return /logs/ as a Path, creating the directory if absent.""" d = get_data_dir() / "logs" d.mkdir(parents=True, exist_ok=True) return d def get_output_dir(): """Return /output/ as a Path, creating the directory if absent.""" d = get_data_dir() / "output" d.mkdir(parents=True, exist_ok=True) return d # --------------------------------------------------------------------------- # 文件夹扫描 # --------------------------------------------------------------------------- def scan_image_folder(folder) -> List: """Recursively scan *folder* for supported images. Returns a list of ImageAsset objects (selected=True by default). Skips unsupported files and logs them at DEBUG level. Raises OSError if *folder* does not exist or is not a directory. """ from core.models import ImageAsset folder = Path(folder) if not folder.exists(): raise OSError("Folder not found: {}".format(folder)) if not folder.is_dir(): raise OSError("Path is not a directory: {}".format(folder)) assets = [] skipped_count = 0 for entry in sorted(folder.rglob("*")): if not entry.is_file(): continue if is_supported_image(entry): assets.append(ImageAsset(path=entry)) else: skipped_count += 1 logger.debug("Skipped unsupported file: %s", entry) if skipped_count: logger.info( "Skipped %d unsupported file(s) in %s", skipped_count, folder ) logger.info("Found %d image(s) in %s", len(assets), folder) return assets # --------------------------------------------------------------------------- # 安全输出文件名 # --------------------------------------------------------------------------- def timestamped_run_dir(base_dir): """Return base_dir/ for grouping one export run. Compute this once per export run (a batch, or a single export) and pass the result as the output_dir to make_safe_output_path, so repeated runs land in separate timestamped folders instead of mixing together. """ return Path(base_dir) / datetime.now().strftime("%Y%m%d_%H%M%S") def make_safe_output_path(output_dir, garment_path, print_path, output_format="PNG"): """Return an output Path that will not overwrite an existing file. Outputs are grouped by print into a subfolder named after the print file: //_. If that path exists, appends _1, _2, ... until a free slot is found. (The composer creates the subfolder when saving.) """ ext = ".png" if output_format.upper() == "PNG" else ".jpg" target_dir = Path(output_dir) / Path(print_path).stem stem = "{}_{}".format(Path(garment_path).stem, Path(print_path).stem) candidate = target_dir / (stem + ext) counter = 1 while candidate.exists(): candidate = target_dir / ("{}_{}{}".format(stem, counter, ext)) counter += 1 return candidate