Files
pqAutomationApp/app/services/ai_image.py

868 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI 图片生成服务:后端请求 + 本地缓存管理。
后端接口(生产/测试环境):
POST {API_BASE_URL}{API_GENERATE_PATH}
body: {"user_message": str, "session_id": str, "upload_image_url"?: str}
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
POST {API_BASE_URL}{API_UPLOAD_PATH} # multipart/form-data, field: file
resp: {"code": 200, "message": "", "data": {"upload_image_url": "..."}}
带 ``upload_image_url`` 启用"图生图"模式;多轮对话需将上一轮返回的 imageUrl
作为下一轮请求的 upload_image_url由 panel 通过会话级缓存自动维护)。
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import json
import logging
import mimetypes
import os
import sys
import shutil
import threading
import time
import uuid
from io import BytesIO
from dataclasses import dataclass, asdict
from typing import Callable, List, Optional
from urllib.error import HTTPError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from PIL import Image
logger = logging.getLogger(__name__)
# ---------- 常量 ----------
_CACHE_DIRNAME = os.path.join("settings", "ai_image_cache")
_META_SUFFIX = ".json"
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
# 测试环境后端
# API_BASE_URL = "http://10.201.44.70:9008/ai-agent/"
API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
API_GENERATE_PATH = "api/v1/pqtest/generate"
API_UPLOAD_PATH = "api/v1/pqtest/upload"
API_TIMEOUT = 300.0 # 后端最长 120s留余量
API_UPLOAD_TIMEOUT = 60.0
# 上传接口限制(来自接口文档)
UPLOAD_MAX_BYTES = 10 * 1024 * 1024
UPLOAD_MAX_PIXELS = 4096
UPLOAD_ALLOWED_EXT = (".png", ".jpg", ".jpeg")
# 兼容旧名(如其他模块仍引用)
API_PATH = API_GENERATE_PATH
# 进程级会话 id多轮对话需保持一致可通过 ``reset_session`` 重置
_session_id: str = str(uuid.uuid4())
_session_lock = threading.Lock()
def get_session_id() -> str:
with _session_lock:
return _session_id
def set_session_id(session_id: str) -> str:
"""切换到指定会话。空值会抛错"""
global _session_id
sid = (session_id or "").strip()
if not sid:
raise ValueError("session_id 不能为空")
with _session_lock:
old = _session_id
_session_id = sid
return _session_id
def reset_session() -> str:
"""开启新一轮会话,返回新的 session_id。"""
global _session_id
with _session_lock:
old = _session_id
_session_id = str(uuid.uuid4())
return _session_id
def _mask_sid(sid: str) -> str:
"""日志安全展示:仅保留前 8 位。"""
if not sid:
return "(none)"
return f"{sid[:8]}"
def _truncate(text: str, n: int = 80) -> str:
s = (text or "").replace("\n", " ").strip()
return s if len(s) <= n else s[:n] + ""
# ---------- 数据结构 ----------
@dataclass
class AIImageRecord:
"""一条缓存记录。
字段说明:
- ``id``: 唯一 id等同于磁盘文件名不含扩展名格式 ``{时间戳}_{md5前8}``。
- ``prompt``: 用户原始输入(完整保留,用于回溯/调试,不应被改写)。
- ``title``: 用户自定义展示标题重命名时写入UI 优先使用,留空则回退 prompt 第一行截断。
- ``image_path``: 图片在缓存目录中的绝对路径。
- ``created_at``: ISO8601 时间字符串。
- ``extra``: 其它元数据,至少包含 ``source`` 与 ``session_id``(标识属于哪一轮对话)。
"""
id: str
prompt: str
image_path: str
created_at: str # ISO8601
extra: Optional[dict] = None
title: Optional[str] = None
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False, indent=2)
@property
def display_name(self) -> str:
"""UI 展示名title 优先,否则回退 prompt 第一行。"""
if self.title:
return self.title.strip()
first = (self.prompt or "").strip().splitlines()[0] if self.prompt else ""
return first or "(未命名)"
@property
def session_id(self) -> str:
if isinstance(self.extra, dict):
return str(self.extra.get("session_id") or "")
return ""
# ---------- 后端 API ----------
def _api_endpoint(path: str = API_GENERATE_PATH) -> str:
base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
return base + path.lstrip("/")
def _pretty_json_text(value) -> str:
"""把对象或 JSON 字符串格式化为易读文本;失败则回退原始字符串。"""
try:
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, indent=2)
text = "" if value is None else str(value)
parsed = json.loads(text)
return json.dumps(parsed, ensure_ascii=False, indent=2)
except Exception:
return "" if value is None else str(value)
def _call_pqtest_generate(
user_message: str,
session_id: str,
upload_image_url: Optional[str] = None,
timeout: float = API_TIMEOUT,
) -> str:
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。
``upload_image_url`` 传入时启用"图生图"模式。
"""
body: dict = {"user_message": user_message, "session_id": session_id}
if upload_image_url:
body["upload_image_url"] = upload_image_url
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
request_headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
}
endpoint = _api_endpoint(API_GENERATE_PATH)
logger.info(
"[AIImage] 请求生成 sid=%s mode=%s prompt_len=%d prompt=%r ref=%s",
_mask_sid(session_id),
"img2img" if upload_image_url else "txt2img",
len(user_message or ""),
_truncate(user_message),
upload_image_url or "-",
)
logger.info(
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
endpoint,
timeout,
_pretty_json_text(request_headers),
_pretty_json_text(payload.decode("utf-8", errors="replace")),
)
request = Request(
endpoint,
data=payload,
method="POST",
headers=request_headers,
)
t0 = time.monotonic()
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read()
http_status = response.status
response_headers = dict(response.headers.items())
raw_text = raw.decode("utf-8", errors="replace")
logger.info(
"[AIImage][RESPONSE]\nstatus=%s\nheaders=%s\nbody=%s",
http_status,
_pretty_json_text(response_headers),
_pretty_json_text(raw_text),
)
except HTTPError as exc:
elapsed = time.monotonic() - t0
err_raw = b""
try:
err_raw = exc.read() or b""
except Exception:
err_raw = b""
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
err_headers = {}
try:
if exc.headers is not None:
err_headers = dict(exc.headers.items())
except Exception:
err_headers = {}
logger.error(
"[AIImage][RESPONSE_ERROR] sid=%s elapsed=%.2fs status=%s reason=%s\nheaders=%s\nbody=%s",
_mask_sid(session_id),
elapsed,
getattr(exc, "code", "?"),
str(exc),
_pretty_json_text(err_headers),
_pretty_json_text(err_text),
)
raise
except Exception as exc:
elapsed = time.monotonic() - t0
logger.error(
"[AIImage] 请求异常 sid=%s elapsed=%.2fs %s: %s",
_mask_sid(session_id), elapsed, type(exc).__name__, exc,
)
raise
elapsed = time.monotonic() - t0
logger.info("[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs", http_status, len(raw), elapsed)
try:
result = json.loads(raw.decode("utf-8"))
except Exception as exc:
raw_text = raw.decode("utf-8", errors="replace")
logger.error("[AIImage] 响应解析失败 sid=%s raw=%s", _mask_sid(session_id), raw_text)
raise RuntimeError(f"AI 接口返回非 JSON{raw_text}") from exc
code = result.get("code")
message = result.get("message") or ""
data = result.get("data") or {}
image_url = (data.get("imageUrl") or "").strip()
if code != 200 or not image_url:
logger.warning(
"[AIImage] 接口失败 sid=%s code=%s msg=%r",
_mask_sid(session_id), code, message,
)
raise RuntimeError(f"AI 接口失败 code={code} msg={message or '生成失败'}")
logger.info(
"[AIImage] 生成成功 sid=%s elapsed=%.2fs url=%s",
_mask_sid(session_id), elapsed, image_url,
)
return image_url
def _call_pqtest_upload(file_path: str, timeout: float = API_UPLOAD_TIMEOUT, auto_resize: bool = True) -> str:
"""以 multipart/form-data 上传本地图片,返回 ``upload_image_url``。失败抛异常。
参数:
auto_resize: True 时,若图片超过 4096×4096 或 10MB 则自动缩放/重压
"""
if not file_path or not os.path.isfile(file_path):
raise FileNotFoundError(f"图片文件不存在: {file_path}")
ext = os.path.splitext(file_path)[1].lower()
if ext not in UPLOAD_ALLOWED_EXT:
raise ValueError(f"不支持的图片格式 ({ext}),仅支持 PNG/JPG/JPEG")
try:
with Image.open(file_path) as img:
iw, ih = img.size
except Exception as exc:
raise ValueError(f"无法读取图片: {exc}") from exc
size = os.path.getsize(file_path)
needs_resize = (iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS or size > UPLOAD_MAX_BYTES)
file_stem = os.path.splitext(os.path.basename(file_path))[0]
upload_ext = ext
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
if needs_resize:
if not auto_resize:
if iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS:
raise ValueError(f"分辨率超过 4096×4096当前 {iw}×{ih}")
raise ValueError(f"图片超过 10MB 限制(当前 {size/1024/1024:.2f}MB")
logger.info(
"[AIImage][UPLOAD] 自动处理超限图片 %dx%d (%.2fMB)",
iw,
ih,
size / 1024 / 1024,
)
with Image.open(file_path) as img:
working = img.copy()
# 先做一次分辨率约束,避免后续压缩开销过大。
scale = min(UPLOAD_MAX_PIXELS / max(1, working.width), UPLOAD_MAX_PIXELS / max(1, working.height), 1.0)
if scale < 1.0:
working = working.resize(
(max(1, int(working.width * scale)), max(1, int(working.height * scale))),
Image.LANCZOS,
)
best_bytes = b""
best_mime = mime
best_ext = upload_ext
# 第一优先:保持原格式。
try:
raw_io = BytesIO()
if ext == ".png":
working.save(raw_io, format="PNG", optimize=True)
raw_mime, raw_ext = "image/png", ".png"
else:
rgb = working.convert("RGB") if working.mode not in {"RGB", "L"} else working
rgb.save(raw_io, format="JPEG", quality=95, optimize=True)
raw_mime, raw_ext = "image/jpeg", ".jpg"
best_bytes = raw_io.getvalue()
best_mime = raw_mime
best_ext = raw_ext
except Exception as exc:
logger.warning("[AIImage][UPLOAD] 原格式编码失败,准备转 JPEG: %s", exc)
# 仍超限时,转 JPEG + 渐进压缩;如仍超限则继续降分辨率。
if len(best_bytes) > UPLOAD_MAX_BYTES:
if best_ext != ".jpg":
logger.info("[AIImage][UPLOAD] 原格式仍超限,切换 JPEG 压缩")
working_jpg = working.convert("RGB") if working.mode != "RGB" else working
while True:
compressed = b""
for q in (95, 90, 85, 80, 75, 70, 65, 60, 55, 50):
tmp = BytesIO()
working_jpg.save(tmp, format="JPEG", quality=q, optimize=True)
data = tmp.getvalue()
compressed = data
if len(data) <= UPLOAD_MAX_BYTES:
break
best_bytes = compressed
best_mime = "image/jpeg"
best_ext = ".jpg"
if len(best_bytes) <= UPLOAD_MAX_BYTES:
break
next_w = max(256, int(working_jpg.width * 0.9))
next_h = max(256, int(working_jpg.height * 0.9))
if next_w == working_jpg.width and next_h == working_jpg.height:
break
if next_w <= 256 or next_h <= 256:
break
working_jpg = working_jpg.resize((next_w, next_h), Image.LANCZOS)
if len(best_bytes) > UPLOAD_MAX_BYTES:
raise ValueError(
f"自动压缩后仍超过 10MB当前 {len(best_bytes)/1024/1024:.2f}MB请更换图片"
)
file_bytes = best_bytes
mime = best_mime
upload_ext = best_ext
iw, ih = working.width, working.height
logger.info(
"[AIImage][UPLOAD] 自动处理完成 %dx%d %.2fMB (%s)",
iw,
ih,
len(file_bytes) / 1024 / 1024,
mime,
)
else:
with open(file_path, "rb") as f:
file_bytes = f.read()
filename = f"{file_stem}{upload_ext}"
boundary = "----pqAuto" + uuid.uuid4().hex
crlf = b"\r\n"
body = b"".join([
b"--", boundary.encode("ascii"), crlf,
b'Content-Disposition: form-data; name="file"; filename="',
filename.encode("utf-8"), b'"', crlf,
b"Content-Type: ", mime.encode("ascii"), crlf, crlf,
file_bytes, crlf,
b"--", boundary.encode("ascii"), b"--", crlf,
])
endpoint = _api_endpoint(API_UPLOAD_PATH)
headers = {
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
"Content-Length": str(len(body)),
}
logger.info(
"[AIImage][UPLOAD] file=%s size=%dB mime=%s wh=%dx%d -> %s",
filename, len(file_bytes), mime, iw, ih, endpoint,
)
request = Request(endpoint, data=body, method="POST", headers=headers)
t0 = time.monotonic()
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read()
http_status = response.status
raw_text = raw.decode("utf-8", errors="replace")
logger.info("[AIImage][UPLOAD_RESP]\nstatus=%s\nbody=%s",
http_status, _pretty_json_text(raw_text))
except HTTPError as exc:
err_raw = b""
try:
err_raw = exc.read() or b""
except Exception:
pass
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
logger.error("[AIImage][UPLOAD_ERR] status=%s reason=%s body=%s",
getattr(exc, "code", "?"), str(exc), _pretty_json_text(err_text))
raise
except Exception as exc:
logger.error("[AIImage][UPLOAD_ERR] %s: %s", type(exc).__name__, exc)
raise
elapsed = time.monotonic() - t0
try:
result = json.loads(raw.decode("utf-8"))
except Exception as exc:
raise RuntimeError(f"上传接口返回非 JSON{raw_text}") from exc
code = result.get("code")
message = result.get("message") or ""
data = result.get("data") or {}
url = (data.get("upload_image_url") or "").strip()
if code != 200 or not url:
raise RuntimeError(f"上传失败 code={code} msg={message or '未知错误'}")
logger.info("[AIImage][UPLOAD_OK] elapsed=%.2fs url=%s", elapsed, url)
return url
# ---------- 缓存路径工具 ----------
def get_cache_dir(base_dir: Optional[str] = None) -> str:
"""返回缓存目录,如不存在则创建。``base_dir`` 留空时使用应用根目录。"""
if base_dir:
root = base_dir
elif getattr(sys, "frozen", False):
root = os.path.dirname(sys.executable)
else:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
path = os.path.join(root, _CACHE_DIRNAME)
os.makedirs(path, exist_ok=True)
return path
def _make_id(prompt: str) -> str:
stamp = _dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
digest = hashlib.md5(prompt.encode("utf-8")).hexdigest()[:8]
return f"{stamp}_{digest}"
def _meta_path_for(image_path: str) -> str:
return os.path.splitext(image_path)[0] + _META_SUFFIX
def _sanitize_image_bytes(image_bytes: bytes, image_ext: str) -> bytes:
"""规范化图片字节,尽量去掉已知有问题的 PNG ICC profile。"""
ext = (image_ext or "").lower()
if ext not in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}:
return image_bytes
try:
with Image.open(BytesIO(image_bytes)) as img:
img.load()
normalized = img.copy()
output = BytesIO()
save_kwargs = {}
if ext == ".png":
save_kwargs["icc_profile"] = None
normalized.save(output, format=normalized.format or ext.lstrip(".").upper(), **save_kwargs)
result = output.getvalue()
if result:
return result
except Exception as exc:
logger.warning("[AIImage] 图片规范化失败 ext=%s %s: %s", ext, type(exc).__name__, exc)
return image_bytes
# ---------- 读写 ----------
def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
"""列出缓存目录下的所有记录,按创建时间倒序(最新在前)。"""
cache_dir = get_cache_dir(base_dir)
records: List[AIImageRecord] = []
for name in os.listdir(cache_dir):
full = os.path.join(cache_dir, name)
if not (os.path.isfile(full) and name.lower().endswith(_SUPPORTED_IMG_EXT)):
continue
meta_path = _meta_path_for(full)
prompt = ""
created_at = ""
extra = None
title = None
rec_id = os.path.splitext(name)[0]
if os.path.isfile(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
data = json.load(f)
prompt = data.get("prompt", "")
created_at = data.get("created_at", "")
extra = data.get("extra")
title = data.get("title")
rec_id = data.get("id", rec_id)
except Exception:
pass
if not created_at:
# fallback 到文件 mtime
try:
mtime = os.path.getmtime(full)
created_at = _dt.datetime.fromtimestamp(mtime).isoformat()
except Exception:
created_at = ""
records.append(
AIImageRecord(
id=rec_id,
prompt=prompt,
image_path=full,
created_at=created_at,
extra=extra,
title=title,
)
)
records.sort(key=lambda r: r.created_at, reverse=True)
return records
def save_image_to_cache(
prompt: str,
image_bytes: bytes,
image_ext: str = ".png",
extra: Optional[dict] = None,
base_dir: Optional[str] = None,
title: Optional[str] = None,
) -> AIImageRecord:
"""把生成的图片字节写入缓存,返回记录。"""
if not image_ext.startswith("."):
image_ext = "." + image_ext
if image_ext.lower() not in _SUPPORTED_IMG_EXT:
image_ext = ".png"
image_bytes = _sanitize_image_bytes(image_bytes, image_ext)
cache_dir = get_cache_dir(base_dir)
rec_id = _make_id(prompt)
image_path = os.path.join(cache_dir, f"{rec_id}{image_ext}")
with open(image_path, "wb") as f:
f.write(image_bytes)
record = AIImageRecord(
id=rec_id,
prompt=prompt,
image_path=image_path,
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
extra=extra,
title=title,
)
try:
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
f.write(record.to_json())
except Exception:
pass
return record
def import_image_from_url(
image_url: str,
prompt: Optional[str] = None,
extra: Optional[dict] = None,
base_dir: Optional[str] = None,
timeout: float = 20.0,
) -> AIImageRecord:
"""下载远程图片并写入缓存。"""
url = (image_url or "").strip()
if not url:
raise ValueError("图片地址不能为空")
request = Request(
url,
headers={
"User-Agent": "pqAutomationApp/1.0",
"Accept": "image/*,*/*;q=0.8",
},
)
with urlopen(request, timeout=timeout) as response:
image_bytes = response.read()
if not image_bytes:
raise ValueError("下载结果为空")
image_ext = _guess_image_ext(
image_url=url,
content_type=response.headers.get_content_type(),
)
merged_extra = dict(extra or {})
merged_extra.update(
{
"source": "remote-url",
"source_url": url,
"content_type": response.headers.get_content_type(),
}
)
record_prompt = (prompt or _default_prompt_from_url(url)).strip()
return save_image_to_cache(
prompt=record_prompt,
image_bytes=image_bytes,
image_ext=image_ext,
extra=merged_extra,
base_dir=base_dir,
)
def delete_record(record: AIImageRecord) -> bool:
"""删除一条缓存记录(图片 + 侧车)。返回是否成功。"""
ok = True
for p in (record.image_path, _meta_path_for(record.image_path)):
try:
if os.path.isfile(p):
os.remove(p)
except Exception:
ok = False
return ok
def export_record(record: AIImageRecord, dest_path: str) -> None:
"""把缓存中的图片另存到 ``dest_path``。"""
shutil.copyfile(record.image_path, dest_path)
def update_record_title(record: AIImageRecord, new_title: Optional[str]) -> bool:
"""更新记录的展示标题并写回侧车 JSON。空串/None 视为清除标题。"""
title = (new_title or "").strip() or None
meta_path = _meta_path_for(record.image_path)
try:
data: dict = {}
if os.path.isfile(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
data = json.load(f) or {}
if title is None:
data.pop("title", None)
else:
data["title"] = title
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception:
return False
record.title = title
return True
def group_records_by_session(records: List[AIImageRecord]) -> List[dict]:
"""按 ``session_id`` 分组。
返回元素:``{"session_id", "records", "started_at", "latest_at"}``。
会话按"最近使用时间"倒序,组内记录按时间倒序。
没有 session_id 的记录归到空串 ``""`` 组。
"""
buckets: dict = {}
for rec in records:
buckets.setdefault(rec.session_id, []).append(rec)
sessions = []
for sid, recs in buckets.items():
recs.sort(key=lambda r: r.created_at, reverse=True)
started_at = min((r.created_at for r in recs if r.created_at), default="")
sessions.append(
{
"session_id": sid,
"records": recs,
"started_at": started_at,
"latest_at": recs[0].created_at if recs else "",
}
)
sessions.sort(key=lambda s: s["latest_at"], reverse=True)
return sessions
# ---------- 异步请求 ----------
def request_image_async(
prompt: str,
on_success: Callable[[AIImageRecord], None],
on_error: Callable[[Exception], None],
base_dir: Optional[str] = None,
session_id: Optional[str] = None,
cancel_event: Optional[threading.Event] = None,
upload_image_url: Optional[str] = None,
) -> threading.Thread:
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
``on_success`` / ``on_error`` 会在 **工作线程** 中被调用UI 侧若需
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
``session_id`` 留空则使用进程级会话 id保证多轮对话上下文
``upload_image_url`` 传入后启用"图生图"模式。
"""
sid = session_id or get_session_id()
cancel = cancel_event
ref_url = (upload_image_url or "").strip() or None
def _worker():
try:
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
return
image_url = _call_pqtest_generate(prompt, sid, upload_image_url=ref_url)
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
return
extra = {
"source": "ai-api",
"session_id": sid,
"mode": "img2img" if ref_url else "txt2img",
}
if ref_url:
extra["upload_image_url"] = ref_url
record = import_image_from_url(
image_url=image_url,
prompt=prompt,
extra=extra,
base_dir=base_dir,
)
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(下载后) sid=%s", _mask_sid(sid))
return
logger.info(
"[AIImage] 已写入缓存 sid=%s id=%s path=%s",
_mask_sid(sid), record.id, record.image_path,
)
on_success(record)
except Exception as exc:
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(异常忽略) sid=%s", _mask_sid(sid))
return
logger.error(
"[AIImage] 生成流程失败 sid=%s %s: %s",
_mask_sid(sid), type(exc).__name__, exc,
)
on_error(exc)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t
def import_image_from_url_async(
image_url: str,
on_success: Callable[[AIImageRecord], None],
on_error: Callable[[Exception], None],
prompt: Optional[str] = None,
extra: Optional[dict] = None,
base_dir: Optional[str] = None,
timeout: float = 20.0,
cancel_event: Optional[threading.Event] = None,
) -> threading.Thread:
"""在后台线程下载远程图片并写入缓存"""
def _worker():
try:
if cancel_event is not None and cancel_event.is_set():
logger.info("[AIImage] URL 导入任务已取消(请求前)")
return
record = import_image_from_url(
image_url=image_url,
prompt=prompt,
extra=extra,
base_dir=base_dir,
timeout=timeout,
)
if cancel_event is not None and cancel_event.is_set():
logger.info("[AIImage] URL 导入任务已取消(下载后)")
return
on_success(record)
except Exception as exc:
if cancel_event is not None and cancel_event.is_set():
logger.info("[AIImage] URL 导入任务已取消(异常忽略)")
return
on_error(exc)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t
def upload_image_async(
file_path: str,
on_success: Callable[[str], None],
on_error: Callable[[Exception], None],
cancel_event: Optional[threading.Event] = None,
timeout: float = API_UPLOAD_TIMEOUT,
auto_resize: bool = True,
) -> threading.Thread:
"""后台上传本地图片到后端,成功回调返回 ``upload_image_url``。
参数:
auto_resize: True 时自动缩放超大图片
"""
def _worker():
try:
if cancel_event is not None and cancel_event.is_set():
return
url = _call_pqtest_upload(file_path, timeout=timeout, auto_resize=auto_resize)
if cancel_event is not None and cancel_event.is_set():
return
on_success(url)
except Exception as exc:
if cancel_event is not None and cancel_event.is_set():
return
on_error(exc)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t
def is_remote_image_url(value: str) -> bool:
"""判断输入是否为 http/https 图片地址。"""
url = (value or "").strip()
if not url:
return False
parsed = urlparse(url)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def _guess_image_ext(image_url: str, content_type: Optional[str]) -> str:
if content_type:
guessed = mimetypes.guess_extension(content_type)
if guessed == ".jpe":
guessed = ".jpg"
if guessed and guessed.lower() in _SUPPORTED_IMG_EXT:
return guessed.lower()
url_path = urlparse(image_url).path
ext = os.path.splitext(url_path)[1].lower()
if ext in _SUPPORTED_IMG_EXT:
return ext
return ".png"
def _default_prompt_from_url(image_url: str) -> str:
path = urlparse(image_url).path
name = os.path.splitext(os.path.basename(path))[0].strip()
return name or "远程导入图片"