212 lines
7.4 KiB
Python
212 lines
7.4 KiB
Python
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 <data_dir>/config/."""
|
|
return get_data_dir() / "config" / relative_path
|
|
|
|
|
|
def get_log_dir():
|
|
"""Return <data_dir>/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: <install root>\\合并后的图片 (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 <data_dir>/output if the install
|
|
root is not writable. Development: <data_dir>/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: <install root>\\穿搭图片 (next to Launcher.exe, not inside app\\).
|
|
Falls back to <data_dir>/output/穿搭图片 if the preferred location is not
|
|
writable. Development: <project root>/穿搭图片.
|
|
|
|
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/<YYYYmmdd_HHMMSS> 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:
|
|
<output_dir>/<print_stem>/<garment_stem>_<print_stem>.<ext>
|
|
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
|