Files
2026-09-27 22:41:33 +08:00

333 lines
16 KiB
Python
Raw Permalink 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.
"""独立后台 Worker:按子项粒度执行持久化图片任务。"""
from __future__ import annotations
import asyncio
import logging
import socket
import uuid
import httpx
from config import get_settings
from db import get_session_factory
from models import ProductAsset
from services.image_assets import get_image_asset_service
from services.generator import OutpaintCanvas, _image_size, _refs_for_job, advance_generation, prepare_outpaint_canvas
from services.jobs import (
ClaimedItem,
claim_next_item,
job_can_write,
mark_item_cancelled,
mark_item_done,
mark_item_failed,
mark_waiting_provider,
recover_expired_leases,
)
from services.prompts import build_context, build_prompt
from services.prompts.common import requirements_block
from services.prompts.single_edit import build_single_edit_prompt
from services.storage import get_storage
from services.suite_service import append_generated_asset
from services.tasks import TaskImage
from services.watermark import apply_watermark
from services.video_renderer import VideoRenderCancelled, render_slideshow
log = logging.getLogger("background.worker")
# 异步供应商任务已经持久化了 task id 后,轮询网络失败不能把任务判为失败。
# 下一次轮询复用同一 task id,不会再次提交/计费。
PROVIDER_POLL_RETRY_DELAY_SECONDS = 30
def _worker_owner() -> str:
return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}"
def _image_meta(data: bytes) -> tuple[str, str]:
return (".png", "image/png") if data[:8] == b"\x89PNG\r\n\x1a\n" else (".jpg", "image/jpeg")
async def _store_image(job_id: str, position: int, data: bytes, version: str) -> str:
suffix, content_type = _image_meta(data)
return await get_storage().save_bytes(data, f"suites/{job_id}/{version}/{position}{suffix}", content_type)
async def _cleanup_temporary(urls: list[str]) -> None:
"""append 成功后由 image_assets 搬运并清理;取消时主动清理本次临时文件。"""
if not urls:
return
try:
from services.image_assets import get_image_asset_service
await get_image_asset_service().delete_temporary_urls(urls)
except Exception: # noqa: BLE001
log.exception("清理取消任务的临时图片失败")
async def _process_watermark_item(claim: ClaimedItem) -> None:
"""批量水印的一个素材子项:基于 original 重做 current,提交后回收旧水印对象。"""
if not await job_can_write(claim.job_id):
await mark_item_cancelled(claim.item_id)
return
payload = claim.item_payload
raw_asset_id = payload.get("asset_id")
try:
asset_id = uuid.UUID(str(raw_asset_id))
except (TypeError, ValueError) as exc:
await mark_item_failed(claim.item_id, "水印任务缺少合法素材 ID")
return
obsolete_key: str | None = None
result_url = ""
source_url = ""
watermark_config: dict | None = None
try:
async with get_session_factory()() as db:
asset = await db.get(ProductAsset, asset_id)
if asset is None:
raise RuntimeError("素材已删除")
if asset.type != "img":
raise RuntimeError("仅支持图片素材")
expected_key = payload.get("expected_current_storage_key")
expected_url = payload.get("expected_current_url")
if expected_key and asset.current_storage_key != expected_key:
raise RuntimeError("素材在等待期间已被其他操作更新,请重新提交水印任务")
if not expected_key and expected_url and asset.stored_url != expected_url:
raise RuntimeError("素材在等待期间已被其他操作更新,请重新提交水印任务")
watermark = claim.request_payload.get("watermark") or {}
if not watermark.get("enabled"):
raise RuntimeError("水印设置已关闭")
image_assets = get_image_asset_service()
update = await image_assets.apply_watermark(asset, watermark)
asset.error = None
result_url = asset.stored_url or ""
source_url = asset.original_url or ""
watermark_config = asset.watermark_config
obsolete_key = update.obsolete_key
await db.commit()
# 数据库已切换到新版本后再删旧对象;删除失败不影响用户可用的新图片。
if obsolete_key:
try:
await get_image_asset_service().delete_key(obsolete_key)
except Exception: # noqa: BLE001
log.exception("水印已切换,但旧水印对象删除失败:%s", obsolete_key)
await mark_item_done(
claim.item_id, result_url=result_url, source_url=source_url or None,
watermark_config=watermark_config, generated_asset_id=None,
)
except Exception as exc: # noqa: BLE001
log.exception("后台水印任务 %s/%s 执行失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc))
async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
"""一个视频任务只有一个子项;输入 URL 在提交时冻结,避免后续选择状态影响成片。"""
payload = claim.request_payload
sources = list(payload.get("sources") or [])
source_urls = [str(source.get("url") or "") for source in sources]
if not 2 <= len(source_urls) <= 5 or any(not url for url in source_urls):
await mark_item_failed(claim.item_id, "视频任务缺少 2–5 张可用图片")
return
try:
async def should_cancel() -> bool:
return not await job_can_write(claim.job_id)
rendered = await render_slideshow(source_urls, should_cancel=should_cancel)
if not await job_can_write(claim.job_id):
await mark_item_cancelled(claim.item_id)
return
key = f"products/{claim.product_id}/videos/{claim.job_id}/result.mp4"
result_url = await get_storage().save_bytes(rendered.data, key, "video/mp4")
async with get_session_factory()() as db:
from models import BackgroundJobItem
item = await db.get(BackgroundJobItem, uuid.UUID(claim.item_id))
if item is not None:
item.payload = {
**dict(item.payload),
"duration_seconds": rendered.duration_seconds,
"width": rendered.width,
"height": rendered.height,
"file_size_bytes": len(rendered.data),
}
await db.commit()
await mark_item_done(claim.item_id, result_url=result_url, source_url=None, watermark_config=None, generated_asset_id=None)
except VideoRenderCancelled:
await mark_item_cancelled(claim.item_id)
except Exception as exc: # noqa: BLE001
log.exception("后台轮播视频任务 %s/%s 执行失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc))
def _outpaint_prompt(requirements: str | None) -> str:
"""仅扩图不复用套图提示词,避免默认风格、营销构图或文字污染边缘生成。"""
requested = (requirements or "").strip()
if not requested:
requested = "Continue the existing surroundings naturally and seamlessly."
return "\n\n".join([
requirements_block(requested),
"OUTPAINT ONLY: Image 1 contains an original photo placed on a 3:4 canvas. Generate pixels only "
"in the transparent masked margins outside the original photo. Do not crop, move, resize, redraw, "
"retouch, recolor, relight, or otherwise alter any pixel of the original photo.",
"Do not add marketing layouts, product captions, overlays, props, or visible text unless the STRICT "
"REQUIREMENTS explicitly ask for them. Make the new margins blend naturally with the original edges.",
"FINAL CHECK: output exactly 3:4 and change only the newly expanded margins.",
])
async def process_item(claim: ClaimedItem) -> None:
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
if claim.job_kind == "watermark_batch":
await _process_watermark_item(claim)
return
if claim.job_kind == "slideshow_video":
await _process_slideshow_video_item(claim)
return
if claim.job_kind not in {"image_suite", "image_edit"}:
await mark_item_failed(claim.item_id, f"Worker 暂不支持任务类型: {claim.job_kind}")
return
settings = get_settings()
payload = claim.request_payload
job_item = claim.item_payload
provider = claim.provider or settings.image_provider
model = claim.model or {
"tongyi": settings.dashscope_model,
"rightapi": settings.rightapi_image_model,
"pcore": "gpt-image-2",
}.get(provider, settings.ark_image_model)
try:
single_edit = payload.get("single_edit")
refs = (
[image["url"] for image in payload.get("ref_images") or []]
if single_edit else _refs_for_job(list(payload.get("ref_images") or []), job_item)
)
operation = payload.get("operation", "edit")
target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model)
edit_mask: str | None = None
outpaint: OutpaintCanvas | None = None
if operation == "outpaint":
if len(refs) != 1:
raise RuntimeError("仅扩图必须且只能使用一张参考底图")
prompt = _outpaint_prompt(payload.get("requirements"))
# 首次提交时才构造图片和蒙版;之后轮询不再向供应商重复上传。
if not claim.provider_task_id:
outpaint = await prepare_outpaint_canvas(refs[0], target_size)
refs = [outpaint.image_data_url]
edit_mask = outpaint.mask_data_url
else:
context = build_context(payload.get("context") or {}, fallback_name="")
if single_edit:
prompt = build_single_edit_prompt(
requirements=payload.get("requirements") or "", config=single_edit,
context=context, style_set=claim.style_set,
style_prompt=payload.get("style_prompt"), ratio=claim.ratio,
is_size_chart=job_item.get("kind") == "size_chart",
reference_count=len(refs) - 1,
)
else:
prompt_extra = job_item.get("prompt_extra") or job_item
prompt = build_prompt(
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"),
requirements_exclusive=bool(payload.get("requirements_exclusive")),
)
is_wan = provider == "tongyi" and model.lower().startswith("wan")
result = await advance_generation(
provider, prompt, refs,
size=target_size, model=model,
provider_task_id=claim.provider_task_id, provider_task_kind=claim.provider_task_kind,
edit_mask=edit_mask,
)
if result.data is None:
if not result.provider_task_id or not result.provider_task_kind:
raise RuntimeError("异步生成未返回可恢复的 provider task id")
# 轮询供应商期间用户可能刚好取消任务。此时不能再写回
# waiting_provider,否则父任务已取消、子项却会永久显示“进行中”。
if not await job_can_write(claim.job_id):
await mark_item_cancelled(claim.item_id)
return
await mark_waiting_provider(claim.item_id, result.provider_task_id, result.provider_task_kind)
return
# 生成完成后仍检查取消/商品删除,避免在用户删除商品后把素材回写回来。
if not await job_can_write(claim.job_id):
await mark_item_cancelled(claim.item_id)
return
if operation == "outpaint":
# 异步任务完成时本轮未必创建过画布,按同一源图与尺寸确定性重建后覆盖原图区域。
outpaint = outpaint or await prepare_outpaint_canvas(refs[0], target_size)
result = type(result)(data=outpaint.restore_original(result.data))
watermark = payload.get("watermark") or {}
position = int(job_item.get("position", 0))
if watermark.get("enabled"):
source_url = await _store_image(claim.job_id, position, result.data, "original")
result_url = await _store_image(claim.job_id, position, apply_watermark(result.data, watermark), "watermarked")
watermark_config = watermark
else:
source_url = ""
result_url = await _store_image(claim.job_id, position, result.data, "result")
watermark_config = None
asset_id: str | None = None
if payload.get("append") and claim.product_id:
image = TaskImage(
type_id=job_item.get("kind", "custom"),
name=job_item.get("title") or "AI 生图",
url=result_url,
source_url=source_url,
watermark_config=watermark_config,
)
try:
asset_id = await append_generated_asset(
claim.product_id, image, after_asset_id=payload.get("after_asset_id"),
)
except Exception:
# 回写失败时保留 suites 临时文件和失败状态,避免用户看不到实际结果而又无法诊断。
raise
result_url, source_url, watermark_config = image.url, image.source_url, image.watermark_config
await mark_item_done(
claim.item_id, result_url=result_url, source_url=source_url or None,
watermark_config=watermark_config, generated_asset_id=asset_id,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if claim.provider_task_id and claim.provider_task_kind:
log.warning(
"供应商轮询网络暂时失败,%ds 后重试同一任务 %s:%s",
PROVIDER_POLL_RETRY_DELAY_SECONDS, claim.provider_task_id, exc.__class__.__name__,
)
await mark_waiting_provider(
claim.item_id,
claim.provider_task_id,
claim.provider_task_kind,
delay_seconds=PROVIDER_POLL_RETRY_DELAY_SECONDS,
)
return
log.exception("后台图片任务 %s/%s 网络请求失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc) or exc.__class__.__name__)
except Exception as exc: # noqa: BLE001
log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc))
async def run_worker(*, once: bool = False, idle_seconds: float = 0.75) -> None:
owner = _worker_owner()
recovered = await recover_expired_leases()
if recovered:
log.warning("恢复了 %d 个过期后台任务租约", recovered)
log.info("后台 Worker 已启动:%s", owner)
while True:
claim = await claim_next_item(owner, lease_seconds=get_settings().background_job_lease_seconds)
if claim is None:
if once:
return
await asyncio.sleep(idle_seconds)
continue
await process_item(claim)
if once:
return