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 launcher layout (docs/10-lan-update.md) this is the app\\ folder, 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 — explicit override for tests or special deployments. 2. Packaged (sys.frozen) → ~/.cmbot (%USERPROFILE%\\.cmbot): always writable, per-user, independent of where the program is installed, so it works even when launched directly rather than through Launcher.exe. 3. Development → project root, so dev runs don't pollute the home directory. """ env = os.environ.get("CMBOT_DATA_DIR", "").strip() if env: return Path(env) if getattr(sys, "frozen", False): return Path.home() / ".cmbot" 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 _is_dir_writable(path): """Create *path* and probe it; True if a file can be written there.""" try: path.mkdir(parents=True, exist_ok=True) probe = path / ".write_test" probe.write_text("x", encoding="ascii") probe.unlink() return True except OSError: return False # Default export folder names, placed next to Launcher.exe in the packaged layout. _OUTPUT_DIR_NAME = "合并后的图片" _OUTFIT_OUTPUT_DIR_NAME = "穿搭图片" def get_output_dir(): """Return the default export directory as a Path, creating it if absent. Packaged: \\合并后的图片 (next to Launcher.exe — easy to find and NOT replaced on update, unlike app\\). Install root is the parent of the app\\ folder (get_app_dir()). Falls back to /output if the install root is not writable. Development: /output (the project dir). This is only the default; the user's chosen output_dir takes precedence. """ if getattr(sys, "frozen", False): candidate = get_app_dir().parent / _OUTPUT_DIR_NAME if _is_dir_writable(candidate): return candidate d = get_data_dir() / "output" d.mkdir(parents=True, exist_ok=True) return d def get_outfit_output_dir(): """Return the default AI outfit output directory as a Path. Packaged: \\穿搭图片 (next to Launcher.exe, not inside app\\). Falls back to /output/穿搭图片 if the preferred location is not writable. Development: /穿搭图片. User-selected outfit_output_dir still takes precedence in the UI. """ if getattr(sys, "frozen", False): candidate = get_app_dir().parent / _OUTFIT_OUTPUT_DIR_NAME else: candidate = get_app_dir() / _OUTFIT_OUTPUT_DIR_NAME if _is_dir_writable(candidate): return candidate d = get_data_dir() / "output" / _OUTFIT_OUTPUT_DIR_NAME 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