Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Génère les sprites PNG (vues de dessus, style SF Starcraft/Warhammer). | |
| Backend au choix (priorité GCP si configuré) : | |
| - GCP Vertex AI Imagen : définir GCP_PROJECT_ID (et optionnellement GCP_LOCATION) dans .env | |
| - Mistral : définir MISTRAL_API_KEY dans .env | |
| Usage : cd backend && python -m scripts.generate_sprites | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| from pathlib import Path | |
| _backend = Path(__file__).resolve().parent.parent | |
| if str(_backend) not in sys.path: | |
| sys.path.insert(0, str(_backend)) | |
| import logging | |
| import random | |
| import time | |
| from typing import Any | |
| from config import GCP_PROJECT_ID, GCP_LOCATION, MISTRAL_API_KEY | |
| from game.units import UnitType | |
| from game.buildings import BuildingType, BUILDING_DEFS | |
| from game.map import ResourceType | |
| logging.basicConfig(level=logging.INFO) | |
| log = logging.getLogger(__name__) | |
| SPRITES_DIR = _backend / "static" / "sprites" | |
| UNITS_DIR = SPRITES_DIR / "units" | |
| BUILDINGS_DIR = SPRITES_DIR / "buildings" | |
| RESOURCES_DIR = SPRITES_DIR / "resources" | |
| ICONS_DIR = SPRITES_DIR / "icons" | |
| # Taille cible et fond transparent obligatoires | |
| TARGET_SIZE = 128 | |
| # Chroma vert : fond #00ff00 demandé à l'API, détourage sur cette couleur uniquement | |
| CHROMA_GREEN_RGB = (0, 255, 0) # #00ff00 pour le prompt | |
| CHROMA_GREEN_TOLERANCE = 55 # pour vert vif proche de #00ff00 | |
| # Vert "dominant" (G > R et G > B) pour attraper les verts plus foncés renvoyés par Imagen | |
| CHROMA_GREEN_MIN_G = 60 # G >= ce seuil et G dominant → considéré fond vert | |
| CHROMA_GREEN_PROMPT = " The background must be solid green #00ff00 only, like a green screen. Subject in gray/blue/metallic, no white or other background." | |
| # Backoff pour rate limit (429) | |
| BACKOFF_BASE_SEC = 30 | |
| BACKOFF_MAX_SEC = 300 | |
| BACKOFF_MAX_RETRIES = 8 | |
| DELAY_BETWEEN_SPRITES_SEC = 5 # Délai entre chaque génération pour éviter le 429 | |
| # Instructions pour l'agent : PETITE image (128x128), pas de grosse résolution = moins de rate limit | |
| AGENT_INSTRUCTIONS = """You are an image generation assistant for a real-time strategy game (sci-fi, Starcraft/Warhammer style). | |
| When asked to generate a sprite, you MUST use the image generation tool to produce exactly one image. | |
| CRITICAL - SMALL SIZE (to avoid rate limits): | |
| - Generate SMALL, LOW-RESOLUTION images only. For units: exactly 128x128 pixels. For buildings: longest side 128 pixels. Do NOT generate large or high-resolution images (no 512, 1024, etc.). Small output is required. | |
| - MANDATORY transparent background: only the subject has visible pixels; everything else fully transparent (PNG with alpha). No ground, no floor, no solid color. | |
| CRITICAL - VIEW ANGLE (NO EXCEPTIONS): | |
| - Strict top-down view ONLY (bird's eye, camera at exactly 90 degrees directly above the subject). This applies to EVERY sprite without exception, including soldiers, mechs, vehicles, and buildings. | |
| - You must ONLY see the top surface/footprint of the subject. No side view, no 3/4 view, no isometric, no perspective angle. | |
| - For units with weapons (marine, goliath, tank, etc.): the weapon barrel must point straight forward, visible from above as a shape pointing toward the top of the image. | |
| - Reject any tendency to show a front/side/3D perspective — the camera is directly overhead, period. | |
| Other: subject fills frame, minimal margins. Dark metallic, blue/gray palette, industrial sci-fi. No text or labels. One image per request, no commentary.""" | |
| # Vue strictement de dessus + fond transparent + remplir le cadre | |
| _TOP = "Strict top-down view only (bird's eye, camera directly above, 90°). Only the top surface/footprint visible, no side or perspective. " | |
| _FILL = "MANDATORY green #00ff00 background only (no ground, no floor, no details). Subject must fill the frame tightly, edge to edge. " | |
| # Petite image demandée à l'API pour éviter le rate limit (pas de post-traitement) | |
| _SMALL = "Generate a SMALL low-resolution image only: 128x128 pixels for square sprites, or longest side 128 for rectangles. Tiny size. Do NOT output large or high-resolution (no 512, 1024, etc.). " | |
| # Prompts par unité : petite 128x128, fond transparent | |
| UNIT_PROMPTS: dict[str, str] = { | |
| UnitType.SCV.value: _TOP + _FILL + _SMALL + "Single SCV worker robot seen strictly from directly above at 90 degrees, bird's eye view only. Unit faces upward (toward the top of the image). Top surface visible: mechanical body footprint, two mechanical arms extended sideways, cockpit hatch on top. Industrial gray and blue. Camera is directly overhead, no side or perspective angle.", | |
| UnitType.MARINE.value: _TOP + _FILL + _SMALL + "Single Terran Marine soldier seen strictly from directly above at 90 degrees, bird's eye view only. Unit faces upward (toward the top of the image), rifle/assault gun barrel pointing straight up. Top of armored helmet visible at center, heavy shoulder pads on both sides. Dark blue armor. No side view, no isometric, camera directly overhead.", | |
| UnitType.MEDIC.value: _TOP + _FILL + _SMALL + "Single Terran Medic soldier seen strictly from directly above at 90 degrees, bird's eye view only. Unit faces upward (toward the top of the image), medkit or injector pointing straight up. Top of helmet visible at center, white/light armor with red cross medical symbol on back. Sci-fi armor. Camera directly overhead, no side or perspective.", | |
| UnitType.GOLIATH.value: _TOP + _FILL + _SMALL + "Goliath combat walker mech seen strictly from directly above at 90 degrees, bird's eye view only. Unit faces upward (toward the top of the image), both autocannon gun pods pointing straight up. Top surface: wide armored torso, two gun pods on shoulders, legs spread below. Dark metal and blue. Camera directly overhead at 90 degrees, no side view.", | |
| UnitType.TANK.value: _TOP + _FILL + _SMALL + "Siege Tank seen strictly from directly above at 90 degrees, bird's eye view only. Tank faces upward (toward the top of the image), cannon barrel pointing straight up. Top surface: elongated armored hull, round turret on top, tank treads visible on both sides. Military gray and blue. Camera directly overhead.", | |
| UnitType.WRAITH.value: _TOP + _FILL + _SMALL + "Wraith starfighter aircraft seen strictly from directly above at 90 degrees, bird's eye view only. Aircraft faces upward (toward the top of the image), nose and weapon pods pointing straight up. Top surface: delta wing shape, cockpit bubble at center-front. Dark metallic with blue highlights. Camera directly overhead.", | |
| } | |
| # Icônes UI : style flat symbolique, fond vert chroma pour détourage | |
| ICON_PROMPTS: dict[str, str] = { | |
| "mineral": _FILL + _SMALL + "Flat symbolic icon of a blue mineral crystal, bold angular shape, solid blue-cyan color, thick black outline, 2D game UI icon style. No background, no text, no shading.", | |
| "gas": _FILL + _SMALL + "Flat symbolic icon of a purple vespene gas canister or flask, bold shape, solid purple-violet color, thick black outline, 2D game UI icon style. No green. No background, no text, no shading.", | |
| "supply": _FILL + _SMALL + "Flat symbolic icon of a chevron/arrow pointing up or a small house shape, bold, solid yellow-orange color, thick black outline, 2D game UI icon style. No background, no text, no shading.", | |
| } | |
| # Prompts par ressource : icône top-down 64x64 | |
| RESOURCE_PROMPTS: dict[str, str] = { | |
| ResourceType.MINERAL.value: _TOP + _FILL + _SMALL + "Mineral crystal cluster from above: angular blue-teal crystalline shards, glowing. Sci-fi RTS style. No text.", | |
| ResourceType.GEYSER.value: _TOP + _FILL + _SMALL + "Vespene gas geyser from above: purple/violet glowing vent/crater with purple gas fumes rising. Purple and magenta colors only, NO green. Sci-fi RTS style. No text.", | |
| } | |
| # Prompts par bâtiment : petite image (longest side 128), fond transparent | |
| def _building_prompt(building_type: BuildingType) -> str: | |
| prompts = { | |
| BuildingType.COMMAND_CENTER: _TOP + _FILL + _SMALL + "Command Center roof from above: rectangle, landing pad, antenna. Terran, dark metal and blue.", | |
| BuildingType.SUPPLY_DEPOT: _TOP + _FILL + _SMALL + "Supply Depot from above: three silo tops. Metallic. Transparent background only.", | |
| BuildingType.BARRACKS: _TOP + _FILL + _SMALL + "Barracks from above: rectangular roof, military. Terran.", | |
| BuildingType.ENGINEERING_BAY: _TOP + _FILL + _SMALL + "Engineering Bay from above: tech roof, rounded or angular. Sci-fi lab.", | |
| BuildingType.REFINERY: _TOP + _FILL + _SMALL + "Refinery from above: extractor, pipes/circles. Industrial.", | |
| BuildingType.FACTORY: _TOP + _FILL + _SMALL + "Factory from above: rectangular industrial roof, vehicle bay. Terran, mechanical.", | |
| BuildingType.ARMORY: _TOP + _FILL + _SMALL + "Armory from above: armored roof, angular. Transparent background only.", | |
| BuildingType.STARPORT: _TOP + _FILL + _SMALL + "Starport from above: hangar roof, landing area.", | |
| } | |
| base = prompts.get(building_type, _TOP + _FILL + _SMALL + "Sci-fi building roof, Terran.") | |
| return base + " Transparent background only. Dark metal and blue." | |
| def _is_chroma_green(r: int, g: int, b: int) -> bool: | |
| """True si le pixel est fond vert à détourer : proche de #00ff00 ou vert dominant (G > R, G > B).""" | |
| # Vert vif type #00ff00 | |
| r0, g0, b0 = CHROMA_GREEN_RGB | |
| if ( | |
| abs(r - r0) <= CHROMA_GREEN_TOLERANCE | |
| and abs(g - g0) <= CHROMA_GREEN_TOLERANCE | |
| and abs(b - b0) <= CHROMA_GREEN_TOLERANCE | |
| ): | |
| return True | |
| # Vert plus foncé (Imagen renvoie souvent ~50,127,70) : G dominant et G assez élevé | |
| if g >= CHROMA_GREEN_MIN_G and g >= r and g >= b: | |
| # exclure les gris (r≈g≈b) et garder un vrai vert (g nettement > r ou b) | |
| if r > 100 and b > 100 and abs(r - g) < 30 and abs(b - g) < 30: | |
| return False # gris, pas vert | |
| return True | |
| return False | |
| def _resize_and_make_transparent(data: bytes, out_path: Path, size: int = TARGET_SIZE) -> None: | |
| """Redimensionne à size px (côté max). Rend transparent : fond vert chroma ET blanc/clair. Sauvegarde en PNG.""" | |
| from PIL import Image | |
| import io | |
| img = Image.open(io.BytesIO(data)).convert("RGBA") | |
| w, h = img.size | |
| if w > size or h > size: | |
| scale = size / max(w, h) | |
| nw, nh = max(1, int(w * scale)), max(1, int(h * scale)) | |
| img = img.resize((nw, nh), Image.Resampling.LANCZOS) | |
| pixels = img.load() | |
| for y in range(img.height): | |
| for x in range(img.width): | |
| r, g, b, a = pixels[x, y] | |
| if _is_chroma_green(r, g, b): | |
| pixels[x, y] = (r, g, b, 0) | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| img.save(out_path, "PNG") | |
| # --------------------------------------------------------------------------- | |
| # GCP Vertex AI Imagen (optionnel) | |
| # --------------------------------------------------------------------------- | |
| def _generate_one_vertex(prompt: str, out_path: Path, progress: str = "") -> bool: | |
| """Génère une image via Vertex AI Imagen et la sauvegarde.""" | |
| from google import genai | |
| from google.genai.types import GenerateImagesConfig | |
| name = out_path.stem | |
| for attempt in range(BACKOFF_MAX_RETRIES): | |
| try: | |
| if attempt == 0: | |
| log.info("[%s] Appel Vertex Imagen pour %s...", progress or name, name) | |
| else: | |
| log.info("[%s] Retry %s/%s — Imagen pour %s...", progress or name, attempt + 1, BACKOFF_MAX_RETRIES, name) | |
| # GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION, GOOGLE_GENAI_USE_VERTEXAI set in main() | |
| client = genai.Client(vertexai=True) | |
| vertex_prompt = prompt + CHROMA_GREEN_PROMPT | |
| response = client.models.generate_images( | |
| model="imagen-3.0-fast-generate-001", | |
| prompt=vertex_prompt, | |
| config=GenerateImagesConfig( | |
| numberOfImages=1, | |
| aspectRatio="1:1", | |
| ), | |
| ) | |
| if not response.generated_images or not response.generated_images[0].image: | |
| log.warning("[%s] Réponse Imagen vide pour %s", progress or name, name) | |
| return False | |
| img = response.generated_images[0].image | |
| raw_bytes: bytes | |
| if hasattr(img, "image_bytes") and img.image_bytes is not None: | |
| raw_bytes = img.image_bytes | |
| elif hasattr(img, "save") and callable(img.save): | |
| import io | |
| buf = io.BytesIO() | |
| img.save(buf) | |
| raw_bytes = buf.getvalue() | |
| else: | |
| log.error("[%s] Format image Imagen inattendu pour %s", progress or name, name) | |
| return False | |
| _resize_and_make_transparent(raw_bytes, out_path) | |
| size_kb = out_path.stat().st_size / 1024 | |
| log.info("[%s] OK %s — sauvegardé 128px transparent (%s Ko) → %s", progress or name, name, round(size_kb, 1), out_path) | |
| return True | |
| except Exception as e: | |
| err_msg = str(e) | |
| if ("429" in err_msg or "rate limit" in err_msg.lower() or "resource exhausted" in err_msg.lower()) and attempt < BACKOFF_MAX_RETRIES - 1: | |
| delay = min(BACKOFF_BASE_SEC * (2 ** attempt), BACKOFF_MAX_SEC) | |
| jitter = random.uniform(0, delay * 0.2) | |
| time.sleep(delay + jitter) | |
| else: | |
| log.error("[%s] Échec Imagen pour %s: %s", progress or name, name, err_msg) | |
| return False | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # Mistral (fallback si GCP non configuré) | |
| # --------------------------------------------------------------------------- | |
| def _find_file_id_in_response(response: Any) -> str | None: | |
| """Extrait le file_id du premier ToolFileChunk dans response.outputs.""" | |
| from mistralai.models import ToolFileChunk | |
| for entry in getattr(response, "outputs", []) or []: | |
| content = getattr(entry, "content", None) | |
| if content is None: | |
| continue | |
| chunks = content if isinstance(content, list) else [content] | |
| for chunk in chunks: | |
| if isinstance(chunk, ToolFileChunk) or getattr(chunk, "type", None) == "tool_file": | |
| fid = getattr(chunk, "file_id", None) | |
| if fid: | |
| return fid | |
| return None | |
| def _is_rate_limit_error(e: Exception) -> bool: | |
| msg = str(e).lower() | |
| return "429" in msg or "rate limit" in msg or "too many requests" in msg | |
| def _generate_one(client: Mistral, agent_id: str, prompt: str, out_path: Path, progress: str = "") -> bool: | |
| """Lance une conversation avec l'agent, récupère l'image générée et la sauvegarde. | |
| Exponential backoff sur 429 (rate limit). Génération strictement séquentielle côté appelant.""" | |
| name = out_path.stem | |
| res = None | |
| for attempt in range(BACKOFF_MAX_RETRIES): | |
| try: | |
| if attempt == 0: | |
| log.info("[%s] Appel API Mistral (conversations.start) pour %s...", progress or name, name) | |
| else: | |
| log.info("[%s] Retry %s/%s — conversation pour %s...", progress or name, attempt + 1, BACKOFF_MAX_RETRIES, name) | |
| res = client.beta.conversations.start( | |
| agent_id=agent_id, | |
| inputs=prompt, | |
| ) | |
| log.info("[%s] Réponse reçue pour %s, extraction file_id...", progress or name, name) | |
| break | |
| except Exception as e: | |
| err_msg = str(e) | |
| if _is_rate_limit_error(e) and attempt < BACKOFF_MAX_RETRIES - 1: | |
| delay = min(BACKOFF_BASE_SEC * (2 ** attempt), BACKOFF_MAX_SEC) | |
| jitter = random.uniform(0, delay * 0.2) | |
| wait = delay + jitter | |
| log.warning( | |
| "[%s] RATE LIMIT (429) sur %s — tentative %s/%s. Attente %.0f s avant retry. Détail: %s", | |
| progress or name, name, attempt + 1, BACKOFF_MAX_RETRIES, wait, err_msg[:500], | |
| ) | |
| time.sleep(wait) | |
| else: | |
| log.error("[%s] Échec conversation pour %s (tentative %s): %s", progress or name, name, attempt + 1, err_msg) | |
| return False | |
| if res is None: | |
| return False | |
| file_id = _find_file_id_in_response(res) | |
| if not file_id: | |
| outputs = getattr(res, "outputs", []) | |
| out_types = [type(o).__name__ for o in (outputs if isinstance(outputs, list) else [outputs])] | |
| log.warning("[%s] Aucun file_id dans la réponse pour %s. outputs (%s): %s", progress or name, name, len(outputs), out_types) | |
| return False | |
| log.info("[%s] Téléchargement file_id=%s pour %s...", progress or name, file_id[:12] + "...", name) | |
| data = None | |
| for attempt in range(BACKOFF_MAX_RETRIES): | |
| try: | |
| data = client.files.download(file_id=file_id).read() | |
| break | |
| except Exception as e: | |
| err_msg = str(e) | |
| if _is_rate_limit_error(e) and attempt < BACKOFF_MAX_RETRIES - 1: | |
| delay = min(BACKOFF_BASE_SEC * (2 ** attempt), BACKOFF_MAX_SEC) | |
| jitter = random.uniform(0, delay * 0.2) | |
| wait = delay + jitter | |
| log.warning( | |
| "[%s] RATE LIMIT (429) au téléchargement pour %s — tentative %s/%s. Attente %.0f s. %s", | |
| progress or name, name, attempt + 1, BACKOFF_MAX_RETRIES, wait, err_msg[:300], | |
| ) | |
| time.sleep(wait) | |
| else: | |
| log.error("[%s] Échec téléchargement %s pour %s: %s", progress or name, file_id, name, err_msg) | |
| return False | |
| if data is None: | |
| return False | |
| _resize_and_make_transparent(data, out_path) | |
| size_kb = out_path.stat().st_size / 1024 | |
| log.info("[%s] OK %s — sauvegardé 128px transparent (%s Ko) → %s", progress or name, name, round(size_kb, 1), out_path) | |
| return True | |
| def main() -> None: | |
| import argparse | |
| parser = argparse.ArgumentParser(description="Generate unit/building sprites (Vertex Imagen or Mistral)") | |
| parser.add_argument("--unit", type=str, metavar="ID", help="Generate only this unit (e.g. marine, scv)") | |
| parser.add_argument("--building", type=str, metavar="ID", help="Generate only this building (e.g. barracks)") | |
| parser.add_argument("--resource", type=str, metavar="ID", help="Generate only this resource (e.g. mineral, geyser)") | |
| parser.add_argument("--icon", type=str, metavar="ID", help="Generate only this UI icon (e.g. mineral, gas, supply)") | |
| parser.add_argument("--skip-existing", action="store_true", help="Skip sprites that already exist (do not regenerate)") | |
| args = parser.parse_args() | |
| only_unit = args.unit.strip().lower() if args.unit else None | |
| skip_existing = args.skip_existing | |
| only_building = args.building.strip().lower().replace(" ", "_") if args.building else None | |
| only_resource = args.resource.strip().lower() if args.resource else None | |
| only_icon = args.icon.strip().lower() if args.icon else None | |
| use_vertex = bool(GCP_PROJECT_ID) | |
| if use_vertex: | |
| os.environ["GOOGLE_CLOUD_PROJECT"] = GCP_PROJECT_ID | |
| os.environ["GOOGLE_CLOUD_LOCATION"] = GCP_LOCATION | |
| os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "true" | |
| log.info("Backend: Vertex AI Imagen (project=%s, location=%s)", GCP_PROJECT_ID, GCP_LOCATION) | |
| def do_generate(prompt: str, out: Path, progress: str) -> bool: | |
| return _generate_one_vertex(prompt, out, progress) | |
| else: | |
| if not MISTRAL_API_KEY: | |
| log.error("Aucun backend image configuré. Définir GCP_PROJECT_ID (ex: mtgbinder) ou MISTRAL_API_KEY dans .env") | |
| sys.exit(1) | |
| from mistralai import Mistral | |
| client = Mistral(api_key=MISTRAL_API_KEY) | |
| log.info("Creating Mistral image generation agent...") | |
| try: | |
| agent = client.beta.agents.create( | |
| model="mistral-medium-2505", | |
| name="Sprite Generator", | |
| description="Generates top-down game sprites for units and buildings.", | |
| instructions=AGENT_INSTRUCTIONS, | |
| tools=[{"type": "image_generation"}], | |
| completion_args={"temperature": 0.3, "top_p": 0.95}, | |
| ) | |
| agent_id = agent.id | |
| log.info("Agent id: %s", agent_id) | |
| except Exception as e: | |
| log.exception("Failed to create agent: %s", e) | |
| sys.exit(1) | |
| def do_generate(prompt: str, out: Path, progress: str) -> bool: | |
| return _generate_one(client, agent_id, prompt, out, progress=progress) | |
| only_resource_flag = only_resource is not None | |
| only_unit_flag = only_unit is not None | |
| only_building_flag = only_building is not None | |
| only_icon_flag = only_icon is not None | |
| UNITS_DIR.mkdir(parents=True, exist_ok=True) | |
| BUILDINGS_DIR.mkdir(parents=True, exist_ok=True) | |
| RESOURCES_DIR.mkdir(parents=True, exist_ok=True) | |
| ICONS_DIR.mkdir(parents=True, exist_ok=True) | |
| units_to_run: list = [] | |
| if not only_building_flag and not only_resource_flag and not only_icon_flag: | |
| units_to_run = [ut for ut in UnitType if not only_unit_flag or ut.value == only_unit] | |
| if only_unit_flag and not units_to_run: | |
| log.error("Unknown unit: %s. Valid: %s", only_unit, [u.value for u in UnitType]) | |
| sys.exit(1) | |
| buildings_to_run: list = [] | |
| if not only_unit_flag and not only_resource_flag and not only_icon_flag: | |
| buildings_to_run = [bt for bt in BuildingType if not only_building_flag or bt.value == only_building] | |
| if only_building_flag and not buildings_to_run: | |
| log.error("Unknown building: %s. Valid: %s", only_building, [b.value for b in BuildingType]) | |
| sys.exit(1) | |
| resources_to_run: list = [] | |
| if not only_unit_flag and not only_building_flag and not only_icon_flag: | |
| valid_resources = list(RESOURCE_PROMPTS.keys()) | |
| resources_to_run = [r for r in valid_resources if not only_resource_flag or r == only_resource] | |
| if only_resource_flag and not resources_to_run: | |
| log.error("Unknown resource: %s. Valid: %s", only_resource, valid_resources) | |
| sys.exit(1) | |
| icons_to_run: list = [] | |
| if not only_unit_flag and not only_building_flag and not only_resource_flag: | |
| valid_icons = list(ICON_PROMPTS.keys()) | |
| icons_to_run = [ic for ic in valid_icons if not only_icon_flag or ic == only_icon] | |
| if only_icon_flag and not icons_to_run: | |
| log.error("Unknown icon: %s. Valid: %s", only_icon, valid_icons) | |
| sys.exit(1) | |
| total = len(units_to_run) + len(buildings_to_run) + len(resources_to_run) + len(icons_to_run) | |
| log.info("========== GÉNÉRATION SPRITES ==========") | |
| log.info("Unités à générer: %s (%s)", [u.value for u in units_to_run], len(units_to_run)) | |
| log.info("Bâtiments à générer: %s (%s)", [b.value for b in buildings_to_run], len(buildings_to_run)) | |
| log.info("Ressources à générer: %s (%s)", resources_to_run, len(resources_to_run)) | |
| log.info("Icônes UI à générer: %s (%s)", icons_to_run, len(icons_to_run)) | |
| log.info("Total: %s sprites. Délai entre chaque: %s s.", total, DELAY_BETWEEN_SPRITES_SEC) | |
| log.info("=========================================") | |
| ok, fail = 0, 0 | |
| t0 = time.monotonic() | |
| generated_count = 0 | |
| for i, ut in enumerate(units_to_run): | |
| out = UNITS_DIR / f"{ut.value}.png" | |
| if skip_existing and out.exists(): | |
| log.info("[unit %s/%s] Déjà présent, ignoré: %s", i + 1, len(units_to_run), out.name) | |
| ok += 1 | |
| continue | |
| if generated_count > 0: | |
| log.info("--- Pause %s s (rate limit) ---", DELAY_BETWEEN_SPRITES_SEC) | |
| time.sleep(DELAY_BETWEEN_SPRITES_SEC) | |
| prompt = UNIT_PROMPTS.get(ut.value, _TOP + _FILL + _SMALL + f"{ut.value} unit from above, sci-fi RTS. Unit faces upward (toward the top of the image), weapon pointing straight up. Transparent background only.") | |
| progress = "unit %s/%s" % (i + 1, len(units_to_run)) | |
| if do_generate(prompt, out, progress): | |
| ok += 1 | |
| else: | |
| fail += 1 | |
| log.warning("Échec pour unité %s", ut.value) | |
| generated_count += 1 | |
| for i, bt in enumerate(buildings_to_run): | |
| out = BUILDINGS_DIR / f"{bt.value}.png" | |
| if skip_existing and out.exists(): | |
| log.info("[building %s/%s] Déjà présent, ignoré: %s", i + 1, len(buildings_to_run), out.name) | |
| ok += 1 | |
| continue | |
| if generated_count > 0: | |
| log.info("--- Pause %s s (rate limit) ---", DELAY_BETWEEN_SPRITES_SEC) | |
| time.sleep(DELAY_BETWEEN_SPRITES_SEC) | |
| prompt = _building_prompt(bt) | |
| progress = "building %s/%s" % (i + 1, len(buildings_to_run)) | |
| if do_generate(prompt, out, progress): | |
| ok += 1 | |
| else: | |
| fail += 1 | |
| log.warning("Échec pour bâtiment %s", bt.value) | |
| generated_count += 1 | |
| for i, rid in enumerate(resources_to_run): | |
| out = RESOURCES_DIR / f"{rid}.png" | |
| if skip_existing and out.exists(): | |
| log.info("[resource %s/%s] Déjà présent, ignoré: %s", i + 1, len(resources_to_run), out.name) | |
| ok += 1 | |
| continue | |
| if generated_count > 0: | |
| log.info("--- Pause %s s (rate limit) ---", DELAY_BETWEEN_SPRITES_SEC) | |
| time.sleep(DELAY_BETWEEN_SPRITES_SEC) | |
| prompt = RESOURCE_PROMPTS[rid] | |
| progress = "resource %s/%s" % (i + 1, len(resources_to_run)) | |
| if do_generate(prompt, out, progress): | |
| ok += 1 | |
| else: | |
| fail += 1 | |
| log.warning("Échec pour ressource %s", rid) | |
| generated_count += 1 | |
| for i, ic in enumerate(icons_to_run): | |
| out = ICONS_DIR / f"{ic}.png" | |
| if skip_existing and out.exists(): | |
| log.info("[icon %s/%s] Déjà présent, ignoré: %s", i + 1, len(icons_to_run), out.name) | |
| ok += 1 | |
| continue | |
| if generated_count > 0: | |
| log.info("--- Pause %s s (rate limit) ---", DELAY_BETWEEN_SPRITES_SEC) | |
| time.sleep(DELAY_BETWEEN_SPRITES_SEC) | |
| prompt = ICON_PROMPTS[ic] | |
| progress = "icon %s/%s" % (i + 1, len(icons_to_run)) | |
| if do_generate(prompt, out, progress): | |
| ok += 1 | |
| else: | |
| fail += 1 | |
| log.warning("Échec pour icône %s", ic) | |
| generated_count += 1 | |
| elapsed = time.monotonic() - t0 | |
| log.info("========== FIN ==========") | |
| log.info("Réussis: %s, Échecs: %s, Total: %s. Temps: %.1f s.", ok, fail, total, elapsed) | |
| if fail: | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() | |