返回 ppt-master
backend_common.py
根目录 / skills / ppt-master / scripts / image_backends / backend_common.py
1 #!/usr/bin/env python3
2 """
3 Shared helpers for image generation backends.
4 """
5
6 import sys
7 from pathlib import Path
8
9 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
10 if str(_SCRIPTS_DIR) not in sys.path:
11 sys.path.insert(0, str(_SCRIPTS_DIR))
12
13 from console_encoding import configure_utf8_stdio # noqa: E402
14
15 configure_utf8_stdio()
16
17 if __name__ == "__main__":
18 print(__doc__)
19 print("This is an internal helper module used by image_gen.py backends.")
20 raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
21
22 import base64
23 import io
24 import os
25 import re
26 import time
27
28 import requests
29
30 try:
31 from PIL import Image as PILImage, ImageOps as PILImageOps
32 HAS_PIL = True
33 except ImportError:
34 HAS_PIL = False
35
36
37 MAX_RETRIES = 3
38 RETRY_BASE_DELAY = 10
39 RETRY_BACKOFF = 2
40
41 _TRANSIENT_CLIENT_STATUSES = {408, 409, 423, 425, 429}
42 _HTTP_ERROR_STATUS = re.compile(r"\(([1-5][0-9]{2})\):")
43 _GLOBAL_PERMANENT_ERROR_TYPES = {
44 "authenticationerror",
45 }
46 _ITEM_PERMANENT_ERROR_TYPES = {
47 "badrequesterror",
48 "notfounderror",
49 "permissiondeniederror",
50 "unprocessableentityerror",
51 }
52 _GLOBAL_PERMANENT_ERROR_MARKERS = (
53 "prepayment credits are depleted",
54 "prepaid credits are depleted",
55 "credits are depleted",
56 "insufficient credits",
57 "insufficient balance",
58 "insufficient_quota",
59 "exceeded your current quota",
60 "payment required",
61 "billing is not enabled",
62 "billing not enabled",
63 "billing must be enabled",
64 "billing is disabled",
65 "invalid api key",
66 "api key not valid",
67 "incorrect api key",
68 "no api key found",
69 "missing api key",
70 "api key required",
71 "api key is required",
72 "api key not set",
73 "api key is not set",
74 "api key expired",
75 "expired api key",
76 "authentication failed",
77 "authentication required",
78 "unauthorized",
79 )
80 _ITEM_PERMANENT_ERROR_MARKERS = (
81 "permission denied",
82 "forbidden",
83 "invalid_argument",
84 "invalid argument",
85 "invalid request",
86 "bad request",
87 "failed_precondition",
88 "failed precondition",
89 "invalid image size",
90 "invalid aspect ratio",
91 "unsupported image size",
92 "unsupported aspect ratio",
93 "unsupported model",
94 "model not found",
95 "model does not exist",
96 "content policy",
97 "request moderated",
98 "content moderated",
99 "prompt was rejected",
100 "blocked by safety",
101 )
102
103
104 class _RetryableBackendError(RuntimeError):
105 """A backend failure whose enclosing operation should be repeated."""
106
107
108 def resolve_output_path(prompt: str, output_dir: str = None,
109 filename: str = None, ext: str = ".png") -> str:
110 """Compute the final output file path based on parameters."""
111 if filename:
112 file_name = os.path.splitext(filename)[0]
113 else:
114 safe = "".join(c for c in prompt if c.isalnum() or c in (" ", "_")).rstrip()
115 safe = safe.replace(" ", "_").lower()[:30]
116 file_name = safe or "generated_image"
117
118 full_name = f"{file_name}{ext}"
119 if output_dir:
120 os.makedirs(output_dir, exist_ok=True)
121 return os.path.join(output_dir, full_name)
122 return full_name
123
124
125 CONTENT_TYPE_TO_EXT = {
126 "image/png": ".png",
127 "image/jpeg": ".jpg",
128 "image/jpg": ".jpg",
129 "image/webp": ".webp",
130 "image/gif": ".gif",
131 "image/bmp": ".bmp",
132 "image/tiff": ".tiff",
133 }
134
135 EXT_TO_PIL_FORMAT = {
136 ".png": "PNG",
137 ".jpg": "JPEG",
138 ".jpeg": "JPEG",
139 ".webp": "WEBP",
140 ".gif": "GIF",
141 ".bmp": "BMP",
142 ".tiff": "TIFF",
143 ".tif": "TIFF",
144 }
145
146
147 def detect_image_extension(image_bytes: bytes, content_type: str = None) -> str | None:
148 """Best-effort detection of the real image format."""
149 if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
150 return ".png"
151 if image_bytes.startswith(b"\xff\xd8\xff"):
152 return ".jpg"
153 if image_bytes.startswith(b"GIF87a") or image_bytes.startswith(b"GIF89a"):
154 return ".gif"
155 if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
156 return ".webp"
157 if image_bytes.startswith(b"BM"):
158 return ".bmp"
159 if image_bytes.startswith((b"II*\x00", b"MM\x00*")):
160 return ".tiff"
161 if content_type:
162 clean_type = content_type.split(";", 1)[0].strip().lower()
163 if clean_type in CONTENT_TYPE_TO_EXT:
164 return CONTENT_TYPE_TO_EXT[clean_type]
165 return None
166
167
168 DATA_URI_HEADER = re.compile(
169 r"data:(?P<mime>image/[A-Za-z0-9.+-]+)(?P<params>;[^,]*)?,",
170 re.IGNORECASE,
171 )
172
173
174 def decode_data_uri(value: str) -> tuple[bytes, str | None]:
175 """
176 Decode a base64 image data URI into raw bytes plus its declared content type.
177
178 The declared type is returned so callers can hand it to `save_image_bytes` instead of
179 assuming the payload matches the output extension.
180 """
181 header = DATA_URI_HEADER.match(value.strip())
182 if not header:
183 raise ValueError("Expected a base64 image data URI (data:image/...;base64,...).")
184
185 params = (header.group("params") or "").lower()
186 if "base64" not in params:
187 raise ValueError("Only base64-encoded image data URIs are supported.")
188
189 payload = "".join(value.strip()[header.end():].split())
190 payload += "=" * (-len(payload) % 4)
191 return base64.urlsafe_b64decode(payload), header.group("mime").lower()
192
193
194 def find_data_uri(content) -> str | None:
195 """
196 Return the first base64 image data URI inside a chat completion `content` value.
197
198 OpenAI-compatible gateways differ here: some return a dedicated image field, others
199 inline the image in the message text (often as `![image](data:image/png;base64,...)`)
200 or in a content-part list.
201 """
202 if isinstance(content, str):
203 header = DATA_URI_HEADER.search(content)
204 if not header:
205 return None
206 payload = re.match(r"[A-Za-z0-9+/=_-]*", content[header.end():]).group(0)
207 return content[header.start():header.end()] + payload
208
209 if isinstance(content, list):
210 for part in content:
211 if isinstance(part, dict):
212 nested = part.get("image_url")
213 if isinstance(nested, dict):
214 nested = nested.get("url")
215 found = find_data_uri(nested if nested else part.get("text"))
216 else:
217 found = find_data_uri(part)
218 if found:
219 return found
220
221 return None
222
223
224 def _normalize_extension(ext: str) -> str:
225 """Normalize equivalent image extensions to a canonical form."""
226 ext = ext.lower()
227 if ext == ".jpeg":
228 return ".jpg"
229 if ext == ".tif":
230 return ".tiff"
231 return ext
232
233
234 def save_image_bytes(image_bytes: bytes, path: str, content_type: str = None) -> str:
235 """
236 Save image bytes to disk while keeping the file extension and the real bytes aligned.
237
238 If the target extension differs from the actual bytes, transcode through Pillow when
239 available. Otherwise fail loudly instead of writing a misleading file.
240 """
241 target_ext = _normalize_extension(os.path.splitext(path)[1])
242 actual_ext = _normalize_extension(detect_image_extension(image_bytes, content_type) or "")
243
244 if not target_ext:
245 raise ValueError(f"Output path must include an image extension: {path}")
246
247 if actual_ext and target_ext == actual_ext:
248 with open(path, "wb") as f:
249 f.write(image_bytes)
250 print(f" File saved to: {path}")
251 report_resolution(path)
252 return path
253
254 if not HAS_PIL:
255 actual_label = actual_ext or "unknown"
256 raise RuntimeError(
257 f"Image format mismatch for {path}: target extension is {target_ext}, "
258 f"but the actual image bytes are {actual_label}. "
259 "Install Pillow to enable automatic format conversion."
260 )
261
262 target_format = EXT_TO_PIL_FORMAT.get(target_ext)
263 if not target_format:
264 raise ValueError(f"Unsupported output image extension: {target_ext}")
265
266 with PILImage.open(io.BytesIO(image_bytes)) as source:
267 image = PILImageOps.exif_transpose(source)
268 try:
269 if target_format == "JPEG":
270 has_alpha = (
271 image.mode in ("RGBA", "LA")
272 or "transparency" in getattr(image, "info", {})
273 )
274 if has_alpha:
275 rgba = image.convert("RGBA")
276 alpha = rgba.getchannel("A")
277 rgb = rgba.convert("RGB")
278 converted = PILImage.new("RGB", image.size, (255, 255, 255))
279 converted.paste(rgb, mask=alpha)
280 rgb.close()
281 alpha.close()
282 rgba.close()
283 if image is not source:
284 image.close()
285 image = converted
286 elif image.mode != "RGB":
287 converted = image.convert("RGB")
288 if image is not source:
289 image.close()
290 image = converted
291 image.save(path, format=target_format)
292 finally:
293 if image is not source:
294 image.close()
295
296 if actual_ext and actual_ext != target_ext:
297 print(f" Converted: {actual_ext} -> {target_ext}")
298 print(f" File saved to: {path}")
299 report_resolution(path)
300 return path
301
302
303 def validate_image_file(path: str) -> str:
304 """Require an existing regular file that Pillow can read as an image."""
305 image_path = Path(path)
306 if not image_path.exists():
307 raise RuntimeError(f"Image output path does not exist: {path}")
308 if not image_path.is_file():
309 raise RuntimeError(f"Image output path is not a file: {path}")
310 if not HAS_PIL:
311 raise RuntimeError(
312 "Pillow is required to verify generated images. "
313 "Install it with: pip install Pillow"
314 )
315
316 try:
317 with PILImage.open(image_path) as image:
318 image.verify()
319 except (OSError, ValueError, SyntaxError) as exc:
320 raise RuntimeError(f"Image output is not readable: {path}: {exc}") from exc
321 return str(image_path)
322
323
324 def report_resolution(path: str) -> None:
325 """Try to report image resolution using PIL."""
326 if HAS_PIL:
327 try:
328 img = PILImage.open(path)
329 print(f" Resolution: {img.size[0]}x{img.size[1]}")
330 except Exception:
331 pass
332
333
334 def normalize_image_size(image_size: str) -> str:
335 """Normalize image size input to standard format."""
336 s = image_size.strip()
337 upper = s.upper()
338 if upper in ("1K", "2K", "4K"):
339 return upper
340 if upper in ("512PX", "512"):
341 return "512px"
342 return s
343
344
345 def _error_status_code(exc: Exception) -> int | None:
346 """Extract an HTTP-like status code from common SDK exception shapes."""
347 candidates = (
348 getattr(exc, "status_code", None),
349 getattr(exc, "code", None),
350 getattr(getattr(exc, "response", None), "status_code", None),
351 )
352 for value in candidates:
353 if isinstance(value, int) and not isinstance(value, bool):
354 return value
355 if isinstance(value, str) and value.isdigit():
356 return int(value)
357
358 match = _HTTP_ERROR_STATUS.search(str(exc))
359 return int(match.group(1)) if match else None
360
361
362 def is_global_permanent_error(exc: Exception) -> bool:
363 """Return whether every unchanged request would fail for this backend."""
364 if isinstance(exc, _RetryableBackendError):
365 return False
366
367 status_code = _error_status_code(exc)
368 if status_code in {401, 402}:
369 return True
370
371 error_name = type(exc).__name__.lower()
372 if error_name in _GLOBAL_PERMANENT_ERROR_TYPES:
373 return True
374
375 err_str = str(exc).lower()
376 return any(marker in err_str for marker in _GLOBAL_PERMANENT_ERROR_MARKERS)
377
378
379 def is_permanent_error(exc: Exception) -> bool:
380 """Return whether retrying the unchanged backend request cannot succeed."""
381 if isinstance(exc, _RetryableBackendError):
382 return False
383 if is_global_permanent_error(exc):
384 return True
385 if isinstance(exc, (FileNotFoundError, NotImplementedError, PermissionError)):
386 return True
387
388 status_code = _error_status_code(exc)
389 if (
390 status_code is not None
391 and 400 <= status_code < 500
392 and status_code not in _TRANSIENT_CLIENT_STATUSES
393 ):
394 return True
395
396 error_name = type(exc).__name__.lower()
397 if error_name in _ITEM_PERMANENT_ERROR_TYPES:
398 return True
399
400 err_str = str(exc).lower()
401 return any(marker in err_str for marker in _ITEM_PERMANENT_ERROR_MARKERS)
402
403
404 def is_rate_limit_error(exc: Exception) -> bool:
405 """Check whether the exception appears to be rate limiting."""
406 if is_permanent_error(exc):
407 return False
408
409 err_str = str(exc).lower()
410 status_code = getattr(exc, "status_code", None)
411 error_code = getattr(exc, "code", None)
412 response = getattr(exc, "response", None)
413 error_name = type(exc).__name__.lower()
414 if (
415 status_code == 429
416 or error_code == 429
417 or getattr(response, "status_code", None) == 429
418 or error_name in {"ratelimiterror", "toomanyrequestserror"}
419 ):
420 return True
421 return (
422 "429" in err_str
423 or "rate limit" in err_str
424 or "rate-limit" in err_str
425 or "rate_limit" in err_str
426 or "too many requests" in err_str
427 or "quota" in err_str
428 or "resource_exhausted" in err_str
429 or "resource exhausted" in err_str
430 or "throttl" in err_str
431 )
432
433
434 def retry_delay(attempt: int, rate_limited: bool) -> int:
435 """Return the retry delay for a given attempt."""
436 if rate_limited:
437 return RETRY_BASE_DELAY * (RETRY_BACKOFF ** attempt)
438 return 5
439
440
441 def download_image(url: str, path: str, headers: dict = None, timeout: int = 180) -> str:
442 """Download an image URL and save it to disk."""
443 try:
444 response = requests.get(url, headers=headers or {}, timeout=timeout)
445 response.raise_for_status()
446 except requests.RequestException as exc:
447 raise _RetryableBackendError(f"Image download failed: {exc}") from exc
448 return save_image_bytes(
449 response.content,
450 path,
451 content_type=response.headers.get("Content-Type"),
452 )
453
454
455 def require_api_key(*candidates: str, message: str) -> str:
456 """Return the first non-empty env var from candidates or raise."""
457 for name in candidates:
458 value = os.environ.get(name)
459 if value:
460 return value
461 raise ValueError(message)
462
463
464 def http_error(response: requests.Response, label: str) -> RuntimeError:
465 """Convert an HTTP response into a readable RuntimeError."""
466 body = response.text.strip()
467 if len(body) > 500:
468 body = body[:500] + "..."
469 return RuntimeError(f"{label} failed ({response.status_code}): {body}")
470
471
472 def poll_json(
473 url: str,
474 headers: dict[str, str],
475 *,
476 interval_seconds: float = 2.0,
477 timeout_seconds: int = 300,
478 status_label: str = "status",
479 ready_values: list[str] | None = None,
480 failed_values: list[str] | None = None,
481 ) -> dict:
482 """Poll a JSON endpoint until it reports a ready or failed status."""
483 ready = {value.lower() for value in (ready_values or ["ready", "success", "succeeded"])}
484 failed = {value.lower() for value in (failed_values or ["error", "failed", "fail"])}
485
486 start = time.time()
487 while True:
488 response = requests.get(url, headers=headers, timeout=180)
489 response.raise_for_status()
490 payload = response.json()
491 raw_status = str(payload.get(status_label, "")).strip()
492 status = raw_status.lower()
493
494 if raw_status:
495 print(f" Status: {raw_status}")
496
497 if status in ready:
498 return payload
499
500 if status in failed:
501 raise RuntimeError(f"Remote generation failed: {payload}")
502
503 if time.time() - start > timeout_seconds:
504 raise RuntimeError(
505 f"Timed out after {timeout_seconds}s while polling {url}"
506 )
507
508 time.sleep(interval_seconds)
509
509 lines PYTHON