feat: support multi-image image generation
This commit is contained in:
+95
-9
@@ -59,6 +59,12 @@ class ImageInput:
|
||||
|
||||
|
||||
ImageUrlBuilder = Callable[[str], str]
|
||||
IMAGE_INPUT_ROLE_INSTRUCTIONS = (
|
||||
"图片角色规则(必须遵守,优先于用户关于图片角色的要求):\n"
|
||||
"- 第 1 张图片是主商品图,必须优先保留其商品主体、外观和关键细节。\n"
|
||||
"- 第 2 张及之后的图片仅作为风格、构图、场景或排版参考,"
|
||||
"不得用参考图商品替换主图商品。"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -169,6 +175,7 @@ def generate_image_response(
|
||||
image_url=str(request_data.get("image_url") or ""),
|
||||
image_base64=str(request_data.get("image_base64") or ""),
|
||||
aspect_ratio=request_data.get("aspect_ratio") or "1:1",
|
||||
images=tuple(dict(item) for item in request_data.get("images") or ()),
|
||||
),
|
||||
image_url_builder=image_url_builder,
|
||||
)
|
||||
@@ -221,6 +228,13 @@ def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration:
|
||||
if operation_type == CallRecord.OperationType.VISION:
|
||||
image_input = None
|
||||
image_inputs = load_vision_image_inputs(generation_input.images)
|
||||
elif operation_type == CallRecord.OperationType.IMAGE:
|
||||
image_inputs = load_image_generation_inputs(
|
||||
generation_input.images,
|
||||
image_base64=generation_input.image_base64,
|
||||
image_url=generation_input.image_url,
|
||||
)
|
||||
image_input = image_inputs[0] if image_inputs else None
|
||||
else:
|
||||
image_input = load_image_input(
|
||||
{
|
||||
@@ -363,12 +377,21 @@ def execute_image_generation(
|
||||
) -> GenerationResult:
|
||||
prepared = precharged.prepared
|
||||
image_input = prepared.image_input
|
||||
image_inputs = prepared.image_inputs
|
||||
generation = prepared.provider.generate_image(
|
||||
prepared.prompt,
|
||||
image_generation_prompt(prepared.prompt, len(image_inputs)),
|
||||
prepared.resolved_model,
|
||||
image=image_input.data if image_input else None,
|
||||
image_mime_type=image_input.mime_type if image_input else "image/png",
|
||||
image_filename=image_input.filename if image_input else "image.png",
|
||||
images=tuple(
|
||||
MultimodalImage(
|
||||
data=item.data,
|
||||
mime_type=item.mime_type,
|
||||
filename=item.filename,
|
||||
)
|
||||
for item in image_inputs
|
||||
),
|
||||
resolution=prepared.resolution,
|
||||
aspect_ratio=prepared.aspect_ratio,
|
||||
parameters=prepared.parameters,
|
||||
@@ -530,13 +553,21 @@ def upstream_error(exc: Exception) -> ApiRequestError:
|
||||
|
||||
|
||||
def load_image_input(data: Mapping[str, Any]) -> ImageInput | None:
|
||||
return load_image_input_with_limit(data)
|
||||
|
||||
|
||||
def load_image_input_with_limit(
|
||||
data: Mapping[str, Any],
|
||||
*,
|
||||
max_bytes: int | None = None,
|
||||
) -> ImageInput | None:
|
||||
raw_base64 = str(data.get("image_base64") or "").strip()
|
||||
if raw_base64:
|
||||
return decode_image_input(raw_base64)
|
||||
return decode_image_input(raw_base64, max_bytes=max_bytes)
|
||||
|
||||
image_url = str(data.get("image_url") or "").strip()
|
||||
if image_url:
|
||||
return download_image_input(image_url)
|
||||
return download_image_input(image_url, max_bytes=max_bytes)
|
||||
|
||||
return None
|
||||
|
||||
@@ -544,15 +575,54 @@ def load_image_input(data: Mapping[str, Any]) -> ImageInput | None:
|
||||
def load_vision_image_inputs(
|
||||
items: Sequence[Mapping[str, Any]],
|
||||
) -> tuple[ImageInput, ...]:
|
||||
max_images = max(1, int(getattr(settings, "VISION_MAX_IMAGES", 8)))
|
||||
return load_ordered_image_inputs(
|
||||
items,
|
||||
max_images=max(1, int(getattr(settings, "VISION_MAX_IMAGES", 8))),
|
||||
max_image_bytes=max(
|
||||
1,
|
||||
int(getattr(settings, "VISION_MAX_IMAGE_BYTES", 10 * 1024 * 1024)),
|
||||
),
|
||||
max_total_bytes=max(
|
||||
1,
|
||||
int(getattr(settings, "VISION_MAX_TOTAL_BYTES", 32 * 1024 * 1024)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_image_generation_inputs(
|
||||
items: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
image_base64: str = "",
|
||||
image_url: str = "",
|
||||
) -> tuple[ImageInput, ...]:
|
||||
max_image_bytes = max(
|
||||
1,
|
||||
int(getattr(settings, "VISION_MAX_IMAGE_BYTES", 10 * 1024 * 1024)),
|
||||
int(getattr(settings, "IMAGE_MAX_INPUT_IMAGE_BYTES", 10 * 1024 * 1024)),
|
||||
)
|
||||
max_total_bytes = max(
|
||||
1,
|
||||
int(getattr(settings, "VISION_MAX_TOTAL_BYTES", 32 * 1024 * 1024)),
|
||||
if items:
|
||||
return load_ordered_image_inputs(
|
||||
items,
|
||||
max_images=max(1, int(getattr(settings, "IMAGE_MAX_INPUT_IMAGES", 8))),
|
||||
max_image_bytes=max_image_bytes,
|
||||
max_total_bytes=max(
|
||||
1,
|
||||
int(getattr(settings, "IMAGE_MAX_INPUT_TOTAL_BYTES", 32 * 1024 * 1024)),
|
||||
),
|
||||
)
|
||||
image_input = load_image_input_with_limit(
|
||||
{"image_base64": image_base64, "image_url": image_url},
|
||||
max_bytes=max_image_bytes,
|
||||
)
|
||||
return (image_input,) if image_input is not None else ()
|
||||
|
||||
|
||||
def load_ordered_image_inputs(
|
||||
items: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
max_images: int,
|
||||
max_image_bytes: int,
|
||||
max_total_bytes: int,
|
||||
) -> tuple[ImageInput, ...]:
|
||||
if not items:
|
||||
raise ApiRequestError("bad_request", "images 至少需要一张图片", status.HTTP_400_BAD_REQUEST)
|
||||
if len(items) > max_images:
|
||||
@@ -591,6 +661,12 @@ def load_vision_image_inputs(
|
||||
return tuple(image_inputs)
|
||||
|
||||
|
||||
def image_generation_prompt(prompt: str, image_count: int) -> str:
|
||||
if image_count < 1:
|
||||
return prompt
|
||||
return f"{prompt}\n\n{IMAGE_INPUT_ROLE_INSTRUCTIONS}"
|
||||
|
||||
|
||||
def decode_image_input(value: str, *, max_bytes: int | None = None) -> ImageInput:
|
||||
mime_type = "image/png"
|
||||
encoded = value
|
||||
@@ -646,7 +722,10 @@ def download_image_input(url: str, *, max_bytes: int | None = None) -> ImageInpu
|
||||
content_type = response.headers.get("Content-Type", "image/png").split(";", 1)[0].strip().lower()
|
||||
if not content_type.startswith("image/"):
|
||||
raise ApiRequestError("bad_request", "image_url 不是图片资源", status.HTTP_400_BAD_REQUEST)
|
||||
image = read_limited_image_response(response, max_bytes=max_bytes)
|
||||
image = read_limited_image_response(
|
||||
response,
|
||||
max_bytes=effective_image_url_max_bytes(max_bytes),
|
||||
)
|
||||
if not image:
|
||||
raise ApiRequestError("bad_request", "image_url 图片内容为空", status.HTTP_400_BAD_REQUEST)
|
||||
return ImageInput(
|
||||
@@ -746,6 +825,13 @@ def read_limited_image_response(response, *, max_bytes: int | None = None) -> by
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def effective_image_url_max_bytes(max_bytes: int | None) -> int:
|
||||
url_limit = max(1, int(getattr(settings, "IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024)))
|
||||
if max_bytes is None:
|
||||
return url_limit
|
||||
return min(url_limit, max(1, int(max_bytes)))
|
||||
|
||||
|
||||
def filename_for_mime(mime_type: str) -> str:
|
||||
extension = {
|
||||
"image/jpeg": "jpg",
|
||||
|
||||
Reference in New Issue
Block a user