返回 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
42 def resolve_output_path(prompt: str, output_dir: str = None,
43 filename: str = None, ext: str = ".png") -> str:
44 """Compute the final output file path based on parameters."""
45 if filename:
46 file_name = os.path.splitext(filename)[0]
47 else:
48 safe = "".join(c for c in prompt if c.isalnum() or c in (" ", "_")).rstrip()
49 safe = safe.replace(" ", "_").lower()[:30]
50 file_name = safe or "generated_image"
51
52 full_name = f"{file_name}{ext}"
53 if output_dir:
54 os.makedirs(output_dir, exist_ok=True)
55 return os.path.join(output_dir, full_name)
56 return full_name
57
58
59 CONTENT_TYPE_TO_EXT = {
60 "image/png": ".png",
61 "image/jpeg": ".jpg",
62 "image/jpg": ".jpg",
63 "image/webp": ".webp",
64 "image/gif": ".gif",
65 "image/bmp": ".bmp",
66 "image/tiff": ".tiff",
67 }
68
69 EXT_TO_PIL_FORMAT = {
70 ".png": "PNG",
71 ".jpg": "JPEG",
72 ".jpeg": "JPEG",
73 ".webp": "WEBP",
74 ".gif": "GIF",
75 ".bmp": "BMP",
76 ".tiff": "TIFF",
77 ".tif": "TIFF",
78 }
79
80
81 def detect_image_extension(image_bytes: bytes, content_type: str = None) -> str | None:
82 """Best-effort detection of the real image format."""
83 if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
84 return ".png"
85 if image_bytes.startswith(b"\xff\xd8\xff"):
86 return ".jpg"
87 if image_bytes.startswith(b"GIF87a") or image_bytes.startswith(b"GIF89a"):
88 return ".gif"
89 if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
90 return ".webp"
91 if image_bytes.startswith(b"BM"):
92 return ".bmp"
93 if image_bytes.startswith((b"II*\x00", b"MM\x00*")):
94 return ".tiff"
95 if content_type:
96 clean_type = content_type.split(";", 1)[0].strip().lower()
97 if clean_type in CONTENT_TYPE_TO_EXT:
98 return CONTENT_TYPE_TO_EXT[clean_type]
99 return None
100
101
102 DATA_URI_HEADER = re.compile(
103 r"data:(?P<mime>image/[A-Za-z0-9.+-]+)(?P<params>;[^,]*)?,",
104 re.IGNORECASE,
105 )
106
107
108 def decode_data_uri(value: str) -> tuple[bytes, str | None]:
109 """
110 Decode a base64 image data URI into raw bytes plus its declared content type.
111
112 The declared type is returned so callers can hand it to `save_image_bytes` instead of
113 assuming the payload matches the output extension.
114 """
115 header = DATA_URI_HEADER.match(value.strip())
116 if not header:
117 raise ValueError("Expected a base64 image data URI (data:image/...;base64,...).")
118
119 params = (header.group("params") or "").lower()
120 if "base64" not in params:
121 raise ValueError("Only base64-encoded image data URIs are supported.")
122
123 payload = "".join(value.strip()[header.end():].split())
124 payload += "=" * (-len(payload) % 4)
125 return base64.urlsafe_b64decode(payload), header.group("mime").lower()
126
127
128 def find_data_uri(content) -> str | None:
129 """
130 Return the first base64 image data URI inside a chat completion `content` value.
131
132 OpenAI-compatible gateways differ here: some return a dedicated image field, others
133 inline the image in the message text (often as `![image](data:image/png;base64,...)`)
134 or in a content-part list.
135 """
136 if isinstance(content, str):
137 header = DATA_URI_HEADER.search(content)
138 if not header:
139 return None
140 payload = re.match(r"[A-Za-z0-9+/=_-]*", content[header.end():]).group(0)
141 return content[header.start():header.end()] + payload
142
143 if isinstance(content, list):
144 for part in content:
145 if isinstance(part, dict):
146 nested = part.get("image_url")
147 if isinstance(nested, dict):
148 nested = nested.get("url")
149 found = find_data_uri(nested if nested else part.get("text"))
150 else:
151 found = find_data_uri(part)
152 if found:
153 return found
154
155 return None
156
157
158 def _normalize_extension(ext: str) -> str:
159 """Normalize equivalent image extensions to a canonical form."""
160 ext = ext.lower()
161 if ext == ".jpeg":
162 return ".jpg"
163 if ext == ".tif":
164 return ".tiff"
165 return ext
166
167
168 def save_image_bytes(image_bytes: bytes, path: str, content_type: str = None) -> str:
169 """
170 Save image bytes to disk while keeping the file extension and the real bytes aligned.
171
172 If the target extension differs from the actual bytes, transcode through Pillow when
173 available. Otherwise fail loudly instead of writing a misleading file.
174 """
175 target_ext = _normalize_extension(os.path.splitext(path)[1])
176 actual_ext = _normalize_extension(detect_image_extension(image_bytes, content_type) or "")
177
178 if not target_ext:
179 raise ValueError(f"Output path must include an image extension: {path}")
180
181 if actual_ext and target_ext == actual_ext:
182 with open(path, "wb") as f:
183 f.write(image_bytes)
184 print(f" File saved to: {path}")
185 report_resolution(path)
186 return path
187
188 if not HAS_PIL:
189 actual_label = actual_ext or "unknown"
190 raise RuntimeError(
191 f"Image format mismatch for {path}: target extension is {target_ext}, "
192 f"but the actual image bytes are {actual_label}. "
193 "Install Pillow to enable automatic format conversion."
194 )
195
196 target_format = EXT_TO_PIL_FORMAT.get(target_ext)
197 if not target_format:
198 raise ValueError(f"Unsupported output image extension: {target_ext}")
199
200 with PILImage.open(io.BytesIO(image_bytes)) as source:
201 image = PILImageOps.exif_transpose(source)
202 try:
203 if target_format == "JPEG":
204 has_alpha = (
205 image.mode in ("RGBA", "LA")
206 or "transparency" in getattr(image, "info", {})
207 )
208 if has_alpha:
209 rgba = image.convert("RGBA")
210 alpha = rgba.getchannel("A")
211 rgb = rgba.convert("RGB")
212 converted = PILImage.new("RGB", image.size, (255, 255, 255))
213 converted.paste(rgb, mask=alpha)
214 rgb.close()
215 alpha.close()
216 rgba.close()
217 if image is not source:
218 image.close()
219 image = converted
220 elif image.mode != "RGB":
221 converted = image.convert("RGB")
222 if image is not source:
223 image.close()
224 image = converted
225 image.save(path, format=target_format)
226 finally:
227 if image is not source:
228 image.close()
229
230 if actual_ext and actual_ext != target_ext:
231 print(f" Converted: {actual_ext} -> {target_ext}")
232 print(f" File saved to: {path}")
233 report_resolution(path)
234 return path
235
236
237 def validate_image_file(path: str) -> str:
238 """Require an existing regular file that Pillow can read as an image."""
239 image_path = Path(path)
240 if not image_path.exists():
241 raise RuntimeError(f"Image output path does not exist: {path}")
242 if not image_path.is_file():
243 raise RuntimeError(f"Image output path is not a file: {path}")
244 if not HAS_PIL:
245 raise RuntimeError(
246 "Pillow is required to verify generated images. "
247 "Install it with: pip install Pillow"
248 )
249
250 try:
251 with PILImage.open(image_path) as image:
252 image.verify()
253 except (OSError, ValueError, SyntaxError) as exc:
254 raise RuntimeError(f"Image output is not readable: {path}: {exc}") from exc
255 return str(image_path)
256
257
258 def report_resolution(path: str) -> None:
259 """Try to report image resolution using PIL."""
260 if HAS_PIL:
261 try:
262 img = PILImage.open(path)
263 print(f" Resolution: {img.size[0]}x{img.size[1]}")
264 except Exception:
265 pass
266
267
268 def normalize_image_size(image_size: str) -> str:
269 """Normalize image size input to standard format."""
270 s = image_size.strip()
271 upper = s.upper()
272 if upper in ("1K", "2K", "4K"):
273 return upper
274 if upper in ("512PX", "512"):
275 return "512px"
276 return s
277
278
279 def is_rate_limit_error(exc: Exception) -> bool:
280 """Check whether the exception appears to be rate limiting."""
281 err_str = str(exc).lower()
282 status_code = getattr(exc, "status_code", None)
283 error_code = getattr(exc, "code", None)
284 response = getattr(exc, "response", None)
285 error_name = type(exc).__name__.lower()
286 if (
287 status_code == 429
288 or error_code == 429
289 or getattr(response, "status_code", None) == 429
290 or error_name in {"ratelimiterror", "toomanyrequestserror"}
291 ):
292 return True
293 return (
294 "429" in err_str
295 or "rate limit" in err_str
296 or "rate-limit" in err_str
297 or "rate_limit" in err_str
298 or "too many requests" in err_str
299 or "quota" in err_str
300 or "resource_exhausted" in err_str
301 or "resource exhausted" in err_str
302 or "throttl" in err_str
303 )
304
305
306 def retry_delay(attempt: int, rate_limited: bool) -> int:
307 """Return the retry delay for a given attempt."""
308 if rate_limited:
309 return RETRY_BASE_DELAY * (RETRY_BACKOFF ** attempt)
310 return 5
311
312
313 def download_image(url: str, path: str, headers: dict = None, timeout: int = 180) -> str:
314 """Download an image URL and save it to disk."""
315 response = requests.get(url, headers=headers or {}, timeout=timeout)
316 response.raise_for_status()
317 return save_image_bytes(
318 response.content,
319 path,
320 content_type=response.headers.get("Content-Type"),
321 )
322
323
324 def require_api_key(*candidates: str, message: str) -> str:
325 """Return the first non-empty env var from candidates or raise."""
326 for name in candidates:
327 value = os.environ.get(name)
328 if value:
329 return value
330 raise ValueError(message)
331
332
333 def http_error(response: requests.Response, label: str) -> RuntimeError:
334 """Convert an HTTP response into a readable RuntimeError."""
335 body = response.text.strip()
336 if len(body) > 500:
337 body = body[:500] + "..."
338 return RuntimeError(f"{label} failed ({response.status_code}): {body}")
339
340
341 def poll_json(
342 url: str,
343 headers: dict[str, str],
344 *,
345 interval_seconds: float = 2.0,
346 timeout_seconds: int = 300,
347 status_label: str = "status",
348 ready_values: list[str] | None = None,
349 failed_values: list[str] | None = None,
350 ) -> dict:
351 """Poll a JSON endpoint until it reports a ready or failed status."""
352 ready = {value.lower() for value in (ready_values or ["ready", "success", "succeeded"])}
353 failed = {value.lower() for value in (failed_values or ["error", "failed", "fail"])}
354
355 start = time.time()
356 while True:
357 response = requests.get(url, headers=headers, timeout=180)
358 response.raise_for_status()
359 payload = response.json()
360 raw_status = str(payload.get(status_label, "")).strip()
361 status = raw_status.lower()
362
363 if raw_status:
364 print(f" Status: {raw_status}")
365
366 if status in ready:
367 return payload
368
369 if status in failed:
370 raise RuntimeError(f"Remote generation failed: {payload}")
371
372 if time.time() - start > timeout_seconds:
373 raise RuntimeError(
374 f"Timed out after {timeout_seconds}s while polling {url}"
375 )
376
377 time.sleep(interval_seconds)
378
378 lines PYTHON