feat: 开发编辑工作台

This commit is contained in:
R524809
2026-08-14 18:27:45 +08:00
parent b27e42dc75
commit c18d70017e
50 changed files with 5496 additions and 57 deletions
+185
View File
@@ -0,0 +1,185 @@
"""多模型图生图 / 图像编辑服务。
按模型路由到不同的 DashScope 调用形态:
- wanx2.1-imageedit → ImageSynthesisfunction=description_edit
- wan2.6-image / qwen-image-edit(-plus) → MultiModalConversation(多模态生成)
职责单一:把请求翻译成 DashScope 调用并返回结果。当前不落盘、不存储图片;
后续接入七牛云时,可在返回结果 URL 之后增加「下载并转存」步骤,而不改 api 层与前端契约。
"""
from __future__ import annotations
import asyncio
import base64
from http import HTTPStatus
import httpx
from fastapi import HTTPException
from config import get_settings
from schemas.image_edit import ImageEditRequest, ImageEditResponse, ImageEditResult
# 服务端代理下载结果图时的体积上限,超出则跳过 base64(前端回退用 url)
_MAX_PROXY_BYTES = 20 * 1024 * 1024
# 走 ImageSynthesis 的模型(其余统一走 MultiModalConversation
_WANX_IMAGEEDIT = "wanx2.1-imageedit"
def _download_to_data_url(url: str) -> str:
"""把生成结果图下载并转成 data URL。
目的:阿里云结果 URL 未开放 CORS,前端直接绘制到 canvas 会导致画布被污染、
无法 toDataURL 导出;由服务端代下载可彻底规避。失败时返回空串,前端回退用 url。
"""
try:
with httpx.Client(timeout=60.0, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
content_type = (resp.headers.get("content-type") or "image/png").split(";")[0].strip()
if not content_type.startswith("image/"):
content_type = "image/png"
payload = resp.content
if len(payload) > _MAX_PROXY_BYTES:
return ""
b64 = base64.b64encode(payload).decode("ascii")
return f"data:{content_type};base64,{b64}"
except Exception: # noqa: BLE001 - 下载失败不影响主流程
return ""
def _apply_base_url(settings) -> None:
"""仅华北2(北京)业务空间需要配置 base_http_api_url。"""
import dashscope
url = (settings.dashscope_base_http_api_url or "").strip()
if url:
dashscope.base_http_api_url = url
def _fail(rsp, label: str) -> None:
code = getattr(rsp, "code", None)
message = getattr(rsp, "message", "")
status = getattr(rsp, "status_code", "?")
raise HTTPException(status_code=502, detail=f"{label}失败(HTTP {status}code={code}):{message}")
def _to_results(urls: list[str]) -> list[ImageEditResult]:
return [ImageEditResult(url=u, image_base64=_download_to_data_url(u)) for u in urls]
def _call_wanx(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
"""wanx2.1-imageeditImageSynthesis 同步调用。"""
from dashscope import ImageSynthesis
kwargs: dict = {
"api_key": api_key,
"model": "wanx2.1-imageedit",
"function": req.function,
"prompt": req.prompt,
"base_image_url": req.base_image,
"n": req.n,
}
if req.mask_image:
kwargs["mask_image_url"] = req.mask_image
if req.size:
kwargs["size"] = req.size
if req.seed is not None:
kwargs["seed"] = req.seed
if req.style:
kwargs["style"] = req.style
if req.prompt_extend:
kwargs["prompt_extend"] = True
if req.strength is not None:
# strength 走 SDK 的 **kwargs,最终落入请求体的 parameters
kwargs["strength"] = req.strength
try:
rsp = ImageSynthesis.call(**kwargs)
except Exception as exc: # noqa: BLE001 - SDK 抛错类型不统一,统一转为 502
raise HTTPException(status_code=502, detail=f"DashScope 调用异常:{exc}") from exc
if rsp.status_code != HTTPStatus.OK:
_fail(rsp, "图生图")
urls: list[str] = []
for item in getattr(rsp.output, "results", None) or []:
url = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
if url:
urls.append(url)
if not urls:
raise HTTPException(status_code=502, detail="模型未返回结果图片 URL")
usage = getattr(rsp, "usage", None)
image_count = int(getattr(usage, "image_count", 0) or 0) if usage else len(urls)
return ImageEditResponse(
task_id=getattr(rsp.output, "task_id", ""),
results=_to_results(urls),
image_count=image_count or len(urls),
request_id=getattr(rsp, "request_id", ""),
)
def _call_multimodal(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
"""wan2.6-image / qwen-image-edit(-plus[-快照])MultiModalConversation。"""
from dashscope import MultiModalConversation
messages = [{"role": "user", "content": [{"image": req.base_image}, {"text": req.prompt}]}]
kwargs: dict = {
"api_key": api_key,
"model": req.model,
"messages": messages,
"prompt_extend": req.prompt_extend,
}
if req.n:
kwargs["n"] = req.n
if req.size:
kwargs["size"] = req.size
try:
rsp = MultiModalConversation.call(**kwargs)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"DashScope 调用异常:{exc}") from exc
if rsp.status_code != HTTPStatus.OK:
_fail(rsp, "图生图")
urls: list[str] = []
try:
content = rsp.output.choices[0].message.content
except (AttributeError, IndexError, KeyError, TypeError):
content = []
if isinstance(content, list):
for item in content:
img = item.get("image") if isinstance(item, dict) else None
if img:
urls.append(img)
if not urls:
raise HTTPException(status_code=502, detail="模型未返回结果图片")
return ImageEditResponse(
task_id="",
results=_to_results(urls),
image_count=len(urls),
request_id=getattr(rsp, "request_id", ""),
)
def _call_dashscope(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
"""同步调用 DashScope(在线程池中执行),按模型路由。"""
if req.model == _WANX_IMAGEEDIT:
return _call_wanx(req, api_key)
return _call_multimodal(req, api_key)
async def edit_image(req: ImageEditRequest) -> ImageEditResponse:
"""入口:校验密钥后在独立线程中执行同步 SDK 调用。"""
settings = get_settings()
api_key = (settings.dashscope_api_key or "").strip()
if not api_key:
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY,请写入 .env")
_apply_base_url(settings)
return await asyncio.to_thread(_call_dashscope, req, api_key)