"""阿里云百炼背景图生成服务。""" from __future__ import annotations import logging from pathlib import Path from typing import Any import httpx from app.core.config import get_settings from app.core.errors import DependencyUnavailableError, UpstreamTimeoutError from app.service.model_gateway import EnvironmentSecretResolver logger = logging.getLogger(__name__) class PromotionImageService: """生成无文字底图,供渲染器美化整页与内容面板。""" def __init__(self) -> None: settings = get_settings() self.enabled = settings.promotion_image_enabled self.api_key = "" if self.enabled: try: self.api_key = EnvironmentSecretResolver().resolve( "env:DASHSCOPE_API_KEY" ) except Exception: logger.warning("DASHSCOPE_API_KEY 未配置,背景图生成已降级", exc_info=True) self.enabled = False self.base_url = settings.promotion_image_base_url.rstrip("/") self.model = settings.promotion_image_model self.timeout_seconds = settings.promotion_image_timeout_seconds self.poll_interval_seconds = settings.promotion_image_poll_interval_seconds async def generate_background( self, *, output_path: str | Path, style_code: str, background_theme: str, fund_type: str, ) -> str | None: if not self.enabled or not self.api_key: return None prompt = self._build_prompt( style_code=style_code, background_theme=background_theme, fund_type=fund_type, ) try: async with httpx.AsyncClient() as client: task_id = await self._submit(client, prompt) image_url = await self._poll(client, task_id) response = await client.get( image_url, timeout=httpx.Timeout(self.timeout_seconds), ) response.raise_for_status() destination = Path(output_path) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(self._tone_down_background(response.content)) return str(destination) except httpx.TimeoutException: logger.warning("阿里云背景图生成超时,使用程序化背景", exc_info=True) return None except ( httpx.HTTPError, DependencyUnavailableError, UpstreamTimeoutError, KeyError, TypeError, ValueError, ): logger.warning("阿里云背景图生成失败,使用程序化背景", exc_info=True) return None async def _submit(self, client: httpx.AsyncClient, prompt: str) -> str: response = await client.post( f"{self.base_url}/api/v1/services/aigc/text2image/image-synthesis", headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", "X-DashScope-Async": "enable", }, json={ "model": self.model, "input": {"prompt": prompt}, "parameters": {"size": "1024*1024", "n": 1}, }, timeout=httpx.Timeout(self.timeout_seconds), ) response.raise_for_status() body: Any = response.json() task_id = body.get("output", {}).get("task_id") if isinstance(body, dict) else None if not isinstance(task_id, str) or not task_id: raise DependencyUnavailableError("阿里云背景图任务响应缺少 task_id") return task_id async def _poll(self, client: httpx.AsyncClient, task_id: str) -> str: url = f"{self.base_url}/api/v1/tasks/{task_id}" max_polls = max(1, int(self.timeout_seconds / self.poll_interval_seconds)) for _ in range(max_polls): response = await client.get( url, headers={"Authorization": f"Bearer {self.api_key}"}, timeout=httpx.Timeout(self.timeout_seconds), ) response.raise_for_status() body: Any = response.json() output = body.get("output", {}) if isinstance(body, dict) else {} status = output.get("task_status") if status == "SUCCEEDED": results = output.get("results") or [] image_url = results[0].get("url") if results else None if isinstance(image_url, str) and image_url: return image_url raise DependencyUnavailableError("阿里云背景图任务缺少图片地址") if status in {"FAILED", "CANCELED", "UNKNOWN"}: raise DependencyUnavailableError("阿里云背景图任务未成功") await self._sleep() raise UpstreamTimeoutError("阿里云背景图任务轮询超时") async def _sleep(self) -> None: import asyncio await asyncio.sleep(self.poll_interval_seconds) @staticmethod def _tone_down_background(payload: bytes) -> bytes: """将模型背景处理成低干扰底图,避免纹理压过材料正文。""" import io from PIL import Image, ImageEnhance, ImageFilter with Image.open(io.BytesIO(payload)) as source: image = source.convert("RGB") image = ImageEnhance.Color(image).enhance(0.22) image = ImageEnhance.Contrast(image).enhance(0.30) image = ImageEnhance.Brightness(image).enhance(1.22) image = image.filter(ImageFilter.GaussianBlur(radius=1.0)) output = io.BytesIO() image.save(output, format="PNG", optimize=True) return output.getvalue() @staticmethod def _build_prompt( *, style_code: str, background_theme: str, fund_type: str, ) -> str: style = { "steady_professional": "稳健、克制、专业的暖色金融视觉", "growth_research": "理性、现代、研究感的蓝色金融视觉", "balanced_allocation": "平衡、清晰、专业的蓝金金融视觉", }.get(style_code, "专业、克制、清晰的金融视觉") theme = { "data_lines": "抽象数据线、轻量节点和流动曲线", "geometric_grid": "低对比几何网格、细线和少量节点", "soft_wave": "柔和抽象波形和层次光影", }.get(background_theme, "低对比抽象几何纹理") return ( "Use case: productivity-visual. " "为基金产品推介材料生成可作为 PPT 和宣传长图底图的抽象背景。" f"基金类型语境:{fund_type}。视觉风格:{style}。主要元素:{theme}。" "宽松留白,主体内容区域保持低对比,适合叠加中文文字和数据图表;" "画面主色应可用于提取同色系的半透明文字板块背景。" "只生成背景和装饰纹理,不生成任何文字、数字、字母、Logo、人物、头像、" "基金图表、表格、产品名称、收益率、金融承诺、印章或水印。" )