|
16 | 16 | import site |
17 | 17 | import secrets |
18 | 18 | import re |
| 19 | +from io import BytesIO |
19 | 20 | from urllib.parse import parse_qs, urlencode |
20 | 21 |
|
21 | 22 | import aiohttp |
|
24 | 25 | from pathlib import Path |
25 | 26 | from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8) |
26 | 27 |
|
| 28 | +try: |
| 29 | + from PIL import Image |
| 30 | + HAS_PIL = True |
| 31 | +except ImportError: |
| 32 | + HAS_PIL = False |
| 33 | + |
27 | 34 | VERSION = "2.0.27" |
28 | 35 | _ROOT = None |
29 | 36 | g_config_path = None |
@@ -201,6 +208,77 @@ def price_to_string(price: float | int | str | None) -> str | None: |
201 | 208 | except (ValueError, TypeError): |
202 | 209 | return None |
203 | 210 |
|
| 211 | +def convert_image_if_needed(image_bytes, mimetype='image/png'): |
| 212 | + """ |
| 213 | + Convert and resize image to WebP if it exceeds configured limits. |
| 214 | +
|
| 215 | + Args: |
| 216 | + image_bytes: Raw image bytes |
| 217 | + mimetype: Original image MIME type |
| 218 | +
|
| 219 | + Returns: |
| 220 | + tuple: (converted_bytes, new_mimetype) or (original_bytes, original_mimetype) if no conversion needed |
| 221 | + """ |
| 222 | + if not HAS_PIL: |
| 223 | + return image_bytes, mimetype |
| 224 | + |
| 225 | + # Get conversion config |
| 226 | + convert_config = g_config.get('convert', {}).get('image', {}) if g_config else {} |
| 227 | + if not convert_config: |
| 228 | + return image_bytes, mimetype |
| 229 | + |
| 230 | + max_size_str = convert_config.get('max_size', '1536x1024') |
| 231 | + max_length = convert_config.get('max_length', 1.5*1024*1024) # 1.5MB |
| 232 | + |
| 233 | + try: |
| 234 | + # Parse max_size (e.g., "1536x1024") |
| 235 | + max_width, max_height = map(int, max_size_str.split('x')) |
| 236 | + |
| 237 | + # Open image |
| 238 | + with Image.open(BytesIO(image_bytes)) as img: |
| 239 | + original_width, original_height = img.size |
| 240 | + |
| 241 | + # Check if image exceeds limits |
| 242 | + needs_resize = original_width > max_width or original_height > max_height |
| 243 | + |
| 244 | + # Check if base64 length would exceed max_length (in KB) |
| 245 | + # Base64 encoding increases size by ~33%, so check raw bytes * 1.33 / 1024 |
| 246 | + estimated_kb = (len(image_bytes) * 1.33) / 1024 |
| 247 | + needs_conversion = estimated_kb > max_length |
| 248 | + |
| 249 | + if not needs_resize and not needs_conversion: |
| 250 | + return image_bytes, mimetype |
| 251 | + |
| 252 | + # Convert RGBA to RGB if necessary (WebP doesn't support transparency in RGB mode) |
| 253 | + if img.mode in ('RGBA', 'LA', 'P'): |
| 254 | + # Create a white background |
| 255 | + background = Image.new('RGB', img.size, (255, 255, 255)) |
| 256 | + if img.mode == 'P': |
| 257 | + img = img.convert('RGBA') |
| 258 | + background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) |
| 259 | + img = background |
| 260 | + elif img.mode != 'RGB': |
| 261 | + img = img.convert('RGB') |
| 262 | + |
| 263 | + # Resize if needed (preserve aspect ratio) |
| 264 | + if needs_resize: |
| 265 | + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) |
| 266 | + _log(f"Resized image from {original_width}x{original_height} to {img.size[0]}x{img.size[1]}") |
| 267 | + |
| 268 | + # Convert to WebP |
| 269 | + output = BytesIO() |
| 270 | + img.save(output, format='WEBP', quality=85, method=6) |
| 271 | + converted_bytes = output.getvalue() |
| 272 | + |
| 273 | + _log(f"Converted image to WebP: {len(image_bytes)} bytes -> {len(converted_bytes)} bytes ({len(converted_bytes)*100//len(image_bytes)}%)") |
| 274 | + |
| 275 | + return converted_bytes, 'image/webp' |
| 276 | + |
| 277 | + except Exception as e: |
| 278 | + _log(f"Error converting image: {e}") |
| 279 | + # Return original if conversion fails |
| 280 | + return image_bytes, mimetype |
| 281 | + |
204 | 282 | async def process_chat(chat): |
205 | 283 | if not chat: |
206 | 284 | raise Exception("No chat provided") |
@@ -231,19 +309,31 @@ async def process_chat(chat): |
231 | 309 | mimetype = get_file_mime_type(get_filename(url)) |
232 | 310 | if 'Content-Type' in response.headers: |
233 | 311 | mimetype = response.headers['Content-Type'] |
| 312 | + # convert/resize image if needed |
| 313 | + content, mimetype = convert_image_if_needed(content, mimetype) |
234 | 314 | # convert to data uri |
235 | 315 | image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}" |
236 | 316 | elif is_file_path(url): |
237 | 317 | _log(f"Reading image: {url}") |
238 | 318 | with open(url, "rb") as f: |
239 | 319 | content = f.read() |
240 | | - ext = os.path.splitext(url)[1].lower().lstrip('.') if '.' in url else 'png' |
241 | 320 | # get mimetype from file extension |
242 | 321 | mimetype = get_file_mime_type(get_filename(url)) |
| 322 | + # convert/resize image if needed |
| 323 | + content, mimetype = convert_image_if_needed(content, mimetype) |
243 | 324 | # convert to data uri |
244 | 325 | image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}" |
245 | 326 | elif url.startswith('data:'): |
246 | | - pass |
| 327 | + # Extract existing data URI and process it |
| 328 | + if ';base64,' in url: |
| 329 | + prefix = url.split(';base64,')[0] |
| 330 | + mimetype = prefix.split(':')[1] if ':' in prefix else 'image/png' |
| 331 | + base64_data = url.split(';base64,')[1] |
| 332 | + content = base64.b64decode(base64_data) |
| 333 | + # convert/resize image if needed |
| 334 | + content, mimetype = convert_image_if_needed(content, mimetype) |
| 335 | + # update data uri with potentially converted image |
| 336 | + image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}" |
247 | 337 | else: |
248 | 338 | raise Exception(f"Invalid image: {url}") |
249 | 339 | elif item['type'] == 'input_audio' and 'input_audio' in item: |
@@ -1481,7 +1571,9 @@ def main(): |
1481 | 1571 |
|
1482 | 1572 | _log("Authentication enabled - GitHub OAuth configured") |
1483 | 1573 |
|
1484 | | - app = web.Application() |
| 1574 | + client_max_size = g_config.get('limits', {}).get('client_max_size', 20*1024*1024) # 20MB max request size (to handle base64 encoding overhead) |
| 1575 | + _log(f"client_max_size set to {client_max_size} bytes ({client_max_size/1024/1024:.1f}MB)") |
| 1576 | + app = web.Application(client_max_size=client_max_size) |
1485 | 1577 |
|
1486 | 1578 | # Authentication middleware helper |
1487 | 1579 | def check_auth(request): |
|
0 commit comments