2026-07-11 12:31:12 +08:00
|
|
|
"""Remote image helpers for the AI image studio."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import io
|
|
|
|
|
import ipaddress
|
|
|
|
|
import os
|
|
|
|
|
import socket
|
|
|
|
|
import threading
|
|
|
|
|
import urllib.parse
|
|
|
|
|
import uuid
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from typing import Callable, Optional
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
|
|
|
|
|
from . import appconfig, image_studio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
THUMBNAIL_MAX_BYTES = 3 * 1024 * 1024
|
|
|
|
|
ORIGINAL_MAX_BYTES = 12 * 1024 * 1024
|
|
|
|
|
DEFAULT_CONNECT_TIMEOUT_SECONDS = 5
|
|
|
|
|
DEFAULT_READ_TIMEOUT_SECONDS = 30
|
|
|
|
|
DEFAULT_THUMBNAIL_SIZE = 220
|
|
|
|
|
DEFAULT_THUMBNAIL_WORKERS = 4
|
2026-07-14 09:53:13 +08:00
|
|
|
MAX_ORIGINAL_ASSETS = 16
|
2026-07-11 12:31:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImageStudioImageError(RuntimeError):
|
|
|
|
|
"""Raised when a remote image cannot be safely loaded or saved."""
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 19:37:22 +08:00
|
|
|
class ImageStudioImageCancelled(ImageStudioImageError):
|
|
|
|
|
"""Raised when an original image download is cancelled safely."""
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 12:31:12 +08:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class RemoteImage:
|
|
|
|
|
url: str
|
|
|
|
|
content: bytes
|
|
|
|
|
content_type: str
|
|
|
|
|
final_url: str
|
|
|
|
|
redirected: bool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ThumbnailResult:
|
|
|
|
|
key: str
|
|
|
|
|
url: str
|
|
|
|
|
image_bytes: bytes
|
|
|
|
|
width: int
|
|
|
|
|
height: int
|
|
|
|
|
from_cache: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _session():
|
|
|
|
|
session = requests.Session()
|
|
|
|
|
session.trust_env = False
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_public_http_url(url):
|
|
|
|
|
parts = urllib.parse.urlsplit(str(url or ""))
|
|
|
|
|
if parts.scheme not in {"http", "https"}:
|
|
|
|
|
raise ImageStudioImageError("远程图片地址只允许 http/https")
|
|
|
|
|
host = parts.hostname
|
|
|
|
|
if not host:
|
|
|
|
|
raise ImageStudioImageError("远程图片地址缺少域名")
|
|
|
|
|
if _is_local_hostname(host):
|
|
|
|
|
raise ImageStudioImageError("远程图片地址不能指向本机或内网")
|
|
|
|
|
try:
|
|
|
|
|
_assert_public_ip(host)
|
|
|
|
|
return
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
addresses = socket.getaddrinfo(
|
|
|
|
|
host,
|
|
|
|
|
parts.port or (443 if parts.scheme == "https" else 80),
|
|
|
|
|
type=socket.SOCK_STREAM,
|
|
|
|
|
)
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
raise ImageStudioImageError(f"远程图片地址无法解析:{exc}") from exc
|
|
|
|
|
if not addresses:
|
|
|
|
|
raise ImageStudioImageError("远程图片地址无法解析")
|
|
|
|
|
for address in addresses:
|
|
|
|
|
_assert_public_ip(address[4][0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_local_hostname(host):
|
|
|
|
|
lowered = str(host or "").strip().lower().rstrip(".")
|
|
|
|
|
return lowered in {"localhost"} or lowered.endswith(".localhost") or lowered.endswith(".local")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_public_ip(value):
|
|
|
|
|
ip = ipaddress.ip_address(value)
|
|
|
|
|
if (
|
|
|
|
|
ip.is_private
|
|
|
|
|
or ip.is_loopback
|
|
|
|
|
or ip.is_link_local
|
|
|
|
|
or ip.is_multicast
|
|
|
|
|
or ip.is_reserved
|
|
|
|
|
or ip.is_unspecified
|
|
|
|
|
):
|
|
|
|
|
raise ImageStudioImageError("远程图片地址不能指向本机或内网")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_headers(referer=None):
|
|
|
|
|
headers = {
|
|
|
|
|
"User-Agent": "Mozilla/5.0",
|
|
|
|
|
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
|
|
|
|
}
|
|
|
|
|
if referer:
|
|
|
|
|
headers["Referer"] = str(referer)
|
|
|
|
|
return headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_content_type(value):
|
|
|
|
|
content_type = str(value or "").split(";", 1)[0].strip().lower()
|
|
|
|
|
if content_type and not content_type.startswith("image/"):
|
|
|
|
|
raise ImageStudioImageError("远程地址返回的不是图片内容")
|
|
|
|
|
return content_type
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def download_remote_image(
|
|
|
|
|
url,
|
|
|
|
|
*,
|
|
|
|
|
referer=None,
|
|
|
|
|
max_bytes=ORIGINAL_MAX_BYTES,
|
|
|
|
|
timeout=(DEFAULT_CONNECT_TIMEOUT_SECONDS, DEFAULT_READ_TIMEOUT_SECONDS),
|
|
|
|
|
session=None,
|
2026-07-16 19:37:22 +08:00
|
|
|
should_stop=None,
|
2026-07-11 12:31:12 +08:00
|
|
|
) -> RemoteImage:
|
|
|
|
|
"""Download remote image bytes with SSRF, timeout and size guards."""
|
|
|
|
|
|
|
|
|
|
url = str(url or "").strip()
|
2026-07-16 19:37:22 +08:00
|
|
|
should_stop = should_stop or (lambda: False)
|
|
|
|
|
if should_stop():
|
|
|
|
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
2026-07-11 12:31:12 +08:00
|
|
|
_assert_public_http_url(url)
|
|
|
|
|
client = session or _session()
|
|
|
|
|
try:
|
|
|
|
|
response = client.get(
|
|
|
|
|
url,
|
|
|
|
|
stream=True,
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
headers=_safe_headers(referer),
|
|
|
|
|
)
|
|
|
|
|
except requests.exceptions.RequestException as exc:
|
|
|
|
|
raise ImageStudioImageError(f"远程图片下载失败:{exc}") from exc
|
2026-07-16 19:37:22 +08:00
|
|
|
try:
|
|
|
|
|
if should_stop():
|
|
|
|
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
|
|
|
|
status = getattr(response, "status_code", 200)
|
|
|
|
|
if status >= 400:
|
|
|
|
|
raise ImageStudioImageError(f"远程图片下载失败:HTTP {status}")
|
|
|
|
|
final_url = str(getattr(response, "url", "") or url)
|
|
|
|
|
_assert_public_http_url(final_url)
|
|
|
|
|
content_type = _validate_content_type(
|
|
|
|
|
getattr(response, "headers", {}).get("Content-Type", "")
|
|
|
|
|
)
|
|
|
|
|
content_length = getattr(response, "headers", {}).get("Content-Length")
|
|
|
|
|
if content_length:
|
|
|
|
|
try:
|
|
|
|
|
if int(content_length) > int(max_bytes):
|
|
|
|
|
raise ImageStudioImageError("远程图片超过大小上限")
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
chunks = []
|
|
|
|
|
total = 0
|
|
|
|
|
iterator = (
|
|
|
|
|
response.iter_content(chunk_size=65536)
|
|
|
|
|
if hasattr(response, "iter_content")
|
|
|
|
|
else [response.content]
|
|
|
|
|
)
|
|
|
|
|
for chunk in iterator:
|
|
|
|
|
if should_stop():
|
|
|
|
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
|
|
|
|
if not chunk:
|
|
|
|
|
continue
|
|
|
|
|
total += len(chunk)
|
|
|
|
|
if total > int(max_bytes):
|
2026-07-11 12:31:12 +08:00
|
|
|
raise ImageStudioImageError("远程图片超过大小上限")
|
2026-07-16 19:37:22 +08:00
|
|
|
chunks.append(chunk)
|
|
|
|
|
if should_stop():
|
|
|
|
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
|
|
|
|
content = b"".join(chunks)
|
|
|
|
|
_image_info(content)
|
|
|
|
|
return RemoteImage(
|
|
|
|
|
url=url,
|
|
|
|
|
content=content,
|
|
|
|
|
content_type=content_type,
|
|
|
|
|
final_url=final_url,
|
|
|
|
|
redirected=final_url != url,
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
close = getattr(response, "close", None)
|
|
|
|
|
if callable(close):
|
|
|
|
|
close()
|
2026-07-11 12:31:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _image_info(image_bytes):
|
|
|
|
|
try:
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
with Image.open(io.BytesIO(image_bytes)) as image:
|
|
|
|
|
image.verify()
|
|
|
|
|
with Image.open(io.BytesIO(image_bytes)) as image:
|
|
|
|
|
return {
|
|
|
|
|
"format": str(image.format or "JPEG").upper(),
|
|
|
|
|
"width": int(image.width),
|
|
|
|
|
"height": int(image.height),
|
|
|
|
|
}
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise ImageStudioImageError(f"图片解码失败:{exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _thumbnail_bytes(image_bytes, max_size=DEFAULT_THUMBNAIL_SIZE):
|
|
|
|
|
try:
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
with Image.open(io.BytesIO(image_bytes)) as image:
|
|
|
|
|
image.thumbnail((int(max_size), int(max_size)))
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
image.convert("RGBA").save(output, format="PNG")
|
|
|
|
|
return output.getvalue(), image.width, image.height
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise ImageStudioImageError(f"缩略图解码失败:{exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_thumbnail(url, *, key=None, max_size=DEFAULT_THUMBNAIL_SIZE, referer=None, session=None):
|
|
|
|
|
remote = download_remote_image(
|
|
|
|
|
url,
|
|
|
|
|
referer=referer,
|
|
|
|
|
max_bytes=THUMBNAIL_MAX_BYTES,
|
|
|
|
|
session=session,
|
|
|
|
|
)
|
|
|
|
|
image_bytes, width, height = _thumbnail_bytes(remote.content, max_size=max_size)
|
|
|
|
|
return ThumbnailResult(
|
|
|
|
|
key=str(key or url),
|
|
|
|
|
url=remote.url,
|
|
|
|
|
image_bytes=image_bytes,
|
|
|
|
|
width=width,
|
|
|
|
|
height=height,
|
|
|
|
|
from_cache=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ThumbnailLoader:
|
|
|
|
|
"""Small thread-pool thumbnail loader with memory cache and cooperative cancel."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, max_workers=DEFAULT_THUMBNAIL_WORKERS, loader: Optional[Callable] = None):
|
|
|
|
|
self._executor = ThreadPoolExecutor(max_workers=max(1, int(max_workers or 1)))
|
|
|
|
|
self._loader = loader or load_thumbnail
|
|
|
|
|
self._cache = {}
|
|
|
|
|
self._cancelled = set()
|
|
|
|
|
self._closed = False
|
|
|
|
|
self._lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
def submit(self, key, url, on_success=None, on_error=None):
|
|
|
|
|
key = str(key)
|
|
|
|
|
url = str(url or "").strip()
|
|
|
|
|
with self._lock:
|
|
|
|
|
if self._closed:
|
|
|
|
|
raise ImageStudioImageError("缩略图加载器已关闭")
|
|
|
|
|
cached = self._cache.get(url)
|
|
|
|
|
if cached is not None:
|
|
|
|
|
result = ThumbnailResult(key, url, cached.image_bytes, cached.width, cached.height, from_cache=True)
|
|
|
|
|
if on_success is not None:
|
|
|
|
|
on_success(result)
|
|
|
|
|
return None
|
|
|
|
|
self._cancelled.discard(key)
|
|
|
|
|
future = self._executor.submit(self._loader, url, key=key)
|
|
|
|
|
|
|
|
|
|
def done_callback(done):
|
|
|
|
|
try:
|
|
|
|
|
result = done.result()
|
|
|
|
|
with self._lock:
|
|
|
|
|
if key in self._cancelled or self._closed:
|
|
|
|
|
return
|
|
|
|
|
self._cache[url] = result
|
|
|
|
|
if on_success is not None:
|
|
|
|
|
on_success(result)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
with self._lock:
|
|
|
|
|
if key in self._cancelled or self._closed:
|
|
|
|
|
return
|
|
|
|
|
if on_error is not None:
|
|
|
|
|
on_error(key, exc)
|
|
|
|
|
|
|
|
|
|
future.add_done_callback(done_callback)
|
|
|
|
|
return future
|
|
|
|
|
|
|
|
|
|
def cancel(self, key=None):
|
|
|
|
|
with self._lock:
|
|
|
|
|
if key is None:
|
|
|
|
|
self._cancelled.add("*")
|
|
|
|
|
self._closed = True
|
|
|
|
|
else:
|
|
|
|
|
self._cancelled.add(str(key))
|
|
|
|
|
|
|
|
|
|
def close(self):
|
|
|
|
|
with self._lock:
|
|
|
|
|
self._closed = True
|
|
|
|
|
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extension_for_format(format_name):
|
|
|
|
|
normalized = str(format_name or "").strip().upper()
|
|
|
|
|
if normalized in {"JPEG", "JPG"}:
|
|
|
|
|
return ".jpg"
|
|
|
|
|
if normalized == "PNG":
|
|
|
|
|
return ".png"
|
|
|
|
|
if normalized == "WEBP":
|
|
|
|
|
return ".webp"
|
|
|
|
|
return ".img"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _original_file_path(project, asset, image_root):
|
|
|
|
|
dirs = image_studio.project_image_dirs(image_root, project)
|
|
|
|
|
digest = hashlib.sha1(str(asset.remote_url or asset.id).encode("utf-8")).hexdigest()[:10]
|
|
|
|
|
stem = "original_%02d_%s" % (max(1, int(asset.source_order or 1)), digest)
|
|
|
|
|
return dirs["originals"], stem
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _existing_local_asset(asset):
|
|
|
|
|
local_path = str(asset.local_path or "").strip()
|
|
|
|
|
if not local_path or not os.path.isfile(local_path):
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
with open(local_path, "rb") as fh:
|
|
|
|
|
_image_info(fh.read())
|
|
|
|
|
return asset
|
|
|
|
|
except ImageStudioImageError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 09:53:13 +08:00
|
|
|
def import_original_files(
|
|
|
|
|
project_id,
|
|
|
|
|
file_paths,
|
|
|
|
|
*,
|
|
|
|
|
path=None,
|
|
|
|
|
config=None,
|
|
|
|
|
image_root=None,
|
|
|
|
|
max_assets=MAX_ORIGINAL_ASSETS,
|
|
|
|
|
):
|
|
|
|
|
"""Validate and atomically copy local product images into one studio project."""
|
|
|
|
|
|
|
|
|
|
imported = []
|
|
|
|
|
errors = []
|
|
|
|
|
for file_path in file_paths or []:
|
|
|
|
|
try:
|
|
|
|
|
with open(os.path.abspath(str(file_path)), "rb") as fh:
|
|
|
|
|
content = fh.read(int(ORIGINAL_MAX_BYTES) + 1)
|
|
|
|
|
imported.append(
|
|
|
|
|
import_original_bytes(
|
|
|
|
|
project_id,
|
|
|
|
|
content,
|
|
|
|
|
filename_hint=os.path.basename(str(file_path)),
|
|
|
|
|
path=path,
|
|
|
|
|
config=config,
|
|
|
|
|
image_root=image_root,
|
|
|
|
|
max_assets=max_assets,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
errors.append({"path": os.path.abspath(str(file_path)), "error": str(exc)})
|
|
|
|
|
return {"assets": imported, "errors": errors, "limit": int(max_assets)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def import_original_bytes(
|
|
|
|
|
project_id,
|
|
|
|
|
content,
|
|
|
|
|
*,
|
|
|
|
|
filename_hint="clipboard.png",
|
|
|
|
|
path=None,
|
|
|
|
|
config=None,
|
|
|
|
|
image_root=None,
|
|
|
|
|
max_assets=MAX_ORIGINAL_ASSETS,
|
|
|
|
|
):
|
|
|
|
|
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 ImageStudioImageError("商品套图任务不存在")
|
|
|
|
|
image_bytes = bytes(content or b"")
|
|
|
|
|
if not image_bytes:
|
|
|
|
|
raise ImageStudioImageError("商品原图内容为空")
|
|
|
|
|
if len(image_bytes) > int(ORIGINAL_MAX_BYTES):
|
|
|
|
|
raise ImageStudioImageError("商品原图超过大小上限")
|
|
|
|
|
info = _image_info(image_bytes)
|
|
|
|
|
originals = image_studio.list_assets(
|
|
|
|
|
project.id,
|
|
|
|
|
kind=image_studio.ASSET_KIND_ORIGINAL,
|
|
|
|
|
path=database_path,
|
|
|
|
|
)
|
|
|
|
|
active_originals = [
|
|
|
|
|
asset for asset in originals if asset.status != image_studio.ASSET_STATUS_MISSING
|
|
|
|
|
]
|
|
|
|
|
digest = hashlib.sha256(image_bytes).hexdigest()[:16]
|
|
|
|
|
duplicate = next(
|
|
|
|
|
(
|
|
|
|
|
asset
|
|
|
|
|
for asset in originals
|
|
|
|
|
if digest in os.path.basename(str(asset.local_path or ""))
|
|
|
|
|
and _existing_local_asset(asset) is not None
|
|
|
|
|
),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
if duplicate is not None:
|
|
|
|
|
return duplicate
|
|
|
|
|
if len(active_originals) >= int(max_assets):
|
|
|
|
|
raise ImageStudioImageError("商品原图最多只能添加%d张" % int(max_assets))
|
|
|
|
|
|
|
|
|
|
directory = image_studio.project_image_dirs(
|
|
|
|
|
image_root or appconfig.image_dir(cfg),
|
|
|
|
|
project,
|
|
|
|
|
)["originals"]
|
|
|
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
|
order = max([int(asset.source_order or 0) for asset in originals] + [0]) + 1
|
|
|
|
|
extension = _extension_for_format(info["format"])
|
|
|
|
|
safe_hint = os.path.splitext(os.path.basename(str(filename_hint or "image")))[0]
|
|
|
|
|
safe_hint = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in safe_hint)
|
|
|
|
|
safe_hint = safe_hint.strip("_")[:32] or "image"
|
|
|
|
|
final_path = os.path.join(
|
|
|
|
|
directory,
|
|
|
|
|
"original_%02d_%s_%s%s" % (order, safe_hint, digest, extension),
|
|
|
|
|
)
|
|
|
|
|
temp_path = final_path + ".tmp-" + uuid.uuid4().hex
|
|
|
|
|
created = False
|
|
|
|
|
try:
|
|
|
|
|
with open(temp_path, "wb") as fh:
|
|
|
|
|
fh.write(image_bytes)
|
|
|
|
|
with open(temp_path, "rb") as fh:
|
|
|
|
|
_image_info(fh.read())
|
|
|
|
|
os.replace(temp_path, final_path)
|
|
|
|
|
created = True
|
|
|
|
|
return image_studio.add_asset(
|
|
|
|
|
project.id,
|
|
|
|
|
image_studio.ASSET_KIND_ORIGINAL,
|
|
|
|
|
local_path=final_path,
|
|
|
|
|
aspect_ratio="%d:%d" % (int(info["width"]), int(info["height"])),
|
|
|
|
|
source_order=order,
|
|
|
|
|
path=database_path,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
for candidate in (temp_path, final_path if created else None):
|
|
|
|
|
if candidate and os.path.exists(candidate):
|
|
|
|
|
try:
|
|
|
|
|
os.remove(candidate)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
if isinstance(exc, ImageStudioImageError):
|
|
|
|
|
raise
|
|
|
|
|
raise ImageStudioImageError(f"保存商品原图失败:{exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def trash_generated_asset(asset_id, *, path=None, config=None, image_root=None):
|
|
|
|
|
"""Move one generated image to the app-managed trash without losing DB history."""
|
|
|
|
|
|
|
|
|
|
cfg = appconfig.load_config() if config is None else config
|
|
|
|
|
database_path = path or appconfig.db_path(cfg)
|
|
|
|
|
asset = image_studio.get_asset(asset_id, path=database_path)
|
|
|
|
|
if asset is None or not str(asset.kind or "").startswith("generated_"):
|
|
|
|
|
raise ImageStudioImageError("只能删除商品套图生成结果")
|
|
|
|
|
source_path = os.path.abspath(str(asset.local_path or ""))
|
|
|
|
|
if not source_path or not os.path.isfile(source_path):
|
|
|
|
|
raise ImageStudioImageError("生成图片文件不存在")
|
|
|
|
|
project = image_studio.get_project(asset.project_id, path=database_path)
|
|
|
|
|
if project is None:
|
|
|
|
|
raise ImageStudioImageError("商品套图任务不存在")
|
|
|
|
|
root = image_studio.project_image_dirs(
|
|
|
|
|
image_root or appconfig.image_dir(cfg),
|
|
|
|
|
project,
|
|
|
|
|
)["root"]
|
|
|
|
|
trash_dir = os.path.join(root, ".trash")
|
|
|
|
|
os.makedirs(trash_dir, exist_ok=True)
|
|
|
|
|
trash_path = os.path.join(
|
|
|
|
|
trash_dir,
|
|
|
|
|
"%s_%s" % (uuid.uuid4().hex, os.path.basename(source_path)),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
os.replace(source_path, trash_path)
|
|
|
|
|
image_studio.update_asset_local_path(
|
|
|
|
|
asset.id,
|
|
|
|
|
trash_path,
|
|
|
|
|
status=image_studio.ASSET_STATUS_MISSING,
|
|
|
|
|
path=database_path,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
if os.path.isfile(trash_path) and not os.path.exists(source_path):
|
|
|
|
|
try:
|
|
|
|
|
os.replace(trash_path, source_path)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
raise ImageStudioImageError(f"删除生成图片失败:{exc}") from exc
|
|
|
|
|
return {
|
|
|
|
|
"asset_id": asset.id,
|
|
|
|
|
"original_path": source_path,
|
|
|
|
|
"trash_path": trash_path,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def restore_trashed_asset(record, *, path=None, config=None):
|
|
|
|
|
cfg = appconfig.load_config() if config is None else config
|
|
|
|
|
database_path = path or appconfig.db_path(cfg)
|
|
|
|
|
payload = dict(record or {})
|
|
|
|
|
asset = image_studio.get_asset(payload.get("asset_id"), path=database_path)
|
|
|
|
|
if asset is None:
|
|
|
|
|
raise ImageStudioImageError("待撤销的生成图片记录不存在")
|
|
|
|
|
trash_path = os.path.abspath(str(payload.get("trash_path") or ""))
|
|
|
|
|
original_path = os.path.abspath(str(payload.get("original_path") or ""))
|
|
|
|
|
if not os.path.isfile(trash_path):
|
|
|
|
|
raise ImageStudioImageError("废纸篓中的生成图片不存在")
|
|
|
|
|
if os.path.exists(original_path):
|
|
|
|
|
stem, extension = os.path.splitext(original_path)
|
|
|
|
|
original_path = "%s_restored_%s%s" % (stem, uuid.uuid4().hex[:8], extension)
|
|
|
|
|
os.makedirs(os.path.dirname(original_path), exist_ok=True)
|
|
|
|
|
os.replace(trash_path, original_path)
|
|
|
|
|
try:
|
|
|
|
|
return image_studio.update_asset_local_path(
|
|
|
|
|
asset.id,
|
|
|
|
|
original_path,
|
|
|
|
|
status=image_studio.ASSET_STATUS_AVAILABLE,
|
|
|
|
|
path=database_path,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
try:
|
|
|
|
|
os.replace(original_path, trash_path)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
raise ImageStudioImageError(f"撤销删除生成图片失败:{exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 19:37:22 +08:00
|
|
|
def download_original_asset(
|
|
|
|
|
asset_id,
|
|
|
|
|
*,
|
|
|
|
|
path=None,
|
|
|
|
|
config=None,
|
|
|
|
|
image_root=None,
|
|
|
|
|
session=None,
|
|
|
|
|
should_stop=None,
|
|
|
|
|
):
|
2026-07-11 12:31:12 +08:00
|
|
|
"""Download one Shopee original image into originals/ and mark its asset available."""
|
|
|
|
|
|
|
|
|
|
cfg = appconfig.load_config() if config is None else config
|
2026-07-16 19:37:22 +08:00
|
|
|
should_stop = should_stop or (lambda: False)
|
|
|
|
|
if should_stop():
|
|
|
|
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
2026-07-11 12:31:12 +08:00
|
|
|
database_path = path or appconfig.db_path(cfg)
|
|
|
|
|
asset = image_studio.get_asset(asset_id, path=database_path)
|
|
|
|
|
if asset is None:
|
|
|
|
|
raise ImageStudioImageError("原图资产不存在")
|
|
|
|
|
if not asset.remote_url:
|
|
|
|
|
raise ImageStudioImageError("原图资产缺少远程 URL")
|
|
|
|
|
existing = _existing_local_asset(asset)
|
|
|
|
|
if existing is not None:
|
|
|
|
|
return existing
|
|
|
|
|
project = image_studio.get_project(asset.project_id, path=database_path)
|
|
|
|
|
if project is None:
|
|
|
|
|
raise ImageStudioImageError("原图资产所属项目不存在")
|
2026-07-16 19:37:22 +08:00
|
|
|
remote = download_remote_image(
|
|
|
|
|
asset.remote_url,
|
|
|
|
|
max_bytes=ORIGINAL_MAX_BYTES,
|
|
|
|
|
session=session,
|
|
|
|
|
should_stop=should_stop,
|
|
|
|
|
)
|
|
|
|
|
if should_stop():
|
|
|
|
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
2026-07-11 12:31:12 +08:00
|
|
|
info = _image_info(remote.content)
|
|
|
|
|
directory, stem = _original_file_path(project, asset, image_root or appconfig.image_dir(cfg))
|
|
|
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
|
final_path = os.path.join(directory, stem + _extension_for_format(info["format"]))
|
|
|
|
|
temp_path = final_path + ".tmp-" + uuid.uuid4().hex
|
|
|
|
|
try:
|
2026-07-16 19:37:22 +08:00
|
|
|
if should_stop():
|
|
|
|
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
2026-07-11 12:31:12 +08:00
|
|
|
with open(temp_path, "wb") as fh:
|
|
|
|
|
fh.write(remote.content)
|
2026-07-16 19:37:22 +08:00
|
|
|
if should_stop():
|
|
|
|
|
raise ImageStudioImageCancelled("用户停止下载商品原图")
|
2026-07-11 12:31:12 +08:00
|
|
|
with open(temp_path, "rb") as fh:
|
|
|
|
|
_image_info(fh.read())
|
|
|
|
|
os.replace(temp_path, final_path)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
try:
|
|
|
|
|
if os.path.exists(temp_path):
|
|
|
|
|
os.remove(temp_path)
|
|
|
|
|
finally:
|
|
|
|
|
if isinstance(exc, ImageStudioImageError):
|
|
|
|
|
raise
|
|
|
|
|
raise ImageStudioImageError(f"保存原图失败:{exc}") from exc
|
|
|
|
|
return image_studio.update_asset_local_path(
|
|
|
|
|
asset.id,
|
|
|
|
|
final_path,
|
|
|
|
|
status=image_studio.ASSET_STATUS_AVAILABLE,
|
|
|
|
|
path=database_path,
|
|
|
|
|
)
|