返回 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 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_rate_limit_error,
41 normalize_image_size,
42 resolve_output_path,
43 retry_delay,
44 save_image_bytes,
45 )
46
47
48 # ╔══════════════════════════════���═══════════════════════════════════╗
49 # ║ Constants ║
50 # ╚═══════════��═════════════════════════════��════════════════════════╝
51
52 VALID_ASPECT_RATIOS = [
53 "1:1", "1:4", "1:8",
54 "2:3", "3:2", "3:4", "4:1", "4:3",
55 "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"
56 ]
57
58 VALID_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
59
60 DEFAULT_MODEL = "gemini-3.1-flash-image-preview"
61
62
63 # ╔══════���═══════════════════════════════════════════════��═══════════╗
64 # ║ Image Generation ║
65 # ╚══════════════════════════════════════════════════════════════════╝
66
67 def _generate_image(api_key: str, prompt: str,
68 aspect_ratio: str = "1:1", image_size: str = "1K",
69 output_dir: str = None, filename: str = None,
70 model: str = DEFAULT_MODEL, base_url: str = None) -> str:
71 """
72 Image generation via Gemini API (streaming).
73
74 Returns:
75 Path of the saved image file
76
77 Raises:
78 RuntimeError: When generation fails
79 """
80 if base_url:
81 client = genai.Client(api_key=api_key, http_options={'base_url': base_url})
82 else:
83 client = genai.Client(api_key=api_key)
84
85 config_kwargs = {
86 "response_modalities": ["IMAGE"],
87 "image_config": types.ImageConfig(
88 aspect_ratio=aspect_ratio,
89 image_size=image_size,
90 ),
91 }
92 if "flash" in model.lower():
93 config_kwargs["thinking_config"] = types.ThinkingConfig(
94 thinking_level="MINIMAL",
95 )
96 config = types.GenerateContentConfig(**config_kwargs)
97
98 mode_label = "Proxy Mode" if base_url else "Official Mode"
99 print(f"[Gemini - {mode_label}]")
100 if base_url:
101 print(f" Base URL: {base_url}")
102 print(f" Model: {model}")
103 print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
104 print(f" Aspect Ratio: {aspect_ratio}")
105 print(f" Image Size: {image_size}")
106 print()
107
108 start_time = time.time()
109 print(f" [..] Generating...", end="", flush=True)
110
111 heartbeat_stop = threading.Event()
112
113 def _heartbeat():
114 while not heartbeat_stop.is_set():
115 heartbeat_stop.wait(5)
116 if not heartbeat_stop.is_set():
117 elapsed = time.time() - start_time
118 print(f" {elapsed:.0f}s...", end="", flush=True)
119
120 hb_thread = threading.Thread(target=_heartbeat, daemon=True)
121 hb_thread.start()
122
123 last_image_data = None
124 chunk_count = 0
125 total_bytes = 0
126
127 for chunk in client.models.generate_content_stream(
128 model=model,
129 contents=[prompt],
130 config=config,
131 ):
132 elapsed = time.time() - start_time
133
134 if chunk.parts is None:
135 continue
136
137 for part in chunk.parts:
138 if part.text is not None:
139 print(f"\n Model says: {part.text}", end="", flush=True)
140 elif part.inline_data is not None:
141 chunk_count += 1
142 data_size = len(part.inline_data.data) if part.inline_data.data else 0
143 total_bytes += data_size
144 size_str = f"{data_size / 1024:.0f}KB" if data_size < 1048576 else f"{data_size / 1048576:.1f}MB"
145 print(f"\n [OK] Chunk #{chunk_count} received ({size_str}, {elapsed:.1f}s)", end="", flush=True)
146 last_image_data = part
147
148 heartbeat_stop.set()
149 hb_thread.join(timeout=1)
150
151 elapsed = time.time() - start_time
152 print(f"\n [DONE] Stream complete ({elapsed:.1f}s, {chunk_count} chunk(s), {total_bytes / 1024:.0f}KB total)")
153
154 if last_image_data is not None and last_image_data.inline_data is not None:
155 if chunk_count > 1:
156 print(f" Keeping the final chunk (highest quality).")
157 path = resolve_output_path(prompt, output_dir, filename, ".png")
158 return save_image_bytes(
159 last_image_data.inline_data.data,
160 path,
161 content_type=getattr(last_image_data.inline_data, "mime_type", None),
162 )
163
164 raise RuntimeError("No image was generated. The server may have refused the request.")
165
166
167 # ╔���═══════════════════════════════════���═════════════════════════════╗
168 # ║ Public Entry Point ║
169 # ╚═════════════���═══════════════════════════���════════════════════════╝
170
171 def generate(prompt: str,
172 aspect_ratio: str = "1:1", image_size: str = "1K",
173 output_dir: str = None, filename: str = None,
174 model: str = None, max_retries: int = MAX_RETRIES) -> str:
175 """
176 Gemini image generation with automatic retry.
177
178 Reads credentials from the current process environment or a `.env` file:
179 GEMINI_API_KEY
180 GEMINI_BASE_URL
181 GEMINI_MODEL (optional override)
182
183 Args:
184 prompt: Prompt text
185 aspect_ratio: Aspect ratio (e.g. "16:9", "1:1")
186 image_size: Image size ("512px", "1K", "2K", "4K", case-insensitive)
187 output_dir: Output directory
188 filename: Output filename (without extension)
189 model: Model name (default: gemini-3.1-flash-image-preview)
190 max_retries: Maximum number of retries
191
192 Returns:
193 Path of the saved image file
194 """
195 api_key = os.environ.get("GEMINI_API_KEY")
196 base_url = os.environ.get("GEMINI_BASE_URL")
197
198 if not api_key:
199 raise ValueError(
200 "No API key found. Set GEMINI_API_KEY in the current environment or a .env file."
201 )
202
203 if model is None:
204 model = os.environ.get("GEMINI_MODEL") or DEFAULT_MODEL
205
206 image_size = normalize_image_size(image_size)
207
208 if aspect_ratio not in VALID_ASPECT_RATIOS:
209 raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
210
211 if image_size not in VALID_IMAGE_SIZES:
212 raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
213
214 last_error = None
215 for attempt in range(max_retries + 1):
216 try:
217 return _generate_image(api_key, prompt,
218 aspect_ratio, image_size, output_dir,
219 filename, model, base_url)
220 except Exception as e:
221 last_error = e
222 if attempt < max_retries and is_rate_limit_error(e):
223 delay = retry_delay(attempt, rate_limited=True)
224 print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
225 f"Waiting {delay}s before retry...")
226 time.sleep(delay)
227 elif attempt < max_retries:
228 delay = retry_delay(attempt, rate_limited=False)
229 print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
230 f"Retrying in {delay}s...")
231 time.sleep(delay)
232 else:
233 break
234
235 raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
236
236 lines PYTHON