Ctrl K
ComunidadGuías…

src/imagegen/service.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Provider-neutral system image-generation service."""

from __future__ import annotations

import asyncio
import ipaddress
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

from .assets import ImageAssetError, ImageAssetStore
from .contracts import IMAGE_PROVIDER_IDS, IMAGE_PURPOSES, ImageGenerationRequest, ImageGenerationResult
from .providers import ImageProvider, ImageProviderError, create_image_provider
from .reference_sheet import ReferenceSheetError, combine_portrait_references
from .storyboards import StoryboardInferenceError, build_storyboard_prompt


PURPOSE_PROMPT_SUFFIXES = {
    "scene": "Wide cinematic environment scene, no text, no interface elements.",
    "avatar": "Single character portrait, centered composition, clear face, no text, no frame.",
    "item": "Single isolated item illustration, centered composition, no text, no interface elements.",
    "map": "Simple practical top-down tabletop map, clear terrain, rooms and paths, restrained colors, no labels, no interface elements.",
    "freeform": "No text or interface elements unless explicitly requested.",
}

DEFAULT_PROMPT_CHAR_LIMIT = 12_000
MINIMAX_STORYBOARD_CHAR_BUDGET = 1_000


class ImageGenerationError(RuntimeError):
    pass


class ImageGenerationService:
    def __init__(self, config: dict[str, Any], assets_dir: Path, *, proxy_url: str = "") -> None:
        self.enabled = bool(config.get("imagegen_enabled", False))
        self.provider_id = str(config.get("imagegen_provider") or "openai-compatible").strip()
        self.base_url = str(config.get("imagegen_base_url") or "").strip()
        self.api_key = str(config.get("imagegen_api_key") or "").strip()
        self.model = str(config.get("imagegen_model") or "").strip()
        self.square_size = str(config.get("imagegen_square_size") or "1024x1024").strip()
        self.landscape_size = str(config.get("imagegen_landscape_size") or "1792x1024").strip()
        self.quality = str(config.get("imagegen_quality") or "").strip()
        self.style_prefix = str(config.get("imagegen_style_prefix") or "").strip()
        self.timeout_seconds = float(config.get("imagegen_timeout_seconds") or 120)
        self.auto_scene = bool(config.get("imagegen_auto_scene", True))
        self.manual_scene = bool(config.get("imagegen_manual_scene", False))
        self.auto_storyboard = bool(config.get("imagegen_auto_storyboard", False))
        self.auto_use_manual_prompt = bool(config.get("imagegen_auto_use_manual_prompt", True))
        self.manual_rules = str(config.get("imagegen_manual_rules") or "").strip()
        self.manual_prompt = str(config.get("imagegen_manual_prompt") or "").strip()
        self.auto_rules = str(config.get("imagegen_auto_rules") or "").strip()
        self.auto_prompt = str(config.get("imagegen_auto_prompt") or "").strip()
        self.proxy_url = "" if _is_local_endpoint(self.base_url) else str(proxy_url or "").strip()
        self.assets = ImageAssetStore(assets_dir)
        self._semaphore = asyncio.Semaphore(2)
        self._validate_config()

    @property
    def available(self) -> bool:
        return self.enabled and bool(self.base_url and self.model)

    def public_config(self) -> dict[str, Any]:
        provider = self._provider()
        return {
            "enabled": self.enabled,
            "available": self.available,
            "provider": provider.provider_id,
            "model": self.model,
            "auto_scene": self.auto_scene,
            "prompt_char_limit": int(
                getattr(provider, "prompt_char_limit", DEFAULT_PROMPT_CHAR_LIMIT)
            ),
        }

    async def generate(self, request: ImageGenerationRequest) -> ImageGenerationResult:
        if not self.available:
            raise ImageGenerationError("系统图像生成尚未配置或启用")
        purpose = str(request.purpose or "").strip().lower()
        if purpose not in IMAGE_PURPOSES:
            raise ImageGenerationError("不支持的图片用途")
        prompt = str(request.prompt or "").strip()
        original_prompt = prompt
        if not prompt:
            raise ImageGenerationError("画面描述为空")
        if len(prompt) > 8000:
            raise ImageGenerationError("画面描述不能超过 8000 个字符")
        provider = self._provider()
        prompt_limit = int(
            getattr(provider, "prompt_char_limit", DEFAULT_PROMPT_CHAR_LIMIT)
        )
        storyboard = request.context.get("storyboard") if isinstance(request.context, dict) else None
        storyboard_metadata: dict[str, Any] = {}
        if purpose == "scene" and isinstance(storyboard, dict):
            try:
                storyboard_prompt, storyboard_metadata = build_storyboard_prompt(
                    storyboard.get("panels"),
                    global_prompt=prompt,
                    character_appearances=storyboard.get("character_appearances"),
                    max_chars=(
                        MINIMAX_STORYBOARD_CHAR_BUDGET
                        if prompt_limit <= 1_500
                        else 8_000
                    ),
                )
            except StoryboardInferenceError as exc:
                raise ImageGenerationError(str(exc)) from exc
            try:
                storyboard_metadata["compressed_count"] += max(0, int(storyboard.get("compressed_count") or 0))
            except (TypeError, ValueError):
                pass
            prompt = storyboard_prompt
        size = self._size_for(request, purpose)
        try:
            references = tuple(request.reference_images or ())
            if references and purpose != "scene":
                raise ImageGenerationError("头像参考图仅支持场景生图,地图生图不会上传头像")
            if references and not getattr(provider, "supports_reference_images", False):
                raise ImageGenerationError("当前图像服务商不支持头像参考图,请关闭该选项后重试")
            if len(references) > 8:
                raise ImageGenerationError("头像参考图最多支持 8 张")
            total_bytes = 0
            for reference in references:
                content = bytes(reference.content or b"")
                if not content or len(content) > 3 * 1024 * 1024:
                    raise ImageGenerationError("头像参考图无效或超过 3 MB")
                total_bytes += len(content)
            if total_bytes > 12 * 1024 * 1024:
                raise ImageGenerationError("头像参考图总大小不能超过 12 MB")
            if references:
                request.context["reference_character_ids"] = list(dict.fromkeys(
                    str(reference.character_id or "")[:80] for reference in references if str(reference.character_id or "").strip()
                ))
                request.context["reference_source_count"] = len(references)
                request.context["reference_combined"] = (
                    len(references) > 1 and request.context.get("combine_avatar_references", True) is True
                )
                # The numbered sheet and text mapping share the actual file
                # order, never roster order or unverified participant names.
                names = request.context.get("avatar_reference_names") or {}
                request.context["avatar_reference_names"] = {
                    reference.character_id: names.get(reference.character_id) or reference.character_id
                    for reference in references
                }
                if request.context["reference_combined"]:
                    references = (await asyncio.to_thread(combine_portrait_references, references),)
                request.context["reference_count"] = len(references)
            composed_prompt, prompt_budget = self._compose_prompt(
                prompt, purpose, request.style, request.context, prompt_limit,
            )
            request.context["prompt_budget"] = prompt_budget
            async with self._semaphore:
                if references:
                    generated = await provider.generate(composed_prompt, size=size, quality=self.quality, reference_images=references)
                else:
                    generated = await provider.generate(composed_prompt, size=size, quality=self.quality)
            body = generated.body
            if storyboard_metadata:
                request.context.setdefault("storyboard", {}).update(storyboard_metadata)
            return self.assets.store(
                body,
                purpose=purpose,
                prompt=original_prompt,
                revised_prompt=generated.revised_prompt,
                provider=getattr(provider, "provider_id", self.provider_id),
                model=self.model,
                owner_type=request.owner_type,
                owner_id=request.owner_id,
                context=request.context,
            )
        except (ImageProviderError, ImageAssetError, ReferenceSheetError, StoryboardInferenceError) as exc:
            raise ImageGenerationError(str(exc)) from exc

    def _provider(self) -> ImageProvider:
        kwargs = {
            "base_url": self.base_url,
            "api_key": self.api_key,
            "model": self.model,
            "timeout_seconds": self.timeout_seconds,
            "proxy_url": self.proxy_url,
            "provider_id": self.provider_id,
        }
        try:
            return create_image_provider(**kwargs)
        except ImageProviderError as exc:
            raise ImageGenerationError(str(exc)) from exc

    def preview_prompt(self, prompt: str, purpose: str, context: dict[str, Any], request_style: str = "") -> tuple[str, dict[str, Any]]:
        provider = self._provider()
        return self._compose_prompt(prompt, purpose, request_style, context, int(getattr(provider, "prompt_char_limit", DEFAULT_PROMPT_CHAR_LIMIT)))

    def _compose_prompt(
        self,
        prompt: str,
        purpose: str,
        request_style: str,
        context: dict[str, Any],
        prompt_limit: int = DEFAULT_PROMPT_CHAR_LIMIT,
    ) -> tuple[str, dict[str, Any]]:
        configured_rules = self.manual_rules if context.get("manual") else self.auto_rules
        configured_prompt = self.manual_prompt if context.get("manual") else self.auto_prompt
        if not context.get("manual") and self.auto_use_manual_prompt:
            configured_rules = self.manual_rules or self.auto_rules
            configured_prompt = self.manual_prompt or self.auto_prompt
        replacements = {
            "scene": str(context.get("scene") or ""),
            "narration": str(context.get("narration") or ""),
            "actions": str(context.get("actions") or ""),
            "panels": str(context.get("panels") or context.get("storyboard") or ""),
        }
        def render(value: str) -> str:
            for key, replacement in replacements.items():
                value = value.replace("{" + key + "}", replacement)
            return value
        segments = {
            "style_prefix": self.style_prefix,
            "request_style": str(request_style or "").strip(),
            "rules": render(configured_rules),
            "template": render(configured_prompt),
            "scene": prompt,
            "purpose": PURPOSE_PROMPT_SUFFIXES[purpose],
        }
        reference_names = context.get("avatar_reference_names")
        if isinstance(reference_names, dict) and reference_names:
            combined = len(reference_names) > 1 and context.get("combine_avatar_references", True) is True
            segments["reference_labels"] = (
                "The single uploaded portrait sheet maps "
                if combined else "Uploaded portrait references correspond to these visible characters: "
            ) + ", ".join(
                (f"Ref {index}: " if combined else "") + f"{str(name)[:100]} ({str(uid)[:80]})"
                for index, (uid, name) in enumerate(reference_names.items(), 1) if str(name).strip()
            ) + ". Keep their identity and appearance consistent."
            if combined:
                segments["reference_labels"] += (
                    " The sheet is for character identity only, not composition. Do not copy its grid, "
                    "labels or portrait count into the output; use the requested scene panels instead."
                )
        reduced: list[str] = []

        def compose() -> str:
            return "\n\n".join(part for part in segments.values() if part)

        prompt_limit = max(
            len(segments["purpose"]),
            int(prompt_limit or DEFAULT_PROMPT_CHAR_LIMIT),
        )
        for key in ("style_prefix", "rules", "template", "request_style", "scene"):
            overflow = len(compose()) - prompt_limit
            if overflow <= 0:
                break
            current = segments.get(key, "")
            if not current:
                continue
            storyboard = context.get("storyboard")
            if key == "scene" and isinstance(storyboard, dict) and storyboard.get("panels"):
                available = len(current) - overflow
                if available < 256:
                    raise ImageGenerationError("提示词预算不足以保留全部分镜和头像标识,请缩短人物或地点名称")
                try:
                    segments[key], _ = build_storyboard_prompt(
                        storyboard["panels"], max_chars=available,
                        character_appearances=storyboard.get("character_appearances"),
                    )
                except StoryboardInferenceError as exc:
                    raise ImageGenerationError(str(exc)) from exc
            else:
                segments[key] = current[:max(0, len(current) - overflow)]
            reduced.append(key)

        composed = compose()
        if len(composed) > prompt_limit:
            raise ImageGenerationError("提示词预算不足以保留全部分镜和头像标识,请缩短人物或地点名称")
        return composed, {
            "limit": prompt_limit,
            "used": len(composed),
            "adjusted": bool(reduced),
            "reduced_segments": reduced,
        }

    def _size_for(self, request: ImageGenerationRequest, purpose: str) -> str:
        ratio = str(request.aspect_ratio or "").strip()
        if ratio in {"1:1", "square"} or purpose in {"avatar", "item"}:
            return self.square_size
        return self.landscape_size

    def _validate_config(self) -> None:
        if self.provider_id not in IMAGE_PROVIDER_IDS:
            raise ValueError(f"不支持的图像生成 provider:{self.provider_id}")
        if self.provider_id == "minimax" and self.model and self.model != "image-01":
            raise ValueError("MiniMax 图像生成目前仅支持 image-01")
        if not self.enabled:
            return
        if self.base_url:
            parsed = urlparse(self.base_url)
            if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
                raise ValueError("图像生成 Base URL 必须是无内嵌凭据的 http(s) 地址")


def _is_local_endpoint(value: str) -> bool:
    hostname = (urlparse(value).hostname or "").strip().lower()
    if hostname in {"localhost", "host.docker.internal"} or hostname.endswith(".local"):
        return True
    try:
        return ipaddress.ip_address(hostname).is_private
    except ValueError:
        return False