#!/usr/bin/env python3
"""Trasforma le foto del gioco con Images Edit; --dry-run non usa rete né scrive file."""
from __future__ import annotations
import argparse
import base64
import hashlib
import html
import io
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from datetime import datetime, timezone
from urllib.parse import quote
ROOT = Path(__file__).resolve().parents[1]
EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
SIZE = (1536, 864)
DEFAULT_PROMPT = """Use case: style-transfer.
Create a clean background for La Leggenda del Regolo, a point-and-click adventure
set in San Piero a Sieve, Mugello, Tuscany, in late May 2007.
The input photograph is the architectural and composition reference.
Transform it into early-1990s (1993) VGA adventure pixel art: carefully placed
pixel clusters, limited warm palette of about 64 colors, crisp edges, no blur,
no smooth gradients, no photographic texture, no anti-aliasing.
Preserve the actual architecture, perspective, relative building positions,
historic details, openings, road layout and ground slope. Do not mirror the view.
Warm late-May afternoon light, consistent across the game's outdoor scenes.
Output one opaque 1536x864 image, 16:9, full bleed, no frame or letterboxing.
For a non-16:9 source, extend the framing conservatively without stretching
buildings or cropping essential architecture. Do not invent landmarks.
Keep existing foreground ground readable for walking without flattening slopes
or moving roads. Remove people and vehicles to leave an empty background; game
characters will be rendered separately. Remove readable lettering from signs.
No new characters, animals, text, logos, UI, inventory, cursor or watermark.
"""
def positive(value):
number = int(value)
if number <= 0:
raise argparse.ArgumentTypeError('deve essere maggiore di zero')
return number
def atomic_write(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
stream.write(data)
try:
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def save_log(path, records):
atomic_write(path, (json.dumps(records, indent=2, ensure_ascii=False) + '\n').encode())
def read_log(path):
if not path.exists():
return {}
records = json.loads(path.read_text(encoding='utf-8'))
if not isinstance(records, dict):
raise ValueError(f'Registro non valido: {path}')
return records
def photos_in(folder):
if not folder.is_dir():
raise ValueError(f'Cartella originali non trovata: {folder}')
photos = sorted((p for p in folder.iterdir() if p.is_file() and
p.suffix.lower() in EXTENSIONS), key=lambda p: p.name.casefold())
names = set()
for photo in photos:
key = photo.stem.casefold()
if key in names:
raise ValueError(f'Nome duplicato: {photo.stem}. Rinominare le foto con lo stesso nome base.')
names.add(key)
return photos
def prompt_for(photo, folder):
common = folder / 'base.txt'
custom = folder / f'{photo.stem}.txt'
text = common.read_text(encoding='utf-8') if common.exists() else DEFAULT_PROMPT
if custom.exists():
text += '\n\nSCENE-SPECIFIC INSTRUCTIONS:\n' + custom.read_text(encoding='utf-8')
if not text.strip() or len(text) > 32000:
raise ValueError(f'Prompt vuoto o troppo lungo per {photo.name}')
return text
def validate_image(path, expected_size=None):
from PIL import Image
with Image.open(path) as image:
image.load()
if expected_size and (image.size != expected_size or image.format != 'PNG'):
raise ValueError(f'PNG {expected_size[0]}x{expected_size[1]} atteso: {path}')
def normalize_response(raw, destination):
from PIL import Image
with Image.open(raw) as image:
image.load()
if abs(image.width / image.height - 16 / 9) > 0.02:
raise ValueError('Risposta non 16:9: conservata in .responses; serve revisione, nessun ritaglio automatico.')
image = image.convert('RGB')
if image.size != SIZE:
image = image.resize(SIZE, Image.Resampling.NEAREST)
data = io.BytesIO()
image.save(data, format='PNG')
atomic_write(destination, data.getvalue())
def gallery(photos, output, records):
def link(path):
return html.escape(quote(os.path.relpath(path, output), safe='/'), quote=True)
cards = []
for photo in photos:
result = output / f'{photo.stem}.png'
state = records.get(photo.name, {}).get('status', 'da generare')
if result.exists() and state != 'completed':
state = 'file presente (verificare)'
after = (f''
if result.exists() else '
Non ancora generata
') cards.append(f'{html.escape(state)}
' f'San Piero a Sieve · originali e risultati. Le immagini mancanti sono indicate senza anteprime simulate.
''' + '\n'.join(cards) + '\n' atomic_write(output / 'index.html', page.encode()) def run(args): photos = photos_in(args.input_dir) if args.input_dir.resolve() == args.output_dir.resolve(): raise ValueError('Originali e risultati devono avere cartelle diverse.') log_path = args.output_dir / 'registro.json' records = read_log(log_path) if args.gallery_only: gallery(photos, args.output_dir, records) print(f'Confronto: {args.output_dir / "index.html"}') return 0 pending = [p for p in photos if not (args.output_dir / f'{p.stem}.png').exists()] limit = 1 if args.test else args.limit selected = pending[:limit] if limit else pending prompts = {p.name: prompt_for(p, args.prompt_dir) for p in selected} cached = {p.name for p in selected if (args.output_dir / '.responses' / f'{p.stem}.png').exists()} api_count = len(selected) - len(cached) print(f'Foto trovate: {len(photos)}; PNG già presenti: {len(photos) - len(pending)}') print(f'Da elaborare: {len(selected)}; da cache: {len(cached)}; immagini da inviare all’API: {api_count}') print(f'Modello: {args.model}; formato: 1536x864; qualità: {args.quality}') for photo in selected: print(f' {photo.name} -> {args.output_dir / (photo.stem + ".png")} ' f'[{"cache locale" if photo.name in cached else "Images Edit"}]') if args.dry_run: print('DRY RUN: nessuna chiamata API e nessun file scritto.') return 0 # Validate all existing results and selected sources before any paid request. for photo in photos: result = args.output_dir / f'{photo.stem}.png' if result.exists(): validate_image(result, SIZE) for photo in selected: validate_image(photo) client = None if api_count: if not os.environ.get('OPENAI_API_KEY'): raise ValueError('Imposta OPENAI_API_KEY nell’ambiente. Non inserirla nel codice o nei prompt.') try: import openai except ImportError as exc: raise ValueError('Installa le dipendenze: python3 -m pip install -r requirements.txt') from exc print(f'Possibili costi: {api_count} immagini, fino a {api_count * args.attempts} richieste con i retry.') print('Un timeout può avvenire dopo l’elaborazione: un retry potrebbe comportare un altro addebito.') try: answer = input('Per procedere scrivi GENERA: ') except EOFError: answer = '' if answer.strip() != 'GENERA': print('Annullato: nessuna chiamata API.') return 0 client = openai.OpenAI(timeout=args.timeout, max_retries=0) failures = 0 try: for photo in selected: target = args.output_dir / f'{photo.stem}.png' raw = args.output_dir / '.responses' / f'{photo.stem}.png' previous = records.get(photo.name, {}) record = dict(previous) if raw.exists() else { 'source': str(photo.resolve()), 'output': str(target.resolve()), 'source_sha256': hashlib.sha256(photo.read_bytes()).hexdigest(), 'prompt': prompts[photo.name], 'model': args.model, 'size': '1536x864', 'quality': args.quality, 'attempts': 0, } records[photo.name] = record try: if not raw.exists(): for attempt in range(args.attempts): record.update(status='processing', attempts=record['attempts'] + 1, updated_at=datetime.now(timezone.utc).isoformat()) save_log(log_path, records) try: from PIL import Image, ImageOps # Honor EXIF rotation and supply PNG regardless of source format. with Image.open(photo) as source: upload = io.BytesIO() ImageOps.exif_transpose(source).convert('RGB').save(upload, format='PNG') if upload.tell() >= 50 * 1024 * 1024: raise ValueError('Immagine convertita oltre il limite di 50 MB.') response = client.images.edit( model=args.model, image=(photo.stem + '.png', upload.getvalue(), 'image/png'), prompt=prompts[photo.name], size='1536x864', quality=args.quality, output_format='png', n=1, ) if not response.data or not response.data[0].b64_json: raise ValueError('Risposta senza immagine base64.') # Persist before post-processing, so local errors never require regeneration. atomic_write(raw, base64.b64decode(response.data[0].b64_json, validate=True)) record['request_id'] = getattr(response, '_request_id', None) usage = getattr(response, 'usage', None) if usage is not None: record['usage'] = usage.model_dump(mode='json') break except (openai.APIConnectionError, openai.APIStatusError) as exc: status = getattr(exc, 'status_code', None) transient = status is None or status in (408, 409, 429) or status >= 500 record['last_api_error'] = {'type': type(exc).__name__, 'status_code': status} save_log(log_path, records) if not transient or attempt + 1 == args.attempts: raise delay = min(2 ** (attempt + 1), 60) print(f' {photo.name}: {type(exc).__name__}, nuovo tentativo tra {delay}s.') time.sleep(delay) normalize_response(raw, target) record.update(status='completed', updated_at=datetime.now(timezone.utc).isoformat()) record.pop('error', None) print(f' Completata: {target}') except Exception as exc: failures += 1 # Do not log SDK exception bodies, headers, credentials or base64 payloads. message = str(exc) if isinstance(exc, (ValueError, OSError)) else type(exc).__name__ record.update(status='error', error=message, updated_at=datetime.now(timezone.utc).isoformat()) print(f' Errore {photo.name}: {message}', file=sys.stderr) finally: save_log(log_path, records) gallery(photos, args.output_dir, records) gallery(photos, args.output_dir, records) finally: if client is not None: client.close() return 1 if failures else 0 def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--input-dir', type=Path, default=ROOT / 'assets/raw/foto') parser.add_argument('--output-dir', type=Path, default=ROOT / 'assets/generated') parser.add_argument('--prompt-dir', type=Path, default=ROOT / 'prompts/backgrounds') parser.add_argument('--model', default='gpt-image-2') parser.add_argument('--quality', choices=['low', 'medium', 'high', 'auto'], default='high') parser.add_argument('--timeout', type=positive, default=180) parser.add_argument('--attempts', type=positive, default=3, help='numero massimo di richieste per foto') limit = parser.add_mutually_exclusive_group() limit.add_argument('--test', action='store_true', help='prima foto non ancora completata') limit.add_argument('--limit', type=positive) mode = parser.add_mutually_exclusive_group() mode.add_argument('--dry-run', action='store_true') mode.add_argument('--gallery-only', action='store_true', help='scrive solo il confronto HTML, senza API') args = parser.parse_args(argv) try: return run(args) except (ValueError, OSError, ImportError) as exc: print(f'Errore: {exc}', file=sys.stderr) return 1 except KeyboardInterrupt: print('\nInterrotto. I risultati già salvati verranno riutilizzati.', file=sys.stderr) return 130 if __name__ == '__main__': sys.exit(main())