Files
cmshoppe/app/image_studio_export.py

369 lines
13 KiB
Python

"""Safe local export helpers for AI image studio final selections."""
from __future__ import annotations
import os
import re
import shutil
import uuid
from dataclasses import dataclass
from datetime import datetime
from . import appconfig, image_studio
EXISTING_FAIL = "fail"
EXISTING_OVERWRITE_MANAGED = "overwrite_managed"
EXISTING_TIMESTAMP = "timestamp"
EXISTING_MODES = {EXISTING_FAIL, EXISTING_OVERWRITE_MANAGED, EXISTING_TIMESTAMP}
class ImageStudioExportError(RuntimeError):
"""Raised when AI studio selections cannot be exported safely."""
class ExportTargetExistsError(ImageStudioExportError):
"""Raised when the default target directory already exists."""
@dataclass(frozen=True)
class ExportedFile:
selection_type: str
asset_id: int
source_path: str
output_path: str
@dataclass(frozen=True)
class ExportResult:
target_dir: str
files: tuple[ExportedFile, ...]
main_count: int
detail_count: int
existing_mode: str
@dataclass(frozen=True)
class GenerationRoundExportedFile:
job_id: int
asset_id: int
job_type: str
source_path: str
output_path: str
@dataclass(frozen=True)
class GenerationRoundExportResult:
target_dir: str
files: tuple[GenerationRoundExportedFile, ...]
skipped_count: int
cancelled: bool
def target_dir_for_project(project, parent_dir, suffix=None):
parent = _existing_parent_dir(parent_dir)
item = _safe_item_id(getattr(project, "item_id", ""))
dirname = item if not suffix else f"{item}_{suffix}"
return os.path.abspath(os.path.join(parent, dirname))
def export_project_selection(
project_id,
parent_dir,
*,
existing_mode=EXISTING_FAIL,
path=None,
config=None,
timestamp=None,
):
"""Export current main/detail selections as ordered JPEG files."""
mode = str(existing_mode or EXISTING_FAIL)
if mode not in EXISTING_MODES:
raise ImageStudioExportError("导出目录处理方式无效")
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
project = image_studio.get_project(project_id, path=database_path)
if project is None:
raise ImageStudioExportError("AI工场项目不存在")
parent = _existing_parent_dir(parent_dir)
planned = _planned_files(project, database_path)
if not planned:
raise ImageStudioExportError("主图和详情图终选都为空,不能导出")
target = _choose_target_dir(project, parent, mode, timestamp=timestamp)
quality = int(appconfig.ai_config(cfg).get("jpg_quality", 90) or 90)
staging = os.path.join(parent, f".{_safe_item_id(project.item_id)}_staging_{uuid.uuid4().hex}")
try:
os.makedirs(staging, exist_ok=False)
staged = []
for item in planned:
output_path = os.path.join(staging, item["filename"])
_save_jpeg(item["source_path"], output_path, quality)
staged.append(ExportedFile(item["selection_type"], item["asset_id"], item["source_path"], output_path))
os.makedirs(target, exist_ok=True)
if os.path.exists(target) and mode == EXISTING_OVERWRITE_MANAGED:
_remove_managed_exports(target, project.item_id)
final_files = []
for staged_file in staged:
final_path = os.path.join(target, os.path.basename(staged_file.output_path))
os.replace(staged_file.output_path, final_path)
final_files.append(
ExportedFile(
staged_file.selection_type,
staged_file.asset_id,
staged_file.source_path,
final_path,
)
)
return ExportResult(
target_dir=target,
files=tuple(final_files),
main_count=sum(1 for item in final_files if item.selection_type == "main"),
detail_count=sum(1 for item in final_files if item.selection_type == "detail"),
existing_mode=mode,
)
except Exception as exc:
if isinstance(exc, ImageStudioExportError):
raise
raise ImageStudioExportError(f"导出终选图片失败:{exc}") from exc
finally:
if os.path.isdir(staging):
shutil.rmtree(staging, ignore_errors=True)
def export_generation_round(
project_id,
generation_round_key,
parent_dir,
*,
path=None,
should_stop=None,
):
"""Copy usable successful output images from one persisted generation round."""
project = image_studio.get_project(project_id, path=path)
if project is None:
raise ImageStudioExportError("商品套图项目不存在,无法导出历史记录")
try:
parent = _existing_parent_dir(parent_dir)
except ImageStudioExportError as exc:
raise ImageStudioExportError("导出父目录不可用") from exc
jobs = image_studio.list_generation_round_current_jobs(
project.id,
generation_round_key,
path=path,
)
planned, skipped_count = _planned_generation_round_files(jobs, path)
if not planned:
raise ImageStudioExportError("本轮没有可导出的成功生成图片")
target = _create_generation_round_target_dir(project, parent, jobs)
copied = []
cancelled = False
try:
for item in planned:
if callable(should_stop) and should_stop():
cancelled = True
break
output_path = os.path.join(target, item["filename"])
try:
shutil.copy2(item["source_path"], output_path)
except OSError:
skipped_count += 1
if os.path.isfile(output_path):
try:
os.remove(output_path)
except OSError:
pass
continue
copied.append(
GenerationRoundExportedFile(
job_id=int(item["job_id"]),
asset_id=int(item["asset_id"]),
job_type=str(item["job_type"]),
source_path=item["source_path"],
output_path=output_path,
)
)
except Exception as exc:
raise ImageStudioExportError("导出本轮生成图片失败") from exc
if not copied:
try:
os.rmdir(target)
except OSError:
pass
if cancelled:
return GenerationRoundExportResult("", (), skipped_count, True)
raise ImageStudioExportError("本轮生成图片导出失败")
return GenerationRoundExportResult(
target_dir=target,
files=tuple(copied),
skipped_count=skipped_count,
cancelled=cancelled,
)
def _choose_target_dir(project, parent_dir, existing_mode, timestamp=None):
target = target_dir_for_project(project, parent_dir)
if not os.path.exists(target):
return target
if not os.path.isdir(target):
raise ImageStudioExportError(f"导出目标已存在但不是目录:{target}")
if existing_mode == EXISTING_FAIL:
raise ExportTargetExistsError(f"商品目录已存在:{target}")
if existing_mode == EXISTING_OVERWRITE_MANAGED:
return target
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
base = target_dir_for_project(project, parent_dir, suffix=stamp)
candidate = base
index = 2
while os.path.exists(candidate):
candidate = f"{base}_{index}"
index += 1
return candidate
def _planned_files(project, db_path):
item = _safe_item_id(project.item_id)
planned = []
for selection_type, label in (("main", "主图"), ("detail", "详情图")):
selections = image_studio.list_selections(project.id, selection_type, path=db_path)
for index, selection in enumerate(selections, start=1):
asset = image_studio.get_asset(selection.asset_id, path=db_path)
if asset is None:
raise ImageStudioExportError(f"终选照片不存在:#{selection.asset_id}")
source_path = str(asset.local_path or "")
if not source_path or not os.path.isfile(source_path):
raise ImageStudioExportError(f"终选照片本地文件缺失:#{asset.id}")
planned.append(
{
"selection_type": selection_type,
"asset_id": int(asset.id),
"source_path": os.path.abspath(source_path),
"filename": f"{item}_{label}_{index}.jpg",
}
)
return planned
def _planned_generation_round_files(jobs, db_path):
planned = []
skipped_count = 0
for index, job in enumerate(jobs, 1):
if str(getattr(job, "status", "") or "") != "succeeded":
continue
asset_id = getattr(job, "output_asset_id", None)
asset = image_studio.get_asset(asset_id, path=db_path) if asset_id else None
source_path = str(getattr(asset, "local_path", "") or "")
if (
asset is None
or str(getattr(asset, "status", "") or "")
== image_studio.ASSET_STATUS_MISSING
or not source_path
or not os.path.isfile(source_path)
):
skipped_count += 1
continue
suffix = os.path.splitext(source_path)[1].lower()
if not suffix or len(suffix) > 8:
suffix = ".jpg"
job_type = _safe_export_component(
getattr(job, "job_type", ""),
fallback="套图",
)
planned.append(
{
"job_id": int(job.id),
"asset_id": int(asset.id),
"job_type": str(getattr(job, "job_type", "") or "套图"),
"source_path": os.path.abspath(source_path),
"filename": "%02d_%s%s" % (index, job_type, suffix),
}
)
return planned, skipped_count
def _create_generation_round_target_dir(project, parent_dir, jobs):
account = _safe_export_component(
getattr(project, "account_name", "") or getattr(project, "account_alias", ""),
fallback="未命名店铺",
)
item = _safe_export_component(getattr(project, "item_id", ""), fallback="临时草稿")
created_at = str(getattr(jobs[0], "created_at", "") or "") if jobs else ""
digits = "".join(character for character in created_at if character.isdigit())[:14]
stamp = digits or datetime.now().strftime("%Y%m%d_%H%M%S")
base = os.path.join(parent_dir, "%s_%s_%s" % (account, item, stamp))
candidate = base
index = 2
while True:
try:
os.makedirs(candidate, exist_ok=False)
return candidate
except FileExistsError:
candidate = "%s_%d" % (base, index)
index += 1
except OSError as exc:
raise ImageStudioExportError("无法创建导出目录") from exc
def _save_jpeg(source_path, output_path, quality):
try:
from PIL import Image
with Image.open(source_path) as image:
if image.mode in {"RGBA", "LA"} or (
image.mode == "P" and "transparency" in getattr(image, "info", {})
):
rgba = image.convert("RGBA")
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
background.alpha_composite(rgba)
final_image = background.convert("RGB")
else:
final_image = image.convert("RGB")
final_image.save(output_path, format="JPEG", quality=max(1, min(95, int(quality or 90))))
with Image.open(output_path) as check:
check.verify()
except Exception as exc:
raise ImageStudioExportError(f"JPEG 转码失败:{exc}") from exc
def _remove_managed_exports(target_dir, item_id):
item = re.escape(_safe_item_id(item_id))
pattern = re.compile(rf"^{item}_(主图|详情图)_\d+\.jpg$", re.IGNORECASE)
for filename in os.listdir(target_dir):
if not pattern.match(filename):
continue
path = os.path.join(target_dir, filename)
if os.path.isfile(path):
os.remove(path)
def _existing_parent_dir(parent_dir):
parent = os.path.abspath(str(parent_dir or ""))
if not os.path.isdir(parent):
raise ImageStudioExportError(f"导出父目录不存在:{parent}")
return parent
def _safe_item_id(item_id):
value = str(item_id or "").strip()
if not value:
raise ImageStudioExportError("商品ID不能为空")
if any(char in value for char in ('/', '\\', os.sep, os.altsep or "\0")):
raise ImageStudioExportError("商品ID包含非法路径字符")
if value in {".", ".."}:
raise ImageStudioExportError("商品ID不能是路径符号")
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in value).strip("_")
if not safe:
raise ImageStudioExportError("商品ID不能作为目录名")
return safe
def _safe_export_component(value, *, fallback):
text = str(value or "").strip()
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in text).strip("_")
if safe:
return safe
return str(fallback)