Come Costruire un Generatore di Immagini Multi-Modello con OpenAI

Un generatore di immagini multi-modello non è un singolo prompt cablato: è un pannello dove modello, dimensione e qualità diventano parametri espliciti, con preset per i casi comuni e una cronologia che tiene traccia di ogni risultato. Vediamo i pezzi fondamentali, con codice reale — incluso il punto dove il prototipo iniziale va corretto prima di andare oltre l'uso locale.

1. La Struttura dell'Interfaccia: un Pannello di Controllo e una Cronologia

Un generatore di immagini multi-modello ha bisogno di due zone ben separate: da un lato i controlli (prompt, modello, dimensione, qualità), dall'altro la cronologia dei risultati. Tenerle in due colonne indipendenti, invece che in un unico form lineare, permette di generare più immagini in sequenza senza perdere le precedenti:

<div class="main-cols">

  <!-- LEFT: pannello di controllo -->
  <div class="col-left">
    <div class="card">
      <div class="field">
        <label>Prompt</label>
        <textarea id="prompt" rows="5" placeholder="Describe the image you want to generate…"></textarea>
      </div>
      <div class="field">
        <label>Model</label>
        <select id="modelSelect"> ... </select>
      </div>
      <!-- Size, Quality: stessa logica, altri <select> -->
    </div>
  </div>

  <!-- RIGHT: cronologia risultati -->
  <div class="col-right">
    <div class="history-header">Generated Images</div>
    <div id="chat-containerIMG">
      <div class="empty-state" id="emptyState">
        <div class="empty-icon">🖼️</div>
        <div>Your generated images will appear here</div>
      </div>
    </div>
  </div>

</div>

Il pannello a sinistra è a larghezza fissa (width: 320px) perché contiene solo controlli, mentre la colonna destra è flex: 1 e scrolla in verticale: la cronologia può crescere all'infinito senza rompere il layout.

Perché conta: separare "input" da "output" in due colonne fisiche, non solo logiche, evita che un prompt lungo o una nuova immagine spostino gli altri controlli — un problema comune nei form single-column con contenuto dinamico.

2. Il Contratto delle Opzioni: Modelli, Dimensioni e Qualità come Parametri

La parte "multi-modello" del generatore non è altro che tre <select> che mappano direttamente sui parametri dell'API di generazione immagini di OpenAI: model, size e quality. Niente testo libero, solo valori enumerati validi:

<div class="field">
  <label>Model</label>
  <select id="modelSelect">
    <option value="gpt-image-1-mini" selected>gpt-image-1-mini</option>
    <option value="gpt-image-1">gpt-image-1</option>
    <option value="gpt-image-1.5">gpt-image-1.5</option>
  </select>
</div>

<div class="field">
  <label>Size</label>
  <select id="sizeSelect">
    <option value="1024x1024" selected>1024×1024 — Square</option>
    <option value="1536x1024">1536×1024 — Landscape</option>
    <option value="1024x1536">1024×1536 — Portrait</option>
  </select>
</div>

<div class="field">
  <label>Quality</label>
  <select id="qualitySelect">
    <option value="low">Low</option>
    <option value="medium" selected>Medium</option>
    <option value="high">High</option>
  </select>
</div>

Tre famiglie di modelli, in ordine di capacità/costo crescente: gpt-image-1-mini (il più economico e veloce, buono per bozze), gpt-image-1 (equilibrio qualità/costo) e gpt-image-1.5 (la versione più capace, indicata quando il risultato finale conta più della velocità). Le tre dimensioni disponibili — quadrata, orizzontale, verticale — coprono la maggior parte degli usi reali senza esporre all'utente rapporti d'aspetto arbitrari che l'API potrebbe rifiutare.

Un dettaglio non ovvio: il parametro quality non è un dettaglio estetico marginale — incide direttamente sul costo per immagine e sul tempo di generazione. Esporlo come scelta esplicita (non un default fisso nel codice) è ciò che rende il pannello davvero "multi-setting" e utile in produzione, dove non tutte le immagini richiedono la qualità massima.

3. Preset Rapidi: Tre Combinazioni Pronte per Casi d'Uso Diversi

Tre <select> indipendenti significano nove possibili combinazioni da impostare a mano ogni volta. Per i casi d'uso più comuni, tre bottoni preset scrivono direttamente i tre valori in un colpo:

<div class="preset-row">
  <div class="btn-preset ultra"   onclick="applyPreset('ultra')">🔥 Ultra</div>
  <div class="btn-preset opt"     onclick="applyPreset('optimized')">⚡ Optimized</div>
  <div class="btn-preset cheap"   onclick="applyPreset('cheap')">💚 Cheap</div>
</div>
function applyPreset(type) {
  const m = document.getElementById("modelSelect");
  const s = document.getElementById("sizeSelect");
  const q = document.getElementById("qualitySelect");
  if (type === "ultra")     { m.value = "gpt-image-1.5";   s.value = "1024x1024"; q.value = "high";   }
  if (type === "optimized") { m.value = "gpt-image-1";     s.value = "1024x1024"; q.value = "medium"; }
  if (type === "cheap")     { m.value = "gpt-image-1-mini"; s.value = "1024x1024"; q.value = "low";   }
}

Ultra punta al modello più capace con qualità alta (per l'immagine finale che conta), Optimized è il compromesso di default (modello intermedio, qualità media), Cheap minimizza costo e tempo (modello mini, qualità bassa) — utile per iterare velocemente su un prompt prima di generare la versione definitiva.

Perché è più di una comodità UI: nominare i preset per intento ("cheap" per iterare, "ultra" per il risultato finale) invece che per parametri tecnici ("modello A, qualità alta") permette a chi usa il pannello di scegliere in base a cosa deve ottenere, non a cosa significano i parametri dell'API.

4. La Chiamata all'API Images: Endpoint e Parametri Dinamici

La chiamata vera e propria va all'endpoint images/generations, con tutti i valori letti dai controlli al momento del click — nessun parametro è hardcoded, così cambiare modello o dimensione nella UI si riflette immediatamente nella richiesta:

const response = await fetch("https://api.openai.com/v1/images/generations", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer " + apikey
  },
  body: JSON.stringify({
    prompt: prompt + " MANDATORY: make sure the image content is not cut, and is all " +
            "included inside the borders of the image with a little internal padding.",
    n: 1,
    size: size,
    model: model,
    moderation: "low",
    quality: quality
  })
});

const data = await response.json();
if (!response.ok) throw new Error(data?.error?.message || response.statusText);
Il trucco nel prompt: notare l'istruzione aggiunta in coda al prompt dell'utente ("make sure the image content is not cut... with a little internal padding"). È un vincolo di prompt engineering, non un parametro dell'API: i modelli di generazione immagini tendono a tagliare i soggetti sui bordi, e questa singola frase, applicata a ogni richiesta, riduce sensibilmente il problema senza dover post-processare l'immagine.

Il parametro moderation: "low" regola la severità del filtro di contenuto lato OpenAI: un valore utile da esporre come impostazione avanzata in un pannello destinato a un pubblico interno, molto meno in un prodotto pubblico dove il default più prudente è preferibile.

5. Gestire la Risposta: URL Temporaneo vs Immagine Base64

L'API di generazione immagini può restituire il risultato in due formati diversi a seconda del modello e della configurazione: un url temporaneo ospitato da OpenAI, oppure i byte dell'immagine direttamente in b64_json. Il codice deve gestire entrambi i casi, perché non è garantito quale dei due arrivi:

let imageSrc;
if (data?.data?.[0]?.url) {
  imageSrc = data.data[0].url;                 // link temporaneo ospitato da OpenAI
} else if (data?.data?.[0]?.b64_json) {
  imageSrc = "data:image/png;base64," + data.data[0].b64_json;  // immagine incorporata
} else {
  throw new Error("No valid image data received.");
}
⚠️ Attenzione agli URL temporanei: quando la risposta contiene url, quel link ha una scadenza (tipicamente breve). Se l'immagine deve essere conservata — non solo mostrata subito nella sessione corrente — va scaricata e salvata lato server non appena arriva, non linkata direttamente a lungo termine.

6. Stato di Caricamento e Cronologia Lato Client

Con chiamate che possono richiedere diversi secondi, l'interfaccia deve comunicare chiaramente cosa sta succedendo: una bolla con il prompt inviato, un indicatore di caricamento, e infine il risultato (o l'errore) al suo posto:

// 1. Bolla con il prompt dell'utente
const promptDiv = document.createElement("div");
promptDiv.className = "user-message";
promptDiv.textContent = `💬 ${prompt}`;
container.appendChild(promptDiv);

// 2. Indicatore di caricamento, rimosso non appena arriva la risposta
const loader = document.createElement("div");
loader.className = "loading-pill";
loader.innerHTML = `<span class="dot"></span> Generating image… this may take a moment`;
container.appendChild(loader);
container.scrollTop = container.scrollHeight;

// ... chiamata API ...

loader.remove();               // tolto sia in caso di successo che di errore
container.appendChild(card);   // card con <img> e link di download

Il pattern è semplice ma efficace: ogni generazione aggiunge due elementi al contenitore (prompt + loader), e il loader viene sempre rimosso — che la chiamata vada a buon fine o fallisca — prima di inserire il risultato finale o un messaggio d'errore. Questo evita loader "fantasma" che restano visibili indefinitamente in caso di eccezione non gestita.

7. Il Punto Critico: la Chiave API Non Deve Vivere nel Browser

Il prototipo iniziale legge la chiave da localStorage e la spedisce direttamente nell'header Authorization da codice eseguito nel browser dell'utente:

// ⚠️ Codice originale del prototipo: la chiave viene letta dal browser
// e spedita così com'è nell'header Authorization.
const apikey = localStorage.getItem("openaikey");

const response = await fetch("https://api.openai.com/v1/images/generations", {
  method: "POST",
  headers: { "Authorization": "Bearer " + apikey },
  // ...
});
⚠️ Perché è un problema serio: qualunque valore accessibile a JavaScript lato client è, di fatto, pubblico — visibile negli strumenti di sviluppo del browser, in eventuali estensioni installate, o intercettabile da chiunque abbia accesso alla macchina. Una chiave API con addebito a consumo, esposta così, può essere copiata e usata da terzi a proprie spese in pochi minuti. Va bene per un prototipo locale mai distribuito, non per nulla che altre persone potranno mai apire in un browser.

La correzione è la stessa vista per la ricerca web nativa: nessuna chiamata diretta a OpenAI dal browser. Il frontend chiama un endpoint proprio, e solo quell'endpoint — in esecuzione sul server, dove le variabili d'ambiente non sono mai visibili al pubblico — conosce la chiave reale:

// server.js — endpoint proxy minimale (Node.js + Express)
// La chiave vive SOLO qui, come variabile d'ambiente sul server.
app.post("/api/generate-image", async (req, res) => {
  const { prompt, model, size, quality } = req.body;

  const openaiRes = await fetch("https://api.openai.com/v1/images/generations", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + process.env.OPENAI_API_KEY   // non lascia mai il server
    },
    body: JSON.stringify({ prompt, model, size, quality, n: 1, moderation: "low" })
  });

  const data = await openaiRes.json();
  res.status(openaiRes.status).json(data);   // il browser riceve solo il risultato
});
// Nel browser: stessa firma di fetch, ma verso il proprio backend —
// nessuna chiave, nessun header Authorization lato client.
const response = await fetch("/api/generate-image", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ prompt, model, size, quality })
});
Il vantaggio aggiuntivo: passare per un endpoint proprio non serve solo a proteggere la chiave — è anche il punto naturale dove aggiungere un limite di generazioni per utente, un log delle richieste, o un controllo sul contenuto del prompt prima di spendere una chiamata a pagamento verso OpenAI.