返回提交历史
Modified
g4f/image/__init__.py
+180
-127
Modified
g4f/image/copy_images.py
+1
-1
XFEstudio/gpt4free
fix: revert image module changes
6639eadc
代码差异
2 个文件
+181
-128
@@ -4,6 +4,8 @@ import os
4
4
import re
5
5
import io
6
6
import base64
7
import socket
8
import ipaddress
7
9
from io import BytesIO
8
10
from pathlib import Path
9
11
from typing import Optional
@@ -11,6 +13,11 @@ from urllib.parse import urlparse
11
13
12
14
import requests
13
15
16
try:
17
from urllib3.util import parse_url as urllib3_parse_url
18
except ImportError:
19
urllib3_parse_url = None
20
14
21
try:
15
22
from PIL import Image, ImageOps
16
23
has_requirements = True
@@ -24,42 +31,40 @@ from ..files import get_bucket_dir
24
31
EXTENSIONS_MAP: dict[str, str] = {
25
32
# Image
26
33
"jpeg": "image/jpeg",
27
"jpg": "image/jpeg",
28
"png": "image/png",
29
"gif": "image/gif",
34
"jpg": "image/jpeg",
35
"png": "image/png",
36
"gif": "image/gif",
30
37
"webp": "image/webp",
31
"bmp": "image/bmp",
32
"tiff": "image/tiff",
33
"tif": "image/tiff",
34
"ico": "image/x-icon",
35
"svg": "image/svg+xml",
36
"avif": "image/avif",
37
"heic": "image/heif",
38
38
# Audio
39
"wav": "audio/wav",
40
"mp3": "audio/mpeg",
39
"wav": "audio/wav",
40
"mp3": "audio/mpeg",
41
41
"flac": "audio/flac",
42
42
"opus": "audio/opus",
43
"ogg": "audio/ogg",
44
"m4a": "audio/mp4", # was "audio/m4a" — non-standard
45
# Video
46
"mkv": "video/x-matroska",
43
"ogg": "audio/ogg",
44
"m4a": "audio/m4a",
45
# Video
46
"mkv": "video/x-matroska",
47
47
"webm": "video/webm",
48
"mp4": "video/mp4",
48
"mp4": "video/mp4",
49
"mov": "video/quicktime",
50
"avi": "video/x-msvideo",
51
"ogv": "video/ogg",
52
"mpg": "video/mpeg",
53
"mpeg": "video/mpeg",
49
54
}
50
55
51
56
MEDIA_TYPE_MAP: dict[str, str] = {value: key for key, value in EXTENSIONS_MAP.items()}
52
57
MEDIA_TYPE_MAP["audio/webm"] = "webm"
53
# Handle duplicate image/jpeg (jpg wins over jpeg as the "canonical" extension)
54
MEDIA_TYPE_MAP["image/jpeg"] = "jpg"
55
56
# Audio formats accepted for input (OpenAI-compatible)
57
ACCEPTED_AUDIO_FORMATS = {"wav", "mp3", "flac", "ogg", "opus", "m4a"}
58
59
58
60
59
def to_image(image: ImageType, is_svg: bool = False) -> Image.Image:
61
60
"""
62
61
Converts the input image to a PIL Image object.
62
63
Args:
64
image (Union[str, bytes, Image]): The input image.
65
66
Returns:
67
Image: The converted PIL Image object.
63
68
"""
64
69
if not has_requirements:
65
70
raise MissingRequirementsError('Install "pillow" package for images')
@@ -89,17 +94,21 @@ def to_image(image: ImageType, is_svg: bool = False) -> Image.Image:
89
94
90
95
return image
91
96
92
93
97
def get_extension(filename: str) -> Optional[str]:
94
98
if '.' in filename:
95
99
ext = os.path.splitext(filename)[1].lower().lstrip('.')
96
100
return ext if ext in EXTENSIONS_MAP else None
97
101
return None
98
102
99
100
103
def is_allowed_extension(filename: str) -> Optional[str]:
101
104
"""
102
Returns the MIME type for allowed extensions, or None.
105
Checks if the given filename has an allowed extension.
106
107
Args:
108
filename (str): The filename to check.
109
110
Returns:
111
bool: True if the extension is allowed, False otherwise.
103
112
"""
104
113
extension = get_extension(filename)
105
114
if extension is None:
@@ -107,6 +116,44 @@ def is_allowed_extension(filename: str) -> Optional[str]:
107
116
return EXTENSIONS_MAP[extension]
108
117
109
118
119
def is_safe_url(url: str) -> bool:
120
"""Return True only for http/https URLs that do not point to private/loopback/reserved addresses."""
121
try:
122
parsed = urlparse(url)
123
124
if parsed.scheme not in ("http", "https"):
125
return False
126
127
if "\\" in url:
128
return False
129
130
hostname = parsed.hostname
131
if hostname is None:
132
return False
133
134
if urllib3_parse_url is not None:
135
parsed_urllib3 = urllib3_parse_url(url)
136
if parsed_urllib3.host and parsed_urllib3.host != hostname:
137
return False
138
hostname = parsed_urllib3.host or hostname
139
140
if hostname is None:
141
return False
142
143
addr_infos = socket.getaddrinfo(hostname, None)
144
if not addr_infos:
145
return False
146
147
for addr_info in addr_infos:
148
addr = ipaddress.ip_address(addr_info[4][0])
149
if (addr.is_private or addr.is_loopback or addr.is_link_local
150
or addr.is_reserved or addr.is_multicast or addr.is_unspecified):
151
return False
152
except Exception:
153
return False
154
return True
155
156
110
157
def is_data_an_media(data, filename: str = None) -> str:
111
158
content_type = is_data_an_audio(data, filename)
112
159
if content_type is not None:
@@ -121,7 +168,6 @@ def is_data_an_media(data, filename: str = None) -> str:
121
168
return "binary/octet-stream"
122
169
return is_data_uri_an_image(data)
123
170
124
125
171
def is_valid_media(data: ImageType = None, filename: str = None) -> str:
126
172
if is_valid_audio(data, filename):
127
173
return True
@@ -137,8 +183,7 @@ def is_valid_media(data: ImageType = None, filename: str = None) -> str:
137
183
return is_accepted_format(data)
138
184
return is_data_uri_an_image(data)
139
185
140
141
def is_data_an_audio(data_uri: str = None, filename: str = None) -> Optional[str]:
186
def is_data_an_audio(data_uri: str = None, filename: str = None) -> str:
142
187
if filename:
143
188
extension = get_extension(filename)
144
189
if extension is not None:
@@ -146,48 +191,49 @@ def is_data_an_audio(data_uri: str = None, filename: str = None) -> Optional[str
146
191
if media_type.startswith("audio/"):
147
192
return media_type
148
193
if isinstance(data_uri, str):
149
audio_format = re.match(r'^data:(audio/[\w+-]+);base64,', data_uri)
194
audio_format = re.match(r'^data:(audio/\w+);base64,', data_uri)
150
195
if audio_format:
151
196
return audio_format.group(1)
152
return None
153
154
197
155
198
def is_valid_audio(data_uri: str = None, filename: str = None) -> bool:
156
"""
157
Returns True if the media is a supported audio format.
158
Accepted: wav, mp3, flac, ogg, opus, m4a
159
"""
160
199
mimetype = is_data_an_audio(data_uri, filename)
161
200
if mimetype is None:
162
201
return False
163
ext = MEDIA_TYPE_MAP.get(mimetype)
164
return ext in ACCEPTED_AUDIO_FORMATS
165
202
if MEDIA_TYPE_MAP.get(mimetype) not in ("wav", "mp3"):
203
return False
204
return True
166
205
167
206
def is_data_uri_an_image(data_uri: str) -> bool:
168
207
"""
169
208
Checks if the given data URI represents an image.
170
209
210
Args:
211
data_uri (str): The data URI to check.
212
171
213
Raises:
172
214
ValueError: If the data URI is invalid or the image format is not allowed.
173
215
"""
174
216
if data_uri.startswith("https:") or data_uri.startswith("http:"):
175
217
return True
176
if not re.match(r'data:image/[\w+]+;base64,', data_uri):
218
# Check if the data URI starts with 'data:image' and contains an image format (e.g., jpeg, png, gif)
219
if not re.match(r'data:image/(\w+);base64,', data_uri):
177
220
raise ValueError(f"Invalid data URI image. {data_uri[:10]}...")
178
image_format = re.match(r'data:image/([\w+]+);base64,', data_uri).group(1).lower()
221
# Extract the image format from the data URI
222
image_format = re.match(r'data:image/(\w+);base64,', data_uri).group(1).lower()
223
# Check if the image format is one of the allowed formats (jpg, jpeg, png, gif)
179
224
if image_format not in EXTENSIONS_MAP and image_format != "svg+xml":
180
225
raise ValueError("Invalid image format (from mime file type).")
181
226
return True
182
227
183
184
228
def is_accepted_format(binary_data: bytes) -> str:
185
229
"""
186
230
Checks if the given binary data represents an image with an accepted format.
187
Returns the MIME type string.
231
232
Args:
233
binary_data (bytes): The binary data to check.
188
234
189
235
Raises:
190
ValueError: If the image format is not recognized.
236
ValueError: If the image format is not allowed.
191
237
"""
192
238
if binary_data.startswith(b'\xFF\xD8\xFF'):
193
239
return "image/jpeg"
@@ -205,16 +251,21 @@ def is_accepted_format(binary_data: bytes) -> str:
205
251
raise ValueError("Invalid image format (from magic code).")
206
252
207
253
208
def detect_file_type(binary_data: bytes) -> tuple[str, str]:
254
255
def detect_file_type(binary_data: bytes) -> tuple[str, str] | None:
209
256
"""
210
257
Detect file type from magic number / header signature.
211
258
259
Args:
260
binary_data (bytes): File binary data
261
212
262
Returns:
213
263
tuple: (extension, MIME type)
214
264
215
265
Raises:
216
ValueError: If file type is unknown or unsupported.
266
ValueError: If file type is unknown
217
267
"""
268
218
269
# ---- Images ----
219
270
if binary_data.startswith(b"\xff\xd8\xff"):
220
271
return ".jpg", "image/jpeg"
@@ -233,14 +284,12 @@ def detect_file_type(binary_data: bytes) -> tuple[str, str]:
233
284
elif binary_data.startswith(b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"):
234
285
return ".jp2", "image/jp2"
235
286
elif len(binary_data) > 12 and binary_data[4:8] == b"ftyp":
236
# ISO Base Media File Format — branch on brand to distinguish mp4/heic/avif
237
287
brand = binary_data[8:12]
238
if brand in (b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1"):
288
if brand in [b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1"]:
239
289
return ".heic", "image/heif"
240
elif brand == b"avif":
290
elif brand in [b"avif"]:
241
291
return ".avif", "image/avif"
242
292
else:
243
# Default to MP4 for any other ftyp brand (isom, mp41, mp42, M4V, ...)
244
293
return ".mp4", "video/mp4"
245
294
elif binary_data.lstrip().startswith((b"<?xml", b"<svg")):
246
295
return ".svg", "image/svg+xml"
@@ -249,8 +298,8 @@ def detect_file_type(binary_data: bytes) -> tuple[str, str]:
249
298
elif binary_data.startswith(b"%PDF"):
250
299
return ".pdf", "application/pdf"
251
300
elif binary_data.startswith(b"PK\x03\x04"):
252
# Could be docx/xlsx/pptx/jar/apk/odt — use generic zip
253
return ".zip", "application/zip"
301
return ".zip", "application/zip-based"
302
# could be docx/xlsx/pptx/jar/apk/odt
254
303
elif binary_data.startswith(b"\xd0\xcf\x11\xe0"):
255
304
return ".doc", "application/vnd.ms-office"
256
305
elif binary_data.startswith(b"{\\rtf"):
@@ -271,39 +320,37 @@ def detect_file_type(binary_data: bytes) -> tuple[str, str]:
271
320
return ".exe", "application/x-msdownload"
272
321
elif binary_data.startswith(b"\x7fELF"):
273
322
return ".elf", "application/x-elf"
274
elif binary_data.startswith(b"\xca\xfe\xba\xbe") or binary_data.startswith(b"\xca\xfe\xd0\x0d"):
323
elif binary_data.startswith(b"\xca\xfe\xba\xbe") or binary_data.startswith(
324
b"\xca\xfe\xd0\x0d"
325
):
275
326
return ".class", "application/java-vm"
276
elif binary_data.startswith(b"\x50\x4b\x03\x04") and b"META-INF" in binary_data[:200]:
327
elif (
328
binary_data.startswith(b"\x50\x4b\x03\x04")
329
and b"META-INF" in binary_data[:200]
330
):
277
331
return ".jar", "application/java-archive"
278
332
279
333
# ---- Audio ----
280
334
elif binary_data.startswith(b"ID3") or binary_data[0:2] == b"\xff\xfb":
281
335
return ".mp3", "audio/mpeg"
336
elif binary_data.startswith(b"OggS"):
337
return ".ogg", "audio/ogg"
282
338
elif binary_data.startswith(b"fLaC"):
283
339
return ".flac", "audio/flac"
284
340
elif binary_data.startswith(b"RIFF") and binary_data[8:12] == b"WAVE":
285
341
return ".wav", "audio/wav"
286
342
elif binary_data.startswith(b"MThd"):
287
343
return ".mid", "audio/midi"
288
elif binary_data.startswith(b"OggS"):
289
# Distinguish Ogg audio from Ogg video by reading the codec header
290
# Vorbis/Opus → audio; Theora → video
291
if b"\x80theora" in binary_data[:64] or b"theora" in binary_data[:64]:
292
return ".ogv", "video/ogg"
293
elif b"\x01vorbis" in binary_data[:64] or b"OpusHead" in binary_data[:64]:
294
return ".ogg", "audio/ogg"
295
else:
296
# Default to audio/ogg for unrecognised Ogg streams
297
return ".ogg", "audio/ogg"
298
344
299
345
# ---- Video ----
346
elif binary_data.startswith(b"\x00\x00\x00") and b"ftyp" in binary_data[4:12]:
347
return ".mp4", "video/mp4"
300
348
elif binary_data.startswith(b"RIFF") and binary_data[8:12] == b"AVI ":
301
349
return ".avi", "video/x-msvideo"
350
elif binary_data.startswith(b"OggS"):
351
return ".ogv", "video/ogg"
302
352
elif binary_data.startswith(b"\x1a\x45\xdf\xa3"):
303
# EBML — could be MKV or WebM; check DocType in first 64 bytes
304
if b"webm" in binary_data[:64]:
305
return ".webm", "video/webm"
306
return ".mkv", "video/x-matroska"
353
return ".mkv", "video/webm"
307
354
elif binary_data.startswith(b"\x00\x00\x01\xba"):
308
355
return ".mpg", "video/mpeg"
309
356
@@ -324,23 +371,39 @@ def detect_file_type(binary_data: bytes) -> tuple[str, str]:
324
371
325
372
326
373
def extract_data_uri(data_uri: str) -> bytes:
327
"""Extract binary data from a data URI."""
328
data = data_uri.split(",")[-1]
329
return base64.b64decode(data)
374
"""
375
Extracts the binary data from the given data URI.
376
377
Args:
378
data_uri (str): The data URI.
330
379
380
Returns:
381
bytes: The extracted binary data.
382
"""
383
data = data_uri.split(",")[-1]
384
data = base64.b64decode(data)
385
return data
331
386
332
def process_image(
333
image: Image.Image,
334
new_width: int = 400,
335
new_height: int = 400,
336
save: str = None
337
) -> Image.Image:
387
def process_image(image: Image.Image, new_width: int = 400, new_height: int = 400, save: str = None) -> Image.Image:
338
388
"""
339
Adjusts orientation, strips transparency, resizes image.
389
Processes the given image by adjusting its orientation and resizing it.
390
391
Args:
392
image (Image): The image to process.
393
new_width (int): The new width of the image.
394
new_height (int): The new height of the image.
395
396
Returns:
397
Image: The processed image.
340
398
"""
341
399
image = ImageOps.exif_transpose(image)
400
# Remove transparency
342
401
if image.mode == "RGBA":
343
pass # keep transparency for PNG output
402
image.load()
403
white = Image.new('RGB', image.size, (255, 255, 255))
404
white.paste(image, mask=image.split()[-1])
405
image = white
406
# Convert to RGB for jpg format
344
407
elif image.mode != "RGB":
345
408
image = image.convert("RGB")
346
409
image_size = image.size
@@ -350,10 +413,15 @@ def process_image(
350
413
return image_size
351
414
return image
352
415
353
354
416
def to_bytes(image: ImageType) -> bytes:
355
417
"""
356
418
Converts the given image to bytes.
419
420
Args:
421
image (ImageType): The image to convert.
422
423
Returns:
424
bytes: The image as bytes.
357
425
"""
358
426
if isinstance(image, bytes):
359
427
return image
@@ -362,17 +430,18 @@ def to_bytes(image: ImageType) -> bytes:
362
430
is_data_uri_an_image(image)
363
431
return extract_data_uri(image)
364
432
elif image.startswith("http://") or image.startswith("https://"):
433
if not is_safe_url(image):
434
raise ValueError("Invalid or disallowed media URL")
365
435
path: str = urlparse(image).path
366
436
if path.startswith("/files/"):
367
local_path = get_bucket_dir(*path.split("/")[2:])
368
if os.path.exists(local_path):
369
return Path(local_path).read_bytes()
437
path = get_bucket_dir(*path.split("/")[2:])
438
if os.path.exists(path):
439
return Path(path).read_bytes()
370
440
else:
371
raise FileNotFoundError(f"File not found: {local_path}")
441
raise FileNotFoundError(f"File not found: {path}")
372
442
else:
373
443
resp = requests.get(image, headers={
374
# Updated to Chrome/145 (current as of Mar 2026)
375
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
444
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0",
376
445
})
377
446
if resp.ok and is_accepted_format(resp.content):
378
447
return resp.content
@@ -381,11 +450,13 @@ def to_bytes(image: ImageType) -> bytes:
381
450
raise ValueError("Invalid image format. Expected bytes, str, or PIL Image.")
382
451
elif isinstance(image, Image.Image):
383
452
bytes_io = BytesIO()
384
image.save(bytes_io, image.format or "PNG")
385
bytes_io.seek(0) # FIX: was image.seek(0) — PIL Image has no seek()
453
image.save(bytes_io, image.format)
454
image.seek(0)
386
455
return bytes_io.getvalue()
387
elif isinstance(image, (os.PathLike, Path)):
456
elif isinstance(image, os.PathLike):
388
457
return Path(image).read_bytes()
458
elif isinstance(image, Path):
459
return image.read_bytes()
389
460
else:
390
461
try:
391
462
image.seek(0)
@@ -393,7 +464,6 @@ def to_bytes(image: ImageType) -> bytes:
393
464
pass
394
465
return image.read()
395
466
396
397
467
def to_data_uri(image: ImageType, filename: str = None) -> str:
398
468
if not isinstance(image, str):
399
469
data = to_bytes(image)
@@ -401,28 +471,26 @@ def to_data_uri(image: ImageType, filename: str = None) -> str:
401
471
return f"data:{is_data_an_media(data, filename)};base64,{data_base64}"
402
472
return image
403
473
404
405
def to_input_audio(audio: ImageType, filename: str = None) -> dict:
474
def to_input_audio(audio: ImageType, filename: str = None) -> str:
406
475
if not isinstance(audio, str):
407
476
if filename is not None:
408
fmt = get_extension(filename)
409
if fmt is None:
477
format = get_extension(filename)
478
if format is None:
410
479
raise ValueError("Invalid input audio")
411
480
return {
412
481
"data": base64.b64encode(to_bytes(audio)).decode(),
413
"format": fmt
482
"format": format
414
483
}
415
484
raise ValueError("Invalid input audio")
416
match = re.match(r'^data:audio/([\w+-]+);base64,(.+)', audio)
417
if match:
485
audio = re.match(r'^data:audio/(\w+);base64,(.+?)', audio)
486
if audio:
418
487
return {
419
"data": match.group(2),
420
"format": match.group(1).replace("mpeg", "mp3")
488
"data": audio.group(2),
489
"format": audio.group(1).replace("mpeg", "mp3")
421
490
}
422
491
raise ValueError("Invalid input audio")
423
492
424
425
def use_aspect_ratio(extra_body: dict, aspect_ratio: str) -> dict:
493
def use_aspect_ratio(extra_body: dict, aspect_ratio: str) -> Image:
426
494
extra_body = {key: value for key, value in extra_body.items() if value is not None}
427
495
if extra_body.get("width") is None or extra_body.get("height") is None:
428
496
width, height = get_width_height(
@@ -437,40 +505,25 @@ def use_aspect_ratio(extra_body: dict, aspect_ratio: str) -> dict:
437
505
}
438
506
return {key: value for key, value in extra_body.items() if value is not None}
439
507
440
441
508
def get_width_height(
442
509
aspect_ratio: str,
443
510
width: Optional[int] = None,
444
511
height: Optional[int] = None
445
512
) -> tuple[int, int]:
446
"""
447
Returns (width, height) for common aspect ratios.
448
All values are multiples of 64 and ≥ 480px on the short side.
449
"""
450
ratio_map = {
451
"1:1": (1024, 1024),
452
"16:9": (1024, 576),
453
"9:16": (576, 1024),
454
"4:3": (1024, 768),
455
"3:4": (768, 1024),
456
"3:2": (1024, 682),
457
"2:3": (682, 1024),
458
"21:9": (1024, 440),
459
"9:21": (440, 1024),
460
"4:5": (832, 1040),
461
"5:4": (1040, 832),
462
"2:1": (1024, 512),
463
"1:2": (512, 1024),
464
}
465
if aspect_ratio in ratio_map:
466
default_w, default_h = ratio_map[aspect_ratio]
467
return width or default_w, height or default_h
513
if aspect_ratio == "1:1":
514
return width or 1024, height or 1024
515
elif aspect_ratio == "16:9":
516
return width or 832, height or 480
517
elif aspect_ratio == "9:16":
518
return width or 480, height or 832,
468
519
return width, height
469
520
470
471
521
class ImageRequest:
472
def __init__(self, options: dict = {}):
522
def __init__(
523
self,
524
options: dict = {}
525
):
473
526
self.options = options
474
527
475
528
def get(self, key: str):
476
return self.options.get(key)
529
return self.options.get(key)
@@ -247,4 +247,4 @@ async def copy_media(
247
247
os.unlink(target_path)
248
248
return image
249
249
250
return await asyncio.gather(*[copy_image(image, target) for image in images])
250
return await asyncio.gather(*[copy_image(image, target) for image in images])