返回 ppt-master
image_gen.py
根目录 / skills / ppt-master / scripts / image_gen.py
1 #!/usr/bin/env python3
2 """
3 Unified Image Generation Tool
4
5 Dispatches to the appropriate backend based on explicit provider configuration.
6
7 Backend selection (`IMAGE_BACKEND` in `.env` or the current process environment):
8 IMAGE_BACKEND=gemini -> Gemini backend (google-genai SDK)
9 IMAGE_BACKEND=openai -> OpenAI-compatible backend (raw HTTP via requests)
10 IMAGE_BACKEND=minimax -> MiniMax image backend
11 IMAGE_BACKEND=stability -> Stability AI backend
12 IMAGE_BACKEND=bfl -> Black Forest Labs FLUX backend
13 IMAGE_BACKEND=ideogram -> Ideogram backend
14 IMAGE_BACKEND=qwen -> Alibaba Qwen image backend
15 IMAGE_BACKEND=zhipu -> Zhipu GLM-Image backend
16 IMAGE_BACKEND=volcengine -> Volcengine Seedream backend
17 IMAGE_BACKEND=modelscope -> ModelScope backend
18 IMAGE_BACKEND=siliconflow -> SiliconFlow backend
19 IMAGE_BACKEND=fal -> fal.ai backend
20 IMAGE_BACKEND=replicate -> Replicate backend
21 IMAGE_BACKEND=openrouter -> OpenRouter backend
22
23 Configuration source (process env wins, `.env` is the fallback layer):
24 1. Current process environment variables
25 2. The first `.env` found among:
26 - Current working directory
27 - Skill directory (e.g. `~/.agents/skills/ppt-master/.env`)
28 - Repo root (when running from a clone)
29 - `~/.ppt-master/.env` (user-level config)
30
31 Supported keys:
32 IMAGE_BACKEND (required) backend name
33
34 Provider-specific keys are used for credentials and overrides, for example:
35 GEMINI_API_KEY / GEMINI_MODEL / GEMINI_BASE_URL
36 OPENAI_API_KEY / OPENAI_MODEL / OPENAI_BASE_URL
37 QWEN_API_KEY / QWEN_MODEL / QWEN_BASE_URL
38 ZHIPU_API_KEY / ZHIPU_MODEL / ZHIPU_BASE_URL
39
40 Usage:
41 python3 image_gen.py "prompt" --aspect_ratio 16:9 --image_size 1K -o images/
42 python3 image_gen.py "edit instruction" --reference-image src.png -o images/
43 python3 image_gen.py --manifest project/images/image_prompts.json -o project/images/
44 python3 image_gen.py --list-backends
45 """
46
47 import argparse
48 import concurrent.futures
49 import json
50 import os
51 import re
52 import sys
53 import tempfile
54 import threading
55 import time
56 from pathlib import Path
57
58 from console_encoding import configure_utf8_stdio
59 from config import load_prefixed_env_file, resolve_env_path
60
61 configure_utf8_stdio()
62
63 ENV_PATH = resolve_env_path()
64 IMAGE_ENV_PREFIXES = (
65 "IMAGE_",
66 "GEMINI_",
67 "OPENAI_",
68 "MINIMAX_",
69 "STABILITY_",
70 "BFL_",
71 "IDEOGRAM_",
72 "QWEN_",
73 "DASHSCOPE_",
74 "ZHIPU_",
75 "BIGMODEL_",
76 "VOLCENGINE_",
77 "ARK_",
78 "MODELSCOPE_",
79 "SILICONFLOW_",
80 "FAL_",
81 "REPLICATE_",
82 "OPENROUTER_",
83 )
84 DEPRECATED_IMAGE_KEYS = {
85 "IMAGE_API_KEY",
86 "IMAGE_MODEL",
87 "IMAGE_BASE_URL",
88 }
89
90 # All aspect ratios accepted by the unified CLI
91 # (each backend validates its own subset internally)
92 ALL_ASPECT_RATIOS = [
93 "1:1", "1:4", "1:8",
94 "2:3", "3:2", "3:4", "4:1", "4:3",
95 "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"
96 ]
97
98 ALL_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
99
100 BACKEND_REGISTRY = {
101 "gemini": {
102 "module": "backend_gemini",
103 "tier": "core",
104 "label": "Google Gemini",
105 "default_model": "gemini-3.1-flash-image-preview",
106 "key_hint": "GEMINI_API_KEY",
107 "aliases": ["google"],
108 },
109 "openai": {
110 "module": "backend_openai",
111 "tier": "core",
112 "label": "OpenAI / OpenAI-compatible",
113 "default_model": "gpt-image-2",
114 "key_hint": "OPENAI_API_KEY",
115 "aliases": ["openai-compatible", "openai_compatible"],
116 },
117 "minimax": {
118 "module": "backend_minimax",
119 "tier": "experimental",
120 "label": "MiniMax Image",
121 "default_model": "image-01",
122 "key_hint": "MINIMAX_API_KEY",
123 "aliases": ["minimaxi"],
124 },
125 "qwen": {
126 "module": "backend_qwen",
127 "tier": "core",
128 "label": "Alibaba Qwen Image",
129 "default_model": "qwen-image-2.0-pro",
130 "key_hint": "QWEN_API_KEY / DASHSCOPE_API_KEY",
131 "aliases": ["alibaba", "dashscope"],
132 },
133 "zhipu": {
134 "module": "backend_zhipu",
135 "tier": "core",
136 "label": "Zhipu GLM-Image",
137 "default_model": "glm-image",
138 "key_hint": "ZHIPU_API_KEY / BIGMODEL_API_KEY",
139 "aliases": ["bigmodel", "glm", "glm-image"],
140 },
141 "volcengine": {
142 "module": "backend_volcengine",
143 "tier": "core",
144 "label": "Volcengine Seedream",
145 "default_model": "doubao-seedream-4-5-251128",
146 "key_hint": "VOLCENGINE_API_KEY / ARK_API_KEY",
147 "aliases": ["ark", "doubao", "seedream"],
148 },
149 "modelscope": {
150 "module": "backend_modelscope",
151 "tier": "experimental",
152 "label": "ModelScope",
153 "default_model": "Tongyi-MAI/Z-Image-Turbo",
154 "key_hint": "MODELSCOPE_API_KEY",
155 "aliases": ["modelscope", "model-scope"]
156 },
157 "stability": {
158 "module": "backend_stability",
159 "tier": "extended",
160 "label": "Stability AI",
161 "default_model": "stable-image-core",
162 "key_hint": "STABILITY_API_KEY",
163 "aliases": ["stabilityai", "stability-ai"],
164 },
165 "bfl": {
166 "module": "backend_bfl",
167 "tier": "extended",
168 "label": "Black Forest Labs FLUX",
169 "default_model": "flux-pro-1.1-ultra",
170 "key_hint": "BFL_API_KEY",
171 "aliases": ["flux", "black-forest-labs", "black_forest_labs"],
172 },
173 "ideogram": {
174 "module": "backend_ideogram",
175 "tier": "extended",
176 "label": "Ideogram",
177 "default_model": "ideogram-v3",
178 "key_hint": "IDEOGRAM_API_KEY",
179 },
180 "siliconflow": {
181 "module": "backend_siliconflow",
182 "tier": "experimental",
183 "label": "SiliconFlow",
184 "default_model": "Qwen/Qwen-Image",
185 "key_hint": "SILICONFLOW_API_KEY",
186 "aliases": ["silicon"],
187 },
188 "fal": {
189 "module": "backend_fal",
190 "tier": "experimental",
191 "label": "fal.ai",
192 "default_model": "fal-ai/imagen3/fast",
193 "key_hint": "FAL_KEY / FAL_API_KEY",
194 "aliases": ["fal-ai"],
195 },
196 "replicate": {
197 "module": "backend_replicate",
198 "tier": "experimental",
199 "label": "Replicate",
200 "default_model": "black-forest-labs/flux-1.1-pro",
201 "key_hint": "REPLICATE_API_TOKEN / REPLICATE_API_KEY",
202 },
203 "openrouter": {
204 "module": "backend_openrouter",
205 "tier": "experimental",
206 "label": "OpenRouter",
207 "default_model": "google/gemini-3.1-flash-image-preview",
208 "key_hint": "OPENROUTER_API_KEY",
209 },
210 }
211
212 TIER_ORDER = {"core": 0, "extended": 1, "experimental": 2}
213 SUPPORTED_BACKENDS = tuple(sorted(BACKEND_REGISTRY))
214
215
216 def _load_image_env_file() -> None:
217 """
218 Load image generation config from the resolved `.env` as a fallback layer.
219
220 Existing process environment variables win over `.env`.
221 """
222 replacements = {
223 "IMAGE_API_KEY": "GEMINI_API_KEY / OPENAI_API_KEY / QWEN_API_KEY / ZHIPU_API_KEY / ...",
224 "IMAGE_MODEL": "GEMINI_MODEL / OPENAI_MODEL / QWEN_MODEL / ZHIPU_MODEL / ...",
225 "IMAGE_BASE_URL": "GEMINI_BASE_URL / OPENAI_BASE_URL / QWEN_BASE_URL / ZHIPU_BASE_URL / ...",
226 }
227 deprecated_messages = {
228 key: (
229 "Global image config keys have been removed.\n"
230 f"Use IMAGE_BACKEND plus provider-specific keys instead, such as {replacement}."
231 )
232 for key, replacement in replacements.items()
233 }
234 load_prefixed_env_file(IMAGE_ENV_PREFIXES, deprecated_keys=deprecated_messages)
235
236
237 def _validate_runtime_config() -> None:
238 """Reject deprecated global image variables from any configuration source."""
239 for key in DEPRECATED_IMAGE_KEYS:
240 if key not in os.environ:
241 continue
242 replacement = {
243 "IMAGE_API_KEY": "GEMINI_API_KEY / OPENAI_API_KEY / QWEN_API_KEY / ZHIPU_API_KEY / ...",
244 "IMAGE_MODEL": "GEMINI_MODEL / OPENAI_MODEL / QWEN_MODEL / ZHIPU_MODEL / ...",
245 "IMAGE_BASE_URL": "GEMINI_BASE_URL / OPENAI_BASE_URL / QWEN_BASE_URL / ZHIPU_BASE_URL / ...",
246 }[key]
247 raise ValueError(
248 f"Unsupported image config key: {key}\n"
249 "Global image config keys have been removed.\n"
250 f"Use IMAGE_BACKEND plus provider-specific keys instead, such as {replacement}."
251 )
252
253
254 def _build_backend_aliases() -> dict[str, str]:
255 """Build a lookup from aliases to canonical backend names."""
256 aliases = {}
257 for canonical_name, config in BACKEND_REGISTRY.items():
258 aliases[canonical_name] = canonical_name
259 for alias in config.get("aliases", []):
260 aliases[alias] = canonical_name
261 return aliases
262
263
264 BACKEND_ALIASES = _build_backend_aliases()
265
266
267 _BACKEND_PIP_HINTS = {
268 "gemini": "google-genai",
269 "openai": "openai",
270 }
271
272
273 def _load_backend(canonical_name: str) -> tuple[object, str]:
274 """Import and return the configured backend module."""
275 module_name = f"image_backends.{BACKEND_REGISTRY[canonical_name]['module']}"
276 try:
277 module = __import__(module_name, fromlist=["*"])
278 except ImportError as exc:
279 pip_name = _BACKEND_PIP_HINTS.get(canonical_name, exc.name or "<dependency>")
280 print(
281 f"Error: backend '{canonical_name}' needs a package that is not installed.\n"
282 f"Missing: {exc.name}\n"
283 f"Run: pip install {pip_name}",
284 file=sys.stderr,
285 )
286 sys.exit(1)
287 return module, canonical_name
288
289
290 def _print_backend_list() -> None:
291 """Print supported backends grouped by support tier."""
292 print("Supported image backends:\n")
293 tiers = ("core", "extended", "experimental")
294 for tier in tiers:
295 print(f"{tier.upper()}:")
296 for name, info in sorted(
297 BACKEND_REGISTRY.items(),
298 key=lambda item: (TIER_ORDER[item[1]["tier"]], item[0]),
299 ):
300 if info["tier"] != tier:
301 continue
302 print(
303 f" {name:<12} {info['label']} | default={info['default_model']} | keys={info['key_hint']}"
304 )
305 print()
306 print("Recommendation: prefer CORE backends for everyday PPT generation.")
307 print(f"Config fallback file: {ENV_PATH}")
308
309
310 def _resolve_backend() -> tuple[object, str]:
311 """
312 Determine which backend to use from explicit configuration.
313
314 Returns:
315 A backend module with a generate() function.
316 """
317 backend_name = os.environ.get("IMAGE_BACKEND", "").strip().lower()
318 if backend_name:
319 canonical = BACKEND_ALIASES.get(backend_name)
320 if not canonical:
321 supported = ", ".join(SUPPORTED_BACKENDS)
322 print(f"Error: Unknown IMAGE_BACKEND='{backend_name}'. Supported: {supported}")
323 sys.exit(1)
324 return _load_backend(canonical)
325
326 supported = ", ".join(SUPPORTED_BACKENDS)
327 print(
328 "Error: No image backend configured for Path A (image_gen.py).\n"
329 "\n"
330 "If your host (Codex / Antigravity / Claude Code / etc.) has a native image\n"
331 "generation tool, do NOT run this script — switch to Path B: invoke the host's\n"
332 "image tool directly with the prompts from images/image_prompts.json and save\n"
333 "the outputs to images/<filename>. See references/image-generator.md §7 Path B.\n"
334 "\n"
335 "To use Path A instead, set IMAGE_BACKEND in one of these places:\n"
336 f" 1. Current process environment\n"
337 f" 2. {ENV_PATH}\n"
338 "\n"
339 f"Supported backends: {supported}\n"
340 "\n"
341 "Example:\n"
342 " IMAGE_BACKEND=openai\n"
343 " OPENAI_API_KEY=sk-xxx\n"
344 )
345 sys.exit(1)
346
347
348 _AI_IMAGE_PATH_ROW_RE = re.compile(
349 r"^\s*\|\s*AI Image Acquisition Path\s*\|\s*([^|]+?)\s*\|\s*$",
350 re.MULTILINE,
351 )
352 VALID_AI_IMAGE_ACQUISITION_PATHS = {
353 "api",
354 "auto",
355 "host-native",
356 "manual",
357 }
358
359
360 def _project_design_spec_for_manifest(manifest_path: str) -> Path | None:
361 """Return a project Design Spec for an images/ manifest, when present."""
362 path = Path(manifest_path).resolve()
363 if path.parent.name != "images":
364 return None
365 design_spec = path.parent.parent / "design_spec.md"
366 return design_spec if design_spec.is_file() else None
367
368
369 def _confirmed_image_acquisition_path_for_manifest(
370 manifest_path: str,
371 ) -> str | None:
372 """Return the Design Spec's AI Image Acquisition Path, if present."""
373 design_spec = _project_design_spec_for_manifest(manifest_path)
374 if design_spec is None:
375 return None
376 try:
377 text = design_spec.read_text(encoding="utf-8")
378 except OSError:
379 return None
380 match = _AI_IMAGE_PATH_ROW_RE.search(text)
381 if not match:
382 return None
383 value = match.group(1).strip().lstrip("`*_ ").strip()
384 token_match = re.match(
385 r"^(host[\s_-]*native|api|auto|manual)(?![A-Za-z0-9_-])",
386 value,
387 re.IGNORECASE,
388 )
389 selected = token_match.group(1) if token_match else value
390 return re.sub(r"[\s_]+", "-", selected.strip().lower())
391
392
393 def _guard_confirmed_non_api_path(manifest_path: str) -> None:
394 """Allow project Path A only when its Design Spec explicitly permits it."""
395 design_spec = _project_design_spec_for_manifest(manifest_path)
396 if design_spec is None:
397 return
398 acquisition_path = _confirmed_image_acquisition_path_for_manifest(manifest_path)
399 if acquisition_path not in VALID_AI_IMAGE_ACQUISITION_PATHS:
400 shown = acquisition_path or "(missing)"
401 valid = ", ".join(sorted(VALID_AI_IMAGE_ACQUISITION_PATHS))
402 print(
403 "Error: project manifest mode requires a valid "
404 "AI Image Acquisition Path in design_spec.md §I.\n"
405 f"Found: {shown!r}. Valid values: {valid}.\n"
406 "Return to Generate Step 4 recovery and record the durable "
407 "selection before running Path A."
408 )
409 sys.exit(1)
410 if acquisition_path in {"api", "auto"}:
411 return
412 if acquisition_path == "host-native":
413 print(
414 "Error: Design Spec confirms AI Image Acquisition Path as 'host-native'.\n"
415 "\n"
416 "Do NOT run image_gen.py --manifest for this project. That command is Path A\n"
417 "and may use the configured API/proxy backend. Use the host's native image\n"
418 "generation tool with prompts from images/image_prompts.json, save outputs to\n"
419 "images/<filename>, update each item status to Generated, then run:\n"
420 " python3 scripts/image_gen.py --render-md images/image_prompts.json\n"
421 )
422 else:
423 print(
424 "Error: Design Spec confirms AI Image Acquisition Path as 'manual'.\n"
425 "\n"
426 "Do NOT run image_gen.py --manifest for this project. Render the Markdown\n"
427 "sidecar and hand images/image_prompts.md to the user for external generation:\n"
428 " python3 scripts/image_gen.py --render-md images/image_prompts.json\n"
429 )
430 sys.exit(1)
431
432
433 DEFAULT_MANIFEST_CONCURRENCY = 3
434 MAX_MANIFEST_RATE_LIMIT_ATTEMPTS = 3
435
436 STATUS_PENDING = "Pending"
437 STATUS_GENERATED = "Generated"
438 STATUS_FAILED = "Failed"
439 STATUS_NEEDS_MANUAL = "Needs-Manual"
440 VALID_STATUSES = {STATUS_PENDING, STATUS_GENERATED, STATUS_FAILED, STATUS_NEEDS_MANUAL}
441 RETRYABLE_STATUSES = {STATUS_PENDING, STATUS_FAILED}
442 REQUIRED_ITEM_FIELDS = ("filename", "prompt", "aspect_ratio", "status")
443 VALID_PAGE_ROLES = {"local", "hero_page", "full_page"}
444 VALID_TEXT_POLICIES = {"none", "embedded"}
445 STRUCTURAL_IMAGE_TYPES = {
446 "infographic",
447 "flowchart",
448 "framework",
449 "matrix",
450 "cycle",
451 "funnel",
452 "pyramid",
453 "comparison",
454 "timeline",
455 "map",
456 "scene",
457 }
458 LEGACY_IMAGE_TYPES = {"background", "hero", "portrait", "typography"}
459 EARLY_LEGACY_IMAGE_TYPES = {"illustration", "photography"}
460 VALID_IMAGE_TYPES = (
461 STRUCTURAL_IMAGE_TYPES
462 | LEGACY_IMAGE_TYPES
463 | EARLY_LEGACY_IMAGE_TYPES
464 )
465
466
467 def _validate_bare_output_name(
468 value: str,
469 *,
470 field_name: str,
471 require_extension: bool = False,
472 reject_parent_marker: bool = False,
473 ) -> Path:
474 """Require one cross-platform-safe basename, optionally with an extension."""
475 value_path = Path(value)
476 if (
477 not value.strip()
478 or value in {".", ".."}
479 or reject_parent_marker and ".." in value
480 or "/" in value
481 or "\\" in value
482 or ":" in value
483 or value_path.is_absolute()
484 or value_path.name != value
485 ):
486 raise ValueError(
487 f"{field_name} must be a bare filename without path components, "
488 f"got {value!r}"
489 )
490 if require_extension and not value_path.suffix:
491 raise ValueError(f"{field_name} must include an extension, got {value!r}")
492 return value_path
493
494
495 def load_manifest(path: str) -> dict:
496 """Load and validate an `image_prompts.json` manifest.
497
498 Schema (top level): {"items": [ ... ]}, optionally with
499 `deck_rendering`, `color_scheme`, `generated_at`.
500
501 Each item requires: `filename`, `prompt`, `aspect_ratio`, `status`.
502 Optional: `image_size`, `model`, `alt_text`, `purpose`, `type`,
503 `page_role`, `text_policy`, `slice_grid`, `slice_names`, `last_error`.
504 """
505 from image_backends.backend_common import normalize_image_size
506
507 try:
508 data = json.loads(Path(path).read_text(encoding="utf-8"))
509 except json.JSONDecodeError as exc:
510 raise ValueError(
511 f"Invalid JSON in {path}: {exc.msg} "
512 f"(line {exc.lineno}, col {exc.colno})"
513 ) from exc
514
515 if not isinstance(data, dict):
516 raise ValueError(
517 f"{path}: top level must be a JSON object, "
518 f"got {type(data).__name__}"
519 )
520
521 for field in ("project", "generated_at", "deck_rendering"):
522 if field not in data:
523 continue
524 if not isinstance(data[field], str) or not data[field].strip():
525 raise ValueError(
526 f"{path}: field '{field}' must be a non-empty string when present"
527 )
528 if "deck_style_anchor" in data:
529 legacy_anchor = data["deck_style_anchor"]
530 if not (
531 isinstance(legacy_anchor, str)
532 and legacy_anchor.strip()
533 or isinstance(legacy_anchor, dict)
534 and legacy_anchor
535 ):
536 raise ValueError(
537 f"{path}: legacy field 'deck_style_anchor' must be a "
538 "non-empty string or object when present"
539 )
540
541 if "color_scheme" in data:
542 color_scheme = data["color_scheme"]
543 if not isinstance(color_scheme, dict) or not color_scheme:
544 raise ValueError(
545 f"{path}: field 'color_scheme' must be a non-empty object when present"
546 )
547 for key, value in color_scheme.items():
548 if (
549 not isinstance(key, str)
550 or not key.strip()
551 or not isinstance(value, str)
552 or not value.strip()
553 ):
554 raise ValueError(
555 f"{path}: color_scheme keys and values must be non-empty strings"
556 )
557
558 items = data.get("items")
559 if not isinstance(items, list) or not items:
560 raise ValueError(f"{path}: 'items' must be a non-empty array")
561
562 claimed_outputs: dict[str, str] = {}
563 seen_stems: set[str] = set()
564 missing_page_role = 0
565 missing_text_policy = 0
566 for i, item in enumerate(items):
567 prefix = f"{path}: items[{i}]"
568 if not isinstance(item, dict):
569 raise ValueError(f"{prefix} must be an object")
570 for field in REQUIRED_ITEM_FIELDS:
571 if field not in item:
572 raise ValueError(f"{prefix} missing required field '{field}'")
573 if not isinstance(item[field], str) or not item[field].strip():
574 raise ValueError(
575 f"{prefix} field '{field}' must be a non-empty string"
576 )
577 if item["status"] not in VALID_STATUSES:
578 raise ValueError(
579 f"{prefix} status '{item['status']}' is invalid. "
580 f"Valid: {sorted(VALID_STATUSES)}"
581 )
582 if item["aspect_ratio"] not in ALL_ASPECT_RATIOS:
583 raise ValueError(
584 f"{prefix} aspect_ratio '{item['aspect_ratio']}' is invalid. "
585 f"Valid: {ALL_ASPECT_RATIOS}"
586 )
587 if "image_size" in item:
588 image_size = item["image_size"]
589 if not isinstance(image_size, str) or not image_size.strip():
590 raise ValueError(
591 f"{prefix} field 'image_size' must be a non-empty string"
592 )
593 normalized_size = normalize_image_size(image_size)
594 if normalized_size not in ALL_IMAGE_SIZES:
595 raise ValueError(
596 f"{prefix} image_size '{image_size}' is invalid. "
597 f"Valid: {ALL_IMAGE_SIZES}"
598 )
599
600 page_role = item.get("page_role")
601 if page_role is None:
602 missing_page_role += 1
603 elif not isinstance(page_role, str) or page_role not in VALID_PAGE_ROLES:
604 raise ValueError(
605 f"{prefix} page_role '{page_role}' is invalid. "
606 f"Valid: {sorted(VALID_PAGE_ROLES)}"
607 )
608
609 text_policy = item.get("text_policy")
610 if text_policy is None:
611 missing_text_policy += 1
612 elif (
613 not isinstance(text_policy, str)
614 or text_policy not in VALID_TEXT_POLICIES
615 ):
616 raise ValueError(
617 f"{prefix} text_policy '{text_policy}' is invalid. "
618 f"Valid: {sorted(VALID_TEXT_POLICIES)}"
619 )
620
621 image_type = item.get("type")
622 if image_type is not None:
623 normalized_type = (
624 image_type.strip().lower()
625 if isinstance(image_type, str)
626 else ""
627 )
628 if normalized_type not in VALID_IMAGE_TYPES:
629 raise ValueError(
630 f"{prefix} type '{image_type}' is invalid. "
631 f"Valid current/legacy values: {sorted(VALID_IMAGE_TYPES)}"
632 )
633
634 for field in ("model", "alt_text", "purpose"):
635 if field in item and (
636 not isinstance(item[field], str) or not item[field].strip()
637 ):
638 raise ValueError(
639 f"{prefix} field '{field}' must be a non-empty string when present"
640 )
641 if "last_error" in item and not isinstance(item["last_error"], str):
642 raise ValueError(f"{prefix} field 'last_error' must be a string")
643 has_slice_grid = "slice_grid" in item
644 has_slice_names = "slice_names" in item
645 slice_outputs: list[str] = []
646 if has_slice_grid != has_slice_names:
647 raise ValueError(
648 f"{prefix} fields 'slice_grid' and 'slice_names' must appear together"
649 )
650 if has_slice_grid:
651 slice_grid = item["slice_grid"]
652 grid_match = (
653 re.fullmatch(r"([1-9]\d*)[xX]([1-9]\d*)", slice_grid.strip())
654 if isinstance(slice_grid, str)
655 else None
656 )
657 if grid_match is None:
658 raise ValueError(
659 f"{prefix} field 'slice_grid' must use positive RxC notation"
660 )
661 slice_names = item["slice_names"]
662 if not isinstance(slice_names, str) or not slice_names.strip():
663 raise ValueError(
664 f"{prefix} field 'slice_names' must be a non-empty string"
665 )
666 names = [name.strip() for name in slice_names.split(",")]
667 if any(not name for name in names):
668 raise ValueError(
669 f"{prefix} field 'slice_names' contains an empty name"
670 )
671 rows, cols = map(int, grid_match.groups())
672 if len(names) != rows * cols:
673 raise ValueError(
674 f"{prefix} field 'slice_names' has {len(names)} names but "
675 f"slice_grid {rows}x{cols} requires {rows * cols}"
676 )
677 normalized_outputs: set[str] = set()
678 for name in names:
679 name_path = _validate_bare_output_name(
680 name,
681 field_name=f"{prefix} slice output name",
682 reject_parent_marker=True,
683 )
684 if name_path.suffix and name_path.suffix.lower() != ".png":
685 raise ValueError(
686 f"{prefix} slice output name {name!r} must omit its "
687 "extension or use .png"
688 )
689 output_name = (
690 name if name_path.suffix else f"{name}.png"
691 ).casefold()
692 if output_name in normalized_outputs:
693 raise ValueError(
694 f"{prefix} field 'slice_names' repeats output "
695 f"{output_name!r}"
696 )
697 normalized_outputs.add(output_name)
698 slice_outputs.append(output_name)
699
700 fname = item["filename"]
701 filename_path = _validate_bare_output_name(
702 fname,
703 field_name=f"{prefix} field 'filename'",
704 require_extension=True,
705 )
706 normalized_filename = fname.casefold()
707 if normalized_filename in claimed_outputs:
708 raise ValueError(
709 f"{prefix} output filename {fname!r} conflicts with "
710 f"{claimed_outputs[normalized_filename]} (case-insensitive)"
711 )
712 claimed_outputs[normalized_filename] = f"manifest output {fname!r}"
713
714 stem = filename_path.stem.casefold()
715 if stem in seen_stems:
716 raise ValueError(
717 f"{prefix} duplicate filename stem '{filename_path.stem}' "
718 "would reuse backend output"
719 )
720 seen_stems.add(stem)
721
722 for output_name in slice_outputs:
723 if output_name in claimed_outputs:
724 raise ValueError(
725 f"{prefix} slice output {output_name!r} conflicts with "
726 f"{claimed_outputs[output_name]} (case-insensitive)"
727 )
728 claimed_outputs[output_name] = (
729 f"slice output {output_name!r} from items[{i}]"
730 )
731
732 legacy_parts = []
733 if missing_page_role:
734 legacy_parts.append(
735 f"{missing_page_role} item(s) missing page_role (resolved as local)"
736 )
737 if missing_text_policy:
738 legacy_parts.append(
739 f"{missing_text_policy} item(s) missing text_policy (resolved as none)"
740 )
741 if legacy_parts:
742 print(
743 f"Warning: {path}: legacy manifest compatibility: "
744 + "; ".join(legacy_parts),
745 file=sys.stderr,
746 )
747
748 return data
749
750
751 def save_manifest(path: str, data: dict) -> None:
752 """Atomically write manifest back to disk (tmp file + rename)."""
753 target = Path(path)
754 fd, tmp_path = tempfile.mkstemp(
755 prefix=target.stem + ".",
756 suffix=".tmp",
757 dir=str(target.parent),
758 )
759 try:
760 with os.fdopen(fd, "w", encoding="utf-8") as f:
761 json.dump(data, f, ensure_ascii=False, indent=2)
762 f.write("\n")
763 os.replace(tmp_path, target)
764 except Exception:
765 try:
766 os.unlink(tmp_path)
767 except OSError:
768 pass
769 raise
770
771
772 def _materialize_manifest_image(saved_path: str, target_path: Path) -> str:
773 """Validate backend output and place it at the manifest's exact target path."""
774 from image_backends.backend_common import (
775 save_image_bytes,
776 validate_image_file,
777 )
778
779 source_path = Path(saved_path)
780 validate_image_file(str(source_path))
781
782 if source_path.resolve() != target_path.resolve():
783 try:
784 image_bytes = source_path.read_bytes()
785 except OSError as exc:
786 raise RuntimeError(
787 f"Could not read image output {source_path}: {exc}"
788 ) from exc
789 save_image_bytes(image_bytes, str(target_path))
790
791 validate_image_file(str(target_path))
792 return str(target_path)
793
794
795 def _run_manifest(manifest: dict, manifest_path: str, backend_module, *,
796 initial_concurrency: int,
797 image_size: str,
798 output_dir: str,
799 model: str | None) -> tuple[int, int, int]:
800 """Run Pending/Failed items through the backend with adaptive concurrency.
801
802 Strategy:
803 - Verify every `Generated` item's target before treating it as done;
804 missing or unreadable output returns to `Failed` for this run.
805 - Start at `initial_concurrency` workers per batch.
806 - On any rate-limit error in a batch, halve concurrency (min 1) and
807 requeue the rate-limited items within a fixed attempt budget.
808 - A rate limit at concurrency 1 or after the budget is exhausted is
809 recorded as `status: Failed` + `last_error`; the current run then stops
810 without switching providers.
811 - Per-item failures are recorded as `status: Failed` + `last_error`
812 and not retried within this run. `Failed` remains retryable and
813 non-terminal; the Step 5 gate must resolve it by rerunning this
814 manifest or marking the item `Needs-Manual`.
815 - Status is written back to the manifest file after each completion;
816 a Ctrl-C in the middle still preserves done items.
817 - `Needs-Manual` items are skipped (user processes them externally).
818
819 Returns (ok_count, failed_count, skipped_count).
820 """
821 manifest_output_dir = Path(manifest_path).resolve().parent
822 if Path(output_dir).resolve() != manifest_output_dir:
823 raise ValueError(
824 "Manifest outputs must stay beside image_prompts.json: "
825 f"expected {manifest_output_dir}, got {Path(output_dir).resolve()}"
826 )
827 output_dir = str(manifest_output_dir)
828
829 from image_backends.backend_common import (
830 is_rate_limit_error,
831 validate_image_file,
832 )
833
834 items = manifest["items"]
835 repaired_generated = False
836 for item in items:
837 if item["status"] != STATUS_GENERATED:
838 continue
839 target_path = Path(output_dir) / item["filename"]
840 try:
841 validate_image_file(str(target_path))
842 except RuntimeError as exc:
843 item["status"] = STATUS_FAILED
844 item["last_error"] = (
845 f"Generated file validation failed: {exc}"
846 )[:500]
847 repaired_generated = True
848 print(
849 f" [RETRY] {item['filename']} was marked Generated but its "
850 f"target is invalid: {exc}"
851 )
852 if repaired_generated:
853 save_manifest(manifest_path, manifest)
854
855 pending_idx = [
856 i for i, it in enumerate(items) if it["status"] in RETRYABLE_STATUSES
857 ]
858 total = len(pending_idx)
859 skipped = len(items) - total
860
861 if total == 0:
862 print(
863 f"[Manifest] Nothing to do — all {len(items)} items already in "
864 "a terminal state (Generated / Needs-Manual)."
865 )
866 return 0, 0, skipped
867
868 print(
869 f"\n[Manifest] {total} item(s) to generate, "
870 f"{skipped} already done. concurrency={initial_concurrency}\n"
871 )
872
873 queue: list[int] = list(pending_idx)
874 ok_count = 0
875 fail_count = 0
876 current = max(1, initial_concurrency)
877 state_lock = threading.Lock()
878 rate_limit_attempts: dict[int, int] = {}
879 stopped_for_rate_limit = False
880
881 def _one(idx: int):
882 item = items[idx]
883 try:
884 saved_path = backend_module.generate(
885 prompt=item["prompt"],
886 aspect_ratio=item["aspect_ratio"],
887 image_size=item.get("image_size", image_size),
888 output_dir=output_dir,
889 filename=Path(item["filename"]).stem,
890 model=item.get("model", model),
891 )
892 saved_path = _materialize_manifest_image(
893 saved_path,
894 Path(output_dir) / item["filename"],
895 )
896 return idx, saved_path, None
897 except Exception as exc: # noqa: BLE001 — backend raises arbitrary types
898 return idx, None, exc
899
900 while queue:
901 batch_size = min(current, len(queue))
902 batch_idx = queue[:batch_size]
903 queue = queue[batch_size:]
904
905 print(
906 f"--- Batch of {batch_size} (concurrency={current}, "
907 f"remaining_after={len(queue)}) ---"
908 )
909
910 rate_limited = False
911 with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as ex:
912 futures = [ex.submit(_one, i) for i in batch_idx]
913 for fut in concurrent.futures.as_completed(futures):
914 idx, saved_path, exc = fut.result()
915 item = items[idx]
916 with state_lock:
917 if exc is None:
918 item["status"] = STATUS_GENERATED
919 item.pop("last_error", None)
920 ok_count += 1
921 print(f" [OK] {item['filename']}")
922 elif is_rate_limit_error(exc):
923 rate_limited = True
924 attempts = rate_limit_attempts.get(idx, 0) + 1
925 rate_limit_attempts[idx] = attempts
926 if (
927 current == 1
928 or attempts >= MAX_MANIFEST_RATE_LIMIT_ATTEMPTS
929 ):
930 boundary = (
931 "serial concurrency reached"
932 if current == 1
933 else "rate-limit attempt budget exhausted"
934 )
935 item["status"] = STATUS_FAILED
936 item["last_error"] = (
937 f"Rate limit persisted ({boundary}; "
938 f"attempt {attempts}): {exc}"
939 )[:500]
940 fail_count += 1
941 stopped_for_rate_limit = True
942 print(
943 f" [FAIL] {item['filename']}: {exc} "
944 f"({boundary}; status=Failed)"
945 )
946 else:
947 queue.append(idx)
948 print(
949 f" [RATE] {item['filename']} — requeued "
950 f"(attempt {attempts}/"
951 f"{MAX_MANIFEST_RATE_LIMIT_ATTEMPTS})"
952 )
953 else:
954 item["status"] = STATUS_FAILED
955 item["last_error"] = str(exc)[:500]
956 fail_count += 1
957 print(
958 f" [FAIL] {item['filename']}: {exc} "
959 "(status=Failed; retry or mark Needs-Manual before Executor)"
960 )
961 save_manifest(manifest_path, manifest)
962
963 if stopped_for_rate_limit:
964 print(
965 "\n Persistent rate limit reached the run boundary. "
966 "Stopping without switching providers; untouched items remain retryable.\n"
967 )
968 break
969 if rate_limited and current > 1 and queue:
970 new_current = max(1, current // 2)
971 print(
972 f"\n ⚠ Rate-limit hit — concurrency {current} → {new_current}, "
973 "pausing 10s before next batch\n"
974 )
975 current = new_current
976 time.sleep(10)
977 elif queue:
978 time.sleep(2)
979
980 run_state = "Stopped" if stopped_for_rate_limit else "Done"
981 remaining_note = ""
982 if stopped_for_rate_limit:
983 remaining = sum(
984 1 for item in items if item["status"] in RETRYABLE_STATUSES
985 )
986 remaining_note = f"; {remaining} item(s) remain retryable"
987 print(
988 f"\n[Manifest] {run_state}: {ok_count} ok / {fail_count} failed "
989 f"({skipped} pre-skipped{remaining_note}). "
990 f"Manifest written to {manifest_path}"
991 )
992 if fail_count:
993 print(
994 "[Manifest] Failed is retryable and non-terminal. "
995 "Resolve failed item(s) by rerunning this manifest or marking them "
996 "Needs-Manual before entering Executor."
997 )
998 return ok_count, fail_count, skipped
999
1000
1001 def _resolve_concurrency(cli_value: int | None) -> int:
1002 """CLI value wins over IMAGE_CONCURRENCY env; default 3."""
1003 if cli_value is not None:
1004 return max(1, cli_value)
1005 env_val = os.environ.get("IMAGE_CONCURRENCY", "").strip()
1006 if env_val.isdigit():
1007 return max(1, int(env_val))
1008 return DEFAULT_MANIFEST_CONCURRENCY
1009
1010
1011 def render_manifest_md(manifest: dict) -> str:
1012 """Render a manifest into the paste-ready Markdown view.
1013
1014 The output is a read-only snapshot of the JSON manifest, intended as a
1015 fallback so a user can copy `Prompt` blocks into ChatGPT / Midjourney
1016 when `--manifest` cannot run (no key, no backend, network down).
1017 """
1018 lines: list[str] = []
1019 lines.append("# Image Generation Prompts")
1020 lines.append("")
1021 lines.append("> Auto-generated from `image_prompts.json` by `image_gen.py --render-md`.")
1022 lines.append("> Do not hand-edit — re-run the command to refresh.")
1023 lines.append("")
1024
1025 project = manifest.get("project")
1026 generated_at = manifest.get("generated_at")
1027 color_scheme = manifest.get("color_scheme") or {}
1028 deck_rendering = manifest.get("deck_rendering")
1029 if not deck_rendering:
1030 legacy_anchor = manifest.get("deck_style_anchor")
1031 if isinstance(legacy_anchor, dict):
1032 deck_rendering = (
1033 legacy_anchor.get("visual_style")
1034 or json.dumps(legacy_anchor, ensure_ascii=False, sort_keys=True)
1035 )
1036 else:
1037 deck_rendering = legacy_anchor
1038
1039 if project:
1040 lines.append(f"> Project: {project}")
1041 if generated_at:
1042 lines.append(f"> Generated: {generated_at}")
1043 if color_scheme:
1044 cs = " | ".join(
1045 f"{k.capitalize()} {v}" for k, v in color_scheme.items()
1046 )
1047 lines.append(f"> Color scheme: {cs}")
1048 if deck_rendering:
1049 lines.append(f"> Deck Rendering: {deck_rendering}")
1050 lines.append("")
1051 lines.append("---")
1052 lines.append("")
1053
1054 for i, item in enumerate(manifest["items"], start=1):
1055 lines.append(f"### Image {i}: {item['filename']}")
1056 lines.append("")
1057 lines.append("| Attribute | Value |")
1058 lines.append("|---|---|")
1059 for label, key in (
1060 ("Purpose", "purpose"),
1061 ("Type", "type"),
1062 ("Page role", "page_role"),
1063 ("Text policy", "text_policy"),
1064 ("Aspect ratio", "aspect_ratio"),
1065 ("Image size", "image_size"),
1066 ("Model", "model"),
1067 ("Slice grid", "slice_grid"),
1068 ("Slice names", "slice_names"),
1069 ("Status", "status"),
1070 ):
1071 value = item.get(key)
1072 if not value and key == "page_role":
1073 value = "local (legacy default)"
1074 elif not value and key == "text_policy":
1075 value = "none (legacy default)"
1076 if value:
1077 lines.append(f"| {label} | {value} |")
1078 if item.get("last_error"):
1079 lines.append(f"| Last error | {item['last_error']} |")
1080 lines.append("")
1081 lines.append("**Prompt**:")
1082 lines.append("")
1083 lines.append(item["prompt"])
1084 lines.append("")
1085 if item.get("alt_text"):
1086 lines.append("**Alt Text**:")
1087 lines.append(f"> {item['alt_text']}")
1088 lines.append("")
1089 lines.append("---")
1090 lines.append("")
1091
1092 return "\n".join(lines).rstrip() + "\n"
1093
1094
1095 def render_manifest_md_to_file(manifest_path: str, manifest: dict | None = None) -> str:
1096 """Render the manifest's Markdown sidecar next to the JSON file.
1097
1098 Returns the written path. If `manifest` is omitted, it is loaded from
1099 `manifest_path` first.
1100 """
1101 if manifest is None:
1102 manifest = load_manifest(manifest_path)
1103 md_path = str(Path(manifest_path).with_suffix(".md"))
1104 Path(md_path).write_text(render_manifest_md(manifest), encoding="utf-8")
1105 return md_path
1106
1107
1108 def main() -> None:
1109 """Run the CLI entry point."""
1110 parser = argparse.ArgumentParser(
1111 description="Generate images using AI image model providers."
1112 )
1113 parser.add_argument(
1114 "prompt", nargs="?", default=None,
1115 help=(
1116 "The text prompt for image generation. With --reference-image, "
1117 "this is the edit instruction (required in that mode)."
1118 )
1119 )
1120 parser.add_argument(
1121 "--aspect_ratio", default="1:1", choices=ALL_ASPECT_RATIOS,
1122 help=f"Aspect ratio. Default: 1:1."
1123 )
1124 parser.add_argument(
1125 "--image_size", default="1K",
1126 help=f"Image size. Choices: {ALL_IMAGE_SIZES}. Default: 1K. (case-insensitive)"
1127 )
1128 parser.add_argument(
1129 "--output", "-o", default=None,
1130 help="Output directory. Default: current directory."
1131 )
1132 parser.add_argument(
1133 "--filename", "-f", default=None,
1134 help="Output filename (without extension). Overrides auto-naming."
1135 )
1136 parser.add_argument(
1137 "--model", "-m", default=None,
1138 help="Model name. Default depends on backend."
1139 )
1140 parser.add_argument(
1141 "--backend", "-b", default=None, choices=SUPPORTED_BACKENDS,
1142 help="Override IMAGE_BACKEND env var."
1143 )
1144 parser.add_argument(
1145 "--list-backends", action="store_true",
1146 help="List available backends grouped by support tier and exit."
1147 )
1148 parser.add_argument(
1149 "--manifest", default=None, metavar="IMAGE_PROMPTS_JSON",
1150 help=(
1151 "Path to image_prompts.json. Runs every Pending/Failed item in "
1152 "parallel; writes status back to the manifest as each completes."
1153 ),
1154 )
1155 parser.add_argument(
1156 "--concurrency", type=int, default=None,
1157 help=(
1158 "Max concurrent requests in --manifest mode. Defaults to "
1159 f"IMAGE_CONCURRENCY env or {DEFAULT_MANIFEST_CONCURRENCY}. "
1160 "Auto-halves on rate-limit; 1 is the serial fallback."
1161 ),
1162 )
1163 parser.add_argument(
1164 "--render-md", dest="render_md", default=None, metavar="IMAGE_PROMPTS_JSON",
1165 help=(
1166 "Render <json>'s read-only Markdown sidecar (image_prompts.md) "
1167 "next to the manifest, then exit. No backend / network needed."
1168 ),
1169 )
1170 parser.add_argument(
1171 "--reference-image", dest="reference_image", default=None, metavar="PATH",
1172 help=(
1173 "Source image for image-to-image editing (single-image mode only). "
1174 "When set, the prompt is used as the edit instruction. Only backends "
1175 "that support editing accept this (currently: openai). Not valid with "
1176 "--manifest / --render-md / --list-backends."
1177 ),
1178 )
1179
1180 args = parser.parse_args()
1181
1182 if args.filename is not None:
1183 try:
1184 _validate_bare_output_name(
1185 args.filename,
1186 field_name="--filename",
1187 )
1188 except ValueError as exc:
1189 parser.error(str(exc))
1190
1191 if args.reference_image is not None:
1192 # Reference editing is a single-image-only enhancement; keep it out of
1193 # the manifest / sidecar / list surfaces entirely.
1194 conflicting = [
1195 name for name, val in (
1196 ("--manifest", args.manifest),
1197 ("--render-md", args.render_md),
1198 ("--list-backends", args.list_backends),
1199 ) if val
1200 ]
1201 if conflicting:
1202 parser.error(
1203 "--reference-image is single-image mode only and cannot be "
1204 f"combined with {', '.join(conflicting)}."
1205 )
1206 if not args.prompt or not args.prompt.strip():
1207 parser.error(
1208 "--reference-image requires a prompt to use as the edit instruction."
1209 )
1210 if not os.path.isfile(args.reference_image):
1211 parser.error(
1212 f"--reference-image file not found: {args.reference_image}"
1213 )
1214
1215 if args.list_backends:
1216 _print_backend_list()
1217 return
1218
1219 if args.render_md:
1220 if not os.path.isfile(args.render_md):
1221 print(f"Error: manifest file not found: {args.render_md}")
1222 sys.exit(1)
1223 try:
1224 manifest = load_manifest(args.render_md)
1225 except ValueError as e:
1226 print(f"Error: {e}")
1227 sys.exit(1)
1228 md_path = render_manifest_md_to_file(args.render_md, manifest)
1229 print(f"Rendered Markdown sidecar: {md_path}")
1230 return
1231
1232 manifest = None
1233 manifest_output_dir = None
1234 if args.manifest:
1235 if not os.path.isfile(args.manifest):
1236 print(f"Error: manifest file not found: {args.manifest}")
1237 sys.exit(1)
1238 _guard_confirmed_non_api_path(args.manifest)
1239 try:
1240 manifest = load_manifest(args.manifest)
1241 except ValueError as e:
1242 print(f"Error: {e}")
1243 sys.exit(1)
1244 manifest_output_dir = Path(args.manifest).resolve().parent
1245 if args.output and Path(args.output).resolve() != manifest_output_dir:
1246 print(
1247 "Error: --output cannot redirect manifest items outside the "
1248 f"manifest directory ({manifest_output_dir})"
1249 )
1250 sys.exit(1)
1251
1252 try:
1253 _load_image_env_file()
1254 _validate_runtime_config()
1255 except ValueError as e:
1256 print(f"Error: {e}")
1257 sys.exit(1)
1258
1259 # CLI --backend overrides the value loaded from .env
1260 if args.backend:
1261 os.environ["IMAGE_BACKEND"] = args.backend
1262
1263 backend, backend_name = _resolve_backend()
1264 print(f"Using backend: {backend_name}\n")
1265
1266 if args.manifest:
1267 concurrency = _resolve_concurrency(args.concurrency)
1268 try:
1269 _, failed, _ = _run_manifest(
1270 manifest, args.manifest, backend,
1271 initial_concurrency=concurrency,
1272 image_size=args.image_size,
1273 output_dir=str(manifest_output_dir),
1274 model=args.model,
1275 )
1276 except KeyboardInterrupt:
1277 print("\n\nInterrupted by user. Partial progress preserved in manifest.")
1278 sys.exit(130)
1279 md_path = render_manifest_md_to_file(args.manifest, manifest)
1280 print(f"Rendered Markdown sidecar: {md_path}")
1281 sys.exit(1 if failed else 0)
1282
1283 # Single-image mode. Backfill the historical default prompt only here, so
1284 # plain generation is byte-for-byte unchanged while edit mode still requires
1285 # an explicit instruction (enforced above).
1286 prompt = args.prompt if args.prompt is not None else "a beautiful landscape"
1287
1288 gen_kwargs = {
1289 "prompt": prompt,
1290 "aspect_ratio": args.aspect_ratio,
1291 "image_size": args.image_size,
1292 "output_dir": args.output,
1293 "filename": args.filename,
1294 "model": args.model,
1295 }
1296 if args.reference_image is not None:
1297 if not getattr(backend, "SUPPORTS_REFERENCE_IMAGE", False):
1298 print(
1299 f"Error: backend '{backend_name}' does not support image editing "
1300 "(--reference-image). Use a backend that does (currently: openai)."
1301 )
1302 sys.exit(1)
1303 gen_kwargs["reference_image"] = args.reference_image
1304
1305 try:
1306 backend.generate(**gen_kwargs)
1307 except (ValueError, FileNotFoundError) as e:
1308 print(f"Error: {e}")
1309 sys.exit(1)
1310 except RuntimeError as e:
1311 print(f"Error: {e}")
1312 sys.exit(1)
1313 except KeyboardInterrupt:
1314 print("\n\nInterrupted by user.")
1315 sys.exit(130)
1316
1317
1318 if __name__ == "__main__":
1319 main()
1320
1320 lines PYTHON