返回 ppt-master
backend_gemini.py
根目录 / skills / ppt-master / scripts / image_backends / backend_gemini.py
1 #!/usr/bin/env python3
2 """
3 Gemini Image Generation Backend
4
5 Generates or edits images via the Google GenAI API (Gemini).
6 Used by image_gen.py as a backend module.
7
8 Configuration keys:
9 GEMINI_API_KEY (required)
10 GEMINI_BASE_URL (optional) Custom API endpoint for proxy services
11 GEMINI_MODEL (optional) Override default model
12
13 Dependencies:
14 pip install google-genai Pillow
15 """
16
17 import sys
18 from pathlib import Path
19
20 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
21 if str(_SCRIPTS_DIR) not in sys.path:
22 sys.path.insert(0, str(_SCRIPTS_DIR))
23
24 from console_encoding import configure_utf8_stdio # noqa: E402
25
26 configure_utf8_stdio()
27
28 if __name__ == "__main__":
29 print(__doc__)
30 print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend gemini")
31 raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
32
33 import os
34 import time
35 import threading
36 from google import genai
37 from google.genai import types
38 from image_backends.backend_common import (
39 MAX_RETRIES,
40 is_permanent_error,
41 is_rate_limit_error,
42 normalize_image_size,
43 resolve_output_path,
44 retry_delay,
45 save_image_bytes,
46 )
47
48
49 # ╔══════════════════════════════���═══════════════════════════════════╗
50 # ║ Constants ║
51 # ╚═══════════��═════════════════════════════��════════════════════════╝
52
53 VALID_ASPECT_RATIOS = [
54 "1:1", "1:4", "1:8",
55 "2:3", "3:2", "3:4", "4:1", "4:3",
56 "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"
57 ]
58
59 VALID_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
60
61 DEFAULT_MODEL = "gemini-3.1-flash-image"
62
63 GEMINI_2_5_FLASH_IMAGE_MODELS = {
64 "gemini-2.5-flash-image",
65 "gemini-2.5-flash-image-preview",
66 }
67 GEMINI_2_5_VALID_ASPECT_RATIOS = [
68 "1:1", "2:3", "3:2", "3:4", "4:3",
69 "4:5", "5:4", "9:16", "16:9", "21:9",
70 ]
71 GEMINI_2_5_VALID_IMAGE_SIZES = ["1K"]
72
73 MINIMAL_THINKING_MODELS = {
74 "gemini-3.1-flash-image",
75 "gemini-3.1-flash-image-preview",
76 }
77
78 REFERENCE_IMAGE_MIME_TYPES = {
79 ".heic": "image/heic",
80 ".heif": "image/heif",
81 ".jpeg": "image/jpeg",
82 ".jpg": "image/jpeg",
83 ".png": "image/png",
84 ".webp": "image/webp",
85 }
86
87 # Signals to image_gen.py that this backend accepts a reference_image as
88 # multimodal input to the same generate_content request used for generation.
89 SUPPORTS_REFERENCE_IMAGE = True
90
91
92 def _model_id(model: str) -> str:
93 """Return the final model path component for official capability checks."""
94 return (model or "").strip().lower().rsplit("/", 1)[-1]
95
96
97 def _request_image_size(image_size: str) -> str:
98 """Map the public 512px preset to Gemini's API value."""
99 return "512" if image_size == "512px" else image_size
100
101
102 def _validate_model_options(model: str, aspect_ratio: str, image_size: str) -> None:
103 """Enforce limits only for Gemini models with a known narrower contract."""
104 if _model_id(model) not in GEMINI_2_5_FLASH_IMAGE_MODELS:
105 return
106 if aspect_ratio not in GEMINI_2_5_VALID_ASPECT_RATIOS:
107 raise ValueError(
108 f"Invalid aspect ratio '{aspect_ratio}' for {model}. "
109 f"Valid: {GEMINI_2_5_VALID_ASPECT_RATIOS}"
110 )
111 if image_size not in GEMINI_2_5_VALID_IMAGE_SIZES:
112 raise ValueError(
113 f"Invalid image size '{image_size}' for {model}. "
114 f"Valid: {GEMINI_2_5_VALID_IMAGE_SIZES}"
115 )
116
117
118 def _reference_image_mime_type(reference_image: str) -> str:
119 """Validate one local reference image and return its Gemini MIME type."""
120 image_path = Path(reference_image)
121 if not image_path.is_file():
122 raise FileNotFoundError(f"Reference image file not found: {reference_image}")
123
124 mime_type = REFERENCE_IMAGE_MIME_TYPES.get(image_path.suffix.lower())
125 if mime_type is None:
126 extensions = ", ".join(sorted(REFERENCE_IMAGE_MIME_TYPES))
127 raise ValueError(
128 f"Unsupported Gemini reference image format '{image_path.suffix or '<none>'}'. "
129 f"Supported extensions: {extensions}"
130 )
131 return mime_type
132
133
134 def _reference_image_part(reference_image: str) -> types.Part:
135 """Load one supported local image as a Gemini inline-data part."""
136 image_path = Path(reference_image)
137 mime_type = _reference_image_mime_type(reference_image)
138
139 return types.Part.from_bytes(
140 data=image_path.read_bytes(),
141 mime_type=mime_type,
142 )
143
144
145 # ╔══════���═══════════════════════════════════════════════��═══════════╗
146 # ║ Image Generation and Editing ║
147 # ╚══════════════════════════════════════════════════════════════════╝
148
149 def _generate_image(api_key: str, prompt: str,
150 aspect_ratio: str = "1:1", image_size: str = "1K",
151 output_dir: str = None, filename: str = None,
152 model: str = DEFAULT_MODEL, base_url: str = None,
153 reference_image: str = None) -> str:
154 """
155 Image generation or editing via Gemini API (streaming).
156
157 Returns:
158 Path of the saved image file
159
160 Raises:
161 RuntimeError: When generation fails
162 """
163 if base_url:
164 client = genai.Client(api_key=api_key, http_options={'base_url': base_url})
165 else:
166 client = genai.Client(api_key=api_key)
167
168 config_kwargs = {
169 "response_modalities": ["IMAGE"],
170 "image_config": types.ImageConfig(
171 aspect_ratio=aspect_ratio,
172 image_size=_request_image_size(image_size),
173 ),
174 }
175 if _model_id(model) in MINIMAL_THINKING_MODELS:
176 config_kwargs["thinking_config"] = types.ThinkingConfig(
177 thinking_level="MINIMAL",
178 )
179 config = types.GenerateContentConfig(**config_kwargs)
180
181 contents = [prompt]
182 if reference_image is not None:
183 contents.append(_reference_image_part(reference_image))
184
185 mode_label = "Proxy Mode" if base_url else "Official Mode"
186 print(f"[Gemini - {mode_label}]")
187 if base_url:
188 print(f" Base URL: {base_url}")
189 print(f" Model: {model}")
190 print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
191 if reference_image is not None:
192 print(f" Reference: {reference_image}")
193 print(f" Aspect Ratio: {aspect_ratio}")
194 print(f" Image Size: {image_size}")
195 print()
196
197 start_time = time.time()
198 operation = "Editing" if reference_image is not None else "Generating"
199 print(f" [..] {operation}...", end="", flush=True)
200
201 heartbeat_stop = threading.Event()
202
203 def _heartbeat():
204 while not heartbeat_stop.is_set():
205 heartbeat_stop.wait(5)
206 if not heartbeat_stop.is_set():
207 elapsed = time.time() - start_time
208 print(f" {elapsed:.0f}s...", end="", flush=True)
209
210 hb_thread = threading.Thread(target=_heartbeat, daemon=True)
211 hb_thread.start()
212
213 last_image_data = None
214 chunk_count = 0
215 total_bytes = 0
216
217 for chunk in client.models.generate_content_stream(
218 model=model,
219 contents=contents,
220 config=config,
221 ):
222 elapsed = time.time() - start_time
223
224 if chunk.parts is None:
225 continue
226
227 for part in chunk.parts:
228 if part.text is not None:
229 print(f"\n Model says: {part.text}", end="", flush=True)
230 elif part.inline_data is not None:
231 chunk_count += 1
232 data_size = len(part.inline_data.data) if part.inline_data.data else 0
233 total_bytes += data_size
234 size_str = f"{data_size / 1024:.0f}KB" if data_size < 1048576 else f"{data_size / 1048576:.1f}MB"
235 print(f"\n [OK] Chunk #{chunk_count} received ({size_str}, {elapsed:.1f}s)", end="", flush=True)
236 last_image_data = part
237
238 heartbeat_stop.set()
239 hb_thread.join(timeout=1)
240
241 elapsed = time.time() - start_time
242 print(f"\n [DONE] Stream complete ({elapsed:.1f}s, {chunk_count} chunk(s), {total_bytes / 1024:.0f}KB total)")
243
244 if last_image_data is not None and last_image_data.inline_data is not None:
245 if chunk_count > 1:
246 print(f" Keeping the final chunk (highest quality).")
247 path = resolve_output_path(prompt, output_dir, filename, ".png")
248 return save_image_bytes(
249 last_image_data.inline_data.data,
250 path,
251 content_type=getattr(last_image_data.inline_data, "mime_type", None),
252 )
253
254 raise RuntimeError("No image was generated. The server may have refused the request.")
255
256
257 # ╔���═══════════════════════════════════���═════════════════════════════╗
258 # ║ Public Entry Point ║
259 # ╚═════════════���═══════════════════════════���════════════════════════╝
260
261 def generate(prompt: str,
262 aspect_ratio: str = "1:1", image_size: str = "1K",
263 output_dir: str = None, filename: str = None,
264 model: str = None, max_retries: int = MAX_RETRIES,
265 reference_image: str = None) -> str:
266 """
267 Gemini image generation or image-to-image editing with automatic retry.
268
269 Reads credentials from the current process environment or a `.env` file:
270 GEMINI_API_KEY
271 GEMINI_BASE_URL
272 GEMINI_MODEL (optional override)
273
274 Args:
275 prompt: Prompt text (edit instruction when reference_image is set)
276 aspect_ratio: Aspect ratio (e.g. "16:9", "1:1")
277 image_size: Image size ("512px", "1K", "2K", "4K", case-insensitive)
278 output_dir: Output directory
279 filename: Output filename (without extension)
280 model: Model name (default: gemini-3.1-flash-image)
281 max_retries: Maximum number of retries
282 reference_image: Optional source image path. When set, the image and
283 edit instruction are sent together as multimodal input.
284
285 Returns:
286 Path of the saved image file
287 """
288 api_key = os.environ.get("GEMINI_API_KEY")
289 base_url = os.environ.get("GEMINI_BASE_URL")
290
291 if not api_key:
292 raise ValueError(
293 "No API key found. Set GEMINI_API_KEY in the current environment or a .env file."
294 )
295
296 if model is None:
297 model = os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL
298
299 image_size = normalize_image_size(image_size)
300
301 if aspect_ratio not in VALID_ASPECT_RATIOS:
302 raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
303
304 if image_size not in VALID_IMAGE_SIZES:
305 raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
306
307 _validate_model_options(model, aspect_ratio, image_size)
308
309 if reference_image is not None:
310 # Validate local input before entering the retry loop. API failures can
311 # be transient; an unsupported or missing source image cannot be fixed
312 # by resending the same request.
313 _reference_image_mime_type(reference_image)
314
315 last_error = None
316 for attempt in range(max_retries + 1):
317 try:
318 return _generate_image(api_key, prompt,
319 aspect_ratio, image_size, output_dir,
320 filename, model, base_url, reference_image)
321 except Exception as e:
322 last_error = e
323 if is_permanent_error(e):
324 raise
325 if attempt < max_retries and is_rate_limit_error(e):
326 delay = retry_delay(attempt, rate_limited=True)
327 print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
328 f"Waiting {delay}s before retry...")
329 time.sleep(delay)
330 elif attempt < max_retries:
331 delay = retry_delay(attempt, rate_limited=False)
332 print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
333 f"Retrying in {delay}s...")
334 time.sleep(delay)
335 else:
336 break
337
338 raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
339
339 lines PYTHON