diff --git a/CHANGELOG.md b/CHANGELOG.md index d259e8f..2de47d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Non rilasciato — engine v2 + +Il motore impara cose nuove (la versione dell'app non cambia qui: la alza chi pubblica). `engine/versioni.json`: motore 9, quindi al primo avvio l'app aggiorna il motore da sola. Il contratto con l'interfaccia è in `docs/contratto-engine-gui.md`. + +- **FLUX.2 Klein 4B** (Apache-2.0, 4 passi) come quarto modello. Misurato su RTX 4060: Crea 5,4 s contro 6,4 s di Klein 3, zona 21 s contro 25 s, Modifica 9,6 s contro 11,5 s (`docs/bench-engine-v2.md`). Non è il predefinito. +- **LoRA multipli con pesi** su tutti i modelli (Qwen e Klein): in fila, forza da −2 a 2. Il riconoscimento dai nomi dei tensori dice per quale modello è fatto un LoRA (`src/shared/loraBase.ts`); nuovo IPC `lora.compatibili(famiglia)`. +- **Senza sfondo** (BiRefNet, MIT) e **estrai fedele** (SAM 2.1, Apache-2.0): ritagliano dai pixel della tua foto, senza ridisegnarla; i modelli si scaricano al primo uso e girano a parte. Con Klein, «sfondo trasparente» in Crea = disegno + BiRefNet. +- **Riquadri nell'Editor box** (regional prompting): Klein 3 li legge nel suo formato (JSON con le coordinate), Qwen e Klein 4B a parole. Nuovo adattatore dalla richiesta della GUI (`box`, `promptScena`, `sfondo`). +- **Catalogo con licenza e ruolo**, modelli «uncensored» marcati e scaricabili solo su richiesta: interruttore acceso, avviso accettato e licenza valida (Apache, MIT, BSD, CC-BY o Qwen Research con avviso; mai FLUX Kommunity). Un LLM uncensored (Qwen3-VL 4B abliterated) per i testi. +- Correzioni: Klein 4B ha la sua chiave VRAM nella regola «una cosa alla volta»; le parole chiave dei LoRA entrano nel prompt anche con Klein. +- Prove: `npm test` (modelli, catalogo, spazio, lora, regioni, adatta, grafi v2, grafi Qwen identici). + ## 1.1.0 — 2026-10-21 Un'app più semplice da usare: tre modelli al posto delle «qualità», una sola scheda per scrivere cosa cambiare (tutta la foto o solo una zona), le ricette dentro Modifica e il testo scritto dal Qwen3-VL 4B anche con Qwen. Il motore passa a ComfyUI 0.42.3. diff --git a/docs/bench-engine-v2.md b/docs/bench-engine-v2.md new file mode 100644 index 0000000..dfe5b98 --- /dev/null +++ b/docs/bench-engine-v2.md @@ -0,0 +1,38 @@ +# Benchmark engine v2 — prima / dopo + +Misurato il 22/10/2026 su RTX 4060 8 GB (driver 617.42), ComfyUI 0.42.3 (engine installato, motore 8), tutto 1024×1024, stesso prompt e stessa maschera di `scripts/misure_gpu.mjs`. +Grafi veri dell'app (`grafi.ts` impacchettato con esbuild). GPU libera prima di ogni giro (< 15 %), un benchmark alla volta, ComfyUI di prova sulla porta 8819 (non quello dell'app, non quello degli asset: il 8818 era fermo e vuoto). Dati completi: `docs/bench-engine-v2.json`. +Come si ripete: `node scripts/bench_serie.mjs --repo . --out --etichetta --rip 3 --porta 8819 ` con `MOTORE_OUT` = cartella di output del motore (`node scripts/misure_gpu.mjs --prova elenco` stampa le prove). + +**Prima** = Klein 3 veloce, 8 passi (il percorso rapido di prima di engine v2). **Dopo** = FLUX.2 Klein 4B FP8, 4 passi (`Flux2Scheduler` + `CFGGuider`). + +## A caldo (mediana di 3 giri dopo un giro di riscaldamento) + +| Scenario | Modello | Passi | Totale | s/passo | VRAM picco* | Peggiore | +|---|---|---:|---:|---:|---:|---:| +| Crea | Klein 3 veloce (prima) | 8 | 6,4 s | 0,471 | 6,1 GB | 6,4 s | +| Crea | **Klein 4B (dopo)** | 4 | **5,4 s** | 0,572 | 5,3 GB | 5,4 s | +| Crea | Klein 4B, 8 passi (controllo) | 8 | 8,5 s | 0,711 | 7,1 GB | 8,5 s | +| Modifica zona | Klein 3 veloce (prima) | 8 | 24,9 s | 1,873 | 7,4 GB | 25,7 s | +| Modifica zona | **Klein 4B (dopo)** | 4 | **21,3 s** | 2,258 | 6,1 GB | 21,5 s | +| Modifica tutta la foto | Klein 3 veloce (prima) | 8 | 11,5 s | 1,050 | 6,9 GB | 11,5 s | +| Modifica tutta la foto | **Klein 4B (dopo)** | 4 | **9,6 s** | 1,307 | 7,3 GB | 9,6 s | + +## A freddo (1 giro; modelli scaricati con `/free`, i file restano nella cache del disco) + +| Scenario | Modello | Passi | Totale | s/passo | VRAM picco* | +|---|---|---:|---:|---:|---:| +| Crea | Klein 3 veloce | 8 | 9,7 s | 0,468 | 6,8 GB | +| Crea | Klein 4B | 4 | 8,6 s | 0,623 | 6,9 GB | +| Crea | Klein 3 Base (CFG 3,5, riferimento) | 50 | 59,4 s | 1,006 | 6,8 GB | +| Crea | Qwen-Image 2.1 Q4_K_M (riferimento) | 40 | 162,9 s | 2,880 | 5,9 GB | + +\* picco letto con `nvidia-smi` una volta al secondo (include la cache di PyTorch): serve a vedere che nessun giro va fuori memoria, non a confrontare i megabyte fra modelli. Il primissimo avvio a freddo di Klein 4B (file mai letti dal disco) è costato 15,6 s. + +## Che cosa si tiene e che cosa no + +- **Tenuto: Klein 4B come quarto modello.** Guadagno reale ma piccolo: −16 % in Crea, −14 % in zona, −17 % in Modifica, perché ogni passo costa di più (0,57 contro 0,47 s) e a 8 passi il 4B non va più veloce di Klein 3 (8,5 contro 6,4 s). Un'immagine di Crea (teiera) e le due modifiche rosse sono pulite a occhio; **non** è stato fatto un confronto di qualità alla cieca con Klein 3. Non diventa il predefinito: il vantaggio vero è la licenza Apache-2.0, non la velocità. +- **Niente altro è stato aggiunto in nome della velocità.** Le ottimizzazioni suggerite dalla ricerca (SageAttention, `torch.compile`, cache approssimate, Nunchaku/SVDQuant) non sono state installate né misurate: richiedono pacchetti esterni nell'engine produttivo (wheel Windows, graph break con l'offload) e la ricerca stessa le classifica «esperimenti separati». Non c'è un numero a loro favore, quindi non entrano. +- Già in 1.1.0 e non rimisurato qui: il 4B che scrive i testi (185,9 → 133,5 s su «Migliora + Crea» con Qwen), vedi `docs/misure-1.1.0.md`. +- BiRefNet e SAM 2 non sono stati cronometrati: i loro nodi (`DaProdSfondo`, `DaProdSegmenta`) arrivano con il motore 9 e il motore installato qui è l'8. Girano a parte, senza il modello che disegna in VRAM. +- Un incidente di misura, scartato: un secondo `bench_serie` partito per errore in parallelo ha sporcato un primo tentativo; i dati sopra vengono da una ripetizione con un solo benchmark vivo (controllato con la lista dei processi). diff --git a/docs/contratto-engine-gui.md b/docs/contratto-engine-gui.md new file mode 100644 index 0000000..b8880ab --- /dev/null +++ b/docs/contratto-engine-gui.md @@ -0,0 +1,70 @@ +# Contratto engine ↔ GUI (engine v2) + +Chi lo usa: la GUI v2 (`feat/gui-v2`). Chi lo possiede: l'engine (`feat/engine-v2`): **`src/shared/tipi.ts`, `src/shared/modelli.ts`, `src/shared/loraBase.ts`, `src/renderer/src/api.ts`, `src/preload/index.ts` sono dell'engine**. La GUI tocca solo pagine e componenti del renderer; se serve un campo nuovo lo chiede (commento sul task), non lo aggiunge. +Tutto passa da `window.daprod` (tipo `Api` in `api.ts`); i canali IPC sono `area:azione` (`gestisci('lavori:accoda', …)` in `src/main/index.ts`). +Provato da: `npm test` (modelli, catalogo, spazio, lora, regioni, adatta, grafi_v2, confronto grafi Qwen) e `npm run typecheck`. + +## 1. Modelli (famiglie) + +`Famiglia = 'qwen' | 'klein' | 'klein-base' | 'klein4b'`. `IdModello` (`modelli.ts`) ha le stesse chiavi più `qwen-base`; `MODELLI[id]` dà nome, passi, CFG, `lora: true` per tutti. + +| Famiglia | Modello | Passi (bozza/alta/massima) | CFG | Licenza pesi | +|---|---|---|---|---| +| `qwen` | Qwen-Image 2.1 GGUF | 20/40/50 | 1 | Qwen Research (solo ricerca) | +| `klein` | FLUX 3 Klein (INT8) | 8/8/16 | 1 | FLUX Kommunity | +| `klein-base` | FLUX 3 Klein Base | 25/50/64 | 3,5 | FLUX Kommunity | +| `klein4b` | FLUX.2 Klein 4B (FP8) | 4/4/8 | 1 | **Apache-2.0** | + +`Richiesta.famiglia` (se manca vale `Impostazioni.famiglia`). Tre cose da sapere: Klein 4B usa `Flux2Scheduler` + `CFGGuider`; con Klein i testi (migliora prompt, descrivi…) li scrive il Qwen3-VL 4B; nelle opere in galleria il modello è `«FLUX.2 Klein 4B · file»` (`modelloDaOpera` lo riconosce). + +## 2. Richiesta (`lavori.accoda(q): Promise` → id dei lavori) + +Campi comuni: `modalita`, `famiglia`, `prompt`, `negativo`, `seed` (−1 = casuale), `passi`, `cfg`, `larghezza/altezza`, `megapixel`, `immagini[]` (percorsi; [0] è la foto), `quante`, `uscita`, `lora`. + +Modalità: `crea`, `modifica`, `zona` (maschera), `espandi`, `rifinisci`, `varia`, `ingrandisci`, `descrivi` (testo), `traduci`, **`sfondo`**, **`segmenta`**. + +### 2.1 LoRA multipli con pesi — `Richiesta.lora: LoraAttiva[]` +`{ file, forza }`, **l'ordine della lista è l'ordine di applicazione**, forza −2…2 (fuori scala si tronca; 0 = ignorato). Vale per **tutte** le famiglie. Il motore mette un `LoraLoaderModelOnly` per ciascuno, in catena. La parola chiave di ogni LoRA acceso entra nel prompt da sola (`conParoleLora`, ora anche con Klein). +Compatibilità: `InfoLora.famiglie: Famiglia[]` (dai nomi dei tensori, `shared/loraBase.ts`) e `InfoLora.compatibile: 'si'|'no'|'?'`. `vaBeneCon(lora, famiglia)` (shared, importabile dal renderer) dice `si|no|?` (`?` = si lascia provare). Un LoRA di Klein 3 **non** va su Klein 4B (e viceversa): sono architetture diverse. +**IPC nuovo:** `lora.compatibili(famiglia): Promise` = solo i `si` e `?` per quel modello. (`lora.elenco()` resta: tutti.) + +### 2.2 Senza sfondo +- Foto esistente: `modalita: 'sfondo'`, `immagini: [foto]` → PNG RGBA (BiRefNet, MIT). I pixel del soggetto restano quelli originali, i bordi sono ripuliti dagli aloni. Non serve il modello che disegna. +- In Crea: `trasparente: true` oppure `sfondo: 'trasparente'`. Qwen usa il suo canale alfa; **Klein (tutte) genera e poi toglie lo sfondo con BiRefNet** (la GUI può dire «Klein: sfondo tolto dopo»). +- `sfondo: { tinta: '#rrggbb' }` → una riga in più nel prompt («on a plain solid #rrggbb background»). `sfondo: 'scena'` = niente. +- Il primo uso scarica BiRefNet (~450 MB) da solo; si vede nella `fase` del lavoro («Scarico BiRefNet · 40%»). Stato: `modelli.segmentatori()`. + +### 2.3 Estrai personaggi/oggetti (SAM 2) +`modalita: 'segmenta'`, `immagini: [foto]`, `segmenta: { box?: [x0,y0,x1,y1], punti?: {x,y,positivo}[], ritaglia?: boolean }`. Coordinate **0–1 sulla foto**, origine in alto a sinistra. `ritaglia` true (predefinito) = PNG stretto attorno alla cosa (+12 px), false = tela intera col resto trasparente. Senza `box` né `punti` il lavoro fallisce con «dimmi cosa estrarre». Sempre dai pixel originali (nessuna ricreazione). Scarica SAM 2.1 small (~185 MB) al primo uso. +**IPC nuovo:** `modelli.segmentatori(): Promise` = le voci BiRefNet e SAM 2 con `presente`. + +### 2.4 Regional prompting (Editor box) — per `crea` e `modifica` +Due modi equivalenti, il motore normalizza (`adattaRichiesta`, `motore/adatta.ts`): + +1. **`box: BoxRegione[]` + `promptScena`** (quello che manda la GUI v2): `{ id, rect:[x0,y0,x1,y1] (0–1 sulla tela finale), tipo, prompt, rif?, rigidita, ordine, nascosta? }`. Le `nascosta` e quelle senza prompt non partono; l'ordine è `ordine`; `promptScena` (se c'è e ci sono riquadri validi) diventa il `prompt`. +2. `regioni: Regione[]` (`{ id, box, prompt }`) già pronte. + +Traduzione per modello: **Klein 3 / Base** → prompt «didascalia che cita ``» + righe JSON con `bbox [y_min,x_min,y_max,x_max]` su 0–1000 (formato nativo, `modifica`: `tgt_bbox`); **Qwen e Klein 4B** → la posizione a parole («at the top left of the frame»). Al massimo 16 riquadri, minimo 2 % per lato. I prompt dei riquadri vengono tradotti in inglese come quello della scena (se `traduci` è acceso). +**Limiti, detti chiari:** i riquadri sono una *guida morbida* (l'elemento può uscire un po'); `rigidita: 'blocca'` per ora vale `'decisa'`; `rif` (foto di riferimento del box) non è ancora usato dal motore. + +### 2.5 Modifica con istruzioni +`modalita: 'modifica'` (tutta la foto) e `'zona'` (solo la maschera, il resto identico al pixel): il `prompt` è l'istruzione («make the teapot red»). Famiglie Klein: le foto di riferimento sono `immagini[1..]` (``…). + +## 3. Catalogo, licenze, uncensored + +`modelli.stato(): StatoVoce[]` restituisce **tutte** le voci con: `id`, `nome`, `tipo`, `presente`, `byte`, `licenza`, `licenzaUrl`, `ruolo` (`denoiser|encoder|llm|vae|lora|segmentatore|sfondo`), `uncensored?`, `avviso?`. `modelli.scarica(id)` / `annulla(id)` / evento `su.download`. +**La guardia è nel main**, non nella UI (`motivoDiBlocco`): una voce `uncensored` si scarica solo se **tutte e tre** le condizioni valgono, e se no `modelli.scarica` va in errore con il motivo in italiano: +1. `Impostazioni.uncensored === true` (interruttore «Modelli uncensored», spento di serie); +2. `Impostazioni.uncensoredAccettato === true` (ha letto l'avviso, `AVVISO_UNCENSORED` in `catalogo.ts`); +3. **licenza valida** (`motivoLicenza`): Apache-2.0, MIT, BSD-3-Clause, CC-BY-4.0 oppure «Qwen Research» con l'avviso sul limite d'uso. FLUX Kommunity (§4(d), niente scorciatoie per togliere le protezioni), «non-commercial» non letti, licenza o link mancanti = mai. +Nessuna voce uncensored è `consigliato`: non si scarica mai da sola né in idle. Nel catalogo ci sono: encoder Qwen3-VL 8B uncensored (AtomicChat, mradermacher; Apache), LoRA `lora-uc` (Qwen Research, con avviso), LLM **Qwen3-VL 4B abliterated** (Huihui, Apache) per i testi con Klein (`Impostazioni.testoUncensored`, serve anche l'interruttore). **Non c'è un denoiser FLUX uncensored** e non verrà aggiunto. +`Impostazioni.uncensored` è un **booleano**, non l'oggetto `{abilitato, accettatoIl…}` che la GUI v2 aveva ipotizzato: la GUI deve leggere/scrivere `uncensored`, `uncensoredAccettato`, `testoUncensored` con `impostazioni.salva`. + +## 4. Coda, eventi, VRAM +Un lavoro alla volta al motore (`lavori.elenco`, `su.lavori`, `su.lavoro`). Il lavoro ha `fase` (testo in italiano, anche i download di BiRefNet/SAM2), `passo/passiTotali`, `stima`, `anteprima`, `risultati[]` (percorsi in galleria), `errore`. «Una cosa alla volta in VRAM»: cambiando famiglia (Qwen → Klein → Klein 4B) il motore scarica il modello precedente (`/free`) e la fase dice «Scarico FLUX.2 Klein 4B». `sfondo` e `segmenta` non chiedono VRAM (il nodo carica e toglie il segmentatore). + +## 5. File e cartelle da conoscere +`modelli.file()` ora include `klein4b` e `klein4bTe`. Un modello Klein 4B è «pronto» con modello + text encoder (Qwen3 4B) + VAE di FLUX.2 (`kleinVae`, lo stesso di Klein 3); voci del catalogo `klein4b-fp8`, `klein4b-te`, `klein-vae`. + +## 6. Cosa NON c'è (ancora) +`estrai.ritaglia` come IPC a parte: si usa `lavori.accoda` con `modalita: 'segmenta'`. `box.rif` e `rigidita: 'blocca'` non cambiano il risultato. Nessuna prova di qualità con GPU di `sfondo`/`segmenta` sul motore installato (i nodi `DaProdSfondo`/`DaProdSegmenta` arrivano col motore 9: al primo avvio dopo l'aggiornamento l'app aggiorna il motore da sola). diff --git a/docs/gui-v2-spec.md b/docs/gui-v2-spec.md new file mode 100644 index 0000000..f9e3b7e --- /dev/null +++ b/docs/gui-v2-spec.md @@ -0,0 +1,675 @@ +# DaProd Image — GUI v2 «Orbita» · specifica di progetto + +**Stato:** bozza 1 per la revisione di Opus · **Base:** `375ac96` (v1.1.0, `feat/klein-upsampler-veloce`) · **Branch:** `design/gui-v2` · **Autore:** claude-sonnet (task t_4a7955f9, subentrato ad Astra per la quota GPT). +**Letti per scriverla:** `CLAUDE.md`, `rapide.ts`, `ricetteBase.ts`, `preset.ts`, `stile.css`, `Crea.tsx`, `Modifica.tsx` / `SchedaScrivi.tsx`, `ScegliModello.tsx`, `Lora.tsx`, `Impostazioni.tsx`, `shared/modelli.ts`, `util.ts`, e la ricerca di Sol (`docs/ricerca-modelli-2026-10.md`, branch `research/modelli-2026-10`). +**Questo documento non cambia `src/`.** Tutto ciò che qui è «proposta di interfaccia» (campi, IPC) va concordato nel thread dei task engine (t_22f93113) e UI (t_3f06c5f1) prima di scriverlo nel codice. +**Mockup:** `docs/mockup/index.html` (statico, si apre con doppio clic; 4 schermate navigabili). + +--- + +## 0. In una pagina + +| Cosa chiede l'utente | Come lo risolve la v2 | +|---|---| +| GUI «spaziale», moderna, divertente | Tema **Orbita**: spazio profondo, vetro scuro, stelle in parallasse, anelli di luce al posto dei bordi, microcopy da missione. Sempre leggibile: l'effetto sta nello sfondo e nei bordi, mai sul testo (§8, §9) | +| Crea: pulsanti, niente tendine | Ogni scelta è una **tessera** (icona grande + nome corto). Zero `` residui di `Impostazioni.tsx` (`voceScelta`) diventano **segmenti a pulsanti** (profilo memoria: Auto · Bassa · Normale · Alta; traduttore: Opus-MT · LLM; ecc.). + +### 9.2 Laboratorio Uncensored + +``` +┌ LABORATORIO UNCENSORED ───────────────────────────────────────────────────┐ +│ 🔥 Modelli senza filtri di rifiuto [ ◯──── Disattivato ] │ +│ Per scrittura libera e immagini mature fra adulti consenzienti. │ +│ Sono più liberi, non “permessi”: la legge e le licenze valgono sempre. │ +└────────────────────────────────────────────────────────────────────────────┘ +(attivato) CATALOGO PER RUOLO + Ruolo Modello Licenza Peso Stato + Testo (LLM) Qwen3-VL 4B abliterated Apache-2.0 ~4 GB [Scarica] (in valutazione) + Encoder Qwen3-VL 8B Uncensored (GGUF) Apache-2.0 ~5 GB [Scarica] + Immagine Qwen-Image 2.1 Uncensored Q4/6/8 Qwen Research 4–7 GB [Scarica] ⚠ solo uso non commerciale + Immagine FLUX / Klein 3 Kommunity — [Non disponibile] ℹ la licenza vieta di aggirare le protezioni +``` + +**Flusso** +1. L'interruttore apre il **disclaimer** in modale (non si attiva finché non lo si accetta): + > **Prima di continuare.** Questi componenti non rifiutano le richieste e possono produrre contenuti sensibili o inesatti. Usali solo per scopi leciti, con i diritti e il consenso necessari. Sono vietati — e non verranno mai supportati — contenuti sessuali che coinvolgono minori, immagini intime non consensuali, deepfake ingannevoli di persone reali, violenza reale o altri usi illegali. «Uncensored» non significa «senza regole»: restano valide le leggi del tuo Paese e le licenze di ogni modello (che possono vietare l'uso commerciale o la ridistribuzione). Rispondi tu di ciò che crei e pubblichi. + Tre caselle **obbligatorie** (non preselezionate): *Ho almeno 18 anni* · *Userò questi modelli solo per scopi leciti e nel rispetto delle licenze* · *Capisco che chi crea i contenuti ne è responsabile*. Pulsante **Attiva** disabilitato finché non sono tutte accese. Si registra `uncensored.accettatoIl` (data) e `versioneDisclaimer`. +2. Attivato: compare il catalogo. Ogni riga ha **ruolo, licenza (link), peso, stato, pulsante**. Per i modelli con licenza non commerciale (Qwen Research) il download chiede una **seconda conferma** con il testo della licenza e non parte da solo. **Niente download automatici** dell'idle (`idle.ts` li esclude esplicitamente). +3. Nessun file illegale o d'abuso nel catalogo; le voci le approva il team (lista chiusa in `catalogo.ts`, `categoria: 'uncensored'`, con `repo`, `revisione`, `licenza`, `ruolo`). +4. **Dopo il download**, il modello compare nel selettore Motore (Studio e Impostazioni) come tessera con **fascetta Uncensored**. **Non è mai scelto da solo**: al riavvio il motore torna a un modello standard (`modelloScelto` non può essere un uncensored al caricamento), le ricette/lavori in coda non lo usano se non è stato scelto esplicitamente; il tooltip dice *«Scelto da te: nessun filtro di rifiuto»*. +5. **Segnali visivi** quando è in uso: fascetta fissa accanto al pulsante Crea, bordo console in `--corona`, `uncensored: true` nelle impostazioni dentro il PNG (e filtro «solo uncensored» in Galleria per trovarli). +6. **Disattiva:** l'interruttore spento nasconde le tessere (i file restano), e il pulsante **Rimuovi i file** li cancella dopo conferma. + +### 9.3 Decisione aperta (D1 — serve Opus/utente) +La ricerca di Sol (§3) dice che **FLUX 3 Klein** ha licenza Kommunity con §4(d) che vieta di aggirare/rimuovere le protezioni; l'encoder Klein uncensored (AtomicChat) non è utilizzabile nel sampler e non esiste un denoiser Klein «de-safety» legittimo da distribuire. **Proposta per la v2:** il catalogo Uncensored contiene **LLM** (testo/Idee/Migliora) e **Qwen-Image 2.1** (con avviso licenza Research). La riga **Klein/FLUX** compare come *«Non disponibile»* con spiegazione cliccabile. Se l'utente vuole comunque una voce Flux, l'unica via pulita è **FLUX.2 Klein 4B (Apache-2.0)** + un encoder uncensored dalla licenza chiara (oggi nessuno verificato): da valutare da Sol. **Rispondere nel thread** prima che Sonnet scriva il catalogo. + +--- + +## 10. Gestione LoRA (Studio + pagina LoRA) + +**Obiettivi:** caricare più LoRA con forza e ordine per **entrambe** le famiglie, capire a colpo d'occhio cosa è compatibile col modello scelto, provare «con e senza». + +### 10.1 Cassetto «Pila LoRA» (da Studio, pulsante `LoRA N` nella strip) + +``` +┌ PILA LORA · per Klein veloce ───────────────── [+ Aggiungi] ┐ +│ ① ⠿ [thumb] Cinema grain Klein 3 ✓ ━━●━━ 0,70 [S] [👁] ⋯ │ +│ parole chiave: [cngrain] ← clic le inserisce nel prompt │ +│ ② ⠿ [thumb] Visi coerenti Klein 3 ✓ ━●━━━ 0,50 [S] [👁] ⋯ │ +│ ③ ⠿ [thumb] Acquerello XL ⚠ non per Klein (spenta, grigia) │ +│ Suggeriti da te in Modifica: (+) Coerenza ← un clic = in pila │ +│ [ ◧ Prova A/B ] [ Salva come look ▾ ] Forza totale 1,20 ⚠│ +└────────────────────────────────────────────────────────────────────┘ +``` + +- **Slot ordinabili** (trascina `⠿`; l'ordine è l'ordine di applicazione dei `LoraLoaderModelOnly`). Max 8 slot attivi (avviso oltre 4). +- **Compatibilità per architettura**, non per «Flux»: badge `Qwen 2.1` · `Klein 3` · `Klein 2 4B/9B` (se aggiunti) · `Altro (SD/SDXL/Flux 1)` · `Sconosciuto`. Si legge dall'intestazione safetensors (come oggi `lora:info`) e dal sidecar `.json` se c'è. Il LoRA incompatibile col modello in uso è **grigio, con motivo** («fatto per Qwen 2.1, qui usi Klein»), non nascosto; si può comunque tenere in pila per l'altro modello (la pila ha **una vista per modello**: cambiando motore cambia la pila; i LoRA condivisi tra famiglie non esistono). +- **Forza** 0–1,5 (avanzate: −1,0…2,0) con valore di partenza dalla scheda o **0,7** (la ricerca lo propone come default UI). Doppio clic sul cursore = ripristina. Nessuna normalizzazione automatica; la **forza totale > 1,8** mostra solo un avviso. +- **S (solo)** = prova questo LoRA da solo (gli altri in pausa per un lancio); **occhio** = spegni senza rimuovere. +- **Parole chiave** come chip (qui servono davvero): un clic le inserisce una sola volta nel prompt; il motore le aggiunge comunque (`conParoleLora`). Il testo da disegnare e le parole chiave restano separati. +- **Gruppo esclusivo «Acceleratori»** (Turbo/Lightning/Distillazione): se ne può accendere uno solo; i conflitti sono spiegati. +- **Suggeriti** (`ChiaveLora` di `rapide.ts`: coerenza, vestiti, camera): compaiono come slot fantasma quando una categoria di Modifica li chiede, un clic li mette in pila. +- **Prova A/B:** lancia 2 immagini con lo stesso seed, una con la pila e una senza (o con/senza il LoRA selezionato), le mostra affiancate con cursore di confronto (`PrimaDopo`). Costa 2 immagini: l'HUD lo dice. +- **Look:** salva la pila (con forze e ordine) come preset nominato; tessere a icona per richiamarli. +- **Nel PNG** (`png.ts`): per ogni LoRA `hash, file, peso, ordine, base, trigger`; cambiare un peso/ordine invalida le cache (lavoro del motore). + +### 10.2 Pagina LoRA (libreria) +- Griglia di **carte con copertina** (immagine generata in app: l'ultima dove è stato usato + miniatura del creatore, se c'è), filtro a **tessere**: *Tutti · Per questo modello · Qwen · Klein · Stili · Persone · Dettagli · Acceleratori*; ordinamento recenti/nome. +- **Aggiungi:** trascina `.safetensors` ovunque nella finestra (già esiste) · incolla link Hugging Face/Civitai · «Scansiona cartella». Dopo l'import: **assistente a 2 passi** (1. «Per quale modello l'hai preso?» con riconoscimento automatico ma correggibile; 2. parole chiave e forza). I pickle (`.pt/.ckpt`) vengono rifiutati con spiegazione. +- **Consigliati** (`catalogo.ts`): tessere per modello (Qwen 2.1: coerenza, vestiti, camera; Klein 3: da aggiungere dopo il test del loader di Sol). +- **Dettaglio carta:** metadati (base, formato, hash, licenza), storico di uso, pulsanti *Prova A/B*, *Apri la pagina*, *Mostra nella cartella*, *Elimina*. +- **Dati richiesti dall'engine (proposta a Sol):** `lora:info(file) → { architettura, baseRevisione?, formato, sha256, trigger[], pesoConsigliato?, distillazione?: 'turbo'|'lightning'|null, compatibili: IdModello[] }`; `Richiesta.lora: { file, peso, ordine }[]` (esiste in forma più semplice); test del loader Klein prima di abilitare il caricamento. + +--- + +## 11. Cassetto «Ingranaggi» (ispirato a ComfyUI, ma semplice) + +Piccolo pulsante `⚙` accanto al pulsante primario. Apre un cassetto a destra con il **piano di volo**: la catena di passi che l'app farà per questa richiesta, disegnata come **grafo verticale di nodi** (6–10 nodi), solo lettura per la struttura ma con i **valori modificabili sul nodo**. + +``` + ◉ Foto base 1024×768 + │ + ▢ Zona (maschera morbida, contesto 0,8) + │ + ◇ Motore: Klein veloce · 8 passi · CFG 1 [seed 123456 ⚄] + │ └─ LoRA ① Cinema grain 0,70 + ◇ Testo: tradotto in inglese · migliorato + │ + ✦ Campionatore euler/simple + │ + ⤢ Ingrandisce ×1,2 (RTX) → 1536×1024 + │ + ⬒ Salva PNG (alpha) + Galleria +``` +- I nodi sono *derivati dalla richiesta*, non un editor libero: niente cavi da collegare. Ogni nodo mostra 1–3 valori modificabili (passi, CFG, seed, megapixel, forza LoRA, denoise). +- **Modifica ricetta:** «Salva questa catena come ricetta» (Le mie). L'Editor ricette diventa una versione dello stesso grafo con passi riordinabili e ingressi esposti (testo, foto, scelta). +- **Esporta grafo ComfyUI** (`.json` workflow) per chi vuole aprirlo altrove — fase 3, utile per i power user. +- Vale come strumento di diagnosi: se un lavoro fallisce, il nodo in errore è evidenziato in `--errore`. + +--- + +## 12. Micro-interazioni + +Tutte rispettano «Meno movimento» (le durate vanno a ~0 e si spegnono rotazioni e parallasse). + +| Dove | Interazione | Dettaglio | +|---|---|---| +| Tessere | **Hover** | sollevamento 2 px, alone tenue `--alone-nebulosa`, icona che «respira» (scala 1→1,06, 600 ms) | +| Tessere | **Selezione** | anello `--aurora` che compare con un'espansione a molla e poi ruota lentamente (12 s/giro); l'icona passa a `--stella` | +| Tessere | **Pallina valore** | quando una categoria ha scelte attive, una pallina luminosa pulsa una volta e mostra il numero | +| Interruttori | **Accendi** | il pianeta scivola e l'anello si accende; click sonoro opzionale (OFF di default) | +| Cursori | **Trascina** | il pomello-stella allunga una scia; il valore appare in una bolla vetro sopra; rilascio = piccolo «ping» luminoso | +| Strip modi | **Cambio modo** | il pannello a sinistra scorre di 24 px con dissolvenza (220 ms); il palco non salta (stessa tela) | +| Palco | **Rilascio foto** | l'anello «wormhole» si apre dal punto del rilascio e la foto vi entra; invito Modifica/Riferimento | +| Palco | **Cambio formato** | la cornice cambia forma con una molla (240 ms); l'HUD fa rotolare i numeri | +| HUD | **Cambio stato** (nativa/ingrandita/ridotta) | l'icona e il colore cambiano con un lampo breve; la seconda riga si espande/collassa | +| Box | **Nuovo box** | scala .9→1 con alone; bordo tratteggiato che avanza («formiche in marcia») finché è selezionato | +| Box | **Elimina** | si ritrae verso il centro e svanisce (140 ms) | +| Box | **Aggancio** | linee guida ciano a comparsa istantanea, un tick a 60 Hz (nessuna latenza) | +| Pulsante primario | **In corso** | un anello di luce gira attorno al bordo, il testo diventa fase+percentuale («Disegno… 42%») | +| Palco | **Anteprima live** | l'anteprima taesd si sfuoca in ingresso e si «mette a fuoco» con ogni passo (blur 8→0) | +| Risultato | **Finito** | piccola **supernova** (anello che si espande e svanisce, 500 ms) attorno alla miniatura; l'immagine si deposita nella striscia con rimbalzo | +| Errore | **Fallito** | la miniatura trema 2 volte, bordo `--errore`, toast con la causa in italiano e azione «Riprova» | +| Estrai | **Sagoma** | il bordo della maschera scorre come un'aurora (gradiente animato); dopo il ritaglio la cosa «esce» dalla foto con ombra e scorre nel pannello Estratti | +| Impostazioni | **Attiva Uncensored** | la modale fa una breve scossa di avvertimento sul pulsante se le caselle non sono tutte accese; dopo l'attivazione la tessera appare con la fascetta fiamma | +| LoRA | **Trascina slot** | lo slot si solleva, gli altri scorrono; sul rilascio fa un «clic» di aggancio | +| Generale | **Parallasse** | lo starfield si sposta di 2–4 px verso il mouse (rAF, throttle 30 fps) | +| Generale | **Stati vuoti** | l'astronauta fluttua; cliccandolo fa una piroetta (easter egg) | +| Generale | **Easter egg** | digitando «cometa» o ↑↑↓↓ passa una cometa sullo schermo (una volta per sessione) | + +**Microcopy spaziale (ma sempre chiaro)** + +| Momento | Testo | +|---|---| +| Stato vuoto | «Cosa costruiamo oggi?» | +| Fasi del lavoro | «Leggo la foto…» · «Traduco il prompt…» · «Preparo i motori…» · «Disegno…» · «Rifinisco i bordi…» · «Metto in orbita…» | +| Fine | «Atterrato.» (+ tempo) | +| Coda | «In attesa di decollo (n°)» | +| Errore | «Intoppo in orbita: . [Riprova]» | +| Pulsanti | CREA ✦ · MODIFICA ✦ · COMPONI ✦ · LANCIA ✦ (nomi chiari prima del colore) | + +**Budget:** ≤ 4 animazioni continue contemporanee; starfield ≤ 3% CPU; nessun `box-shadow` animato su più di 12 elementi (animare `opacity` di uno pseudo-elemento); mai animare `width/height/top/left`. + +--- + +## 13. Elenco asset da generare (per Sol / Haiku — Flux Klein, modalità veloce) + +**Scopo:** dare un'identità visiva coerente senza appesantire l'installer (< 6 MB totali aggiunti ai `resources/`; WebP qualità 82; PNG con alpha solo dove serve). +**Cartella:** `resources/gui//.webp` (o `.png`); un `resources/gui/README.md` con prompt, seed, modello e data di ogni asset. +**Motore:** Klein veloce 8 passi (stesso modello dell'app), seed fissato, un seed per gruppo. **Stile comune** (prefisso di ogni prompt): *«polished 3D stylized icon, soft glossy materials, deep-space color palette of violet, magenta, cyan and gold, subtle rim light, dark navy background, clean composition, no text»* (per i gruppi con trasparenza: aggiungere *«isolated on plain dark background»* e poi **togliere lo sfondo con BiRefNet** quando l'engine v2 lo permette, altrimenti con il modello Qwen «Rimuovi sfondo» esistente). + +### 13.1 Set di prova (4–6, da mostrare a Opus/Astra PRIMA di generare il resto) +| id | File | Misura | Prompt (dopo il prefisso comune) | +|---|---|---|---| +| `prova-persone` | `tessera/persone.png` | 256² alpha | an astronaut helmet visor reflecting a smiling face, three-quarter view | +| `prova-estrai` | `tessera/estrai.png` | 256² alpha | scissors cutting a glowing outline around a small planet, sparks | +| `prova-sfondo` | `sfondi/nebulosa.webp` | 2560×1440 | deep space nebula in violet and magenta with faint stars, large empty dark area on the right, cinematic, no planet | +| `prova-stile-1` | `stili/anime-film.webp` | 320² | a young woman in a red scarf holding a coffee cup on a city street at sunset, anime movie still style | +| `prova-stile-2` | `stili/vetro.webp` | 320² | the same woman rendered as liquid glass and crystal, refractive translucent material | +| `prova-locandina` | `ricette/foglio.webp` | 512×320 | a character turnaround sheet floating in space, multiple poses of an astronaut fox, neat grid | + +### 13.2 Gruppi completi + +| Gruppo | Cartella | Quanti | Misura | Contenuto | Alpha | +|---|---|---|---|---|---| +| **Sfondi** | `sfondi/` | 6 | 2560×1440 (WebP ≤ 350 kB) | `nebulosa` (principale) · `nebulosa-calda` (oro/magenta) · `stelle` (tile 1024², trasparente, stelle sparse) · `aurora` (banner Impostazioni 1600×400) · `hero-pianeta` (stato vuoto, pianeta in basso a sinistra) · `setup-pianeta` | `stelle`, `hero-pianeta` sì | +| **Tessere Modifica** | `tessera/` | 10 | 256² | Estrai · Persone · Vestiti · Situazioni · Prospettiva · Luce e colore · Stili · Testi · Ritocco · Espandi — un oggetto simbolo 3D per ognuna | sì | +| **Modi** | `modi/` | 4 | 128² | Crea · Modifica · Box · Ricette (versione piccola, per la strip) | sì | +| **Generi di Crea** | `generi/` | 12 | 256² | Foto · Ritratto · Prodotto · Poster · Infografica · Logo · Social · Miniatura · Fumetto · Invito · Menù · Libero | sì | +| **Anteprime stili** | `stili/` | 28 | 320² | **stessa scena** per tutti (*ragazza con sciarpa rossa che beve un caffè in una strada di città al tramonto*, seed fisso), un file per stile con il frammento del §7.4 come prompt | no | +| **Ricette** | `ricette/` | 6 | 512×320 | locandine di Foglio · Mondi · Mix · Set · Formati · Rinnova | no | +| **Luoghi** | `luoghi/` | 10 | 160² | miniature per Situazioni: città, spiaggia, montagna, bosco, deserto, interno, studio bianco, spazio, sott'acqua, futuro | no | +| **Meteo/stagioni** | `meteo/` | 10 | 128² | sole, nuvole, pioggia, neve, nebbia, temporale · primavera, estate, autunno, inverno | sì | +| **Costumi** | `costumi/` | 9 | 128² | astronauta, cavaliere, pirata, supereroe, samurai, mago, chef, medico, sposa (busto stilizzato) | sì | +| **Animali** | `animali/` | 5 | 128² | cane, gatto, cavallo, uccello, drago | sì | +| **Espressioni** | `facce/` | 6 | 128² | sorriso, ridere, serio, sorpreso, triste, occhiolino (faccia 3D tonda) | sì | +| **Disposizioni Box** | `box/` | 9 | 160×100 | *non da generare*: SVG disegnati a mano (schemi dei box) | — | +| **Personaggi** | `mascotte/` | 3 | 512² | astronauta che fluttua (stato vuoto), astronauta con cartello (errore), astronauta che saluta (setup) | sì | +| **LoRA** | `lora/` | 2 | 256² | copertina generica «Qwen» e «Klein» per i LoRA senza anteprima | no | +| **Uncensored** | `uncensored/` | 1 | 256² | icona fiamma arancio (fascetta e tessera) — meglio SVG | sì | + +**Totale:** circa 125 immagini (di cui 8 sono SVG scritti a mano e non vanno in coda). +**Priorità:** P0 = set di prova + sfondi + tessere Modifica + modi (indispensabili alla fase 1); P1 = anteprime stili + generi + ricette; P2 = luoghi, meteo, costumi, animali, facce, mascotte. + +### 13.3 Regole +- Niente testo dentro le immagini (le scritte le fa l'interfaccia). Niente volti di persone reali riconoscibili. Niente loghi o personaggi con diritti (per «stili» usare nomi generici: vedi §7.4, es. «Anime da film», non marchi). +- Stile coerente: stessa luce (rim light ciano a sinistra, magenta a destra), stessa famiglia di colori. Ogni asset 3D su fondo scuro neutro (poi alpha). +- Per ogni asset il README riporta: `id, file, prompt, negative, modello (nome file), passi, seed, dimensioni originali, dimensioni finali, data`. +- Se un asset non arriva, l'interfaccia mostra un **fallback a icona SVG + gradiente** (nessuna tessera vuota). + +### 13.4 Icone SVG nuove (le disegna Sonnet, `Icone.tsx`) +`estrai, box, ricette, studio, pila, pennello-magico, bacchetta-sam, orbita, sole-dir, sole-qualita, temperatura, eta, sorriso, ridere, serio, sorpreso, triste, occhiolino, occhiali, barba, capelli, cappello, scarpe, giacca, abito, pantaloni, maglia, cappotto, ora-giorno, epoca, folla, meteo-*, stagione-*, lingua, titolo, cartello, restaura, dettagli, pelle, bianco-nero, grading, ritaglio, ricrea, su-sfondo, fiamma, lucchetto, occhio-barrato, trascina, dado, quadrante, wormhole, supernova, disposizione-*`. + +--- + +## 14. Piano di consegna (per i task successivi) + +| Fase | Contenuto | Responsabile | Cosa si vede | +|---|---|---|---| +| **F0** | Token + `stile.css` «Orbita», componenti base (`Tessera`, interruttore, cursore, segnaposto), starfield, `test/interfaccia.mjs` verde con i nuovi stili | Sonnet UI | Nuovo aspetto su tutte le pagine senza cambiare funzioni | +| **F1** | `Studio` con 4 modi, strip, console, **HUD misura**, Crea a tessere (Motore, Cosa, Atmosfera, Stile, Formato, Misura, Sfondo, Quante), Senza sfondo (engine: RGBA Qwen / genera+BiRefNet Klein) | Sonnet UI + Sol (`Richiesta.sfondo`, `bg:rimuovi`) | Crea completo, risoluzione chiara | +| **F2** | Modifica: griglia categorie + `controlli.ts` (sostituisce `rapide.ts`), Estrai (Ricrea + Trova già pronti; Ritaglio con SAM2/BiRefNet), stili nuovi, ricette 6 | Sonnet UI + Sol (`estrai:ritaglia`) | Modifica a pulsanti, Estrai in 2 clic | +| **F3** | Box (disegno, pannello, disposizioni, adattatore Klein 3 / maschere Qwen), Bozza veloce | Sonnet UI + Sol (`Richiesta.box`) | Editor regionale | +| **F4** | Pila LoRA + pagina LoRA nuova, Klein LoRA (dopo test loader), Laboratorio Uncensored, Ingranaggi, A/B | Sonnet UI + Sol (`lora:info`, catalogo uncensored) | Gestione completa | +| **F5** | Rifiniture: microcopy, easter egg, audio opzionale, test visivi `test/foto.mjs`, README/CHANGELOG | Sonnet | Release 1.2.0 | + +**Interfacce da fissare nel thread di t_22f93113 (proposta):** +1. `Richiesta.sfondo?: 'scena' | 'trasparente' | { tinta: string }` + `Opera.alpha: boolean` nelle impostazioni dentro il PNG. +2. `Richiesta.box?: BoxRegione[]` e `Richiesta.promptScena?: string` (§5). +3. `estrai:ritaglia`, `estrai:maschera` (clic/box → maschera SAM2), IPC di progresso (fase, percentuale). +4. `lora:info` esteso, `Richiesta.lora` ordinato con peso, supporto Klein (se il loader funziona). +5. `modelli.catalogo({categoria:'uncensored'})` → voci con `ruolo, licenza, licenzaUrl, peso, stato ('disponibile'|'in-valutazione'|'bloccato-licenza'), richiedeConferma` ; `impostazioni.uncensored = { abilitato, accettatoIl, versioneDisclaimer }`. +6. `MODELLI` (in `shared/modelli.ts`) estendibile con `uncensored?: true` e `ruolo` senza rompere `modelloDa`. +7. `test/finto.js` allineato a ogni nuovo campo (CLAUDE.md lo impone). + +**Criteri di accettazione trasversali:** `npm run typecheck`, `npm run build`, `node test/interfaccia.mjs`, `node test/modelli.mjs`, `node test/ricette.mjs` (adattato), foto di ogni schermata in `test/.out/`; nessun ``/`Segmenti`) | Studio · Crea (tessere) | +| Pagina Modifica (Scrivi/Espandi/Ricette/Estrai) | Studio · Modifica (3 tessere ambito + categorie), Espandi è una tessera, Estrai in cima | +| Scheda Ricette (Laboratorio) | Studio · Ricette (6 locandine + Mie) | +| Idee/azioni rapide (chip) | Controlli interattivi (§6.2) | +| Preset (16) con stili per genere | Generi (11 + Libero) + Atmosfera (icone) + Stile (28) | +| `ScegliModello` (3 pillole nella barra) | Tessere Motore (+ Uncensored) | +| Misura, ScegliMp, Uscita in «Altro» | HUD misura sempre visibile + popover | +| `Lora.tsx` sub-tab di Impostazioni, `SceltaLora` | Pagina LoRA + Pila LoRA nel cassetto | +| `Impostazioni` con ` p.cambia(Number(e.target.value))} onDoubleClick={() => p.cambia(c.neutro)} /> + {c.estremi[1]} + {c.etichette ? c.etichette[p.valore - c.min] : et} + + ) +} + +function Multi(props: { c: Extract; valore: Record; cambia: (v: Record) => void }): JSX.Element { + // Una scelta incompleta (colore prima del capo, posizione prima del testo) non è ancora un'idea, + // ma non deve sparire. La bozza vive qui; solo le istruzioni complete entrano nel lavoro. + const esterno = scriviMulti(props.valore) + const [bozza, setBozza] = useState(props.valore) + useEffect(() => { setBozza(leggiMulti(esterno)) }, [esterno]) + const p = { ...props, valore: bozza, cambia: (v: Record): void => { setBozza(v); props.cambia(v) } } + const imposta = (parte: string, v: string): void => p.cambia({ ...p.valore, [parte]: p.valore[parte] === v ? '' : v }) + return ( +
+ {p.c.parti.map((x: Parte) => { + if (x.tipo === 'testo') { + return p.cambia({ ...p.valore, [x.id]: e.target.value })} /> + } + if (x.tipo === 'colori') { + return ( +
+ {x.opzioni.map((o) =>
+ ) + } + if (x.tipo === 'quadrante') { + // 3×3: le direzioni/posizioni stanno attorno; se sono 8 il centro è il soggetto + const celle = x.opzioni.length === 8 ? [...x.opzioni.slice(0, 4), null, ...x.opzioni.slice(4)] : x.opzioni + return ( +
+ {celle.map((o, i) => o + ? + : ●)} +
+ ) + } + return ( + + {x.opzioni.map((o) => {o.icona}} nome={o.nome} su={p.valore[x.id] === o.id} titolo={`${o.nome} · un clic la accende, un altro la spegne`} onClick={() => imposta(x.id, o.id)} />)} + + ) + })} +
+ ) +} diff --git a/src/renderer/src/componenti/Estrai.tsx b/src/renderer/src/componenti/Estrai.tsx index 184fdfd..cae0acd 100644 --- a/src/renderer/src/componenti/Estrai.tsx +++ b/src/renderer/src/componenti/Estrai.tsx @@ -16,7 +16,7 @@ import { apriInModifica, fotoDaOpera } from '../azioni' import { durata, stimaSecondi } from '../util' import { FORMATI_ESTRAI, INQUADRATURE, misuraEstrai, POSE_ESTRAI, SFONDI_ESTRAI, VISTE_OGGETTO, attesiEstrai, dimensioniEstrai, impostaEstrai, proporzioniEstrai, richiestaEstrai, - trovaElementi, usaEstrai, type Elemento + richiestaRitaglio, trovaElementi, usaEstrai, type Elemento } from '../estrai' const corto = (e: Elemento, i: number): string => (e.nome ? e.nome.split(/[,.;]/)[0].slice(0, 28) : e.tipo === 'persona' ? `Persona ${i + 1}` : `Oggetto ${i + 1}`) @@ -63,7 +63,12 @@ function FotoElementi(p: { foto: FotoBase }): JSX.Element { // un clic sceglie la cosa più piccola lì sotto; cliccando ancora si passa a quella più grande che la contiene, e poi si toglie la scelta const l = sotto(punto(e)) const i = l.findIndex((x) => x.id === scelto) - impostaEstrai({ scelto: !l.length ? null : i < 0 ? l[0].id : i + 1 < l.length ? l[i + 1].id : null }) + if (!l.length) { + // un clic nel vuoto: SAM 2 trova la cosa da quel punto (un solo punto alla volta; si vede un segno attorno) + const q = punto(e) + const n: Elemento = { id: 'p-' + crypto.randomUUID().slice(0, 6), box: [Math.max(0, q.x - 0.05), Math.max(0, q.y - 0.05), Math.min(1, q.x + 0.05), Math.min(1, q.y + 0.05)], nome: '', tipo: 'oggetto', manuale: true, punto: q } + impostaEstrai({ elementi: [...usaEstrai.getState().elementi.filter((x) => !x.punto), n], scelto: n.id }) + } else impostaEstrai({ scelto: i < 0 ? l[0].id : i + 1 < l.length ? l[i + 1].id : null }) } setBozza(null) } @@ -105,7 +110,7 @@ export function PalcoEstrai(p: { base: FotoBase | null; daGalleria: () => void;
ESTRAI DA UNA FOTO
-

Dai una foto e premi Trova persone e oggetti: clicchi quello che vuoi e lo ottieni da solo, su sfondo pulito.

+

Dai una foto, clicca la cosa che vuoi e premi Ritaglia: la ottieni su sfondo trasparente, con i pixel della foto (due clic). Con «Trova persone e oggetti» il modello le riquadra per te.

Una persona a figura intera (T-pose, A-pose, una posa disegnata), un busto o un volto; un oggetto intero, da davanti, di lato o dall'alto.

@@ -127,7 +132,7 @@ export function PalcoEstrai(p: { base: FotoBase | null; daGalleria: () => void; {inAttesa > 0 && {inCorso ? `si sta creando${percentuale(inCorso) !== null ? ` · ${percentuale(inCorso)}%` : ''}` : 'in coda'}{inAttesa > 1 ? ` · +${inAttesa - 1}` : ''}}
{!ris.length && !inAttesa ? ( -
Scegli una persona o un oggetto sulla foto e premi Estrai: compare qui, e finisce anche in Galleria.
+
Clicca una cosa sulla foto e premi Ritaglia (o Ricrea): compare qui, e finisce anche in Galleria.
) : (
@@ -167,6 +172,20 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element { const dim = dimensioniEstrai(m.mp, r) const fin = misuraEstrai(s.lato, dim) const persona = el?.tipo === 'persona' + const [stretto, setStretto] = useState(true) + const dalPunto = !!el?.punto + const trovati = s.elementi.filter((x) => !x.punto).length + + /** il ritaglio fedele: i pixel sono quelli della foto, lo sfondo diventa trasparente (SAM 2) */ + const ritaglio = async (): Promise => { + if (!p.base || !el) return + try { + const [id] = await api.lavori.accoda(richiestaRitaglio(p.base, el, stretto)) + attesiEstrai.set(id, { etichetta: `Ritaglio · ${corto(el, s.elementi.indexOf(el))}` }) + } catch (e) { + avvisa((e as Error).message, 'errore') + } + } const estrai = async (): Promise => { if (!p.base || !el) return @@ -189,7 +208,7 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element {
-

1 Cosa estrarre {s.elementi.length ? `${s.elementi.length} trovati` : ''}

+

1 Cosa estrarre {trovati ? `${trovati} trovati` : ''}

{el ? (
{persona ? : } @@ -197,7 +216,7 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element {
) : ( -
{s.cercando ? 'Il modello sta guardando la foto…' : !p.base ? 'Prima scegli una foto.' : s.elementi.length ? 'Clicca sulla foto la persona o l\'oggetto che vuoi (cliccando ancora nello stesso punto passi a quello più grande che lo contiene).' : 'Premi il pulsante: il modello trova le persone e gli oggetti nella foto. Puoi anche riquadrare a mano trascinando.'}
+
{s.cercando ? 'Il modello sta guardando la foto…' : !p.base ? 'Prima scegli una foto.' : s.elementi.length ? 'Clicca sulla foto la persona o l\'oggetto che vuoi (cliccando ancora nello stesso punto passi a quello più grande che lo contiene).' : 'Clicca sulla foto la cosa da ritagliare (SAM 2 la trova da solo), oppure premi il pulsante: il modello trova persone e oggetti. Puoi anche riquadrare a mano trascinando.'}
)} {el && (
@@ -208,12 +227,12 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element {
)} -
- {el && ( + {el && !dalPunto && (

2 Come lo vuoi

{persona ? ( @@ -252,7 +271,7 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element {
)} -
+

{el ? 3 : 2} Dimensioni e qualità

Proporzioni
@@ -283,10 +302,14 @@ export function ControlliEstrai(p: { base: FotoBase | null }): JSX.Element {
- +
Ritaglio stretto attorno alla cosa
+ -
{fin.w} × {fin.h} px · ~{durata(stima)}
+
{fin.w} × {fin.h} px · ~{durata(stima)}
) diff --git a/src/renderer/src/componenti/Fusion.tsx b/src/renderer/src/componenti/Fusion.tsx index 710740f..575ffa4 100644 --- a/src/renderer/src/componenti/Fusion.tsx +++ b/src/renderer/src/componenti/Fusion.tsx @@ -112,7 +112,7 @@ export function Fusion(p: { {p.inAttesa > 0 && {p.inCorso ? `si sta creando${percentuale(p.inCorso) !== null ? ` · ${percentuale(p.inCorso)}%` : ''}` : 'in coda'}{p.inAttesa > 1 ? ` · +${p.inAttesa - 1}` : ''}}
{!p.risultati.length && !p.inAttesa ? ( -
Scrivi cosa vuoi a destra e premi il pulsante: la foto nuova compare qui e finisce anche in Galleria.
+
Scrivi cosa vuoi a sinistra e premi il pulsante: la foto nuova compare qui e finisce anche in Galleria.
) : (
diff --git a/src/renderer/src/componenti/Generi.tsx b/src/renderer/src/componenti/Generi.tsx new file mode 100644 index 0000000..12b5976 --- /dev/null +++ b/src/renderer/src/componenti/Generi.tsx @@ -0,0 +1,106 @@ +// I generi di Crea (ex «preset») come tessere, e l'atmosfera: una tessera per ogni stile di luce del genere scelto. +// Scegliere un genere mette formato, risoluzione e sfondo giusti e scrive il prompt dagli esempi; +// cambiando un campo o l'atmosfera il prompt si riscrive (e si può sempre ritoccare a mano). +// Dalla GUI v2 il genere non è più un menu a tendina: 11 tessere + «Libero». Copertina, slide, app, arte e sticker non hanno la tessera +// (stanno nei Formati, nello Stile e nel Set delle Ricette) ma restano nell'elenco per leggere i lavori salvati. +import type { JSX } from 'react' +import { usaStato } from '../stato' +import { I } from './Icone' +import { Casella } from './Casella' +import { Tessera, Tessere } from './Tessere' +import { PRESET, campiCon, componiPreset, presetDa } from '../preset' + +const icona = (nome: string): JSX.Element => { + const Ic = (I as Record JSX.Element>)[nome] || I.crea + return +} + +/** i generi che hanno la tessera, nell'ordine della spec */ +const GENERI = ['foto', 'ritratto', 'prodotto', 'poster', 'infografica', 'logo', 'social', 'miniatura', 'fumetto', 'invito', 'menu'] + +/** sceglie un genere (o «Libero» con '') */ +export function scegliGenere(id: string, prima: (prompt: string) => void): void { + const { crea, setCrea } = usaStato.getState() + const p = presetDa(id) + if (!p) { + setCrea({ preset: '' }) + return + } + if (!crea.preset && crea.prompt.trim()) prima(crea.prompt) + const stile = p.stili[0]?.id || '' + setCrea({ + preset: p.id, stile, campi: {}, formato: p.formato, ris: p.ris, trasparente: !!p.trasparente, sfondo: p.trasparente ? 'trasparente' : 'scena', + prompt: componiPreset(p, {}, stile) + }) +} + +/** «Cosa»: la griglia dei generi 4×3 */ +export function GrigliaGeneri({ prima }: { prima: (prompt: string) => void }): JSX.Element { + const crea = usaStato((s) => s.crea) + const attivo = presetDa(crea.preset) + const nascosto = !!attivo && !GENERI.includes(attivo.id) + return ( + + {GENERI.map((id) => { + const p = presetDa(id)! + return scegliGenere(id, prima)} /> + })} + scegliGenere('', prima)} /> + + ) +} + +const ATMOSFERE: [RegExp, string][] = [ + [/naturale|luce/i, '☀️'], [/oro/i, '🌅'], [/notte|neon/i, '🌙'], [/pellicola/i, '🎞️'], [/finestra/i, '🪟'], [/studio/i, '💡'], [/strada/i, '🛣️'], + [/vetro/i, '🔮'], [/pop/i, '💥'], [/lusso|elegante/i, '💎'], [/minimal|pulita|chiaro/i, '⚪'], [/scuro/i, '🌑'], [/acquerello/i, '🎨'], [/pixel/i, '👾'] +] +const emojiAtmosfera = (nome: string, i: number): string => ATMOSFERE.find(([r]) => r.test(nome))?.[1] || ['✨', '🌈', '🎯', '🔆'][i % 4] +const SFONDI = ['linear-gradient(135deg,#3a2c7a,#f2a65a)', 'linear-gradient(135deg,#f6c453,#c0392b)', 'linear-gradient(135deg,#0b1d4a,#7b2cbf)', 'linear-gradient(135deg,#3b6b5e,#d8c3a5)'] + +/** «Atmosfera»: gli stili del genere come tessere con icona, una sola scelta */ +export function AtmosferaGenere(): JSX.Element | null { + const { crea, setCrea } = usaStato() + const p = presetDa(crea.preset) + if (!p || p.stili.length < 2) return null + const valori = campiCon(p, crea.campi) + return ( + + {p.stili.map((s, i) => ( + {emojiAtmosfera(s.nome, i)}} nome={s.nome} fondo={SFONDI[i % SFONDI.length]} titolo={`${s.nome}: ${s.testo}`} su={crea.stile === s.id} onClick={() => setCrea({ stile: s.id, prompt: componiPreset(p, valori, s.id) })} /> + ))} + + ) +} + +/** i campi del genere scelto (1–3 righe in italiano, con esempi in grigio) */ +export function CampiGenere(): JSX.Element | null { + const { crea, setCrea } = usaStato() + const p = presetDa(crea.preset) + if (!p) return null + const valori = campiCon(p, crea.campi) + const cambiaCampo = (id: string, valore: string): void => { + const campi = { ...valori, [id]: valore } + setCrea({ campi, prompt: componiPreset(p, campi, crea.stile) }) + } + return ( +
+
+ {p.campi.map((c) => ( + + ))} +
+
+ Scrivi in italiano: i testi da mettere nell'immagine restano identici (anche gli accenti). Il prompt qui sotto si aggiorna da solo. +
+
+ ) +} + +export { PRESET } diff --git a/src/renderer/src/componenti/Hud.tsx b/src/renderer/src/componenti/Hud.tsx new file mode 100644 index 0000000..9eb146b --- /dev/null +++ b/src/renderer/src/componenti/Hud.tsx @@ -0,0 +1,35 @@ +// HUD misura: la striscia fissa sul palco (e la versione corta accanto al pulsante) con i pixel che escono davvero. +// Prima riga: forma in scala, pixel finali, rapporto, megapixel. Seconda riga solo quando c'è una conversione (verde nativa, ciano ingrandita, ambra ridotta). +import type { JSX, ReactNode } from 'react' +import type { StatoMisura } from '../misura' + +export interface DatiHud { + w: number + h: number + rapporto: string + mp: number + stato: StatoMisura + nota: string +} + +const testoMp = (mp: number): string => (Math.round(mp * 100) / 100).toFixed(2).replace('.', ',') + +export function Hud(p: { d: DatiHud; corto?: boolean; onClick?: () => void; extra?: ReactNode; titolo?: string }): JSX.Element { + const k = 26 / Math.max(p.d.w, p.d.h) + return ( +
e.key === 'Enter' && p.onClick?.()}> + + + + {p.d.w} × {p.d.h}px + {p.d.rapporto.replace(':', ' : ')} + {testoMp(p.d.mp)} MP + + {!p.corto && p.d.stato !== 'nativa' && {p.d.nota}} + {!p.corto && p.d.stato === 'nativa' && {p.d.nota}} + + {p.extra} +
+ ) +} diff --git a/src/renderer/src/componenti/Icone.tsx b/src/renderer/src/componenti/Icone.tsx index 1c900d3..01633f9 100644 --- a/src/renderer/src/componenti/Icone.tsx +++ b/src/renderer/src/componenti/Icone.tsx @@ -79,5 +79,26 @@ export const I = { catena: () => , freccia: () => , casa: () => , - gioca: () => + gioca: () => , + // --- GUI v2 «Orbita» --- + studio: () => , + estrai: () => , + box: () => , + ricette: () => , + pila: () => , + fiamma: () => , + lucchetto: () => , + scacchi: () => , + scena: () => , + tinta: () => , + ritaglio: () => , + ricrea: () => , + susfondo: () => , + ingranaggi: () => , + interruttore: () => , + cartello: () => , + occhioBarrato: () => , + sposta: () => , + cursore: () => , + duplica: () => } diff --git a/src/renderer/src/componenti/LabVista.tsx b/src/renderer/src/componenti/LabVista.tsx index 06a31c2..dd645de 100644 --- a/src/renderer/src/componenti/LabVista.tsx +++ b/src/renderer/src/componenti/LabVista.tsx @@ -28,7 +28,7 @@ export function Catalogo(p: { cerca: string; scelto: string | null; scegli: (id:

{icona(c.icona)} {c.nome} {c.testo}

{voci.map((w) => ( -
+
} + {acceso && <> + {!p.catalogo.some((v) => v.uncensored) &&

Nessun componente Uncensored approvato disponibile nel catalogo di questa versione.

} + {p.catalogo.filter((v) => v.uncensored).map((v) => { + const d = p.download[v.nome] + const inCorso = d && (d.stato === 'in corso' || d.stato === 'verifica') + return
+
{v.nome} Uncensored
{v.ruolo || v.tipo} · {byte(v.byte)} · {v.licenza || 'Licenza da verificare'}
+
{v.licenzaUrl && }{v.presente ? Presente : }
+ {v.avviso || v.descrizione} +
+ })} + {p.catalogo.some((v) => v.id === 'llm-huihui-4b' && v.presente) && void p.salva({ testoUncensored: v })}>Con Klein, scrivi i testi con l’LLM Uncensored (Qwen3-VL 4B)} +

FLUX / Klein 3 Uncensored: non disponibile. Non distribuiamo varianti che aggirano protezioni vietate dalla licenza Kommunity.

+ } +
+ +} diff --git a/src/renderer/src/componenti/Preset.tsx b/src/renderer/src/componenti/Preset.tsx deleted file mode 100644 index b9e976d..0000000 --- a/src/renderer/src/componenti/Preset.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// Il menu degli stili (in cima alla colonna di Crea) e la scheda coi campi del preset scelto. -// Scegliere un preset mette formato, risoluzione e sfondo giusti e scrive il prompt dagli esempi; -// cambiando un campo o uno stile il prompt si riscrive (e si può sempre ritoccare a mano). -import type { JSX } from 'react' -import { usaStato } from '../stato' -import { I } from './Icone' -import { Casella } from './Casella' -import { PRESET, campiCon, componiPreset, presetDa } from '../preset' - -const icona = (nome: string): JSX.Element => { - const Ic = (I as Record JSX.Element>)[nome] || I.crea - return -} - -/** gli stili in un menu solo: «Libero» (scrivi tu) o uno dei preset */ -export function MenuStili({ prima }: { prima: (prompt: string) => void }): JSX.Element { - const { crea, setCrea } = usaStato() - const scegli = (id: string): void => { - const p = presetDa(id) - if (!p) { - setCrea({ preset: '' }) - return - } - if (!crea.preset && crea.prompt.trim()) prima(crea.prompt) - const stile = p.stili[0]?.id || '' - setCrea({ preset: p.id, stile, campi: {}, formato: p.formato, ris: p.ris, trasparente: !!p.trasparente, prompt: componiPreset(p, {}, stile) }) - } - const attivo = presetDa(crea.preset) - return ( - - ) -} - -export function SchedaPreset(): JSX.Element | null { - const { crea, setCrea } = usaStato() - const p = presetDa(crea.preset) - if (!p) return null - const valori = campiCon(p, crea.campi) - const cambiaCampo = (id: string, valore: string): void => { - const campi = { ...valori, [id]: valore } - setCrea({ campi, prompt: componiPreset(p, campi, crea.stile) }) - } - const cambiaStile = (stile: string): void => setCrea({ stile, prompt: componiPreset(p, valori, stile) }) - return ( -
-

- {icona(p.icona)} {p.nome} - - - -

-
- {p.stili.map((s) => ( - - ))} -
-
- {p.campi.map((c) => ( - - ))} -
-
- Scrivi in italiano: i testi da mettere nell'immagine restano identici (anche gli accenti). Il prompt qui sotto si aggiorna da solo. -
-
- ) -} diff --git a/src/renderer/src/componenti/ScaffaliStili.tsx b/src/renderer/src/componenti/ScaffaliStili.tsx new file mode 100644 index 0000000..893d109 --- /dev/null +++ b/src/renderer/src/componenti/ScaffaliStili.tsx @@ -0,0 +1,29 @@ +// Gli stili nuovi come scaffali di tessere + l'intensità a tre pianeti. Si usano in Crea (si aggiungono al prompt) e in Modifica (trasformano la foto). +import type { JSX } from 'react' +import { INTENSITA, SCAFFALI, STILI, type Intensita } from '../stili' +import { Pianeti, Tessera } from './Tessere' + +export function ScaffaliStili(p: { valore: string; scegli: (id: string) => void }): JSX.Element { + return ( +
+ {SCAFFALI.map((sc) => ( +
+
{sc.nome}
+
+ {STILI.filter((s) => s.scaffale === sc.id).map((s) => ( + {s.icona}} nome={s.nome} fondo={s.fondo} titolo={`${s.nome}: ${s.frammento}`} su={p.valore === s.id} onClick={() => p.scegli(p.valore === s.id ? '' : s.id)} /> + ))} +
+
+ ))} +
+ ) +} + +export function IntensitaStile(p: { valore: Intensita; cambia: (i: Intensita) => void }): JSX.Element { + return ( +
+ ({ id: i.id, nome: i.nome, titolo: i.titolo }))} /> +
+ ) +} diff --git a/src/renderer/src/componenti/ScegliModello.tsx b/src/renderer/src/componenti/ScegliModello.tsx index d20abb3..dc03f49 100644 --- a/src/renderer/src/componenti/ScegliModello.tsx +++ b/src/renderer/src/componenti/ScegliModello.tsx @@ -7,18 +7,19 @@ import { MODELLI, ORDINE_MODELLI, modelloDaImpostazioni, type IdModello } from ' import { api } from '../api' import { ricaricaImp, usaStato } from '../stato' import { I } from './Icone' +import { Tessera, Tessere } from './Tessere' /** le voci del catalogo da avere per usare un modello (il modello INT8 sta nelle schede da 8 GB) */ -const SERVONO: Record, string[]> = { klein: ['klein-int8', 'klein-te', 'klein-vae'], 'klein-base': ['klein-base-int8', 'klein-te', 'klein-vae'] } -const PESO: Record, string> = { klein: 'circa 10 GB', 'klein-base': 'circa 10 GB (4,7 se hai già Klein)' } -const ETICHETTA: Record = { 'qwen-base': 'Qwen base', klein: 'Klein veloce', 'klein-base': 'Klein Base' } +const SERVONO: Record, string[]> = { klein: ['klein-int8', 'klein-te', 'klein-vae'], 'klein-base': ['klein-base-int8', 'klein-te', 'klein-vae'], klein4b: ['klein4b-fp8', 'klein4b-te', 'klein-vae'] } +const PESO: Record, string> = { klein: 'circa 10 GB', 'klein-base': 'circa 10 GB (4,7 se hai già Klein)', klein4b: 'circa 12,5 GB' } +const ETICHETTA: Record = { 'qwen-base': 'Qwen base', klein: 'Klein veloce', 'klein-base': 'Klein Base', klein4b: 'Klein 4B' } -export function SelettoreModello(): JSX.Element { +export function SelettoreModello({ variante = 'pillole' }: { variante?: 'pillole' | 'tessere' }): JSX.Element { const imp = usaStato((s) => s.imp) const avvisa = usaStato((s) => s.avvisa) const inCorso = usaStato((s) => s.lavori.some((l) => l.stato === 'in coda' || l.stato === 'in corso')) const scelto = modelloDaImpostazioni(imp) - const [pronti, setPronti] = useState>({ 'qwen-base': true, klein: false, 'klein-base': false }) + const [pronti, setPronti] = useState>({ 'qwen-base': true, klein: false, 'klein-base': false, klein4b: false }) const [aperto, setAperto] = useState(null) const [scarico, setScarico] = useState<{ id: IdModello; passo: string; perc: number } | null>(null) const radice = useRef(null) @@ -30,7 +31,8 @@ export function SelettoreModello(): JSX.Element { setPronti({ 'qwen-base': true, klein: comuni && !!f.klein?.some((n) => !/base/i.test(n)), - 'klein-base': comuni && !!f.klein?.some((n) => /base/i.test(n)) + 'klein-base': comuni && !!f.klein?.some((n) => /base/i.test(n)), + klein4b: (f.klein4b?.length ?? 0) > 0 && (f.klein4bTe?.length ?? 0) > 0 && (f.kleinVae?.length ?? 0) > 0 }) } useEffect(() => { @@ -68,7 +70,7 @@ export function SelettoreModello(): JSX.Element { const scarica = async (id: Exclude): Promise => { try { const stato = await api.modelli.stato() - const nomi: Record = { 'klein-int8': 'il modello Klein', 'klein-base-int8': 'il modello Klein Base', 'klein-te': 'il text encoder', 'klein-vae': 'il VAE' } + const nomi: Record = { 'klein-int8': 'il modello Klein', 'klein-base-int8': 'il modello Klein Base', 'klein4b-fp8': 'il modello Klein 4B', 'klein4b-te': 'il text encoder (Qwen3 4B)', 'klein-te': 'il text encoder', 'klein-vae': 'il VAE' } for (const voce of SERVONO[id]) { if (stato.find((v) => v.id === voce)?.presente) continue setScarico({ id, passo: nomi[voce], perc: 0 }) @@ -87,7 +89,26 @@ export function SelettoreModello(): JSX.Element { } return ( -
+
+ {variante === 'tessere' ? ( + + {ORDINE_MODELLI.map((id) => { + const m = MODELLI[id] + return ( + : id === 'klein' ? : } + nome={ETICHETTA[id]} + su={id === scelto} + daScaricare={!pronti[id]} + onClick={() => void scegli(id)} + titolo={`${m.nome}: ${m.sotto}${!pronti[id] ? ' (da scaricare)' : ''}${inCorso ? ' · vale dal prossimo lavoro' : ''}`} + /> + ) + })} + + ) : (
{ORDINE_MODELLI.map((id) => { const m = MODELLI[id] @@ -106,6 +127,7 @@ export function SelettoreModello(): JSX.Element { ) })}
+ )} {aperto && aperto !== 'qwen-base' && !pronti[aperto] && (
{MODELLI[aperto].nome} @@ -121,8 +143,7 @@ export function SelettoreModello(): JSX.Element { ) } -/** Il vecchio selettore a schede dentro Crea, Estrai e Modifica: ora il modello si sceglie nella barra in alto (`SelettoreModello`). - * Resta vuoto (e il `.sezione:empty` in stile.css lo nasconde) finché Modifica.tsx non ne toglie la riga (T6). */ -export function ScegliModello(): null { - return null +/** Lo stesso selettore di Crea, anche nel pannello di Modifica. */ +export function ScegliModello(): JSX.Element { + return <>

Motore

} diff --git a/src/renderer/src/componenti/SezioniCrea.tsx b/src/renderer/src/componenti/SezioniCrea.tsx new file mode 100644 index 0000000..ef1b6aa --- /dev/null +++ b/src/renderer/src/componenti/SezioniCrea.tsx @@ -0,0 +1,141 @@ +// Le sezioni di Crea fatte a tessere/pulsanti, condivise con il modo Box (stesso formato, stessa misura, stesso sfondo): +// Formato (la forma del rettangolo in scala), Misura (S · M · L · XL · MAX coi pixel veri), Sfondo, Quante + Seed, e il HUD con il suo popover. +import { useState, type JSX } from 'react' +import { usaStato } from '../stato' +import { I } from './Icone' +import { Hud } from './Hud' +import { Tessera, Tessere, PulsanteS } from './Tessere' +import { SceltaMisura, ScegliMp, MISURA0, Uscita } from './Misura' +import { FORMATI, dimensioniRis } from '../util' +import { misuraCrea } from '../misura' + +export const TAGLIE: { ris: number; nome: string; titolo: string }[] = [ + { ris: 512, nome: 'S', titolo: 'Lato corto 512 px: veloce, per provare' }, + { ris: 768, nome: 'M', titolo: 'Lato corto 768 px' }, + { ris: 1024, nome: 'L', titolo: 'Lato corto 1024 px: l\'equilibrio giusto' }, + { ris: 1440, nome: 'XL', titolo: 'Lato corto 1440 px: per testi piccoli, poster e infografiche' }, + { ris: 2048, nome: 'MAX', titolo: 'Il massimo nativo (4,2 MP): lento su 6-8 GB' } +] +/** le misure «bozza» di prima (256 … 1080), dietro il lampo */ +const BOZZE: number[] = [256, 384, 480, 576, 720, 1080] + +export function SezFormato(): JSX.Element { + const { crea, setCrea } = usaStato() + return ( +
+ {FORMATI.map((f) => { + const k = 24 / Math.max(f.w, f.h) + return ( + + ) + })} +
+ ) +} + +export function SezMisura(): JSX.Element { + const { crea, setCrea } = usaStato() + const [bozze, setBozze] = useState(false) + const bozzaAttiva = BOZZE.includes(crea.ris) || !TAGLIE.some((t) => t.ris === crea.ris) + return ( +
+
+ {TAGLIE.map((t) => { + const d = dimensioniRis(crea.formato, t.ris) + return setCrea({ ris: t.ris, misura: crea.misura?.tipo === 'custom' ? MISURA0 : crea.misura })} /> + })} + setBozze(!bozze)} titolo="Bozze veloci: 256p … 1080p" /> +
+ {(bozze || (bozzaAttiva && BOZZE.includes(crea.ris))) && ( +
+ {BOZZE.map((r) => { + const d = dimensioniRis(crea.formato, r) + return setCrea({ ris: r, misura: crea.misura?.tipo === 'custom' ? MISURA0 : crea.misura })} /> + })} +
+ )} +
+ ) +} + +const TINTE = ['#ffffff', '#f2f2f2', '#000000', '#e63946', '#f4a261', '#ffd54a', '#2a9d8f', '#3fe0ff', '#8a5cff', '#ff3df2'] + +export function SezSfondo(): JSX.Element { + const { crea, setCrea, imp } = usaStato() + const klein = (imp?.famiglia || 'qwen') !== 'qwen' + const scegli = (sfondo: 'scena' | 'trasparente' | 'tinta'): void => setCrea({ sfondo, trasparente: sfondo === 'trasparente' }) + return ( +
+ + } nome="Scena" titolo="L'immagine con il suo sfondo" su={crea.sfondo === 'scena'} onClick={() => scegli('scena')} /> + } nome="Senza sfondo" titolo="PNG con trasparenza vera: solo il soggetto" su={crea.sfondo === 'trasparente'} onClick={() => scegli('trasparente')} /> + } nome="Tinta unita" titolo="Il soggetto su un colore pieno (prodotti, sticker)" su={crea.sfondo === 'tinta'} onClick={() => scegli('tinta')} /> + + {crea.sfondo === 'tinta' && ( +
+ {TINTE.map((c) => ( +
+ )} + {crea.sfondo === 'trasparente' && ( +
+ {([['scacchi', 'Scacchi'], ['bianco', 'Bianco'], ['nero', 'Nero'], ['colore', 'Colore']] as const).map(([id, nome]) => ( + {nome}} su={crea.anteSfondo === id} onClick={() => setCrea({ anteSfondo: id })} titolo="Cambia solo la vista, non il file" /> + ))} +
+ )} + {crea.sfondo === 'trasparente' && klein && ( +
Klein non disegna la trasparenza da sé: crea il soggetto su fondo pulito e il motore toglie lo sfondo dopo (serve il motore v2). Con Qwen il PNG è già trasparente.
+ )} +
+ ) +} + +export function SezQuante(): JSX.Element { + const { crea, setCrea } = usaStato() + return ( +
+ {[1, 2, 4, 8].map((n) => ( + setCrea({ quante: n })} titolo={n === 1 ? 'Una immagine' : `${n} immagini, una dopo l'altra`} /> + ))} + +
+ ) +} + +/** Il HUD con il popover «Misura»: la cifra che vedi è quella che esce, e un clic apre le scelte */ +export function HudCrea(): JSX.Element { + const { crea, setCrea } = usaStato() + const [aperto, setAperto] = useState(false) + const m = misuraCrea(crea) + const mis = crea.misura?.tipo === 'custom' ? crea.misura : null + return ( + <> + setAperto(!aperto)} titolo="Clic per cambiare formato e misura" /> + {aperto && ( +
undefined}> +
MISURA
+ + +
+ Megapixel a cui disegna il modello + setCrea({ mpLavoro })} ar={{ w: m.finale.w, h: m.finale.h }} auto="come la misura" /> +
+
+ Misura precisa {mis ? '(attiva)' : ''} + setCrea({ misura })} base={null} mp={Math.round((m.lav.w * m.lav.h) / 1e5) / 10} ammetti={['auto', 'custom']} auto={{ w: m.lav.w, h: m.lav.h }} senzaUscita /> +
+ +
+ )} + + ) +} diff --git a/src/renderer/src/componenti/Tessere.tsx b/src/renderer/src/componenti/Tessere.tsx new file mode 100644 index 0000000..40267ad --- /dev/null +++ b/src/renderer/src/componenti/Tessere.tsx @@ -0,0 +1,93 @@ +// I pezzi base della GUI v2 «Orbita»: la tessera (icona grande + una parola), la griglia, la fascetta Uncensored e i pulsanti di misura. +// Regola d'oro: una tessera ha un'icona e una parola; il nome lungo sta nel tooltip (`titolo`). Niente chip di testo sotto le tessere. +import type { CSSProperties, JSX, ReactNode } from 'react' +import { I } from './Icone' + +export interface PropsTessera { + /** un'icona SVG (di `I`), o un emoji/carattere */ + icona: ReactNode + nome: string + su?: boolean + onClick?: () => void + /** il nome lungo e la spiegazione: compare al passaggio del mouse */ + titolo?: string + disabilitata?: boolean + /** il puntino ambra: file da scaricare */ + daScaricare?: boolean + /** la pallina luminosa con il numero di scelte attive */ + pallina?: number + /** tessera larga: icona a sinistra, nome e sottotitolo */ + sotto?: string + /** tessera con anteprima: un gradiente di fondo (il fallback delle immagini che non ci sono ancora) */ + fondo?: string + uncensored?: boolean + id?: string +} + +export function Tessera(p: PropsTessera): JSX.Element { + const larga = p.sotto !== undefined + const classe = ['tessera-o', larga ? 'larga' : '', p.fondo ? 'anteprima' : '', p.su ? 'su' : '', p.daScaricare ? 'da-scaricare' : '', p.uncensored ? 'fiamma' : '', p.disabilitata ? 'spenta' : ''].filter(Boolean).join(' ') + return ( + + ) +} + +/** una griglia di tessere con N colonne */ +export function Tessere(p: { colonne?: 2 | 3 | 4 | 5 | 6; children: ReactNode; stile?: CSSProperties }): JSX.Element { + const c = p.colonne ?? 4 + return ( +
+ {p.children} +
+ ) +} + +/** la fascetta arancio dei modelli senza filtri di rifiuto: riservata a questo scopo */ +export function Fascetta(): JSX.Element { + return ( + + Uncensored + + ) +} + +/** un pulsante di misura/quantità: un valore grande e (sotto) i pixel o la nota */ +export function PulsanteS(p: { nome: ReactNode; sotto?: ReactNode; su?: boolean; onClick: () => void; titolo?: string }): JSX.Element { + return ( + + ) +} + +/** tre pianeti di grandezza crescente: per l'intensità, la fantasia */ +export function Pianeti(p: { valore: T; voci: { id: T; nome: string; titolo?: string }[]; cambia: (v: T) => void }): JSX.Element { + return ( +
+
+ {p.voci.map((v) => ( + + ))} +
+
{p.voci.map((v) => {v.nome})}
+
+ ) +} + +/** l'emoji o il carattere in una tessera: stesso spazio di un'icona */ +export const E = (s: string): JSX.Element => {s} diff --git a/src/renderer/src/controlli.ts b/src/renderer/src/controlli.ts new file mode 100644 index 0000000..f3729b3 --- /dev/null +++ b/src/renderer/src/controlli.ts @@ -0,0 +1,274 @@ +// I controlli interattivi di Modifica, per categoria (GUI v2 §6.2): cursori, griglie a icona, campioni colore, quadranti. +// Ogni controllo ha un valore (stringa) e lo trasforma in una frase in inglese (`compila`): `null` = spento, non entra nel lavoro. +// Il valore sta nell'id dell'idea attiva (`ctl-:`): niente stato in più, le Idee sono la fonte di verità. +// Qui sta solo il catalogo (puro: si prova senza interfaccia); i pezzi grafici sono in componenti/ControlliCategoria.tsx. +import type { ChiaveLora, Rapida } from './rapide' + +export interface Opzione { id: string; nome: string; icona: string; frase: string } + +/** una riga di un controllo: una griglia di icone, i campioni colore, il quadrante 3×3 o una riga di testo */ +export type Parte = + | { id: string; tipo: 'griglia' | 'colori' | 'quadrante'; nome: string; opzioni: Opzione[] } + | { id: string; tipo: 'testo'; nome: string; esempio: string } + +export interface Compilato { frase: string; lora?: ChiaveLora[] } + +export type Controllo = + | { id: string; categoria: string; tipo: 'cursore'; nome: string; icona: string; tooltip: string; min: number; max: number; neutro: number; estremi: [string, string]; etichette?: string[]; compila: (v: number) => Compilato | null } + | { id: string; categoria: string; tipo: 'multi'; nome: string; icona: string; tooltip: string; parti: Parte[]; compila: (v: Record) => Compilato | null } + +const INVARIATO = 'Keep the identity, face, pose and everything else unchanged.' +/** [id, nome, icona, frase in inglese] */ +type Voce = [string, string, string, string] +const op = (l: Voce[]): Opzione[] => l.map(([id, nome, icona, frase]) => ({ id, nome, icona, frase })) +const frase = (o: Opzione[], id: string | undefined): string => o.find((x) => x.id === id)?.frase || '' + +const COLORI = op([ + ['#111111', 'Nero', '#111111', 'black'], ['#5a3a22', 'Castano', '#5a3a22', 'dark brown'], ['#e6c880', 'Biondo', '#e6c880', 'blonde'], ['#b5361f', 'Rosso', '#b5361f', 'copper red'], + ['#aaaaaa', 'Grigio', '#aaaaaa', 'silver gray'], ['#f2f2f2', 'Bianco', '#f2f2f2', 'white'], ['#2563eb', 'Blu', '#2563eb', 'electric blue'], ['#16a34a', 'Verde', '#16a34a', 'green'], + ['#ec4899', 'Rosa', '#ec4899', 'pink'], ['#9333ea', 'Viola', '#9333ea', 'purple'] +]) +const ESPRESSIONI = op([ + ['sorriso', 'Sorriso', '😊', 'a warm natural smile'], ['ride', 'Ride', '😂', 'laughing out loud'], ['serio', 'Serio', '😐', 'a serious, neutral look'], + ['sorpreso', 'Sorpreso', '😮', 'a surprised look with wide eyes'], ['triste', 'Triste', '😢', 'a sad look'], ['occhiolino', 'Occhiolino', '😉', 'a playful wink'] +]) +const BARBE = op([ + ['rasata', 'Rasata', '🪒', 'a clean-shaven face, with no beard or stubble'], ['ombra', 'Ombra', '😶', 'a light stubble'], ['corta', 'Corta', '🧔‍♂️', 'a short trimmed beard'], ['folta', 'Folta', '🧔', 'a full thick beard'] +]) +const CAPI = op([ + ['giacca', 'Giacca', '🧥', 'a jacket'], ['abito', 'Abito', '👗', 'a dress'], ['maglia', 'Maglia', '👕', 'a sweater'], ['pantaloni', 'Pantaloni', '👖', 'trousers'], + ['cappotto', 'Cappotto', '🧣', 'a long coat'], ['cappello', 'Cappello', '🎩', 'a hat'], ['scarpe', 'Scarpe', '👟', 'a pair of shoes'] +]) +const MATERIALI = op([['jeans', 'Jeans', '🔷', 'denim'], ['seta', 'Seta', '🎀', 'silk'], ['pelle', 'Pelle', '🟫', 'leather'], ['lana', 'Lana', '🧶', 'wool'], ['metallo', 'Metallo', '⚙️', 'metallic fabric']]) +const OBIETTIVI = op(['14', '24', '35', '50', '85', '200'].map((n): Voce => [n, `${n}mm`, n, `${n}mm`])) +/** il quadrante 3×3 attorno al soggetto: 8 direzioni (il centro non c'è) */ +const DIREZIONI = op([ + ['tl', 'Alto sinistra', '↖', 'the upper left'], ['t', 'Sopra', '↑', 'directly above'], ['tr', 'Alto destra', '↗', 'the upper right'], + ['l', 'Sinistra', '←', 'the left side'], ['r', 'Destra', '→', 'the right side'], + ['bl', 'Basso sinistra', '↙', 'the lower left'], ['b', 'Sotto', '↓', 'directly below'], ['br', 'Basso destra', '↘', 'the lower right'] +]) +const QUALITA_LUCE = op([['morbida', 'Morbida', '☁️', 'soft diffused light with gentle shadows'], ['dura', 'Dura', '☀️', 'hard direct light with crisp defined shadows']]) +const LINGUE = op([ + ['en', 'Inglese', '🇬🇧', 'English'], ['es', 'Spagnolo', '🇪🇸', 'Spanish'], ['fr', 'Francese', '🇫🇷', 'French'], ['de', 'Tedesco', '🇩🇪', 'German'], ['ja', 'Giapponese', '🇯🇵', 'Japanese'], ['zh', 'Cinese', '🇨🇳', 'Simplified Chinese'] +]) +const POSIZIONI = op([ + ['tl', 'Alto sinistra', '◤', 'in the top left corner'], ['t', 'In alto', '▀', 'across the top'], ['tr', 'Alto destra', '◥', 'in the top right corner'], + ['l', 'A sinistra', '▌', 'on the left side'], ['c', 'Al centro', '■', 'in the center'], ['r', 'A destra', '▐', 'on the right side'], + ['bl', 'Basso sinistra', '◣', 'in the bottom left corner'], ['b', 'In basso', '▄', 'across the bottom'], ['br', 'Basso destra', '◢', 'in the bottom right corner'] +]) +const FORMATI_TESTO = op([ + ['titolo', 'Titolo', '🅰️', 'as a huge bold sans-serif headline'], ['cartello', 'Cartello', '🪧', 'on a wooden sign placed naturally in the scene'], + ['meme', 'Meme', '😂', 'as bold white Impact-style letters with a black outline'], ['firma', 'Firma', '✍️', 'as a small elegant handwritten signature'] +]) +const SFONDI = op([['bianco', 'Bianco', '⬜', 'a plain pure white'], ['grigio', 'Grigio', '◻️', 'a neutral light gray studio'], ['sfumato', 'Sfumato', '🌫️', 'a soft blurred gradient']]) + +const ORE = ['', 'at dawn with soft pink and orange light on the horizon', 'in the bright clear morning', 'at noon under harsh overhead sun', 'at sunset with a glowing golden-red sky', 'at blue hour just after sunset, deep blue sky with warm lights', 'at night with a dark sky and artificial lights'] +const EPOCHE = ['', 'ancient Rome: marble columns, togas, Mediterranean sun', 'the Middle Ages: stone buildings, period clothes, torch light', 'the 1920s: art deco style, period clothes and cars, sepia tones', 'the 1980s: neon, big hair, retro tech and film grain', 'a far future: sleek technology, holograms, silver architecture'] + +const griglia = (id: string, categoria: string, nome: string, icona: string, voci: Voce[], compila: (frase: string) => string, lora?: ChiaveLora[]): Controllo => { + const opzioni = op(voci) + return { id, categoria, nome, icona, tooltip: nome, tipo: 'multi', parti: [{ id: 'scelta', tipo: 'griglia', nome, opzioni }], compila: (v) => { + const f = frase(opzioni, v.scelta) + return f ? { frase: compila(f), lora } : null + } } +} + +export const CONTROLLI: Controllo[] = [ + griglia('capelli', 'persone', 'Acconciatura', '💇', [ + ['corti', 'Corti', '👨', 'short cropped hair'], ['lunghi', 'Lunghi', '👩', 'long flowing hair'], ['ricci', 'Ricci', '🧑‍🦱', 'curly hair'], ['lisci', 'Lisci', '👩‍🦰', 'straight hair'], + ['coda', 'Coda', '🎀', 'a ponytail'], ['trecce', 'Trecce', '🪢', 'braided hair'], ['bob', 'Caschetto', '👩‍🦳', 'a bob haircut'], ['rasati', 'Rasati', '🪒', 'a shaved head'] + ], (f) => `Give the person ${f}. Keep the face, expression, clothing and everything else unchanged.`), + griglia('animale', 'persone', 'Animale', '🐾', [ + ['cane', 'Cane', '🐕', 'a dog'], ['gatto', 'Gatto', '🐈', 'a cat'], ['cavallo', 'Cavallo', '🐎', 'a horse'], ['uccello', 'Uccello', '🐦', 'a bird'], ['drago', 'Drago', '🐉', 'a friendly dragon'] + ], (f) => `Add ${f} next to the subject, naturally integrated with matching light, shadows and scale. Keep everything else unchanged.`), + griglia('costume', 'vestiti', 'Costume', '🎭', [ + ['astronauta', 'Astronauta', '🧑‍🚀', 'an astronaut'], ['cavaliere', 'Cavaliere', '🛡️', 'a medieval knight'], ['pirata', 'Pirata', '🏴‍☠️', 'a pirate'], ['supereroe', 'Supereroe', '🦸', 'a superhero'], + ['samurai', 'Samurai', '⚔️', 'a samurai'], ['mago', 'Mago', '🧙', 'a wizard'], ['chef', 'Chef', '👨‍🍳', 'a chef'], ['medico', 'Medico', '🩺', 'a doctor'], ['sposa', 'Sposa', '👰', 'a bride'] + ], (f) => `Dress the person as ${f}. Keep face, hair, pose and background unchanged.`, ['vestiti']), + griglia('luogo', 'situazioni', 'Luogo', '🌍', [ + ['citta', 'Città', '🏙️', 'a lively city street'], ['spiaggia', 'Spiaggia', '🏖️', 'a tropical beach'], ['montagna', 'Montagna', '🏔️', 'a mountain landscape'], ['bosco', 'Bosco', '🌲', 'a lush forest'], ['deserto', 'Deserto', '🏜️', 'a sandy desert'], + ['interno', 'Interno', '🏠', 'a cozy interior'], ['studio-bianco', 'Studio', '⬜', 'a white photography studio'], ['spazio', 'Spazio', '🪐', 'outer space with stars and planets'], ['sott-acqua', 'Sott’acqua', '🐠', 'an underwater scene'], ['futuro', 'Futuro', '🛸', 'a futuristic city'] + ], (f) => `Place the subject in ${f}, matching lighting, perspective and scale. Keep the subject exactly the same.`), + griglia('meteo', 'situazioni', 'Meteo', '🌦️', [ + ['sole', 'Sole', '☀️', 'bright sunshine'], ['nuvole', 'Nuvole', '☁️', 'an overcast sky'], ['pioggia', 'Pioggia', '🌧️', 'rain with wet reflections'], ['neve', 'Neve', '❄️', 'fresh falling snow'], ['nebbia', 'Nebbia', '🌫️', 'thick atmospheric fog'], ['temporale', 'Temporale', '⛈️', 'a dramatic thunderstorm'] + ], (f) => `Show the same scene with ${f}. Keep the composition and subjects identical.`, ['coerenza']), + griglia('stagione', 'situazioni', 'Stagione', '🍂', [ + ['primavera', 'Primavera', '🌸', 'spring with blooming flowers'], ['estate', 'Estate', '☀️', 'summer with lush foliage'], ['autunno', 'Autunno', '🍁', 'autumn with orange leaves'], ['inverno', 'Inverno', '❄️', 'winter with bare trees and frost'] + ], (f) => `Change the season to ${f}. Keep the composition and subjects identical.`, ['coerenza']), + griglia('inquadratura', 'prospettiva', 'Inquadratura', '📷', [ + ['dettaglio', 'Dettaglio', '🔎', 'an extreme detail close-up'], ['primo', 'Primo piano', '🙂', 'a close-up portrait'], ['busto', 'Busto', '👤', 'a waist-up portrait'], ['intera', 'Intera', '🧍', 'a full-body shot'], ['largo', 'Campo largo', '🌄', 'a wide establishing shot'] + ], (f) => `Reframe the photograph as ${f}. Keep the same subject, outfit, colors and lighting.`, ['camera']), + griglia('mood', 'luce', 'Atmosfera luce', '💡', [ + ['studio', 'Studio', '💡', 'soft studio lighting'], ['oro', 'Ora d’oro', '🌅', 'warm golden-hour light'], ['controluce', 'Controluce', '🌞', 'strong backlight and glowing rim light'], ['neon', 'Neon', '🌃', 'magenta and cyan neon'], ['drammatica', 'Drammatica', '🎭', 'dramatic low-key lighting'], ['candela', 'Candela', '🕯️', 'warm candlelight'] + ], (f) => `Relight the photo with ${f}. Keep subject, pose and composition identical.`, ['coerenza']), + griglia('grading', 'luce', 'Colore', '🎨', [ + ['pellicola', 'Pellicola', '🎞️', 'a soft film color grade with fine grain'], ['bn', 'B/N', '◐', 'classic black and white tones'], ['vivace', 'Vivace', '🌈', 'rich vibrant but natural colors'], ['cinema', 'Cinema', '🎬', 'a cinematic color grade'], ['colora', 'Colora', '🖌️', 'realistic natural colors instead of black and white'] + ], (f) => `Give the photo ${f}. Keep the composition and all details identical.`, ['coerenza']), + // ---- Persone + { + id: 'eta', categoria: 'persone', tipo: 'cursore', nome: 'Età', icona: '⏳', tooltip: 'Più giovane o più anziano: lo zero lascia com\'è', min: -30, max: 30, neutro: 0, estremi: ['🧒', '🧓'], + compila: (v) => (v === 0 ? null : { lora: ['coerenza'], frase: v > 0 ? `Age the person by about ${v} years: gray hair, wrinkles, same person. ${INVARIATO}` : `Make the person look about ${-v} years younger: smoother skin, fewer wrinkles, same person. ${INVARIATO}` }) + }, + { + id: 'espressione', categoria: 'persone', tipo: 'multi', nome: 'Espressione', icona: '🙂', tooltip: 'Cambia l\'espressione del viso', + parti: [{ id: 'esp', tipo: 'griglia', nome: 'Espressione', opzioni: ESPRESSIONI }], + compila: (v) => (v.esp ? { lora: ['coerenza'], frase: `Change the facial expression of the person to ${frase(ESPRESSIONI, v.esp)}. ${INVARIATO}` } : null) + }, + { + id: 'colore-capelli', categoria: 'persone', tipo: 'multi', nome: 'Capelli', icona: '💇', tooltip: 'Colore dei capelli', + parti: [{ id: 'col', tipo: 'colori', nome: 'Colore', opzioni: COLORI }], + compila: (v) => (v.col ? { lora: ['coerenza'], frase: `Change the hair color to ${frase(COLORI, v.col)}. Keep the hairstyle, face and everything else unchanged.` } : null) + }, + { + id: 'barba', categoria: 'persone', tipo: 'multi', nome: 'Barba', icona: '🧔', tooltip: 'Barba: dalla rasatura alla barba folta', + parti: [{ id: 'barba', tipo: 'griglia', nome: 'Barba', opzioni: BARBE }], + compila: (v) => (v.barba ? { frase: `Give the person ${frase(BARBE, v.barba)}. Keep the face, expression and everything else unchanged.` } : null) + }, + // ---- Vestiti + { + id: 'capo', categoria: 'vestiti', tipo: 'multi', nome: 'Capo su misura', icona: '👗', tooltip: 'Scegli il capo, il colore e il materiale', + parti: [ + { id: 'capo', tipo: 'griglia', nome: 'Capo', opzioni: CAPI }, + { id: 'colore', tipo: 'colori', nome: 'Colore', opzioni: COLORI }, + { id: 'materiale', tipo: 'griglia', nome: 'Materiale', opzioni: MATERIALI } + ], + compila: (v) => { + const capo = frase(CAPI, v.capo) + if (!capo) return null + const stoffa = [frase(COLORI, v.colore), frase(MATERIALI, v.materiale)].filter(Boolean).join(' ') + return { lora: ['vestiti'], frase: `Dress the person in ${capo}${stoffa ? ` in ${stoffa}` : ''}. Keep their face, hair, hands, pose and the background exactly the same.` } + } + }, + // ---- Situazioni + { + id: 'ora', categoria: 'situazioni', tipo: 'cursore', nome: 'Ora del giorno', icona: '🕒', tooltip: 'Dall\'alba alla notte: la scena resta, cambia la luce del giorno', min: 0, max: 6, neutro: 0, estremi: ['🌅', '🌙'], + etichette: ['—', 'Alba', 'Mattino', 'Mezzogiorno', 'Tramonto', 'Ora blu', 'Notte'], + compila: (v) => (ORE[v] ? { lora: ['coerenza'], frase: `Show the exact same scene ${ORE[v]}. Keep the composition and all subjects identical; only the time of day and lighting change.` } : null) + }, + { + id: 'epoca', categoria: 'situazioni', tipo: 'cursore', nome: 'Epoca', icona: '📜', tooltip: 'Porta la scena in un\'altra epoca: dal passato al futuro', min: 0, max: 5, neutro: 0, estremi: ['🏛️', '🛸'], + etichette: ['—', 'Antica Roma', 'Medioevo', 'Anni \'20', 'Anni \'80', 'Futuro'], + compila: (v) => (EPOCHE[v] ? { frase: `Reimagine the photo in ${EPOCHE[v]}. Keep the subject's face and identity recognizable and the composition similar.` } : null) + }, + { + id: 'folla', categoria: 'situazioni', tipo: 'cursore', nome: 'Folla', icona: '👥', tooltip: 'Aggiunge persone sullo sfondo: poche, tante, una folla', min: 0, max: 3, neutro: 0, estremi: ['🧍', '👥'], + etichette: ['—', 'Poche', 'Tante', 'Folla'], + compila: (v) => (v ? { frase: `Add ${['', 'a few passers-by', 'many people', 'a dense crowd'][v]} in the background, naturally integrated with matching lighting, scale and perspective. Keep the main subject and everything else unchanged.` } : null) + }, + // ---- Prospettiva + { + id: 'altezza', categoria: 'prospettiva', tipo: 'cursore', nome: 'Altezza camera', icona: '📐', tooltip: 'Da sotto in su a dall\'alto in giù', min: -2, max: 2, neutro: 0, estremi: ['⬇️', '⬆️'], + compila: (v) => (v === 0 ? null : { lora: ['camera'], frase: `Re-shoot the photo from ${{ '-2': 'a very low angle looking steeply up', '-1': 'a low angle looking up', '1': 'a high angle looking down', '2': 'directly above, a top-down view' }[String(v)]}. Keep the same subject, outfit, colors and lighting.` }) + }, + { + id: 'obiettivo', categoria: 'prospettiva', tipo: 'multi', nome: 'Obiettivo', icona: '🔭', tooltip: 'La lunghezza focale: grandangolo o tele', + parti: [{ id: 'mm', tipo: 'griglia', nome: 'Obiettivo', opzioni: OBIETTIVI }], + compila: (v) => (v.mm ? { frase: `Re-shoot the photo as if taken with a ${v.mm}mm lens: ${Number(v.mm) <= 24 ? 'wide-angle perspective with stretched depth' : Number(v.mm) >= 85 ? 'compressed telephoto perspective with a softly blurred background' : 'natural perspective'}. Keep the same subject, outfit, colors and lighting.` } : null) + }, + // ---- Luce e colore + { + id: 'luce', categoria: 'luce', tipo: 'multi', nome: 'Luce su misura', icona: '💡', tooltip: 'Da dove arriva la luce, com\'è e di che colore', + parti: [ + { id: 'dir', tipo: 'quadrante', nome: 'Direzione', opzioni: DIREZIONI }, + { id: 'qualita', tipo: 'griglia', nome: 'Qualità', opzioni: QUALITA_LUCE }, + { id: 'colore', tipo: 'colori', nome: 'Colore della luce', opzioni: COLORI } + ], + compila: (v) => { + if (!v.dir && !v.qualita && !v.colore) return null + const d = frase(DIREZIONI, v.dir) + const q = frase(QUALITA_LUCE, v.qualita) + const c = frase(COLORI, v.colore) + return { lora: ['coerenza'], frase: `Relight the photo${d ? ` with the main light coming from ${d}` : ''}${q ? `, ${q}` : ''}${c ? `, with a ${c} tint on the light` : ''}. Keep the subject, pose, clothing and composition identical.` } + } + }, + { + id: 'temperatura', categoria: 'luce', tipo: 'cursore', nome: 'Temperatura', icona: '🌡️', tooltip: 'Dal freddo al caldo: la striscia colore della luce', min: -2, max: 2, neutro: 0, estremi: ['🧊', '🔥'], + compila: (v) => (v === 0 ? null : { lora: ['coerenza'], frase: `Shift the color temperature of the photo ${{ '-2': 'to a strongly cold blue tone', '-1': 'to a slightly cold tone', '1': 'to a slightly warm tone', '2': 'to a strongly warm golden-orange tone' }[String(v)]}. Keep everything else identical.` }) + }, + // ---- Testi + { + id: 'lingua', categoria: 'testo', tipo: 'multi', nome: 'Traduci i testi', icona: '🌐', tooltip: 'Traduce le scritte nella foto, con lo stesso stile', + parti: [{ id: 'lingua', tipo: 'griglia', nome: 'Lingua', opzioni: LINGUE }], + compila: (v) => (v.lingua ? { lora: ['coerenza'], frase: `Translate all the text in the photo into ${frase(LINGUE, v.lingua)}, keeping the same fonts, layout, colors and style. Change nothing else.` } : null) + }, + { + id: 'titolo', categoria: 'testo', tipo: 'multi', nome: 'Aggiungi un testo', icona: '🔤', tooltip: 'Scrivi il testo e scegli dove va e come', + parti: [ + { id: 'dove', tipo: 'quadrante', nome: 'Dove', opzioni: POSIZIONI }, + { id: 'come', tipo: 'griglia', nome: 'Come', opzioni: FORMATI_TESTO }, + { id: 'testo', tipo: 'testo', nome: 'Il testo', esempio: 'BUON COMPLEANNO' } + ], + compila: (v) => { + const t = (v.testo || '').trim() + if (!t) return null + return { frase: `Add the text "${t}" ${frase(POSIZIONI, v.dove) || 'in a natural place'}, ${frase(FORMATI_TESTO, v.come) || 'in clean readable letters'}. Spell every letter exactly as written, with good contrast. Keep the rest of the image identical.` } + } + }, + // ---- Ritocco + { + id: 'dettagli', categoria: 'ritocco', tipo: 'cursore', nome: 'Dettagli', icona: '🔎', tooltip: 'Nitidezza e dettagli fini', min: 0, max: 2, neutro: 0, estremi: ['·', '✦'], etichette: ['—', 'Nitida', 'Molto nitida'], + compila: (v) => (v ? { lora: ['coerenza'], frase: `Enhance the image quality: ${v === 1 ? 'sharp fine details' : 'very sharp ultra-fine details'}, clean textures, no noise or compression artifacts. Keep everything identical.` } : null) + }, + { + id: 'pelle', categoria: 'ritocco', tipo: 'cursore', nome: 'Pelle', icona: '✨', tooltip: 'Dalla pelle naturale a quella liscia', min: 0, max: 2, neutro: 0, estremi: ['🧑', '🪞'], etichette: ['—', 'Naturale', 'Liscia'], + compila: (v) => (v ? { lora: ['coerenza'], frase: v === 1 ? 'Retouch the skin naturally: remove blemishes but keep realistic pores and texture. Keep everything else identical.' : 'Retouch the skin to a smooth, polished beauty finish while keeping the person recognizable. Keep everything else identical.' } : null) + }, + { + id: 'sfondo-colore', categoria: 'ritocco', tipo: 'multi', nome: 'Cambia sfondo', icona: '🏞️', tooltip: 'Sfondo pulito: bianco, grigio, sfumato o un colore', + parti: [ + { id: 'tipo', tipo: 'griglia', nome: 'Sfondo', opzioni: SFONDI }, + { id: 'colore', tipo: 'colori', nome: 'Oppure un colore', opzioni: COLORI } + ], + compila: (v) => { + const t = v.colore ? `a plain solid ${frase(COLORI, v.colore)}` : frase(SFONDI, v.tipo) + return t ? { frase: `Replace the background with ${t} background. Keep the subject, its edges and everything in front of the background exactly the same.` } : null + } + } +] + +export const controlliDi = (categoria: string): Controllo[] => CONTROLLI.filter((c) => c.categoria === categoria) +/** Restano le azioni a un clic e i segnaposto foto; le varianti sono nei controlli, non in un secondo catalogo. */ +const AZIONI_DIRETTE = new Set(['persona-rif', 'persona-agg', 'persona-togli', 'occhi', 'occhiali', 'prova', 'svuota', 't-cambia', 't-togli', 'sfondo-via', 'restaura', 'foto-lavoro']) +export const rapidaVisibile = (r: Rapida): boolean => AZIONI_DIRETTE.has(r.id) || r.id.startsWith('z-') +export const prefissoIdea = (id: string): string => `ctl-${id}:` + +/** i valori di una riga multipla stanno in una stringa `parte=valore;parte=valore` (i valori sono codificati) */ +export const scriviMulti = (v: Record): string => + Object.entries(v).filter(([, x]) => x).map(([k, x]) => `${k}=${encodeURIComponent(x)}`).join(';') +export const leggiMulti = (s: string): Record => Object.fromEntries(s.split(';').flatMap((p) => { + const i = p.indexOf('=') + if (i < 1) return [] + try { return [[p.slice(0, i), decodeURIComponent(p.slice(i + 1))]] } catch { return [] } +})) + +/** la «voce» (idea) che un controllo produce con quel valore, o null se spento. Il valore sta nell'id: `ctl-eta:10` */ +export function ideaDa(c: Controllo, valore: string): Rapida | null { + let k: Compilato | null + if (c.tipo === 'cursore') { + const n = Number(valore) + if (!Number.isInteger(n) || n < c.min || n > c.max) return null + k = c.compila(n) + } else { + const valori = leggiMulti(valore) + const validi = Object.fromEntries(c.parti.flatMap((p) => { + const v = valori[p.id] + return v && (p.tipo === 'testo' || p.opzioni.some((o) => o.id === v)) ? [[p.id, v]] : [] + })) + k = c.compila(validi) + } + if (!k || !k.frase.trim()) return null + return { id: prefissoIdea(c.id) + valore, nome: c.nome, prompt: k.frase, lora: k.lora } +} + +/** il valore attuale di un controllo, letto dalle idee attive (il neutro se è spento) */ +export function valoreDa(c: Controllo, idee: Pick[]): string { + const x = idee.find((r) => r.id.startsWith(prefissoIdea(c.id))) + return x ? x.id.slice(prefissoIdea(c.id).length) : c.tipo === 'cursore' ? String(c.neutro) : '' +} + +/** mette (o toglie, se spento) l'idea del controllo nelle idee attive */ +export function conControllo>(idee: T[], c: Controllo, valore: string): (T | Rapida)[] { + const senza = idee.filter((r) => !r.id.startsWith(prefissoIdea(c.id))) + const idea = ideaDa(c, valore) + return idea ? [...senza, idea] : senza +} diff --git a/src/renderer/src/creaPrompt.ts b/src/renderer/src/creaPrompt.ts new file mode 100644 index 0000000..429bcc5 --- /dev/null +++ b/src/renderer/src/creaPrompt.ts @@ -0,0 +1,29 @@ +// Il prompt che parte da Crea: quello scritto (o composto dal genere) + lo stile scelto + lo sfondo. +// Lo stile e lo sfondo non si scrivono nella casella: si aggiungono qui, al momento di mandare, e si vedono come icone sopra il pulsante. +import type { SfondoRichiesto } from '@shared/tipi' +import { fraseStileCrea, stileDa } from './stili' +import type { StatoCrea } from './stato' + +const NOMI_TINTA: [string, string][] = [['#ffffff', 'pure white'], ['#f2f2f2', 'light gray'], ['#000000', 'solid black'], ['#e63946', 'red'], ['#f4a261', 'orange'], ['#ffd54a', 'yellow'], ['#2a9d8f', 'teal'], ['#3fe0ff', 'cyan'], ['#8a5cff', 'violet'], ['#ff3df2', 'magenta']] +export const nomeTinta = (hex: string): string => NOMI_TINTA.find(([h]) => h.toLowerCase() === hex.toLowerCase())?.[1] || `${hex} colored` + +/** quello che si dice del fondo (in inglese) */ +export function fraseSfondo(crea: Pick, qwen: boolean): string { + if (crea.sfondo === 'tinta') return `isolated on a plain flat ${nomeTinta(crea.tinta)} background, no scenery, nothing else in the frame` + // Qwen disegna già il canale alfa (il motore avvolge il prompt); Klein ha bisogno di un fondo pulito da togliere dopo + if (crea.sfondo === 'trasparente' && !qwen) return 'isolated subject on a plain pure white background, no scenery, no shadows on the background, nothing else in the frame' + return '' +} + +export function sfondoRichiesto(crea: Pick): SfondoRichiesto { + return crea.sfondo === 'trasparente' ? 'trasparente' : crea.sfondo === 'tinta' ? { tinta: crea.tinta } : 'scena' +} + +export function promptCrea(crea: Pick, qwen: boolean): string { + const parti = [crea.prompt.trim().replace(/[.,;\s]+$/, '')] + const s = stileDa(crea.stileId) + if (s) parti.push(fraseStileCrea(s, crea.intensita)) + const f = fraseSfondo(crea, qwen) + if (f) parti.push(f) + return parti.filter(Boolean).join(', ') +} diff --git a/src/renderer/src/estrai.ts b/src/renderer/src/estrai.ts index 44adabf..751ff76 100644 --- a/src/renderer/src/estrai.ts +++ b/src/renderer/src/estrai.ts @@ -267,3 +267,20 @@ ascoltatoriLavoro.push((uno) => { }) } else if (uno.stato === 'errore' || uno.stato === 'annullato') attesiEstrai.delete(uno.id) }) + +// --------------------------------------------------------------------------------------------------------------- +// il ritaglio fedele (SAM 2): due clic, i pixel sono quelli della foto +// --------------------------------------------------------------------------------------------------------------- +/** ritaglia la cosa scelta dalla foto: nessuna ricreazione, il PNG ha lo sfondo trasparente (contratto engine §2.3: `modalita: 'segmenta'`) */ +export function richiestaRitaglio(foto: FotoBase, el: Elemento, ritaglia: boolean): Richiesta { + const nome = el.nome ? el.nome.split(/[,.;]/)[0].slice(0, 30) : el.tipo === 'persona' ? 'Persona' : 'Cosa' + return { + ...richiestaBase(), + modalita: 'segmenta', + prompt: '', + immagini: [foto.percorso], + segmenta: el.punto ? { punti: [{ x: el.punto.x, y: el.punto.y, positivo: true }], ritaglia } : { box: el.box, ritaglia }, + quante: 1, + etichetta: `Estrai · ${nome} (ritaglio)` + } +} diff --git a/src/renderer/src/iconeRapide.ts b/src/renderer/src/iconeRapide.ts new file mode 100644 index 0000000..0ed2c65 --- /dev/null +++ b/src/renderer/src/iconeRapide.ts @@ -0,0 +1,30 @@ +// Le azioni rapide di Modifica (rapide.ts) come tessere: ognuna ha un'icona (emoji, in attesa degli asset disegnati) e UNA parola. +// Il nome lungo resta nel tooltip. Niente chip di testo: le tessere sono quadrate, come la griglia delle Idee. +import type { Rapida } from './rapide' + +/** id → [icona, parola] */ +const ICONE: Record = { + 'persona-rif': ['🔁', 'Cambia'], 'persona-agg': ['🧑‍🤝‍🧑', 'Aggiungi'], 'persona-togli': ['🫥', 'Togli'], sorriso: ['😊', 'Sorriso'], occhi: ['👀', 'Occhi'], + giovane: ['🧒', 'Giovane'], anziano: ['🧓', 'Anziano'], capelli: ['💇', 'Capelli'], 'capelli-col': ['🌈', 'Colore'], occhiali: ['🕶️', 'Occhiali'], barba: ['🧔', 'Barba'], animale: ['🐕', 'Animale'], + prova: ['👕', 'Prova'], vestito: ['👗', 'Vestito'], abito: ['🥂', 'Da sera'], elegante: ['🤵', 'Elegante'], casual: ['👖', 'Casual'], invernale: ['🧥', 'Inverno'], costume: ['🎭', 'Costume'], + divisa: ['🎖️', 'Divisa'], armatura: ['🛡️', 'Armatura'], spazio: ['👨‍🚀', 'Astronauta'], + sfondo: ['🏞️', 'Sfondo'], mettilo: ['📍', 'Mettilo in'], notte: ['🌙', 'Notte'], pioggia: ['🌧️', 'Pioggia'], neve: ['❄️', 'Neve'], nebbia: ['🌫️', 'Nebbia'], tramonto: ['🌇', 'Tramonto'], + primavera: ['🌸', 'Primavera'], autunno: ['🍂', 'Autunno'], 'sott-acqua': ['🌊', 'Sott\'acqua'], 'spazio-sit': ['🪐', 'Spazio'], anni80: ['📼', 'Anni 80'], futuro: ['🔮', 'Futuro'], folla: ['👥', 'Folla'], svuota: ['🧹', 'Svuota'], + alto: ['⬆️', 'Dall\'alto'], basso: ['⬇️', 'Dal basso'], 'dall-alto-top': ['🦅', 'Zenitale'], 'primo-piano': ['🔍', 'Primo piano'], largo: ['🏔️', 'Campo largo'], lato: ['↔️', 'Di lato'], dietro: ['🔙', 'Da dietro'], + 'tre-quarti': ['📐', 'Tre quarti'], grandangolo: ['🔭', 'Grandangolo'], tele: ['📷', 'Tele'], + studio: ['💡', 'Studio'], oro: ['🌅', 'Ora d\'oro'], controluce: ['🌄', 'Controluce'], neon: ['🌃', 'Neon'], drammatica: ['🎬', 'Drammatica'], candela: ['🕯️', 'Candela'], 'bn-colora': ['🎨', 'Colora'], bn: ['⚫', 'B/N'], + pellicola: ['🎞️', 'Pellicola'], vivace: ['🌈', 'Vivace'], + anime: ['🌸', 'Anime'], 'pittura-anime': ['🏯', 'Ghibli'], acquerello: ['🖌️', 'Acquerello'], olio: ['🖼️', 'Olio'], cartoon: ['🐭', 'Cartoon'], lego: ['🧱', 'Mattoncini'], creta: ['🏺', 'Plastilina'], + pixel: ['👾', 'Pixel'], matita: ['✏️', 'Matita'], fumetto: ['💬', 'Fumetto'], caricatura: ['🤪', 'Caricatura'], 'foto-real': ['📸', 'Realistica'], action: ['🦸', 'Action figure'], + 't-cambia': ['✍️', 'Cambia'], 't-traduci': ['🌐', 'Traduci'], 't-togli': ['🧽', 'Togli'], 't-aggiungi': ['🔤', 'Aggiungi'], 't-scrivi': ['🪧', 'Scrivi'], 't-cartello': ['🚏', 'Cartello'], 't-meme': ['😂', 'Meme'], + 'sfondo-via': ['✂️', 'Via sfondo'], 'sfondo-bianco': ['⬜', 'Su bianco'], restaura: ['🛠️', 'Restaura'], dettagli: ['🔎', 'Dettagli'], pelle: ['✨', 'Pelle'], 'foto-lavoro': ['💼', 'Foto CV'], + 'z-rimuovi': ['🗑️', 'Rimuovi'], 'z-sostituisci': ['🔄', 'Sostituisci'], 'z-colore': ['🎨', 'Colore'], 'z-aggiungi': ['➕', 'Aggiungi'], 'z-ritocca': ['🪄', 'Ritocca'], 'z-oggetto': ['📦', 'Oggetto'], + 'z-persona': ['🧍', 'Persona'], 'z-vestito': ['👚', 'Vestito'], 'z-sfoca': ['🫧', 'Sfoca'] +} + +/** l'icona e la parola di un'azione rapida (se non c'è nella tabella: la prima parola del nome) */ +export function iconaRapida(r: Rapida, ripiego = '✨'): { icona: string; nome: string } { + const x = ICONE[r.id] + if (x) return { icona: x[0], nome: x[1] } + return { icona: ripiego, nome: r.nome.replace('…', '').split(' ')[0] } +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index c4beac9..b12d916 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from 'react-dom/client' import './stile.css' +import './orbita.css' import { App } from './App' import { Component, type ReactNode } from 'react' import { usaStato } from './stato' diff --git a/src/renderer/src/misura.ts b/src/renderer/src/misura.ts new file mode 100644 index 0000000..68fd033 --- /dev/null +++ b/src/renderer/src/misura.ts @@ -0,0 +1,69 @@ +// La misura dell'immagine che esce da Crea: UNA funzione sola, usata sia dal pulsante che costruisce la richiesta sia dal HUD +// («la cifra che vedi è quella che esce»). Il modello disegna a `lav` e il motore porta il risultato a `finale`. +import { dimMp } from './componenti/Misura' +import { FORMATI, dimensioniRis } from './util' +import type { StatoCrea } from './stato' + +export type StatoMisura = 'nativa' | 'ingrandita' | 'ridotta' + +export interface MisuraCrea { + /** la foto che esce, in pixel */ + finale: { w: number; h: number } + /** la misura a cui disegna il modello */ + lav: { w: number; h: number } + /** la misura chiesta dal formato e dalla risoluzione, prima del limite del modello (4,2 MP, 2688 px) */ + voluta: { w: number; h: number } + /** nativa = disegna proprio alla misura finale · ingrandita = disegna più piccolo e poi ingrandisce · ridotta = disegna più grande o il limite l'ha abbassata */ + stato: StatoMisura + /** di quanto ingrandisce (>1) o riduce (<1) dal disegno alla misura finale */ + fattore: number + /** la frase per il HUD */ + nota: string + mp: number + rapporto: string +} + +const m16 = (x: number): number => Math.max(256, Math.round(x / 16) * 16) +const mcd = (a: number, b: number): number => (b ? mcd(b, a % b) : a) +/** il rapporto più vicino fra quelli noti, se no w:h ridotto */ +export const rapportoDi = (w: number, h: number): string => { + const noto = FORMATI.find((f) => Math.abs(f.w / f.h - w / h) < 0.012) + if (noto) return noto.id + const d = mcd(Math.round(w), Math.round(h)) + const a = Math.round(w) / d + const b = Math.round(h) / d + return a > 40 || b > 40 ? (w / h).toFixed(2).replace('.', ',') + ':1' : `${a}:${b}` +} + +export function misuraCrea(crea: Pick): MisuraCrea { + const dim = dimensioniRis(crea.formato, crea.ris) + const f = FORMATI.find((x) => x.id === crea.formato) || FORMATI[0] + const ar = f.w / f.h + const corto = crea.ris + const voluta = { w: Math.round(ar >= 1 ? corto * ar : corto), h: Math.round(ar >= 1 ? corto : corto / ar) } + const mis = crea.misura?.tipo === 'custom' ? crea.misura : null + const finale = mis ? { w: mis.w, h: mis.h } : dim + const mpLav = crea.mpLavoro ? Math.min(crea.mpLavoro, 4.19) : 0 + const lav = mpLav + ? dimMp(mpLav, finale.w, finale.h) + : mis + ? { w: m16(Math.sqrt(dim.w * dim.h * (mis.w / mis.h))), h: m16(Math.sqrt((dim.w * dim.h) / (mis.w / mis.h))) } + : dim + const fattore = Math.sqrt((finale.w * finale.h) / (lav.w * lav.h)) + const limitato = !mis && dim.w * dim.h < voluta.w * voluta.h * 0.97 + let stato: StatoMisura = 'nativa' + let nota = `Nativa: il modello disegna proprio a ${finale.w}×${finale.h}` + if (Math.abs(finale.w - lav.w) > 1 || Math.abs(finale.h - lav.h) > 1) { + if (fattore > 1.01) { + stato = 'ingrandita' + nota = `Disegna a ${lav.w}×${lav.h} → ingrandisce ×${fattore.toFixed(2).replace('.', ',')}` + } else { + stato = 'ridotta' + nota = `Disegna a ${lav.w}×${lav.h} → riduce a ${finale.w}×${finale.h}` + } + } else if (limitato) { + stato = 'ridotta' + nota = `Limite del modello: scende a ${finale.w}×${finale.h}` + } + return { finale, lav, voluta, stato, fattore, nota, mp: (finale.w * finale.h) / 1048576, rapporto: rapportoDi(finale.w, finale.h) } +} diff --git a/src/renderer/src/orbita.css b/src/renderer/src/orbita.css new file mode 100644 index 0000000..4de5ff0 --- /dev/null +++ b/src/renderer/src/orbita.css @@ -0,0 +1,312 @@ +/* DaProd Image — GUI v2 «Orbita»: spazio profondo, vetro scuro, tessere con icona. + Sta sopra stile.css (che resta per le pagine non ancora ridisegnate): qui ci sono i token nuovi, il palco con le stelle + e i pezzi nuovi (tessere, HUD misura, strip dei modi, Box, Uncensored, Pila LoRA). Le animazioni usano solo transform e opacity + e si spengono con «Meno movimento» (prefers-reduced-motion). */ +:root { + /* palette: stessi nomi di stile.css, più scuri; i nuovi vengono dalla spec (docs/gui-v2-spec.md §8) */ + --fondo: #06040f; + --fondo2: #0a0716; + --pannello: #110c24; + --pannello2: #1a1338; + --rialzo: #251c4d; + --bordo: #2a2056; + --bordo2: #43397f; + --testo: #f1ecff; + --tenue: #b2a7d8; + --spento: #7a70a3; + --nebulosa: #8a5cff; + --supernova: #ff3df2; + --plasma: #3fe0ff; + --stella: #ffd54a; + --ember: #ff8a3d; + --errore: #ff5470; + --aurora: linear-gradient(135deg, #8a5cff 0%, #ff3df2 55%, #3fe0ff 100%); + --corona: linear-gradient(135deg, #ffd54a, #ff8a3d); + --vetro: rgba(22, 15, 50, 0.66); + --bordo-vetro: rgba(190, 160, 255, 0.18); + --raggio-s: 8px; + --raggio-l: 18px; + --alone-nebulosa: 0 0 0 1px rgba(138, 92, 255, 0.7), 0 0 22px rgba(138, 92, 255, 0.45); + --alone-plasma: 0 0 0 1px rgba(63, 224, 255, 0.7), 0 0 20px rgba(63, 224, 255, 0.35); + --t-rapido: 120ms; + --t-base: 200ms; + --curva: cubic-bezier(0.2, 0.8, 0.2, 1); + --molla: cubic-bezier(0.34, 1.56, 0.64, 1); + --tessera: 84px; +} + +/* ---------- sfondo: nebulosa e stelle (solo CSS, nessun canvas), si vedono nel palco ---------- */ +.palco { + position: relative; + background: + radial-gradient(ellipse at 18% 100%, rgba(138, 92, 255, 0.22) 0%, transparent 55%), + radial-gradient(ellipse at 90% 0%, rgba(255, 61, 242, 0.13) 0%, transparent 50%), + radial-gradient(ellipse at 50% 40%, #150f2c 0%, var(--fondo) 72%); + isolation: isolate; +} +.palco::before { + content: ''; + position: absolute; + inset: -40px; + z-index: -1; + pointer-events: none; + background-image: + radial-gradient(1px 1px at 6% 12%, rgba(255, 255, 255, 0.8), transparent), + radial-gradient(1.4px 1.4px at 14% 64%, rgba(200, 185, 255, 0.8), transparent), + radial-gradient(1px 1px at 22% 33%, rgba(255, 255, 255, 0.55), transparent), + radial-gradient(1.6px 1.6px at 31% 82%, rgba(160, 230, 255, 0.75), transparent), + radial-gradient(1px 1px at 38% 8%, rgba(255, 255, 255, 0.7), transparent), + radial-gradient(1.2px 1.2px at 47% 47%, rgba(255, 200, 250, 0.6), transparent), + radial-gradient(1px 1px at 55% 90%, rgba(255, 255, 255, 0.6), transparent), + radial-gradient(1.5px 1.5px at 63% 22%, rgba(200, 185, 255, 0.8), transparent), + radial-gradient(1px 1px at 71% 71%, rgba(255, 255, 255, 0.55), transparent), + radial-gradient(1.3px 1.3px at 78% 38%, rgba(160, 230, 255, 0.7), transparent), + radial-gradient(1px 1px at 86% 85%, rgba(255, 255, 255, 0.7), transparent), + radial-gradient(1.6px 1.6px at 92% 14%, rgba(255, 200, 250, 0.8), transparent), + radial-gradient(1px 1px at 97% 55%, rgba(255, 255, 255, 0.5), transparent), + radial-gradient(1.2px 1.2px at 3% 90%, rgba(200, 185, 255, 0.6), transparent); + animation: deriva 90s linear infinite alternate; +} +@keyframes deriva { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-28px, 14px, 0); } } +.palco > * { position: relative; } + +/* ---------- la rotaia e la strip dei modi ---------- */ +.rotaia { background: linear-gradient(180deg, #0b0819, #070512); border-right-color: var(--bordo-vetro); } +.rotaia button.attiva { background: linear-gradient(160deg, rgba(138, 92, 255, 0.28), rgba(255, 61, 242, 0.12)); color: var(--stella); box-shadow: inset 0 0 0 1px rgba(138, 92, 255, 0.55); } +.rotaia button.attiva::before { background: var(--aurora); } + +.studio { position: absolute; inset: 0; display: flex; flex-direction: column; min-height: 0; } +.studio-corpo { position: relative; flex: 1; min-height: 0; } +.strip-modi { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid var(--bordo-vetro); background: linear-gradient(180deg, #0d0a1d, #090614); flex: none; } +.strip-modi .modo { + position: relative; display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 16px 0 12px; border-radius: 12px; border: 1px solid var(--bordo); background: var(--pannello); + color: var(--tenue); font-weight: 700; letter-spacing: 0.3px; cursor: pointer; transition: transform var(--t-rapido) var(--curva), background var(--t-rapido), color var(--t-rapido); +} +.strip-modi .modo svg { width: 20px; height: 20px; } +.strip-modi .modo:hover { color: var(--testo); background: var(--pannello2); transform: translateY(-1px); } +.strip-modi .modo.su { color: #fff; border-color: transparent; background: linear-gradient(var(--pannello2), var(--pannello2)) padding-box, var(--aurora) border-box; box-shadow: 0 6px 24px -10px rgba(138, 92, 255, 0.8); } +.strip-modi .modo.su svg { color: var(--stella); } +.strip-modi .spazio { flex: 1; } +.strip-modi .pillola-motore { display: inline-flex; align-items: center; gap: 7px; height: 30px; padding: 0 12px; border-radius: 15px; border: 1px solid var(--bordo-vetro); background: var(--vetro); color: var(--tenue); font-size: 13px; font-weight: 700; cursor: pointer; } +.strip-modi .pillola-motore:hover { color: var(--testo); border-color: var(--nebulosa); } +.strip-modi .pillola-motore b { color: var(--stella); font-weight: 700; } + +/* ---------- tessere: icona grande + una parola ---------- */ +.tessere { display: grid; grid-template-columns: repeat(var(--col, 4), minmax(0, 1fr)); gap: 8px; } +.tessere.col2 { --col: 2; } .tessere.col3 { --col: 3; } .tessere.col5 { --col: 5; } .tessere.col6 { --col: 6; } +.tessera-o { + position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; min-height: 66px; padding: 8px 4px 6px; border-radius: 12px; + border: 1px solid var(--bordo); background: var(--pannello2); color: var(--tenue); cursor: pointer; font-size: 12.5px; font-weight: 700; line-height: 1.1; text-align: center; + transition: transform var(--t-rapido) var(--curva), box-shadow var(--t-base), border-color var(--t-rapido), color var(--t-rapido); min-width: 0; +} +.tessera-o .ic { display: grid; place-items: center; width: 30px; height: 30px; font-size: 22px; line-height: 1; transition: transform var(--t-base) var(--molla); } +.tessera-o .ic svg { width: 26px; height: 26px; } +.tessera-o .nome { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tessera-o:hover { transform: translateY(-2px); color: var(--testo); border-color: var(--nebulosa); box-shadow: 0 8px 22px -12px rgba(138, 92, 255, 0.9); } +.tessera-o:hover .ic { transform: scale(1.1); } +.tessera-o.su { color: #fff; border-color: transparent; background: linear-gradient(#20194a, #20194a) padding-box, var(--aurora) border-box; box-shadow: 0 0 0 1px rgba(138, 92, 255, 0.25), 0 8px 24px -10px rgba(255, 61, 242, 0.7); } +.tessera-o.su .ic { color: var(--stella); } +.tessera-o:disabled, .tessera-o.spenta { opacity: 0.4; cursor: default; transform: none; box-shadow: none; } +.tessera-o.da-scaricare::after { content: ''; position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--oro2); box-shadow: 0 0 8px var(--oro2); } +.tessera-o .pallina { position: absolute; top: -5px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--aurora); color: #fff; font-size: 11px; display: grid; place-items: center; box-shadow: 0 0 12px rgba(255, 61, 242, 0.6); } +.tessera-o.larga { flex-direction: row; justify-content: flex-start; gap: 10px; padding: 10px 12px; min-height: 58px; text-align: left; } +.tessera-o.larga .testi { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.tessera-o.larga small { font-size: 11.5px; font-weight: 500; color: var(--spento); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tessera-o.larga.su small { color: var(--tenue); } +.tessera-o.anteprima { min-height: 76px; padding: 0; justify-content: flex-end; overflow: hidden; border-color: transparent; } +.tessera-o.anteprima .fondo { position: absolute; inset: 0; opacity: 0.95; } +.tessera-o.anteprima .ic { position: absolute; left: 50%; top: 22px; transform: translate(-50%, -50%); font-size: 26px; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); } +.tessera-o.anteprima:hover .ic { transform: translate(-50%, -50%) scale(1.12); } +.tessera-o.anteprima .nome { position: relative; width: 100%; padding: 3px 4px 4px; background: rgba(6, 4, 15, 0.72); color: var(--testo); font-size: 11.5px; } +.tessera-o.anteprima.su { box-shadow: 0 0 0 2px var(--supernova), 0 8px 24px -10px rgba(255, 61, 242, 0.8); background: none; } +.tessera-o.fiamma { border-color: rgba(255, 138, 61, 0.55); } +.fascetta-uc { display: inline-flex; align-items: center; gap: 4px; height: 17px; padding: 0 7px; border-radius: 9px; background: var(--ember); color: #1d0b00; font-size: 9.5px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; vertical-align: 2px; } +.fascetta-uc svg { width: 10px; height: 10px; } + +.scaffale { display: flex; gap: 8px; overflow-x: auto; padding: 2px 2px 8px; scroll-snap-type: x proximity; } +.scaffale .tessera-o { flex: none; width: 76px; scroll-snap-align: start; } +.titolo-scaffale { font-size: 11.5px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--spento); margin: 4px 0 4px; font-weight: 700; } + +.pianeti { display: flex; align-items: flex-end; gap: 10px; } +.pianeti button { display: grid; place-items: center; border-radius: 50%; border: 1px solid var(--bordo2); background: radial-gradient(circle at 32% 30%, #6a5bd6, #241a5a 70%); color: #fff; font-size: 11px; font-weight: 700; cursor: pointer; transition: transform var(--t-base) var(--molla), box-shadow var(--t-base); } +.pianeti button:nth-child(1) { width: 34px; height: 34px; } .pianeti button:nth-child(2) { width: 42px; height: 42px; } .pianeti button:nth-child(3) { width: 50px; height: 50px; } +.pianeti button.su { box-shadow: var(--alone-nebulosa); border-color: transparent; background: radial-gradient(circle at 32% 30%, #ff9bf7, #8a2cff 70%); } +.pianeti button:hover { transform: scale(1.08); } +.pianeti-et { display: flex; gap: 10px; color: var(--spento); font-size: 11.5px; margin-top: 2px; } + +/* ---------- HUD misura: la cifra che vedi è quella che esce ---------- */ +.hud { position: absolute; left: 14px; bottom: 12px; z-index: 6; display: flex; align-items: center; gap: 12px; max-width: calc(100% - 28px); padding: 9px 14px; border-radius: 14px; border: 1px solid var(--bordo-vetro); background: var(--vetro); backdrop-filter: blur(14px) saturate(1.2); cursor: pointer; color: var(--testo); box-shadow: 0 10px 30px -12px #000; transition: border-color var(--t-base), transform var(--t-rapido); } +.hud:hover { border-color: var(--plasma); transform: translateY(-1px); } +.hud .forma { width: 34px; height: 34px; display: grid; place-items: center; flex: none; } +.hud .forma i { display: block; border: 2px solid var(--plasma); border-radius: 3px; background: rgba(63, 224, 255, 0.1); transition: width 240ms var(--molla), height 240ms var(--molla); } +.hud .px { font-family: var(--titolo); font-weight: 700; font-size: 19px; letter-spacing: 0.5px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.hud .px small { font-family: var(--font); font-size: 12px; font-weight: 600; color: var(--tenue); margin-left: 3px; letter-spacing: 0; } +.hud .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.hud .linea1 { display: flex; align-items: baseline; gap: 12px; white-space: nowrap; } +.hud .rapporto, .hud .mp { color: var(--tenue); font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; } +.hud .conv { font-size: 12.5px; font-weight: 600; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.hud .conv::before { content: ''; width: 7px; height: 7px; border-radius: 50%; flex: none; background: currentColor; box-shadow: 0 0 8px currentColor; } +.hud.nativa .conv { color: var(--verde); } +.hud.ingrandita .conv { color: var(--plasma); } +.hud.ridotta .conv { color: var(--oro2); } +.hud.corto { position: static; padding: 4px 10px; gap: 8px; box-shadow: none; background: rgba(63, 224, 255, 0.07); } +.hud.corto .px { font-size: 14px; } +.hud-pop { position: absolute; left: 14px; bottom: 76px; z-index: 30; width: 400px; max-height: calc(100% - 110px); overflow-y: auto; padding: 14px; border-radius: 16px; border: 1px solid var(--bordo-vetro); background: rgba(14, 10, 32, 0.94); backdrop-filter: blur(16px); box-shadow: var(--ombra, 0 24px 60px -10px #000); display: flex; flex-direction: column; gap: 12px; } + +/* ---------- colonna del modo + bottone principale ---------- */ +.colonna.destra { background: linear-gradient(180deg, #0b0819, #080612); border-left: 1px solid var(--bordo-vetro); width: 400px; } +/* Studio: i comandi (Motore, Cosa, prompt, CREA) stanno a SINISTRA del palco, per tutti i modi (Crea, Modifica, Box, Ricette). + La classe resta «destra» perché i test la cercano così. Il pulsante CREA è fuori dallo scorrimento: resta in fondo. */ +.studio-corpo .pagina .colonna.destra { order: -1; border-left: 0; border-right: 1px solid var(--bordo-vetro); } +.colonna .sezione { border-bottom-color: rgba(190, 160, 255, 0.08); } +.btn.primario.aurora { background: var(--aurora); color: #fff; border-color: transparent; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.35); box-shadow: 0 10px 30px -10px rgba(255, 61, 242, 0.7); position: relative; } +.btn.primario.aurora:hover { filter: brightness(1.1); } +.btn.primario.aurora:active { transform: scale(0.98); } +.azione-prima { display: flex; flex-direction: column; gap: 6px; } +.azione-prima .riga-info { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 28px; } + +.scelte-attive { display: flex; flex-wrap: wrap; gap: 6px; } +.scelte-attive button { position: relative; width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--bordo2); background: var(--pannello2); font-size: 16px; display: grid; place-items: center; cursor: pointer; color: var(--stella); } +.scelte-attive button svg { width: 17px; height: 17px; } +.scelte-attive button:hover::after { content: '✕'; position: absolute; inset: 0; border-radius: 50%; background: rgba(255, 84, 112, 0.9); color: #fff; display: grid; place-items: center; font-size: 13px; } + +.sezione h3 .dx.oro { color: var(--stella); } +.riga-s { display: flex; gap: 6px; flex-wrap: wrap; } +.pulsante-s { min-width: 54px; flex: 1; padding: 7px 4px 5px; border-radius: 11px; border: 1px solid var(--bordo); background: var(--pannello2); color: var(--tenue); cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 1px; font-weight: 800; font-size: 15px; transition: transform var(--t-rapido) var(--curva), border-color var(--t-rapido); } +.pulsante-s small { font-size: 10.5px; font-weight: 600; color: var(--spento); font-variant-numeric: tabular-nums; } +.pulsante-s:hover { color: var(--testo); border-color: var(--nebulosa); transform: translateY(-1px); } +.pulsante-s.su { color: #fff; border-color: transparent; background: linear-gradient(#20194a, #20194a) padding-box, var(--aurora) border-box; } +.pulsante-s.su small { color: var(--tenue); } +.formati .formato { border-radius: 11px; background: var(--pannello2); } +.formati .formato.su { border-color: transparent; background: linear-gradient(#20194a, #20194a) padding-box, var(--aurora) border-box; color: var(--stella); } + +/* tinta unita e anteprima dello sfondo */ +.campioni { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +.campioni button { width: 24px; height: 24px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; } +.campioni button.su { border-color: var(--stella); box-shadow: 0 0 0 2px #000 inset; } +.scacchi-btn { background-image: linear-gradient(45deg, #888 25%, transparent 25%), linear-gradient(-45deg, #888 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #888 75%), linear-gradient(-45deg, transparent 75%, #888 75%); background-size: 8px 8px; background-position: 0 0, 0 4px, 4px -4px, -4px 0; background-color: #ccc; } +.vista.sfondo-bianco .scacchi { background: #fff; } +.vista.sfondo-nero .scacchi { background: #000; } +.vista.sfondo-colore .scacchi { background: var(--tinta-vista, #8a5cff); } +.vista.sfondo-bianco .scacchi, .vista.sfondo-nero .scacchi, .vista.sfondo-colore .scacchi { background-image: none; } + +/* ---------- Box: editor regionale ---------- */ +.box-tela { position: absolute; inset: 0; display: grid; place-items: center; padding: 18px 18px 78px; } +.box-cornice { position: relative; border-radius: 10px; background: rgba(12, 8, 28, 0.78); border: 1px solid var(--bordo-vetro); box-shadow: 0 24px 70px -20px #000, 0 0 0 1px rgba(138, 92, 255, 0.15); overflow: hidden; touch-action: none; cursor: crosshair; user-select: none; } +.box-cornice::before { content: ''; position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(rgba(190, 160, 255, 0.07) 1px, transparent 1px), linear-gradient(90deg, rgba(190, 160, 255, 0.07) 1px, transparent 1px); background-size: 33.333% 33.333%; } +.box-regione { position: absolute; border: 2px solid var(--c); background: color-mix(in srgb, var(--c) 16%, transparent); border-radius: 4px; cursor: move; box-sizing: border-box; animation: nasce 180ms var(--molla); } +.box-regione.sel { border-style: dashed; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5), 0 0 22px color-mix(in srgb, var(--c) 55%, transparent); background: color-mix(in srgb, var(--c) 24%, transparent); } +.box-regione.nascosta { opacity: 0.25; } +.box-regione .numero { position: absolute; left: -2px; top: -2px; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 4px 0 8px 0; background: var(--c); color: #0a0716; font-weight: 800; font-size: 13px; display: grid; place-items: center; pointer-events: none; } +.box-regione .testo-box { position: absolute; left: 4px; right: 4px; top: 26px; bottom: 4px; overflow: hidden; color: #fff; font-size: 13px; font-weight: 600; line-height: 1.2; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); pointer-events: none; } +.box-regione .maniglia { position: absolute; width: 11px; height: 11px; background: #fff; border: 2px solid var(--c); border-radius: 3px; } +.box-regione .maniglia.nw { left: -6px; top: -6px; cursor: nwse-resize; } .box-regione .maniglia.ne { right: -6px; top: -6px; cursor: nesw-resize; } +.box-regione .maniglia.sw { left: -6px; bottom: -6px; cursor: nesw-resize; } .box-regione .maniglia.se { right: -6px; bottom: -6px; cursor: nwse-resize; } +.box-regione .maniglia.n { left: calc(50% - 5px); top: -6px; cursor: ns-resize; } .box-regione .maniglia.s { left: calc(50% - 5px); bottom: -6px; cursor: ns-resize; } +.box-regione .maniglia.w { left: -6px; top: calc(50% - 5px); cursor: ew-resize; } .box-regione .maniglia.e { right: -6px; top: calc(50% - 5px); cursor: ew-resize; } +.box-regione.disegno { border-style: dotted; pointer-events: none; } +@keyframes nasce { from { transform: scale(0.92); opacity: 0.4; } } +.box-vuoto { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: var(--spento); pointer-events: none; padding: 20px; font-size: 14px; } +.box-vuoto b { color: var(--tenue); display: block; font-family: var(--titolo); letter-spacing: 1px; margin-bottom: 4px; } +.box-lista { display: flex; flex-direction: column; gap: 8px; } +.box-scheda { border: 1px solid var(--bordo); border-left: 4px solid var(--c); border-radius: 10px; background: var(--pannello2); padding: 8px 10px; display: flex; flex-direction: column; gap: 8px; } +.box-scheda.sel { border-color: var(--c); box-shadow: 0 0 20px -8px var(--c); } +.box-scheda .testa { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.box-scheda .testa .n { width: 22px; height: 22px; border-radius: 6px; background: var(--c); color: #0a0716; font-weight: 800; display: grid; place-items: center; font-size: 13px; flex: none; } +.box-scheda .testa .riassunto { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: var(--tenue); } +.box-scheda .testa button { width: 26px; height: 26px; border: 0; background: transparent; color: var(--tenue); cursor: pointer; border-radius: 6px; display: grid; place-items: center; } +.box-scheda .testa button:hover { background: var(--rialzo); color: var(--testo); } +.box-scheda .testa button svg { width: 16px; height: 16px; } +.disposizioni { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } +.disposizioni button { aspect-ratio: 1.6; border-radius: 8px; border: 1px solid var(--bordo); background: var(--pannello2); padding: 4px; cursor: pointer; position: relative; } +.disposizioni button:hover { border-color: var(--nebulosa); } +.disposizioni button i { position: absolute; border: 1.5px solid var(--tenue); border-radius: 2px; background: rgba(138, 92, 255, 0.25); } +.rigidita { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } + +/* ---------- Estrai: tre uscite ---------- */ +.uscite-estrai { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } + +/* ---------- Impostazioni: navigazione a sinistra + Laboratorio Uncensored ---------- */ +.opz-nav { width: 168px; flex: none; padding: 16px 10px; display: flex; flex-direction: column; gap: 6px; border-right: 1px solid var(--bordo-vetro); background: linear-gradient(180deg, #0b0819, #080612); overflow-y: auto; } +.opz-nav .tessera-o { flex-direction: row; justify-content: flex-start; gap: 10px; min-height: 42px; padding: 6px 10px; font-size: 13.5px; } +.opz-nav .tessera-o .ic { width: 22px; height: 22px; font-size: 18px; } .opz-nav .tessera-o .ic svg { width: 20px; height: 20px; } +.uc-lab { border: 1px solid rgba(255, 138, 61, 0.45); border-radius: 14px; background: linear-gradient(180deg, rgba(255, 138, 61, 0.1), rgba(255, 138, 61, 0.02)); padding: 16px 18px; } +.uc-lab h3 { margin: 0 0 6px; font-family: var(--titolo); font-size: 15px; letter-spacing: 1.2px; color: var(--ember); display: flex; align-items: center; gap: 8px; } +.uc-riga { display: grid; grid-template-columns: 96px 1fr 120px 78px 120px; gap: 10px; align-items: center; padding: 9px 4px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); font-size: 13.5px; } +.uc-riga.testa { color: var(--spento); font-size: 11.5px; letter-spacing: 1px; text-transform: uppercase; font-weight: 700; } +.uc-riga .nome { font-weight: 700; min-width: 0; } +.uc-riga .nome small { display: block; font-weight: 500; color: var(--spento); font-size: 12px; } +.disclaimer { position: fixed; inset: 0; z-index: 120; display: grid; place-items: center; background: rgba(4, 3, 12, 0.82); backdrop-filter: blur(6px); } +.disclaimer .scheda-d { width: min(640px, calc(100vw - 40px)); max-height: calc(100vh - 60px); overflow-y: auto; padding: 22px 24px; border-radius: 18px; border: 1px solid rgba(255, 138, 61, 0.6); background: #120c24; box-shadow: 0 30px 90px -20px #000, 0 0 40px -10px rgba(255, 138, 61, 0.5); } +.disclaimer h2 { margin: 0 0 10px; font-family: var(--titolo); color: var(--ember); letter-spacing: 1.2px; font-size: 17px; display: flex; align-items: center; gap: 10px; } +.disclaimer p { margin: 0 0 12px; color: var(--tenue); font-size: 14.5px; line-height: 1.45; } +.disclaimer label.spunta { display: flex; gap: 10px; align-items: flex-start; padding: 8px 10px; border-radius: 10px; background: #0f0b1c; border: 1px solid var(--bordo); margin-bottom: 8px; cursor: pointer; font-size: 14.5px; } +.disclaimer label.spunta input { margin-top: 3px; accent-color: var(--ember); width: 17px; height: 17px; } +.disclaimer .scuoti { animation: scuoti 320ms; } +@keyframes scuoti { 20% { transform: translateX(-6px); } 40% { transform: translateX(6px); } 60% { transform: translateX(-4px); } 80% { transform: translateX(4px); } } + +/* ---------- Pila LoRA ---------- */ +.pila { display: flex; flex-direction: column; gap: 8px; } +.slot { display: grid; grid-template-columns: 22px 38px minmax(0, 1fr); gap: 8px 10px; align-items: center; padding: 8px 10px; border-radius: 12px; border: 1px solid var(--bordo); background: var(--pannello2); transition: transform var(--t-base) var(--curva), opacity var(--t-base); } +.slot.spenta { opacity: 0.5; } +.slot.incompatibile { border-style: dashed; opacity: 0.6; } +.slot.trascinato { transform: scale(1.02); box-shadow: var(--alone-nebulosa); z-index: 2; } +.slot .maniglia-s { cursor: grab; color: var(--spento); text-align: center; font-size: 15px; user-select: none; } +.slot .mini-c { width: 38px; height: 38px; border-radius: 8px; overflow: hidden; display: grid; place-items: center; font-family: var(--titolo); font-weight: 900; font-size: 12px; } +.slot .mini-c img { width: 100%; height: 100%; object-fit: cover; } +.slot .nome-s { min-width: 0; display: flex; align-items: center; gap: 8px; font-weight: 700; } +.slot .nome-s span.n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } +.slot .corpo-s { grid-column: 2 / -1; display: flex; align-items: center; gap: 8px; } +.slot .corpo-s input[type='range'] { flex: 1; } +.slot .corpo-s output { width: 40px; text-align: right; color: var(--stella); font-weight: 800; font-variant-numeric: tabular-nums; } +.slot .parole-s { grid-column: 2 / -1; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; font-size: 12px; color: var(--spento); } +.slot .motivo { grid-column: 2 / -1; font-size: 12.5px; color: var(--oro2); } +.slot button.mini { width: 28px; height: 28px; border-radius: 8px; border: 1px solid var(--bordo); background: transparent; color: var(--tenue); cursor: pointer; display: grid; place-items: center; font-size: 12px; font-weight: 800; } +.slot button.mini:hover { color: var(--testo); border-color: var(--nebulosa); } +.slot button.mini.su { background: var(--nebulosa); color: #fff; border-color: transparent; } +.slot button.mini svg { width: 16px; height: 16px; } +.cassetto { position: absolute; top: 0; right: 0; bottom: 0; width: 420px; z-index: 40; padding: 16px; display: flex; flex-direction: column; gap: 12px; background: rgba(12, 8, 28, 0.96); backdrop-filter: blur(14px); border-left: 1px solid var(--bordo-vetro); box-shadow: -24px 0 60px -20px #000; overflow-y: auto; animation: scivola-dx 220ms var(--curva); } +@keyframes scivola-dx { from { transform: translateX(24px); opacity: 0; } } +.cassetto h2 { margin: 0; font-family: var(--titolo); font-size: 14px; letter-spacing: 1.5px; color: var(--stella); display: flex; align-items: center; gap: 8px; } +.badge-arch { display: inline-flex; align-items: center; height: 18px; padding: 0 7px; border-radius: 6px; font-size: 10.5px; font-weight: 800; letter-spacing: 0.4px; white-space: nowrap; } +.badge-arch.si { background: rgba(62, 224, 143, 0.14); color: var(--verde); } +.badge-arch.no { background: rgba(255, 84, 112, 0.14); color: var(--rosso); } +.badge-arch.forse { background: rgba(178, 167, 216, 0.14); color: var(--tenue); } +.badge-arch.presto { background: rgba(255, 182, 39, 0.14); color: var(--oro2); } +.filtri-lora { display: flex; gap: 6px; flex-wrap: wrap; } +.filtri-lora .tessera-o { flex-direction: row; min-height: 30px; padding: 4px 10px; gap: 6px; font-size: 12.5px; flex: none; } + +/* ---------- movimento ridotto ---------- */ +@media (prefers-reduced-motion: reduce) { + .palco::before, .box-regione, .cassetto, .disclaimer .scuoti { animation: none !important; } + .tessera-o, .tessera-o .ic, .pulsante-s, .hud, .hud .forma i, .strip-modi .modo, .slot, .pianeti button { transition: none !important; transform: none !important; } +} +body.meno-movimento .palco::before { animation: none; } + +/* ---------- Modifica: i modi (Scrivi · Espandi · Estrai) in cima alla tela ---------- */ +.modi-modifica { display: flex; gap: 6px; } +.modi-modifica .tessera-o { flex-direction: row; min-height: 36px; padding: 0 14px 0 10px; gap: 8px; font-size: 14px; } +.modi-modifica .tessera-o .ic { width: 22px; height: 22px; } .modi-modifica .tessera-o .ic svg { width: 20px; height: 20px; } + +/* ---------- Modifica: i modi (Scrivi · Espandi · Estrai) in cima alla tela ---------- */ +.modi-modifica { display: flex; gap: 6px; } +.modi-modifica .tessera-o { flex-direction: row; min-height: 36px; padding: 0 14px 0 10px; gap: 8px; font-size: 14px; } +.modi-modifica .tessera-o .ic { width: 22px; height: 22px; } .modi-modifica .tessera-o .ic svg { width: 20px; height: 20px; } + +/* ---------- Modifica: i controlli interattivi per categoria (cursori, campioni colore, quadrante) ---------- */ +.controlli-cat { display: flex; flex-direction: column; gap: 14px; margin-top: 12px; } +.ctl { display: flex; flex-direction: column; gap: 6px; padding: 10px; border-radius: 12px; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--bordo-vetro, rgba(255, 255, 255, 0.08)); } +.ctl-testa { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--stella); } +.ctl-cursore { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px; } +.ctl-cursore input[type=range] { width: 100%; accent-color: var(--oro, #ffd54a); } +.ctl-cursore .estremo { font-size: 16px; line-height: 1; } +.ctl-cursore .valore { grid-column: 1 / -1; text-align: center; font-size: 12px; } +.campioni { display: flex; flex-wrap: wrap; gap: 6px; } +.campione { width: 26px; height: 26px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.25); cursor: pointer; padding: 0; } +.campione:hover { transform: scale(1.12); } +.campione.su { border-color: #fff; box-shadow: 0 0 0 2px var(--nebulosa, #8b5cf6), 0 0 12px rgba(255, 255, 255, 0.4); } +.quadrante { display: grid; grid-template-columns: repeat(3, 38px); grid-auto-rows: 38px; gap: 4px; align-self: flex-start; } +.quadrante .cella { display: grid; place-items: center; border-radius: 9px; border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.04); color: var(--stella); font-size: 15px; cursor: pointer; padding: 0; } +.quadrante .cella.su { background: var(--nebulosa, #8b5cf6); color: #fff; border-color: transparent; } +.quadrante .cella.soggetto { cursor: default; opacity: 0.4; border-style: dashed; } +@media (prefers-reduced-motion: reduce) { .campione:hover { transform: none; } } diff --git a/src/renderer/src/pagine/Box.tsx b/src/renderer/src/pagine/Box.tsx new file mode 100644 index 0000000..5aa7778 --- /dev/null +++ b/src/renderer/src/pagine/Box.tsx @@ -0,0 +1,392 @@ +// Box: l'editor regionale. Disegni dei riquadri sulla tela (stesso formato e stessa misura di Crea), ognuno con il suo testo, +// il suo tipo e il suo testo. Il motore riceve `box` + `promptScena` secondo docs/contratto-engine-gui.md. +// Le regioni sono guide morbide: niente controlli di blocco o riferimenti che il motore ignorerebbe. +import { useEffect, useMemo, useRef, useState, type JSX, type PointerEvent as PE } from 'react' +import { usaStato } from '../stato' +import { api, urlFile } from '../api' +import { I } from '../componenti/Icone' +import { Tessera, Tessere, E } from '../componenti/Tessere' +import { AdattaFoto } from '../componenti/AdattaFoto' +import { Casella } from '../componenti/Casella' +import { SezioneChiudibile } from '../componenti/Controlli' +import { SelettoreModello } from '../componenti/ScegliModello' +import { HudCrea, SezFormato, SezMisura, SezQuante, SezSfondo } from '../componenti/SezioniCrea' +import { IntensitaStile, ScaffaliStili } from '../componenti/ScaffaliStili' +import { Hud } from '../componenti/Hud' +import { InCorso } from './Crea' +import { conParoleLora } from '../componenti/SceltaLora' +import { percentuale } from '../componenti/Coda' +import { VistaZoom } from '../componenti/VistaZoom' +import { MODELLI, modelloDaImpostazioni, parametriModello } from '@shared/modelli' +import { durata, stimaSecondi } from '../util' +import { misuraCrea } from '../misura' +import { promptCrea, sfondoRichiesto } from '../creaPrompt' +import { stileDa } from '../stili' +import { richiestaBase } from '../azioni' +import { DISPOSIZIONI, MAX_BOX, TIPI_BOX, coloreBox, fraseBox, nuovaRegione, promptDaBox, rettDaPunti, trasforma, type Maniglia, type Rect } from '../box' +import type { BoxRegione, Opera } from '@shared/tipi' + +const MANIGLIE: Maniglia[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] +const ICONA_TIPO: Record JSX.Element> = { persona: I.ritratto, oggetto: I.prodotto, testo: I.testo, sfondo: I.scena } + +/** quel che succede con il mouse: disegnare un riquadro nuovo, spostarne uno, o tirarne una maniglia */ +type Gesto = { tipo: 'disegna'; x: number; y: number } | { tipo: 'muovi'; id: string; m: Maniglia; x: number; y: number; rect: Rect } + +export function Box(): JSX.Element { + const { crea, setCrea, box, setBox, imp, lavori, avvisa } = usaStato() + const cornice = useRef(null) + const gesto = useRef(null) + const [bozza, setBozza] = useState(null) + const [opere, setOpere] = useState>({}) + const [scelto, setScelto] = useState(null) + const [vedoInCorso, setVedoInCorso] = useState(true) + const [vistaRis, setVistaRis] = useState(false) + + const mis = misuraCrea(crea) + const { finale, lav } = mis + const modello = MODELLI[modelloDaImpostazioni(imp)] + const quante = Math.max(1, Math.min(8, crea.quante)) + const stima = stimaSecondi(imp?.tempi, (lav.w * lav.h) / 1048576, modello.passi) * quante + const stileAttivo = stileDa(crea.stileId) + const { regioni, selezionata, scena } = box + const sel = regioni.find((r) => r.id === selezionata) || null + + // i lavori partiti da qui (hanno `box`): il risultato si guarda sulla tela al posto dei riquadri + const miei = useMemo(() => lavori.filter((l) => l.richiesta.modalita === 'crea' && !!l.richiesta.box), [lavori]) + const inCorso = miei.find((l) => l.stato === 'in corso') + useEffect(() => { + if (inCorso) { + setVedoInCorso(true) + setVistaRis(false) + } + }, [inCorso?.id]) + const risultati = useMemo(() => miei.flatMap((l) => l.risultati).reverse(), [miei]) + useEffect(() => { + const mancano = risultati.filter((id) => !opere[id]) + if (!mancano.length) return + void Promise.all(mancano.map((id) => api.galleria.opera(id))).then((l) => { + const n = { ...opere } + l.forEach((o) => o && (n[o.id] = o)) + setOpere(n) + }) + }, [risultati, opere]) + useEffect(() => { + if (risultati[0]) { + setScelto(risultati[0]) + setVistaRis(true) + setVedoInCorso(false) + } + }, [risultati[0]]) + const op = scelto ? opere[scelto] : undefined + const mostraInCorso = !!inCorso && vedoInCorso + + const cambia = (id: string, m: Partial): void => setBox({ regioni: usaStato.getState().box.regioni.map((r) => (r.id === id ? { ...r, ...m } : r)) }) + const seleziona = (id: string | null): void => setBox({ selezionata: id }) + const togli = (id: string): void => { + const s = usaStato.getState().box + setBox({ regioni: s.regioni.filter((x) => x.id !== id), selezionata: s.selezionata === id ? null : s.selezionata }) + } + const duplica = (id: string): void => { + const s = usaStato.getState().box + const o = s.regioni.find((r) => r.id === id) + if (!o || s.regioni.length >= MAX_BOX) return + const n = { ...nuovaRegione(trasforma(o.rect, 'corpo', 0.04, 0.04), s.regioni, o.tipo), prompt: o.prompt, rigidita: o.rigidita } + setBox({ regioni: [...s.regioni, n], selezionata: n.id }) + } + + // ---- il mouse sulla tela ---- + const punto = (e: PE): { x: number; y: number } => { + const r = cornice.current!.getBoundingClientRect() + return { x: Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)), y: Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)) } + } + const giuTela = (e: PE): void => { + if (e.button !== 0 || vistaRis) return + if (usaStato.getState().box.regioni.length >= MAX_BOX) { + avvisa(`Al massimo ${MAX_BOX} riquadri`, 'errore') + return + } + const p = punto(e) + gesto.current = { tipo: 'disegna', ...p } + setBozza([p.x, p.y, p.x, p.y]) + e.currentTarget.setPointerCapture(e.pointerId) + seleziona(null) + } + const giuRegione = (e: PE, r: BoxRegione, m: Maniglia): void => { + if (e.button !== 0) return + e.stopPropagation() + const p = punto(e) + gesto.current = { tipo: 'muovi', id: r.id, m, x: p.x, y: p.y, rect: r.rect } + cornice.current?.setPointerCapture(e.pointerId) + seleziona(r.id) + } + const muoviTela = (e: PE): void => { + const g = gesto.current + if (!g) return + const p = punto(e) + if (g.tipo === 'disegna') setBozza(rettDaPunti(g.x, g.y, p.x, p.y)) + else cambia(g.id, { rect: trasforma(g.rect, g.m, p.x - g.x, p.y - g.y) }) + } + const suTela = (e: PE): void => { + const g = gesto.current + gesto.current = null + if (!g) return + try { + cornice.current?.releasePointerCapture(e.pointerId) + } catch { + /* già rilasciato */ + } + if (g.tipo !== 'disegna') return + const p = punto(e) + const r = rettDaPunti(g.x, g.y, p.x, p.y) + setBozza(null) + // un clic senza trascinare non fa un riquadro + if (r[2] - r[0] < 0.03 || r[3] - r[1] < 0.03) return + const s = usaStato.getState().box + const n = nuovaRegione(r, s.regioni, r[2] - r[0] > 0.9 && r[3] - r[1] > 0.9 ? 'sfondo' : 'oggetto') + setBox({ regioni: [...s.regioni, n], selezionata: n.id }) + } + + // ---- tastiera: Canc elimina, Ctrl+D duplica, frecce muovono (Maiusc = 10×), Tab passa al successivo, Ctrl+Invio lancia ---- + useEffect(() => { + const t = (e: KeyboardEvent): void => { + if (usaStato.getState().pagina !== 'box') return + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + void componi() + return + } + if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return + const s = usaStato.getState().box + const id = s.selezionata + if (!id) return + if (e.key === 'Delete' || e.key === 'Backspace') togli(id) + else if (e.ctrlKey && e.key.toLowerCase() === 'd') { + e.preventDefault() + duplica(id) + } else if (e.key.startsWith('Arrow')) { + e.preventDefault() + const passo = e.shiftKey ? 0.02 : 0.002 + const r = s.regioni.find((x) => x.id === id) + if (r) cambia(id, { rect: trasforma(r.rect, 'corpo', e.key === 'ArrowLeft' ? -passo : e.key === 'ArrowRight' ? passo : 0, e.key === 'ArrowUp' ? -passo : e.key === 'ArrowDown' ? passo : 0) }) + } else if (e.key === 'Tab' && s.regioni.length) { + e.preventDefault() + const i = s.regioni.findIndex((x) => x.id === id) + seleziona(s.regioni[(i + (e.shiftKey ? s.regioni.length - 1 : 1)) % s.regioni.length].id) + } else if (e.key === 'Escape') seleziona(null) + } + window.addEventListener('keydown', t) + return () => window.removeEventListener('keydown', t) + }) + + // ---- le disposizioni pronte: i riquadri nuovi tengono i testi di quelli che c'erano ---- + const applicaDisposizione = (id: string): void => { + const d = DISPOSIZIONI.find((x) => x.id === id) + if (!d) return + const vecchie = usaStato.getState().box.regioni + const nuove: BoxRegione[] = d.box.map((b, i) => ({ ...nuovaRegione(b.rect, [], b.tipo), ordine: i + 1, prompt: vecchie[i]?.prompt ?? '', rif: vecchie[i]?.rif, rigidita: vecchie[i]?.rigidita ?? 'decisa' })) + setBox({ regioni: nuove, selezionata: nuove[0]?.id ?? null }) + } + + + // ---- il lancio ---- + async function componi(): Promise { + const s = usaStato.getState() + const nascosti = new Set(s.box.regioni.filter((r) => r.nascosta).map((r) => r.id)) + const attive = s.box.regioni.filter((r) => !nascosti.has(r.id)) + if (!attive.length && !s.box.scena.trim()) { + avvisa('Disegna almeno un riquadro (o scrivi la scena)', 'errore') + return + } + if (attive.some((r) => r.tipo !== 'sfondo' && !r.prompt.trim())) { + avvisa('Ogni riquadro ha bisogno di un testo', 'errore') + return + } + const m = misuraCrea(s.crea) + const pronti = promptDaBox(s.box.scena, s.box.regioni.map((r) => ({ ...r, rif: undefined })), nascosti) + const q = { + ...richiestaBase(), + modalita: 'crea' as const, + prompt: conParoleLora(promptCrea({ ...s.crea, prompt: pronti.prompt }, (s.imp?.famiglia || 'qwen') === 'qwen')), + // il disegno vale più di una didascalia: niente espansione del prompt + potenzia: false, + negativo: '', + seed: s.crea.casuale ? -1 : s.crea.seed, + cfg: 1, + sampler: 'euler', + scheduler: 'simple', + larghezza: m.lav.w, + altezza: m.lav.h, + megapixel: (m.lav.w * m.lav.h) / 1048576, + ...(Math.abs(m.finale.w - m.lav.w) > 1 || Math.abs(m.finale.h - m.lav.h) > 1 ? { uscita: { w: m.finale.w, h: m.finale.h } } : {}), + quante: Math.max(1, Math.min(8, s.crea.quante)), + immagini: pronti.immagini, + trasparente: s.crea.sfondo === 'trasparente', + sfondo: sfondoRichiesto(s.crea), + box: pronti.regioni.map((r) => ({ ...r, prompt: fraseBox(r), rigidita: 'guida' as const })), + // L'adattatore sostituisce prompt con promptScena: stile, sfondo e parole LoRA devono esserci anche qui. + promptScena: conParoleLora(promptCrea({ ...s.crea, prompt: s.box.scena.trim() || 'Compose the following scene' }, (s.imp?.famiglia || 'qwen') === 'qwen')), + ...parametriModello(MODELLI[modelloDaImpostazioni(s.imp)].id), + etichetta: `Box · ${pronti.regioni.length} ${pronti.regioni.length === 1 ? 'riquadro' : 'riquadri'}` + } + setVistaRis(false) + await api.lavori.accoda(q) + if (q.quante > 1) avvisa(`${q.quante} immagini in coda`, 'ok') + } + + const attivi = regioni.filter((r) => !r.nascosta).length + + return ( +
+
+
+ {mostraInCorso && inCorso ? ( + + ) : vistaRis && op ? ( + + ) : ( + +
+ {regioni.length === 0 && !bozza && ( +
DISEGNA UN RIQUADROTrascina sulla tela e di' cosa ci va dentro. Oppure scegli una disposizione a sinistra.
+ )} + {regioni.map((r, i) => ( +
giuRegione(e, r, 'corpo')} + data-box={r.id} + > + {i + 1} +
{r.prompt || (r.rif ? 'foto di riferimento' : '')}
+ {r.id === selezionata && MANIGLIE.map((m) => giuRegione(e, r, m)} />)} +
+ ))} + {bozza &&
} +
+ + )} + {(mostraInCorso || (vistaRis && op)) && ( + + )} + +
+
+ {miei.filter((l) => l.stato === 'in corso' || l.stato === 'in coda').map((l) => ( +
{ setVedoInCorso(true); setVistaRis(false) }}> + {l.anteprima ? : null} +
{l.stato === 'in corso' ? (percentuale(l) !== null ? `${percentuale(l)}%` : '…') : 'in coda'}
+
+ ))} + {risultati.map((id) => { + const o = opere[id] + return ( +
{ setScelto(id); setVistaRis(true); setVedoInCorso(false) }}> + {o && } +
+ ) + })} + {risultati.length === 0 && miei.length === 0 && Qui compaiono le composizioni di questa sessione. Tutte finiscono anche in Galleria.} +
+
+ +
+
+
+

Motore {modello.nome}

+ + {modello.id === 'qwen-base' &&
Con Qwen i riquadri guidano la composizione a parole; con Klein 3 il motore li userà come regioni vere.
} +
+ +
+

Disposizioni

+
+ {DISPOSIZIONI.map((d) => ( + + ))} +
+
+ +
+

Scena generale facoltativa

+ setBox({ scena: s })} minimo={64} placeholder="Luce, atmosfera, stile dell'insieme: «al tramonto, colori caldi, fotografia»" /> +
+ +
+

Riquadri {regioni.length} / {MAX_BOX}

+ {regioni.length === 0 &&
Nessun riquadro: trascina sulla tela per disegnarne uno.
} +
+ {regioni.map((r, i) => { + const aperto = r.id === selezionata + const Ic = ICONA_TIPO[r.tipo] + return ( +
+
seleziona(aperto ? null : r.id)}> + {i + 1} + + {r.prompt || (r.rif ? 'foto di riferimento' : da descrivere…)} + + + +
+ {aperto && ( + <> + + {TIPI_BOX.map((t) => { + const T = ICONA_TIPO[t.id] + return } nome={t.nome} titolo={t.titolo} su={r.tipo === t.id} onClick={() => cambia(r.id, { tipo: t.id })} /> + })} + + cambia(r.id, { prompt })} + minimo={64} + senzaCestino + placeholder={r.tipo === 'testo' ? 'Il testo da scrivere (fra virgolette resta identico)' : r.tipo === 'sfondo' ? 'Com\'è lo sfondo' : r.tipo === 'persona' ? 'Chi è e cosa fa: «una ragazza con un cappotto rosso»' : 'Cos\'è: «un cane che dorme»'} + /> +

Il riquadro guida la composizione: non blocca i pixel. Per conservare i pixel fuori da una zona usa Modifica.

+ + )} +
+ ) + })} +
+
+ +
+

Formato {finale.w} × {finale.h} px

+ +
+
+

Misura

+ +
+ + setCrea({ stileId })} /> + {stileAttivo && setCrea({ intensita })} />} + + + + +
+

Quante

+ +
+
+ +
+
+ {attivi} {attivi === 1 ? 'riquadro' : 'riquadri'}{sel ? ` · selezionato il ${regioni.indexOf(sel) + 1}` : ''} + +
+ +
Ctrl+Invio · ~{durata(stima)}
+
+
+
+ ) +} diff --git a/src/renderer/src/pagine/Crea.tsx b/src/renderer/src/pagine/Crea.tsx index 691f96a..4687beb 100644 --- a/src/renderer/src/pagine/Crea.tsx +++ b/src/renderer/src/pagine/Crea.tsx @@ -1,24 +1,31 @@ -// Crea: dal testo all'immagine. -import { useEffect, useMemo, useState, type JSX } from 'react' +// Crea: dal testo all'immagine. GUI v2: ogni scelta è una tessera (motore, genere, atmosfera, stile, formato, misura, sfondo, quante), +// la misura finale è sempre sul palco (HUD) e il prompt di stile/sfondo si aggiunge da sé al momento di mandare. +import { useEffect, useMemo, useRef, useState, type JSX } from 'react' import { usaStato } from '../stato' import { api, urlFile } from '../api' import { I } from '../componenti/Icone' import { AdattaFoto } from '../componenti/AdattaFoto' -import { Interruttore, SezioneChiudibile, Segmenti } from '../componenti/Controlli' -import { SceltaMisura, ScegliMp, Uscita, MISURA0, dimMp } from '../componenti/Misura' -import { SceltaLora, conParoleLora } from '../componenti/SceltaLora' +import { SezioneChiudibile } from '../componenti/Controlli' +import { conParoleLora } from '../componenti/SceltaLora' import { percentuale } from '../componenti/Coda' -import { MenuStili, SchedaPreset } from '../componenti/Preset' import { VistaZoom } from '../componenti/VistaZoom' -import { FORMATI, RISOLUZIONI, dimensioniRis, durata, normalizza, stimaSecondi } from '../util' +import { SelettoreModello } from '../componenti/ScegliModello' +import { AtmosferaGenere, CampiGenere, GrigliaGeneri } from '../componenti/Generi' +import { IntensitaStile, ScaffaliStili } from '../componenti/ScaffaliStili' +import { HudCrea, SezFormato, SezMisura, SezQuante, SezSfondo } from '../componenti/SezioniCrea' +import { Hud } from '../componenti/Hud' +import { SceltaLora } from '../componenti/SceltaLora' import { Riferimenti } from '../componenti/Riferimenti' -import { SISTEMA_DESCRIVI, SISTEMA_IDEA, SISTEMA_MIGLIORA, apriInModifica, fotoDaOpera, richiestaBase, rifaiCon, rifacibileIn, rifinisci, riusa, varia, ingrandisci } from '../azioni' +import { Casella } from '../componenti/Casella' +import { durata, normalizza, stimaSecondi, FORMATI } from '../util' +import { SISTEMA_DESCRIVI, SISTEMA_IDEA, SISTEMA_MIGLIORA, apriInEstrai, apriInModifica, fotoDaOpera, richiestaBase, rifaiCon, rifacibileIn, rifinisci, riusa, varia, ingrandisci } from '../azioni' import { presetDa } from '../preset' +import { stileDa } from '../stili' +import { misuraCrea } from '../misura' +import { promptCrea, sfondoRichiesto } from '../creaPrompt' import { MODELLI, modelloDaImpostazioni, parametriModello } from '@shared/modelli' -import { Casella } from '../componenti/Casella' import type { Lavoro, Opera } from '@shared/tipi' - const ar = (f: string): string => { const x = FORMATI.find((v) => v.id === f) || FORMATI[0] return `${x.w} / ${x.h}` @@ -31,30 +38,23 @@ export function Crea(): JSX.Element { const [scelto, setScelto] = useState(null) const [opere, setOpere] = useState>({}) const [vedoInCorso, setVedoInCorso] = useState(true) + const prompt = useRef(null) // le ultime opere della galleria, per ripartire da lì quando la pagina è vuota const [recenti, setRecenti] = useState([]) useEffect(() => { void api.galleria.elenco({ modalita: 'tutte' }).then((l) => setRecenti(l.slice(0, 8))) }, [lavori.filter((l) => l.stato === 'fatto').length]) - const dim = dimensioniRis(crea.formato, crea.ris) - // la foto che esce (la misura precisa se c'è, se no la risoluzione scelta) e le dimensioni a cui la disegna il modello: con i megapixel - // scelti il modello lavora a quelli (anche 4 MP per una foto da 480p) e poi la riduce alla misura finale - const mis = crea.misura?.tipo === 'custom' ? crea.misura : null - const finale = mis ? { w: mis.w, h: mis.h } : dim - const mpLav = crea.mpLavoro ? Math.min(crea.mpLavoro, 4.19) : 0 - const m16 = (x: number): number => Math.max(256, Math.round(x / 16) * 16) - const lav = mpLav - ? dimMp(mpLav, finale.w, finale.h) - : mis - ? { w: m16(Math.sqrt(dim.w * dim.h * (mis.w / mis.h))), h: m16(Math.sqrt((dim.w * dim.h) / (mis.w / mis.h))) } - : dim - // il modello si sceglie dalla barra in alto e porta con sé i passi (e il CFG): qui non c'è niente da regolare + // la misura: la stessa funzione che costruisce la richiesta e che disegna il HUD («la cifra che vedi è quella che esce») + const mis = misuraCrea(crea) + const { finale, lav } = mis + // il modello si sceglie dalle tessere «Motore» e porta con sé i passi (e il CFG) const modello = MODELLI[modelloDaImpostazioni(imp)] const passi = modello.passi - const quante = crea.quante <= 1 ? 1 : crea.quante <= 2 ? 2 : 4 + const quante = Math.max(1, Math.min(8, crea.quante)) const preset = presetDa(crea.preset) const stima = stimaSecondi(imp?.tempi, (lav.w * lav.h) / 1048576, passi) * quante + const qwen = (imp?.famiglia || 'qwen') === 'qwen' // i lavori di questa pagina (creati, rifiniti, variati) const miei = useMemo(() => lavori.filter((l) => ['crea', 'rifinisci', 'varia'].includes(l.richiesta.modalita)), [lavori]) @@ -88,7 +88,7 @@ export function Crea(): JSX.Element { const q = { ...richiestaBase(), modalita: 'crea' as const, - prompt: conParoleLora(crea.prompt.trim()), + prompt: conParoleLora(promptCrea(crea, qwen)), // Klein: la richiesta breve si espande in una didascalia dettagliata (se non è un preset, che il prompt lo scrive già lungo) potenzia: !preset, // i valori ufficiali del modello (Klein sceglie CFG, sampler e scheduler da sé nel motore) @@ -104,9 +104,10 @@ export function Crea(): JSX.Element { ...(Math.abs(finale.w - lav.w) > 1 || Math.abs(finale.h - lav.h) > 1 ? { uscita: { w: finale.w, h: finale.h } } : {}), quante, immagini: crea.riferimenti.map((r) => r.percorso), - trasparente: crea.trasparente, + trasparente: crea.sfondo === 'trasparente', + sfondo: sfondoRichiesto(crea), ...parametriModello(modello.id), - etichetta: preset ? preset.nome : undefined + etichetta: preset ? preset.nome : stileDa(crea.stileId)?.nome } await api.lavori.accoda(q) if (quante > 1) avvisa(`${quante} immagini in coda`, 'ok') @@ -178,133 +179,27 @@ export function Crea(): JSX.Element { const op = scelto ? opere[scelto] : undefined const inserisci = (testo: string): void => { - const t = document.querySelector('.colonna:not(.destra) textarea') + const t = prompt.current const v = crea.prompt const i = t ? t.selectionStart : v.length setCrea({ prompt: v.slice(0, i) + testo + v.slice(i) }) } + const stileAttivo = stileDa(crea.stileId) + const nLora = usaStato((s) => s.loraAttive.length) + return (
-
-
-
- -
- -
-

- {preset ? 'Prompt (lo scrive il preset)' : 'Cosa vuoi vedere'} - {crea.prompt.length > 0 && `${crea.prompt.length} car.`} -

- setCrea({ prompt })} - minimo={preset ? 110 : 150} - disabled={scrivendo} - /> -
- - - - {precedente !== null && ( - - )} -
- {(imp?.famiglia || 'qwen') === 'qwen' &&
} -
- -
-

Formato {finale.w} × {finale.h} px

-
- {FORMATI.map((f) => { - const k = 22 / Math.max(f.w, f.h) - return ( - - ) - })} -
-

Lato corto

-
- {RISOLUZIONI.map((r) => ( - - ))} -
-
- -
- setCrea({ riferimenti })} - inizio={1} - inserisci={inserisci} - esempio={'Una persona, un vestito, un oggetto, uno stile: "una donna come su una spiaggia", "la giacca di addosso a un robot". Fino a 9 foto.'} - /> -
- -
-

Quante {quante > 1 ? `${quante} immagini, una dopo l'altra` : ''}

- setCrea({ quante: q })} /> -
- - -
-
-

Seed

-
- - {!crea.casuale && setCrea({ seed: Math.max(0, Number(e.target.value) || 0) })} style={{ width: 130 }} />} -
-
- setCrea({ trasparente })} sotto="PNG con sfondo trasparente (canale alfa nativo di Qwen-Image 2.1)"> - Sfondo trasparente - -
-

Megapixel del modello {crea.mpLavoro ? `${(lav.w * lav.h / 1048576).toFixed(2).replace('.', ',')} MP` : 'come la misura finale'}

- A quanti megapixel disegna il modello. Con più megapixel della risoluzione finale disegna grande e poi riduce: più dettagli e linee più pulite anche per una foto piccola (es. 480p disegnata a 4 MP). - setCrea({ mpLavoro })} ar={{ w: finale.w, h: finale.h }} auto="come la misura" /> - 1 || Math.abs(finale.h - lav.h) > 1 ? `disegnata a ${lav.w}×${lav.h} e poi portata qui` : undefined} /> -

Misura precisa {crea.misura?.tipo === 'custom' ? 'attiva' : 'no'}

- setCrea({ misura })} base={null} mp={Math.round((dim.w * dim.h) / 1e5) / 10} ammetti={['auto', 'custom']} auto={{ w: dim.w, h: dim.h }} senzaUscita /> -
-
-
-
-
- -
Ctrl+Invio · ~{durata(stima)}
-
-
-
-
+
{mostraInCorso && inCorso ? ( ) : op ? ( ) : (
-
CREA CON QWEN-IMAGE 2.1
-

Scrivi a sinistra cosa vuoi vedere e premi Crea (Ctrl+Invio).

+
COSA COSTRUIAMO OGGI?
+

Scegli a sinistra un genere e uno stile, scrivi cosa vuoi vedere e premi Crea (Ctrl+Invio).

Puoi accodare quante immagini vuoi: partono una dopo l'altra.

{recenti.length > 0 && (
@@ -320,10 +215,12 @@ export function Crea(): JSX.Element { )}
)} +
{op && !mostraInCorso && (
+ {rifacibileIn(op).includes('klein-base') && } {rifacibileIn(op).includes('qwen-base') && } @@ -356,6 +253,123 @@ export function Crea(): JSX.Element { {risultati.length === 0 && miei.length === 0 && Qui compaiono le immagini di questa sessione. Tutte finiscono anche in Galleria.}
+ +
+
+
+

Motore {modello.nome}

+ +
+ +
+

Cosa {preset ? preset.nome : 'libero'}

+ + {preset &&
} +
+ + {preset && preset.stili.length > 1 && ( +
+

Atmosfera

+ +
+ )} + +
+

+ {preset ? 'Prompt (lo scrive il genere)' : 'Cosa vuoi vedere'} + {crea.prompt.length > 0 && `${crea.prompt.length} car.`} +

+ setCrea({ prompt: p })} + minimo={preset ? 110 : 150} + disabled={scrivendo} + /> +
+ + + + {precedente !== null && ( + + )} +
+
+ + + setCrea({ stileId })} /> + {stileAttivo && setCrea({ intensita })} />} + + +
+

Formato {finale.w} × {finale.h} px

+ +
+ +
+

Misura lato corto {Math.min(finale.w, finale.h)} px

+ +
+ +
+

Sfondo

+ +
+ +
+ setCrea({ riferimenti })} + inizio={1} + inserisci={inserisci} + esempio={'Una persona, un vestito, un oggetto, uno stile: "una donna come su una spiaggia", "la giacca di addosso a un robot". Fino a 9 foto.'} + /> +
+ +
+

Quante {quante > 1 ? `${quante} immagini, una dopo l'altra` : ''}

+ + {!crea.casuale && ( +
+ Seed + setCrea({ seed: Math.max(0, Number(e.target.value) || 0) })} style={{ width: 130 }} /> +
+ )} +
+ + {qwen && ( + + + + )} +
+ +
+
+
+ {preset && } + {stileAttivo && } + {crea.sfondo !== 'scena' && } + {crea.riferimenti.length > 0 && } + {qwen && nLora > 0 && } +
+ +
+ +
Ctrl+Invio · ~{durata(stima)}
+
+
) } diff --git a/src/renderer/src/pagine/Impostazioni.tsx b/src/renderer/src/pagine/Impostazioni.tsx index b6d8123..2744ab7 100644 --- a/src/renderer/src/pagine/Impostazioni.tsx +++ b/src/renderer/src/pagine/Impostazioni.tsx @@ -1,35 +1,30 @@ // Impostazioni: modelli, prestazioni, cartelle, motore. import { useEffect, useRef, useState, type JSX } from 'react' import { usaStato, ricaricaImp } from '../stato' -import { Lora } from './Lora' import { api, type FileModelli } from '../api' import { I } from '../componenti/Icone' import { Interruttore, Segmenti } from '../componenti/Controlli' import { byte } from '../util' import type { Download, Impostazioni as Imp, StatoVoce } from '@shared/tipi' import type { FileInutile } from '../api' +import { LaboratorioUncensored } from '../componenti/LaboratorioUncensored' const GRUPPI_MODELLI: [StatoVoce['tipo'], string][] = [ ['diffusione', 'Modello di diffusione (denoiser)'], ['encoder', 'Text encoder (l\'LLM)'], ['klein', 'FLUX 3 Klein · modelli'], ['klein-te', 'FLUX 3 Klein · text encoder'], ['klein-vae', 'FLUX 3 Klein · VAE'], ['traduttore', 'Traduttore'], ['vae', 'VAE'], ['upscaler', 'Ingrandimento'], ['anteprima', 'Anteprime dal vivo'] ] -/** Opzioni: due schede, le impostazioni di sempre e i LoRA (prima avevano una voce a sinistra, nella rotaia). */ +/** Opzioni: le impostazioni di sempre (i LoRA hanno la pagina loro, nella rotaia). */ export function Impostazioni(): JSX.Element { - const scheda = usaStato((s) => s.schedaOpzioni) return (
-
- - -
-
{scheda === 'lora' ? : }
+
) } function OpzioniGenerali(): JSX.Element { const { imp, motore, avvisa, agg } = usaStato() - const [file, setFile] = useState({ diffusione: [], encoder: [], vae: [], upscaler: [], klein: [], kleinTe: [], kleinVae: [] }) + const [file, setFile] = useState({ diffusione: [], encoder: [], vae: [], upscaler: [], klein: [], kleinTe: [], kleinVae: [], klein4b: [], klein4bTe: [] }) const [catalogo, setCatalogo] = useState([]) const [download, setDownload] = useState>({}) const [log, setLog] = useState([]) @@ -110,6 +105,7 @@ function OpzioniGenerali(): JSX.Element { return (
+

alterna('MODELLI IN USO')}>{chiusi['MODELLI IN USO'] ? '▸' : '▾'} MODELLI IN USO

@@ -136,7 +132,7 @@ function OpzioniGenerali(): JSX.Element {

alterna('SCARICA ALTRI MODELLI')}>{chiusi['SCARICA ALTRI MODELLI'] ? '▸' : '▾'} SCARICA ALTRI MODELLI

{GRUPPI_MODELLI.map(([tipo, titolo]) => { - const voci = catalogo.filter((v) => v.tipo === tipo) + const voci = catalogo.filter((v) => v.tipo === tipo && !v.uncensored) if (!voci.length) return null return (
diff --git a/src/renderer/src/pagine/Lora.tsx b/src/renderer/src/pagine/Lora.tsx index eb9e56b..e90b95c 100644 --- a/src/renderer/src/pagine/Lora.tsx +++ b/src/renderer/src/pagine/Lora.tsx @@ -6,9 +6,11 @@ import { I } from '../componenti/Icone' import { Copertina } from '../componenti/SceltaLora' import { byte } from '../util' import type { Download, InfoLora, StatoVoce } from '@shared/tipi' +import { compatibilitaLora, forzaLora, spostaLora } from '../pilaLora' export function Lora(): JSX.Element { - const { lore, loraAttive, toggleLora, setLoraAttive, avvisa } = usaStato() + const { lore, loraAttive, toggleLora, setLoraAttive, avvisa, imp } = usaStato() + const famiglia = imp?.famiglia || 'qwen' const [link, setLink] = useState('') const [sopra, setSopra] = useState(false) const [download, setDownload] = useState>({}) @@ -73,7 +75,7 @@ export function Lora(): JSX.Element {
- Servono LoRA fatti per Qwen-Image 2.1: quelli per SDXL, Flux o Qwen-Image 1.x vengono segnati e non funzionano. Per Civitai a volte serve il token (Impostazioni). + La compatibilità dipende dal modello: Qwen, Klein e gli altri Flux non sono intercambiabili. Per Civitai a volte serve il token (Impostazioni). La pila non converte un LoRA incompatibile.
@@ -84,6 +86,29 @@ export function Lora(): JSX.Element {
))} +
+

Pila LoRA

{famiglia} · {loraAttive.length} slot
+

Dall'alto verso il basso: questo è l'ordine di applicazione. Forza 0 lascia lo slot spento senza cancellarlo.

+ {!loraAttive.length && Accendi un LoRA dalla libreria per aggiungerlo alla pila.} + {loraAttive.map((a, i) => { + const l = lore.find((x) => x.file === a.file) + const compatibile = l ? compatibilitaLora(l, famiglia) : '?' + const nome = l?.nome || a.file + return
+
{i + 1}. {nome}{compatibile === 'si' ? 'Compatibile' : compatibile === 'no' ? 'Non compatibile' : 'Da verificare'}
+
+ + + setLoraAttive(loraAttive.map((x) => x.file === a.file ? { ...x, forza: forzaLora(Number(e.target.value)) } : x))} /> + {a.forza.toFixed(2)} + +
+ {l?.parole && Parole chiave: {l.parole}} + {compatibile !== 'si' && {compatibile === 'no' ? 'Spegni questo slot prima di generare con il modello selezionato.' : 'Architettura non riconosciuta: controlla la scheda del LoRA prima di usarlo.'}} +
+ })} +
+ {consigliati.some((c) => !c.presente) && (

CONSIGLIATI PER QWEN-IMAGE 2.1

@@ -111,7 +136,13 @@ export function Lora(): JSX.Element { {lore.length === 0 &&
Ancora nessun LoRA. Un LoRA aggiunge uno stile, un personaggio o un dettaglio al modello: trascinane uno qui sopra.
}
{visibili.map((l) => ( - a.file === l.file)} toggle={() => toggleLora(l.file, l.forza)} /> + a.file === l.file)} toggle={() => { + if (!loraAttive.some((a) => a.file === l.file) && compatibilitaLora(l, famiglia) === 'no') { + avvisa('Questo LoRA non è compatibile con il modello selezionato.', 'errore') + return + } + toggleLora(l.file, forzaLora(l.forza)) + }} /> ))}
@@ -120,7 +151,8 @@ export function Lora(): JSX.Element { } function CartaLora({ l, attiva, toggle }: { l: InfoLora; attiva: boolean; toggle: () => void }): JSX.Element { - const { avvisa, loraAttive, setLoraAttive } = usaStato() + const { avvisa, loraAttive, setLoraAttive, imp } = usaStato() + const compatibile = compatibilitaLora(l, imp?.famiglia || 'qwen') const [parole, setParole] = useState(l.parole) const [forza, setForza] = useState(l.forza) useEffect(() => { @@ -152,7 +184,7 @@ function CartaLora({ l, attiva, toggle }: { l: InfoLora; attiva: boolean; toggle
{l.nome}
- {l.compatibile === 'si' ? '✓ Qwen 2.1' : l.compatibile === 'no' ? '⚠ ' + (l.base?.split(' (')[0] || 'altro') : '?'} + {compatibile === 'si' ? '✓ Compatibile' : compatibile === 'no' ? '⚠ ' + (l.base?.split(' (')[0] || 'altro modello') : '? Da verificare'}
Forza { - const v = Number(e.target.value) + const v = forzaLora(Number(e.target.value)) setForza(v) if (attiva) setLoraAttive(loraAttive.map((a) => (a.file === l.file ? { ...a, forza: v } : a))) }} onPointerUp={() => salva({ forza })} + onBlur={() => salva({ forza })} /> {forza.toFixed(2)}
diff --git a/src/renderer/src/pagine/Studio.tsx b/src/renderer/src/pagine/Studio.tsx new file mode 100644 index 0000000..c4e0c2a --- /dev/null +++ b/src/renderer/src/pagine/Studio.tsx @@ -0,0 +1,66 @@ +// Studio: Crea, Modifica, Box e Ricette sullo stesso palco. Qui c'è la strip dei modi (in cima) e il pezzo che la lega al resto: +// i modi non sono pagine nuove, sono le pagine di sempre (Crea, Modifica, Box) che restano montate, così la maschera disegnata, +// il prompt e i riquadri non si perdono passando da un modo all'altro. +import type { JSX } from 'react' +import { usaStato, type Pagina } from '../stato' +import { MODELLI, modelloDaImpostazioni } from '@shared/modelli' +import { I } from '../componenti/Icone' + +export type ModoStudio = 'crea' | 'modifica' | 'box' | 'ricette' + +export const PAGINE_STUDIO: Pagina[] = ['crea', 'modifica', 'box'] + +/** in quale modo dello Studio siamo (Ricette è Modifica col catalogo aperto) */ +export function modoStudio(): ModoStudio | null { + const s = usaStato.getState() + if (s.pagina === 'crea' || s.pagina === 'box') return s.pagina + if (s.pagina === 'modifica') return s.modifica.modo === 'ricette' ? 'ricette' : 'modifica' + return null +} + +/** apre un modo dello Studio */ +export function vaiModo(modo: ModoStudio): void { + const s = usaStato.getState() + if (modo === 'ricette') { + s.setModifica({ modo: 'ricette' }) + s.vai('modifica') + return + } + if (modo === 'modifica' && s.modifica.modo === 'ricette') s.setModifica({ modo: 'scrivi' }) + s.vai(modo) +} + +const MODI: { id: ModoStudio; nome: string; icona: () => JSX.Element; titolo: string; tasto: string }[] = [ + { id: 'crea', nome: 'Crea', icona: I.crea, titolo: 'Dal testo all\'immagine (1)', tasto: '1' }, + { id: 'modifica', nome: 'Modifica', icona: I.modifica, titolo: 'Cambia una foto: tutta, una zona, espandila o estrai (2)', tasto: '2' }, + { id: 'box', nome: 'Box', icona: I.box, titolo: 'Disegna dei riquadri e di\' cosa ci va in ognuno (3)', tasto: '3' }, + { id: 'ricette', nome: 'Ricette', icona: I.ricette, titolo: 'Cose già pronte in più passi, anche su più foto (4)', tasto: '4' } +] + +export function StripModi(): JSX.Element { + const pagina = usaStato((s) => s.pagina) + const modo = usaStato((s) => s.modifica.modo) + const imp = usaStato((s) => s.imp) + const nLora = usaStato((s) => s.loraAttive.length) + const vai = usaStato((s) => s.vai) + const attivo: ModoStudio = pagina === 'modifica' ? (modo === 'ricette' ? 'ricette' : 'modifica') : pagina === 'box' ? 'box' : 'crea' + const m = MODELLI[modelloDaImpostazioni(imp)] + return ( +
+ {MODI.map((x) => ( + + ))} + + + Motore {m.nome} + + {( + + )} +
+ ) +} diff --git a/src/renderer/src/pagine/modifica/SchedaIdee.tsx b/src/renderer/src/pagine/modifica/SchedaIdee.tsx new file mode 100644 index 0000000..4ab3e5b --- /dev/null +++ b/src/renderer/src/pagine/modifica/SchedaIdee.tsx @@ -0,0 +1,112 @@ +// Modifica: le Idee come griglia di tessere con icona (nessun chip di testo). Le categorie sono tessere; aprendone una compaiono le sue azioni, +// anch'esse tessere (un'icona e una parola, il nome lungo nel tooltip). Le scelte si sommano e il modello le mescola. +// «Estrai» è la tessera grande in cima; la categoria Stili ha gli scaffali dei 28 stili con l'intensità; il comando Camera resta in Prospettiva. +import { type JSX } from 'react' +import { usaStato } from '../../stato' +import { I } from '../../componenti/Icone' +import { Casella } from '../../componenti/Casella' +import { AvvisoLora } from '../../componenti/AvvisoLora' +import { ControlloCamera } from '../../componenti/Camera' +import { Pianeti, Tessera, Tessere } from '../../componenti/Tessere' +import { IntensitaStile, ScaffaliStili } from '../../componenti/ScaffaliStili' +import { ControlliCategoria } from '../../componenti/ControlliCategoria' +import { controlliDi, prefissoIdea, rapidaVisibile } from '../../controlli' +import { FANTASIE } from '../../regista' +import { iconaRapida } from '../../iconeRapide' +import { frasiStileModifica, stileDa, type Intensita } from '../../stili' +import type { PropsScrivi } from './SchedaScrivi' + +const ID_STILE = 'stile-' + +export function SchedaIdee({ griglia, zona, categorie, cat, setCategoria, idee, setIdee, usaRapida, chiaveIdee, camera, cambiaCamera, loraCamera }: PropsScrivi): JSX.Element | null { + const { modifica: m, setModifica } = usaStato() + if (m.modo === 'espandi' || griglia) return null + + const stileAttivo = idee.find((r) => r.id.startsWith(ID_STILE))?.id.slice(ID_STILE.length) || '' + /** quante idee attive ci sono in una categoria (la pallina luminosa sulla tessera) */ + const quante = (id: string): number => { + if (id === 'stili') return idee.filter((r) => r.id.startsWith(ID_STILE)).length + return idee.filter((r) => categorie.find((c) => c.id === id)?.voci.some((v) => v.id === r.id) || (r.id.startsWith('ctl-') && controlliDi(id).some((c) => r.id.startsWith(prefissoIdea(c.id))))).length + } + const scegliStile = (id: string): void => { + const senza = idee.filter((r) => !r.id.startsWith(ID_STILE)) + const s = stileDa(id) + setIdee(s ? [...senza, { id: ID_STILE + s.id, nome: s.nome, prompt: frasiStileModifica(s, m.intensitaStile) }] : senza) + } + const cambiaIntensita = (i: Intensita): void => { + setModifica({ intensitaStile: i }) + setIdee(idee.map((r) => (r.id.startsWith(ID_STILE) ? { ...r, prompt: frasiStileModifica(stileDa(r.id.slice(ID_STILE.length))!, i) } : r))) + } + + return ( +
+

Idee sommale: il modello le mescola

+ + } nome="Estrai" sotto="persone e oggetti, un clic" titolo="Estrai una persona o un oggetto dalla foto: un clic sulla cosa, poi scegli l'uscita" onClick={() => setModifica({ modo: 'estrai' })} /> + } nome="Espandi" sotto="allarga oltre i bordi" titolo="Allarga la foto oltre i bordi" onClick={() => setModifica({ modo: 'espandi' })} /> + + + {categorie.map((c) => { + const Ic = (I as Record JSX.Element>)[c.icona] || I.crea + return } nome={c.nome} su={cat?.id === c.id} pallina={quante(c.id)} onClick={() => setCategoria(c.id)} /> + })} + + + {cat && cat.id === 'prospettiva' && !zona && ( +
+ + +
+ )} + {cat && cat.id === 'vestiti' &&
} + + {cat && cat.id !== 'stili' && } + + {cat && cat.id === 'stili' ? ( +
+ + {stileAttivo && } +
+ ) : ( +
+ + {cat?.voci.filter(rapidaVisibile).map((r) => { + const x = iconaRapida(r) + return ( + {x.icona}} + nome={x.nome} + su={idee.some((y) => y.id === r.id)} + onClick={() => void usaRapida(r)} + titolo={`${r.nome.replace('…', '')}${r.rif ? ` · serve ${r.rifNome || 'una foto di riferimento'}` : ''} · un clic la accende, un altro la spegne`} + /> + ) + })} + +
+ )} + + {idee.length > 0 && ( +
+
+ {idee.length} {idee.length === 1 ? 'idea' : 'idee'}: {idee.map((r) => r.nome.replace('…', '')).join(' + ')} + +
+ Fantasia del modello + setModifica({ fantasia })} voci={FANTASIE.map((f) => ({ id: f.id, nome: f.nome, titolo: f.sotto }))} /> + {m.pianoIdee && m.pianoIdee.chiave === chiaveIdee ? ( +
+
Il piano del modello (puoi correggerlo)
+ setModifica({ pianoIdee: { chiave: m.pianoIdee!.chiave, testo } })} /> +
+ ) : ( +
Quando premi «Modifica» il modello guarda la foto, mescola le idee e inventa i dettagli. Qui vedrai cosa ha deciso.
+ )} +
+ )} + {cat && ['luce', 'ritocco', 'situazioni'].includes(cat.id) &&
} +
+ ) +} diff --git a/src/renderer/src/pagine/modifica/SchedaRicette.tsx b/src/renderer/src/pagine/modifica/SchedaRicette.tsx index 55d5e3e..cd749b1 100644 --- a/src/renderer/src/pagine/modifica/SchedaRicette.tsx +++ b/src/renderer/src/pagine/modifica/SchedaRicette.tsx @@ -21,7 +21,6 @@ import { specialeOra, POSA_TUA, VISTA_TUA, leggiCamera, WORKFLOW_BASE, avviaLott import { usaRicette, type RicettaUtente } from '../../ricette' import { EditorRicetta } from '../../componenti/EditorRicetta' import { Catalogo, Dettaglio, Risultati, icona } from '../../componenti/LabVista' -import { SelettoreModo } from './SelettoreModo' import type { Opera } from '@shared/tipi' @@ -63,6 +62,8 @@ export function SchedaRicette(): JSX.Element { const run = runs.find((r) => r.id === attiva) const scegli = (id: string): void => { + // Mix si apre sulla tela di Modifica: parte da «Unisci foto» e da lì si passa a «Come l'esempio» + if (id === 'r-mix') id = 'unisci' const nuovo = workflowDa(id)! usaLab.setState({ scelto: id, vista: 'catalogo' }) setValori(valoriIniziali(nuovo)) @@ -164,10 +165,10 @@ export function SchedaRicette(): JSX.Element { return ( <>
-
+ {/* i modi dello Studio stanno nella strip in cima: qui niente selettore */}
- + @@ -216,8 +217,8 @@ export function SchedaRicette(): JSX.Element { {!w ? (

Ricette

-

Ricette già pronte: scegline una a sinistra, dai la foto (o il testo) e premi Avvia. Il motore fa tutti i passi da solo, uno dopo l'altro, e i risultati restano in Galleria.

-

Due tipi: le serie (la stessa foto in tante versioni: pose, stili, luci…) e le catene (ogni passo parte dal risultato del precedente: restauro, il tempo che passa…).

+

Sei ricette già pronte: scegline una a destra, dai la foto (o il testo) e premi Avvia. Il motore fa tutti i passi da solo, uno dopo l'altro, e i risultati restano in Galleria.

+

Due tipi: le serie (la stessa foto in tante versioni: viste, pose, mondi…) e le catene (ogni passo parte dal risultato del precedente: Rinnova).

) : ( <> diff --git a/src/renderer/src/pagine/modifica/SchedaScrivi.tsx b/src/renderer/src/pagine/modifica/SchedaScrivi.tsx index c496011..c664679 100644 --- a/src/renderer/src/pagine/modifica/SchedaScrivi.tsx +++ b/src/renderer/src/pagine/modifica/SchedaScrivi.tsx @@ -1,13 +1,11 @@ // Modifica, scheda Scrivi: il prompt, Migliora, i segnaposto delle foto e le Idee (azioni rapide che si sommano). // Se sulla tela c'è una zona disegnata il lavoro vale solo per la zona, se no per tutta la foto (lo decide Modifica.tsx). import { type JSX, type RefObject } from 'react' +import type { PosizioneCamera } from '../../componenti/Camera' import { usaStato, type FotoBase } from '../../stato' import { I } from '../../componenti/Icone' import { Casella } from '../../componenti/Casella' -import { Segmenti } from '../../componenti/Controlli' -import { AvvisoLora } from '../../componenti/AvvisoLora' -import { ControlloCamera, type PosizioneCamera } from '../../componenti/Camera' -import { FANTASIE } from '../../regista' +import { SchedaIdee } from './SchedaIdee' import { usaSpeciale } from '../../workflow' import type { Rapida, CategoriaRapide } from '../../rapide' @@ -99,58 +97,3 @@ function SchedaTesto({ prompt, base, griglia, zona, scrivendo, migliora, setCame
) } - -/** Idee (le azioni rapide, che si sommano), il comando Camera e il piano delle idee */ -function SchedaIdee({ griglia, zona, categorie, cat, setCategoria, idee, setIdee, usaRapida, chiaveIdee, camera, cambiaCamera, loraCamera }: PropsScrivi): JSX.Element | null { - const { modifica: m, setModifica } = usaStato() - if (m.modo === 'espandi' || griglia) return null - return ( -
-

Idee sommale: il modello le mescola

-
- {categorie.map((c) => { - const Ic = (I as Record JSX.Element>)[c.icona] || I.crea - return ( - - ) - })} -
- {cat && cat.id === 'prospettiva' && !zona && ( -
- - -
- )} - {cat && cat.id === 'vestiti' &&
} -
- {cat?.voci.map((r) => ( - - ))} -
- {idee.length > 0 && ( -
-
- {idee.length} {idee.length === 1 ? 'idea' : 'idee'}: {idee.map((r) => r.nome.replace('…', '')).join(' + ')} - -
- Fantasia del modello - setModifica({ fantasia })} voci={FANTASIE.map((f) => ({ id: f.id, nome: f.nome, sotto: f.sotto }))} /> - {m.pianoIdee && m.pianoIdee.chiave === chiaveIdee ? ( -
-
Il piano del modello (puoi correggerlo)
- setModifica({ pianoIdee: { chiave: m.pianoIdee!.chiave, testo } })} /> -
- ) : ( -
Quando premi «Modifica» il modello guarda la foto, mescola le idee e inventa i dettagli. Qui vedrai cosa ha deciso.
- )} -
- )} - {cat && ['luce', 'ritocco', 'situazioni'].includes(cat.id) &&
} -
- ) -} diff --git a/src/renderer/src/pagine/modifica/SelettoreModo.tsx b/src/renderer/src/pagine/modifica/SelettoreModo.tsx index bfa55ef..affd7ab 100644 --- a/src/renderer/src/pagine/modifica/SelettoreModo.tsx +++ b/src/renderer/src/pagine/modifica/SelettoreModo.tsx @@ -1,22 +1,22 @@ -// I modi di Modifica (Scrivi, Espandi, Ricette, Estrai): sta in cima alla pagina, anche quando si guarda il catalogo delle ricette. +// I modi di Modifica (Scrivi, Espandi, Estrai) come tessere: stanno in cima alla tela. Ricette ha il suo bottone nella strip dello Studio. import { type JSX } from 'react' import { usaStato, type ModoModifica } from '../../stato' -import { Segmenti } from '../../componenti/Controlli' +import { I } from '../../componenti/Icone' +import { Tessera } from '../../componenti/Tessere' + +const MODI: { id: ModoModifica; nome: string; icona: () => JSX.Element; titolo: string }[] = [ + { id: 'scrivi', nome: 'Scrivi', icona: I.modifica, titolo: 'Scrivi cosa cambiare: se segni una zona col pennello cambia solo lì, se no tutta la foto' }, + { id: 'espandi', nome: 'Espandi', icona: I.espandi, titolo: 'Allarga la foto oltre i bordi' }, + { id: 'estrai', nome: 'Estrai', icona: I.estrai, titolo: 'Dalla foto estrai una persona o un oggetto: un clic sulla cosa, poi scegli l\'uscita' } +] export function SelettoreModo(): JSX.Element { const { modifica: m, setModifica } = usaStato() return ( -
- - valore={m.modo} - cambia={(modo) => setModifica({ modo })} - voci={[ - { id: 'scrivi', nome: 'Scrivi', titolo: 'Scrivi cosa cambiare: se segni una zona col pennello cambia solo lì, se no tutta la foto' }, - { id: 'espandi', nome: 'Espandi' }, - { id: 'ricette', nome: 'Ricette', titolo: 'Cose già pronte in più passi (pose, stili, luci…), anche su tante foto; con Unisci foto e Come l\'esempio' }, - { id: 'estrai', nome: 'Estrai', titolo: 'Dalla foto estrai una persona o un oggetto, su sfondo pulito' } - ]} - /> +
+ {MODI.map((x) => ( + } nome={x.nome} titolo={x.titolo} su={m.modo === x.id} onClick={() => setModifica({ modo: x.id })} /> + ))}
) } diff --git a/src/renderer/src/pagine/modifica/Tela.tsx b/src/renderer/src/pagine/modifica/Tela.tsx index b768773..9a123cc 100644 --- a/src/renderer/src/pagine/modifica/Tela.tsx +++ b/src/renderer/src/pagine/modifica/Tela.tsx @@ -56,9 +56,11 @@ export function TelaModifica(p: Props): JSX.Element {
{speciale && ( - + <> + + + + )} {disegna && base && !confrontoAttivo && ( <> diff --git a/src/renderer/src/pilaLora.ts b/src/renderer/src/pilaLora.ts new file mode 100644 index 0000000..5c74dc1 --- /dev/null +++ b/src/renderer/src/pilaLora.ts @@ -0,0 +1,23 @@ +// Operazioni pure della pila: non cambiano la libreria né il modello selezionato. +import type { Famiglia, InfoLora, LoraAttiva } from '@shared/tipi' + +export function forzaLora(v: number): number { + return Number.isFinite(v) ? Math.max(-2, Math.min(2, v)) : 0 +} + +export function spostaLora(pila: LoraAttiva[], file: string, verso: -1 | 1): LoraAttiva[] { + const i = pila.findIndex((x) => x.file === file) + const j = i + verso + if (i < 0 || j < 0 || j >= pila.length) return pila + const nuova = [...pila] + ;[nuova[i], nuova[j]] = [nuova[j], nuova[i]] + return nuova +} + +// I metadati nuovi sono opzionali finché non è integrato il contratto engine v2. +// Nelle librerie vecchie «si» identifica Qwen 2.1, NON tutte le architetture. +export function compatibilitaLora(l: Pick & { famiglie?: Famiglia[] }, famiglia: Famiglia): 'si' | 'no' | '?' { + if (l.famiglie?.length) return l.famiglie.includes(famiglia) ? 'si' : 'no' + if (l.compatibile === '?') return '?' + return famiglia === 'qwen' ? l.compatibile : 'no' +} diff --git a/src/renderer/src/ricetteBase.ts b/src/renderer/src/ricetteBase.ts index dccf49c..83004c8 100644 --- a/src/renderer/src/ricetteBase.ts +++ b/src/renderer/src/ricetteBase.ts @@ -6,19 +6,19 @@ import { componiCamera, conLora } from './componenti/Camera' import { POSA_TUA, VISTA_TUA, leggiCamera, type Ctx, type Ingresso, type PassoWf, type Workflow } from './workflow' export const E = (etichetta: string, prompt: string, extra: Partial = {}): PassoWf => ({ etichetta, prompt, ...extra }) -const FOTO = 'the photo ' +export const FOTO = 'the photo ' /** il comando Camera dentro la ricetta: muovi i cerchi e ottieni il passo "La tua vista" */ -const CAMERA_TUA: Ingresso = { id: 'vista', tipo: 'camera', nome: 'Muovi la camera: una vista tua, oltre a quelle della ricetta' } +export const CAMERA_TUA: Ingresso = { id: 'vista', tipo: 'camera', nome: 'Muovi la camera: una vista tua, oltre a quelle della ricetta' } /** il passo della camera scelta (se l'hai mossa); `intorno` è quello che resta uguale in ogni vista della ricetta */ -const vistaTua = (c: Ctx, intorno: string): PassoWf[] => { +export const vistaTua = (c: Ctx, intorno: string): PassoWf[] => { const pos = leggiCamera(c.v.vista) const cam = pos && componiCamera(pos, c.camera) if (!pos || !cam) return [] return [E(VISTA_TUA, `${cam} ${intorno}`, { lora: c.camera && conLora(pos) ? ['camera'] : [] })] } /** l'omino dentro la ricetta: lo muovi e ottieni il passo "La tua posa" (la posa è un'immagine in più, `{posa}` nel prompt) */ -const POSA_TUA_IN: Ingresso = { id: 'posa', tipo: 'posa', nome: 'Muovi l\'omino: una posa tua (anche mani e dita), oltre a quelle della ricetta' } -const posaTua = (c: Ctx, intorno: string): PassoWf[] => { +export const POSA_TUA_IN: Ingresso = { id: 'posa', tipo: 'posa', nome: 'Muovi l\'omino: una posa tua (anche mani e dita), oltre a quelle della ricetta' } +export const posaTua = (c: Ctx, intorno: string): PassoWf[] => { const po = leggiPosa(c.v.posa) if (!po) return [] // il manichino di posa è la prima immagine ({posa}), la foto del personaggio la seconda ({foto}): vedi richiestaPasso @@ -38,7 +38,7 @@ export const righe = (s: string | undefined): string[] => (s || '').split('\n'). // --------------------------------------------------------------------------------------------------------------- // i workflow fatti a mano // --------------------------------------------------------------------------------------------------------------- -const POSE: [string, string][] = [ +export const POSE: [string, string][] = [ ['In piedi', 'standing straight with arms crossed, confident'], ['Seduto', 'sitting on a chair, relaxed'], ['Cammina', 'walking forward, mid-step'], @@ -49,11 +49,11 @@ const POSE: [string, string][] = [ ['Guarda indietro', 'seen from behind, looking back over the shoulder'], ['Appoggiato', 'leaning casually against a wall'] ] -const ESPRESSIONI: [string, string][] = [ +export const ESPRESSIONI: [string, string][] = [ ['Felice', 'a warm genuine smile'], ['Ride', 'laughing out loud'], ['Triste', 'a sad, teary look'], ['Arrabbiato', 'an angry frown'], ['Sorpreso', 'a surprised look with wide eyes and open mouth'], ['Spaventato', 'a frightened look'], ['Pensieroso', 'a thoughtful, pensive look'], ['Occhiolino', 'a playful wink'] ] -const ARMADIO: [string, string][] = [ +export const ARMADIO: [string, string][] = [ ['Elegante', 'a tailored dark suit with a crisp white shirt'], ['Casual', 'jeans, a white t-shirt and sneakers'], ['Sportivo', 'a modern athletic tracksuit'], ['Invernale', 'a warm winter coat, scarf and wool hat'], ['Estate', 'light summer clothes: linen shirt and shorts'], ['Serata', 'an elegant evening outfit'], ['Business', 'a smart business outfit'], ['Halloween', 'a spooky Halloween costume'] @@ -72,15 +72,15 @@ const INQUADRATURE: [string, string][] = [ ['Primo piano', 'Re-frame as a tight close-up on the face'], ['Mezzo busto', 'Re-frame as a medium shot from the waist up'], ['Figura intera', 'Re-frame as a full-body shot showing the whole figure'], ['Grandangolo', 'Re-shoot with a wide-angle lens showing a lot of the surroundings'], ['Dall\'alto', 'Re-shoot from a high angle looking down'], ['Dal basso', 'Re-shoot from a low angle looking up'] ] -const GIORNO: [string, string][] = [ +export const GIORNO: [string, string][] = [ ['Alba', 'at dawn with soft pink and orange light on the horizon'], ['Mattino', 'in the bright clear morning'], ['Mezzogiorno', 'at noon under harsh overhead sun'], ['Tramonto', 'at sunset with a glowing golden-red sky'], ['Ora blu', 'at blue hour just after sunset, deep blue sky with warm lights'], ['Notte', 'at night with a dark sky and artificial lights'] ] -const STAGIONI: [string, string][] = [ +export const STAGIONI: [string, string][] = [ ['Primavera', 'in spring: blooming flowers and fresh green leaves'], ['Estate', 'in summer: lush green, strong warm sunlight'], ['Autunno', 'in autumn: orange and red leaves, soft golden light'], ['Inverno', 'in winter: covered with snow, cold blue light'] ] -const METEO: [string, string][] = [ +export const METEO: [string, string][] = [ ['Sole', 'on a perfect sunny day'], ['Nuvoloso', 'under a heavy overcast sky'], ['Pioggia', 'in steady rain with wet surfaces and reflections'], ['Neve', 'in falling snow'], ['Nebbia', 'in thick atmospheric fog'], ['Temporale', 'during a dramatic thunderstorm with dark clouds and lightning'] ] @@ -88,18 +88,18 @@ const LUCI: [string, string][] = [ ['Studio morbido', 'soft professional studio lighting'], ['Rembrandt', 'Rembrandt lighting with a triangle of light on the cheek'], ['Controluce', 'strong backlight with a glowing rim light'], ['Neon', 'colorful magenta and cyan neon lights'], ['Candela', 'warm flickering candlelight in a dark room'], ['Drammatica', 'dramatic low-key lighting with deep shadows'] ] -const EPOCHE: [string, string][] = [ +export const EPOCHE: [string, string][] = [ ['Anni \'20', 'the 1920s: art deco style, period clothes and cars, sepia tones'], ['Anni \'50', 'the 1950s: retro diner era, period clothes, pastel colors'], ['Anni \'80', 'the 1980s: neon, big hair, retro tech and film grain'], ['Medioevo', 'the Middle Ages: stone buildings, period clothes, torch light'], ['Antica Roma', 'ancient Rome: marble columns, togas, Mediterranean sun'], ['Western', 'the Wild West: dusty frontier town, cowboy clothes'], ['Steampunk', 'a steampunk world: brass gears, airships, Victorian clothes'], ['Futuro lontano', 'a far future: sleek technology, holograms, silver architecture'] ] -const MONDI: [string, string][] = [ +export const MONDI: [string, string][] = [ ['Sott\'acqua', 'underwater with light rays, bubbles and coral'], ['Nello spazio', 'in outer space with planets and a nebula'], ['Deserto', 'in a vast desert with dunes at golden hour'], ['Giungla', 'in a dense tropical jungle with mist'], ['Cyberpunk', 'in a rainy cyberpunk city full of neon signs'], ['Post-apocalisse', 'in a ruined post-apocalyptic world overgrown with plants'], ['Nel ghiaccio', 'in a frozen arctic landscape with ice and aurora'], ['Fantasy', 'in an epic high-fantasy world with floating islands'] ] -const STILI: [string, string][] = [ +export const STILI: [string, string][] = [ ['Anime', 'a high quality anime illustration'], ['Acquerello', 'a delicate watercolor painting'], ['Olio', 'a classical oil painting with visible brush strokes'], ['3D cartoon', 'a 3D animated movie render'], ['LEGO', 'a scene built of LEGO bricks and minifigures'], ['Creta', 'a stop-motion claymation scene made of modeling clay'], ['Pixel art', 'detailed 32-bit pixel art'], ['Matita', 'a detailed pencil sketch on paper'], ['Fumetto', 'a comic book illustration with bold ink and halftone shading'], @@ -120,12 +120,12 @@ const PRODOTTO: [string, string][] = [ ['Lifestyle', 'in a cozy lifestyle scene on a wooden table with warm morning light and props'], ['Marmo', 'on a white marble slab with soft reflections, luxury look'], ['In mano', 'held in a hand, natural light, shallow depth of field'], ['Flat lay', 'in a top-down flat lay with complementary props on a textured surface'] ] -const MOCKUP: [string, string][] = [ +export const MOCKUP: [string, string][] = [ ['Maglietta', 'printed on the chest of a white cotton t-shirt, worn by a model, studio photo'], ['Tazza', 'printed on a white ceramic mug on a wooden table, soft light'], ['Borsa', 'printed on a natural canvas tote bag hanging on a hook'], ['Poster', 'as a framed poster on a bright living room wall'], ['Cartellone', 'on a large city billboard at dusk'], ['Custodia', 'printed on a phone case held in a hand'] ] -const FORMATI_SOCIAL: [string, string, number][] = [['1:1 Post', '1:1', 1080], ['4:5 Instagram', '4:5', 1080], ['9:16 Storia', '9:16', 1080], ['16:9 Video', '16:9', 1080], ['2:3 Pinterest', '2:3', 1080]] +export const FORMATI_SOCIAL: [string, string, number][] = [['1:1 Post', '1:1', 1080], ['4:5 Instagram', '4:5', 1080], ['9:16 Storia', '9:16', 1080], ['16:9 Video', '16:9', 1080], ['2:3 Pinterest', '2:3', 1080]] const STILI_TESTO: [string, string][] = [ ['Titolo in alto', 'as a huge bold sans-serif headline across the top'], ['Neon', 'as a glowing neon sign effect'], ['Lettere 3D', 'as big 3D chrome letters'], ['Calligrafia', 'in elegant hand-drawn brush calligraphy'], ['Giornale', 'as a newspaper headline'], ['Adesivo', 'as a cute handwritten sticker label'] @@ -351,18 +351,18 @@ export const WORKFLOW_BASE: Workflow[] = [ // ---- sticker e icone di qualsiasi cosa (non solo persone) -const STILI_STICKER: [string, string][] = [ +export const STILI_STICKER: [string, string][] = [ ['Kawaii', 'a cute kawaii chibi style with big shiny eyes and soft pastel colors'], ['Fumetto', 'a bold cartoon style with thick black outlines and flat bright colors'], ['Vettoriale', 'a clean flat vector illustration with simple geometric shapes'], ['Acquerello', 'a delicate watercolor style with soft bleeding colors'], ['Pixel art', 'a detailed 32-bit pixel art style'], ['Creta 3D', 'a cute 3D clay render with soft lighting'], ['Retro anni \'70', 'a retro 1970s style with warm earthy colors and rounded shapes'], ['Neon', 'a glowing neon line style on dark colors'] ] -const AZIONI_STICKER: [string, string][] = [ +export const AZIONI_STICKER: [string, string][] = [ ['Saluta', 'waving hello with a big smile'], ['Dorme', 'sleeping peacefully with little "z" bubbles'], ['Festeggia', 'celebrating with confetti and a party hat'], ['Corre', 'running fast with motion lines'], ['Mangia', 'happily eating a snack'], ['Cuore', 'holding a big red heart'], ['Occhiali da sole', 'wearing cool sunglasses, looking confident'], ['Arrabbiato', 'looking grumpy and angry with a little storm cloud'] ] -const E2 = (etichetta: string, prompt: string): PassoWf => E(etichetta, prompt, { crea: { formato: '1:1', ris: 1024 }, trasparente: true }) +export const E2 = (etichetta: string, prompt: string): PassoWf => E(etichetta, prompt, { crea: { formato: '1:1', ris: 1024 }, trasparente: true }) WORKFLOW_BASE.push( { id: 'sticker-tutto', nome: 'Sticker di qualsiasi cosa', categoria: 'sticker', icona: 'sticker', modo: 'serie', foto: false, rif: 'si', rifNome: 'una foto di riferimento (facoltativa)', diff --git a/src/renderer/src/ricetteV2.ts b/src/renderer/src/ricetteV2.ts new file mode 100644 index 0000000..5b06ed4 --- /dev/null +++ b/src/renderer/src/ricetteV2.ts @@ -0,0 +1,194 @@ +// Le sei ricette della GUI v2 «Orbita»: Foglio · Mondi · Mix · Set · Formati · Rinnova. +// Ognuna sostituisce un gruppo di ricette vecchie (che restano in `WORKFLOW_BASE` solo per leggere i PNG già fatti). +// Tutto passa dai segnaposti e dalle scelte a pulsanti: niente elenchi lunghi di testo. +import { componiCamera } from './componenti/Camera' +import { + AZIONI_STICKER, CAMERA_TUA, E, E2, EPOCHE, ESPRESSIONI, FOTO, MONDI, MOCKUP, ARMADIO, METEO, POSA_TUA_IN, POSE, STAGIONI, STILI, STILI_STICKER, + posaTua, righe, vistaTua +} from './ricetteBase' +import type { Ctx, Ingresso, PassoWf, Workflow } from './workflow' + +/** quante immagini fare: 4, 6 o 9 */ +const QUANTE: Ingresso = { id: 'quante', tipo: 'scelta', nome: 'Quante', predefinita: '6', opzioni: [{ id: '4', nome: '4' }, { id: '6', nome: '6' }, { id: '9', nome: '9' }] } +const quante = (c: Ctx): number => Math.max(1, Math.min(12, Number(c.v.quante) || 6)) +/** le etichette di una ricetta devono essere tutte diverse (sono le chiavi dei passi): se due coincidono, la seconda prende un numero */ +const univoche = (passi: PassoWf[]): PassoWf[] => { + const viste = new Map() + return passi.map((p) => { + const n = (viste.get(p.etichetta) || 0) + 1 + viste.set(p.etichetta, n) + return n === 1 ? p : { ...p, etichetta: `${p.etichetta} ${n}` } + }) +} +const uguale = 'Keep exactly the same face, hairstyle, outfit, colors and art style.' + +// --------------------------------------------------------------------------------------------------------------- +// 1 · Foglio: da un soggetto, una serie che varia UNA dimensione +// --------------------------------------------------------------------------------------------------------------- +const VISTE_FOGLIO: [string, string][] = [ + ['Davanti', 'seen from the front'], ['Tre quarti', 'rotated 45 degrees to the right (three-quarter view)'], ['Di lato', 'in a perfect side profile view'], + ['Tre quarti dietro', 'seen from behind and 45 degrees to the left'], ['Dietro', 'seen directly from behind'], ['Volto', 'as a close-up portrait of the face, looking at the camera'], + ['Dall\'alto', 'seen from a high angle looking down'], ['Dal basso', 'seen from a low angle looking up'], ['Figura intera', 'full body, centered, slightly turned'] +] +const ETA = [8, 20, 30, 40, 50, 60, 70, 80, 90] +const ANGOLI: [string, { az: number; el: number }][] = [ + ['45° destra', { az: 45, el: 0 }], ['90° destra', { az: 90, el: 0 }], ['135° destra', { az: 135, el: 0 }], ['Dietro', { az: 180, el: 0 }], + ['135° sinistra', { az: -135, el: 0 }], ['90° sinistra', { az: -90, el: 0 }], ['45° sinistra', { az: -45, el: 0 }], ['Dall\'alto', { az: 0, el: 35 }], ['Dal basso', { az: 0, el: -25 }] +] +const FOGLIO_VARIA = [ + { id: 'viste', nome: 'Viste' }, { id: 'pose', nome: 'Pose' }, { id: 'emozioni', nome: 'Emozioni' }, { id: 'eta', nome: 'Età' }, + { id: 'vestiti', nome: 'Vestiti' }, { id: 'azioni', nome: 'Azioni' }, { id: 'giro', nome: 'Giro 360°' } +] +const foglio: Workflow = { + id: 'r-foglio', nome: 'Foglio', categoria: 'ricette', icona: 'serie', modo: 'serie', foto: true, rif: 'no', + descrizione: 'Da una foto fa una serie coerente: la stessa persona o cosa che cambia una sola dimensione (viste, pose, emozioni, età, vestiti, azioni o un giro attorno).', + nota: 'Con i LoRA «Coerenza», «Vestiti» e «Camera» il risultato è più fedele.', + ingressi: [{ id: 'varia', tipo: 'scelta', nome: 'Cosa cambia', predefinita: 'viste', opzioni: FOGLIO_VARIA }, QUANTE, CAMERA_TUA, POSA_TUA_IN], + passi: (c) => { + const n = quante(c) + const sfondo = 'Plain white background, flat even lighting.' + const per = (voci: [string, string][], fn: (d: string) => string, extra: Partial = {}): PassoWf[] => voci.slice(0, n).map(([e, d]) => E(e, fn(d), extra)) + let base: PassoWf[] + switch (c.v.varia || 'viste') { + case 'pose': base = per(POSE, (d) => `Show the same character from ${FOTO} ${d}. ${uguale} The full body is visible.`); break + case 'emozioni': base = per([...ESPRESSIONI, ['Neutro', 'a calm neutral look']], (d) => `Change the facial expression of the person in ${FOTO} to ${d}. Keep the same identity, hairstyle, clothing, pose and background.`, { lora: ['coerenza'] }); break + case 'eta': base = ETA.slice(0, n).map((a) => E(`${a} anni`, `Show the same person from ${FOTO} as they would look at age ${a}. Keep the identity recognizable, with the same pose, framing and background, and age-appropriate hair, skin and clothes.`)); break + case 'vestiti': base = per([...ARMADIO, ['Pigiama', 'cozy pajamas']], (d) => `Dress the person in ${FOTO} in ${d}. Keep their face, hair, hands, pose and the background exactly the same.`, { lora: ['vestiti'] }); break + case 'azioni': base = per([...AZIONI_STICKER, ['Beve un caffè', 'enjoying a cup of coffee']], (d) => `Show the same character from ${FOTO} ${d}. ${uguale} The full body is visible.`); break + case 'giro': base = ANGOLI.slice(0, n).map(([e, pos]) => E(e, componiCamera({ ...pos, dist: 0 }, c.camera) || `Show the subject of ${FOTO} from another angle. Keep the same subject, outfit, colors and lighting.`, { lora: c.camera ? ['camera'] : [] })); break + default: base = per(VISTE_FOGLIO, (d) => `Show the same character from ${FOTO} in a neutral standing A-pose, ${d}. ${uguale} ${sfondo}`) + } + return univoche([...base, ...vistaTua(c, `Keep the same subject, outfit, colors and lighting. ${sfondo}`), ...posaTua(c, `${sfondo} Full body visible.`)]) + } +} + +// --------------------------------------------------------------------------------------------------------------- +// 2 · Mondi: «E se…?» — la stessa scena in più mondi +// --------------------------------------------------------------------------------------------------------------- +const LUOGHI: [string, string][] = [...MONDI, ['Città sulle nuvole', 'in a city built on the clouds at sunset']] +const EPOCHE9: [string, string][] = [...EPOCHE, ['Preistoria', 'the Stone Age: caves, furs, a wild primeval landscape']] +const STAGIONI_METEO: [string, string][] = [...STAGIONI, ...METEO] +const CAMBIA_MONDI = [{ id: 'luogo', nome: 'Luogo' }, { id: 'epoca', nome: 'Epoca' }, { id: 'stagione', nome: 'Stagione' }, { id: 'stile', nome: 'Stile' }, { id: 'tutto', nome: 'Tutto' }] +const mondi: Workflow = { + id: 'r-mondi', nome: 'Mondi', categoria: 'ricette', icona: 'situazione', modo: 'serie', foto: true, rif: 'no', + descrizione: '«E se…?»: la stessa foto in più mondi: luoghi, epoche, stagioni, stili. Il soggetto resta riconoscibile. Puoi aggiungere un tuo «E se…».', + ingressi: [ + { id: 'cambia', tipo: 'scelta', nome: 'Cosa cambia', predefinita: 'luogo', opzioni: CAMBIA_MONDI }, QUANTE, + { id: 'efse', tipo: 'testo', nome: 'E se… (facoltativo)', esempio: 'fosse in una città costruita sulle nuvole', opzionale: true } + ], + passi: (c) => { + const n = quante(c) + const luogo = LUOGHI.map(([e, d]): PassoWf => E(e, `Place the subject of ${FOTO} ${d}. Keep the subject exactly the same, with matching light, shadows and perspective.`)) + const epoca = EPOCHE9.map(([e, d]): PassoWf => E(e, `Reimagine ${FOTO} in ${d}. Keep the subject's face and identity recognizable and the composition similar.`)) + const stagione = STAGIONI_METEO.map(([e, d]): PassoWf => E(e, `Show the exact same scene from ${FOTO} ${d}. Keep the composition and subjects identical; only the ${STAGIONI.some((s) => s[0] === e) ? 'season' : 'weather'} changes.`, { lora: ['coerenza'] })) + const stile = STILI.map(([e, d]): PassoWf => E(e, `Transform ${FOTO} into ${d}. Keep the composition and the subject recognizable.`)) + const liste = { luogo, epoca, stagione, stile } + const scelta = c.v.cambia || 'luogo' + let tutti: PassoWf[] + if (scelta === 'tutto') { + // un po' di ognuno, a giro, finché basta + tutti = [] + for (let k = 0; tutti.length < n && k < 12; k++) for (const l of [luogo, epoca, stagione, stile]) if (l[k] && tutti.length < n + 3) tutti.push(l[k]) + } else tutti = liste[scelta as keyof typeof liste] || luogo + const libero = (c.v.efse || '').trim() + const speciali = libero ? [E('E se…', `Reimagine ${FOTO} as if ${libero}. Keep the main subject recognizable.`)] : [] + return univoche([...speciali, ...tutti.slice(0, libero ? Math.max(1, n - 1) : n)]) + } +} + +// --------------------------------------------------------------------------------------------------------------- +// 3 · Mix: più foto con un ruolo (si usa la tela di Modifica; i due modi sono «Unisci» e «Come l'esempio») +// --------------------------------------------------------------------------------------------------------------- +const mix: Workflow = { + id: 'r-mix', nome: 'Mix', categoria: 'ricette', icona: 'foto', modo: 'speciale', foto: false, rif: 'serve', rifNome: 'le foto da unire', + descrizione: 'Unisce più foto, ognuna con il suo ruolo (base, persona, oggetto, vestito) in una immagine nuova, oppure rifà un esempio con i dettagli delle tue foto («Stile da copiare»).', + ingressi: [], + passi: () => [E('Foto nuova', 'Crea una immagine nuova partendo dalle foto di riferimento, unendo ognuna con il suo ruolo.')] +} + +// --------------------------------------------------------------------------------------------------------------- +// 4 · Set: una serie di cose con stile coerente, sfondo trasparente di serie +// --------------------------------------------------------------------------------------------------------------- +const TIPI_SET: Record = { + sticker: 'A die-cut sticker of {x}, in {s}. Thick white die-cut outline, the subject centered and fully visible, no text, on a plain background.', + icone: 'An app icon of {x}, in {s}. Centered, a single object, consistent style and palette, no text, plain background.', + loghi: 'A clean modern logo mark for {x}, in {s}. Simple, memorable, centered, vector look, no extra text, plain background.', + mascotte: 'A friendly mascot character of {x}, in {s}. Full body, consistent character design, centered, no extra text, plain background.' +} +const STILI_SET = [ + ...STILI_STICKER.slice(0, 6).map(([e]) => e), 'Piatto' +].filter((x, i, a) => a.indexOf(x) === i) +const descrizioneStile = (id: string): string => (id === 'Piatto' ? 'a flat design with rounded corners and a soft shadow' : STILI_STICKER.find(([e]) => e === id)?.[1] || STILI_STICKER[0][1]) +const set: Workflow = { + id: 'r-set', nome: 'Set', categoria: 'ricette', icona: 'sticker', modo: 'serie', foto: false, rif: 'si', rifNome: 'una foto di riferimento (facoltativa)', + descrizione: 'Una serie di cose nello stesso stile, tutte con lo sfondo trasparente: sticker, icone, loghi o mascotte. Scrivi un soggetto per riga.', + ingressi: [ + { id: 'tipo', tipo: 'scelta', nome: 'Cosa', predefinita: 'sticker', opzioni: [{ id: 'sticker', nome: 'Sticker' }, { id: 'icone', nome: 'Icone' }, { id: 'loghi', nome: 'Loghi' }, { id: 'mascotte', nome: 'Mascotte' }] }, + { id: 'stile', tipo: 'scelta', nome: 'Stile', predefinita: 'Kawaii', opzioni: STILI_SET.map((s) => ({ id: s, nome: s })) }, + { id: 'soggetti', tipo: 'righe', nome: 'Un soggetto per riga', esempio: 'una pizza\nun gatto astronauta\nuna chitarra elettrica\nun fiore di loto' } + ], + passi: (c) => { + const modello = TIPI_SET[c.v.tipo || 'sticker'] || TIPI_SET.sticker + const stile = descrizioneStile(c.v.stile || 'Kawaii') + const coi = c.nRif > 0 ? ' Match the character and style of the reference .' : '' + return univoche(righe(c.v.soggetti).slice(0, 40).map((r) => E2(r.slice(0, 28), modello.replace('{x}', r).replace('{s}', stile) + coi))) + } +} + +// --------------------------------------------------------------------------------------------------------------- +// 5 · Formati: un'idea → tutti i formati e i mockup +// --------------------------------------------------------------------------------------------------------------- +const FORMATI_V2: [string, string, number][] = [['1:1 Post', '1:1', 1080], ['4:5 Instagram', '4:5', 1080], ['9:16 Storia', '9:16', 1080], ['16:9 Video', '16:9', 1080], ['2:3 Pinterest', '2:3', 1080]] +const STAMPA: [string, string, number][] = [['Stampa verticale', '3:4', 1440], ['Stampa orizzontale', '4:3', 1440]] +const formati: Workflow = { + id: 'r-formati', nome: 'Formati', categoria: 'ricette', icona: 'social', modo: 'serie', foto: false, rif: 'si', rifNome: 'il tuo logo o una foto (facoltativi)', + descrizione: 'Un\'idea in tutti i formati (social, stampa) o su tanti oggetti: maglietta, tazza, borsa, poster, cartellone, custodia. Se aggiungi un logo, lo riporta uguale.', + ingressi: [ + { id: 'idea', tipo: 'testo', nome: 'Cosa vuoi vedere', esempio: 'una Vespa rossa in un vicolo di Positano al tramonto' }, + { id: 'uscita', tipo: 'scelta', nome: 'Dove', predefinita: 'social', opzioni: [{ id: 'social', nome: 'Social' }, { id: 'stampa', nome: 'Stampa' }, { id: 'mockup', nome: 'Mockup' }, { id: 'tutto', nome: 'Tutto' }] } + ], + passi: (c) => { + const idea = (c.v.idea || 'a beautiful scene').trim() + const dove = c.v.uscita || 'social' + const fmt = (l: [string, string, number][]): PassoWf[] => l.map(([e, f, ris]) => E(e, idea, { crea: { formato: f, ris } })) + const mock = (): PassoWf[] => + MOCKUP.map(([e, d]) => E(`Mockup · ${e}`, c.nRif > 0 + ? `A realistic product mockup photograph: the exact design from ${d}. Reproduce the design exactly as it is, with its colors and proportions.` + : `A realistic product mockup photograph: ${idea} ${d}. Reproduce the design exactly as described.`, { crea: { formato: '1:1', ris: 1024 } })) + return univoche(dove === 'stampa' ? fmt(STAMPA) : dove === 'mockup' ? mock() : dove === 'tutto' ? [...fmt(FORMATI_V2), ...fmt(STAMPA), ...mock()] : fmt(FORMATI_V2)) + } +} + +// --------------------------------------------------------------------------------------------------------------- +// 6 · Rinnova: una trasformazione prima/dopo, a scelta +// --------------------------------------------------------------------------------------------------------------- +const rinnova: Workflow = { + id: 'r-rinnova', nome: 'Rinnova', categoria: 'ricette', icona: 'bacchetta', modo: 'catena', foto: true, rif: 'no', + descrizione: 'Una foto, una trasformazione a scelta: restaura una foto vecchia, dal disegno al render, svuota e arreda una stanza, rifinisci i dettagli o colora. Ogni passo parte dal precedente.', + ingressi: [ + { id: 'cosa', tipo: 'scelta', nome: 'Cosa', predefinita: 'restaura', opzioni: [{ id: 'restaura', nome: 'Restaura' }, { id: 'render', nome: 'Dal disegno al render' }, { id: 'arreda', nome: 'Svuota e arreda' }, { id: 'rifinisci', nome: 'Rifinisci' }, { id: 'colora', nome: 'Colora' }] }, + { id: 'stile', tipo: 'testo', nome: 'Come arredarla (solo per «Svuota e arreda»)', esempio: 'un salotto moderno con divano grigio, tappeto e piante', opzionale: true } + ], + passi: (c) => { + switch (c.v.cosa || 'restaura') { + case 'render': return [ + E('Linea pulita', `Redraw ${FOTO} as clean, confident ink line art with consistent line weight on a white background. Keep the composition and every element.`), + E('A colori', 'Color this line art as a vibrant, polished digital illustration with soft shading. Keep the composition and every element.'), + E('Realistico', 'Turn this illustration into a photorealistic image with natural lighting and real textures. Keep the composition and every element.') + ] + case 'arreda': return [ + E('Stanza vuota', `Remove all the furniture, rugs and decorations from the room in ${FOTO}, leaving an empty room with clean walls and floor. Keep the architecture, windows, doors and lighting.`), + E('Arredata', `Furnish this empty room as ${(c.v.stile || 'a cozy modern living room').trim()}. Keep the architecture, windows, doors and lighting unchanged.`) + ] + case 'rifinisci': return [E('Nitida', `Enhance the image quality of ${FOTO}: sharp fine details, clean textures, natural skin, no noise or compression artifacts. Keep everything identical.`, { lora: ['coerenza'] })] + case 'colora': return [E('A colori', `Colorize ${FOTO} with natural, realistic colors, accurate skin tones and clothes. Keep everything else identical.`, { lora: ['coerenza'] })] + default: return [ + E('Pulita', 'Restore this old photo: remove scratches, dust, stains, noise and blur, and fix the faded contrast. Keep the people and the composition identical.', { lora: ['coerenza'] }), + E('A colori', 'Colorize this black and white photo with natural, realistic colors, accurate skin tones and clothes. Keep everything else identical.', { lora: ['coerenza'] }), + E('Nitida', 'Enhance the image quality: sharp fine details, clean textures, no noise or compression artifacts. Keep everything identical.', { lora: ['coerenza'] }) + ] + } + } +} + +export const WORKFLOW_V2: Workflow[] = [foglio, mondi, mix, set, formati, rinnova] diff --git a/src/renderer/src/riquadri.ts b/src/renderer/src/riquadri.ts index 9c73ffa..01fb027 100644 --- a/src/renderer/src/riquadri.ts +++ b/src/renderer/src/riquadri.ts @@ -11,6 +11,8 @@ export interface Elemento { nome: string tipo: TipoElemento manuale?: boolean + /** un clic sulla foto (frazioni 0–1): la cosa la trova SAM 2 da quel punto (il riquadro è solo un segno per l'occhio) */ + punto?: { x: number; y: number } } const PAROLE_PERSONA = /\b(person|people|man|men|woman|women|boy|girl|child|children|kid|baby|toddler|guy|lady|human|player|dancer|bride|groom)\b/i diff --git a/src/renderer/src/stato.ts b/src/renderer/src/stato.ts index f647b19..164db83 100644 --- a/src/renderer/src/stato.ts +++ b/src/renderer/src/stato.ts @@ -1,13 +1,14 @@ // Lo stato dell'interfaccia (zustand). Le scelte di Crea/Modifica e i LoRA attivi restano fra un avvio e l'altro. import { create } from 'zustand' -import { FAMIGLIE, type Download, type Famiglia, type Impostazioni, type InfoLora, type InfoMotore, type Lavoro, type LoraAttiva, type Opera, type Bordi, type Risorse, type StatoAggiornamento } from '@shared/tipi' +import { FAMIGLIE, type BoxRegione, type Download, type Famiglia, type Impostazioni, type InfoLora, type InfoMotore, type Lavoro, type LoraAttiva, type Opera, type Bordi, type Risorse, type StatoAggiornamento } from '@shared/tipi' import { senzaTurbo } from '@shared/modelli' import { api } from './api' import type { Misura } from './componenti/Misura' // 'laboratorio' è sparito dalla rotaia (T7): T9 ha spostato le sue ricette nella scheda 'ricette' di Modifica. // I LoRA stanno dentro Opzioni (`SchedaOpzioni`). -export type Pagina = 'crea' | 'modifica' | 'album' | 'galleria' | 'impostazioni' +/** 'crea', 'modifica' e 'box' sono i tre modi dello Studio (con Ricette = modifica + modo 'ricette'); 'lora' ha la pagina sua */ +export type Pagina = 'crea' | 'modifica' | 'box' | 'album' | 'galleria' | 'lora' | 'impostazioni' export type SchedaOpzioni = 'generali' | 'lora' export type Qualita = 'bozza' | 'alta' | 'massima' /** Scrivi = tutta la foto, o solo la zona se ne hai disegnata una (si decide al momento dell'invio) */ @@ -48,6 +49,20 @@ export interface StatoCrea { campi: Record /** le dimensioni precise (se scelte): il modello lavora ai megapixel, poi si porta a questa misura */ misura: Misura + /** GUI v2: lo stile nuovo (id di `stili.ts`, '' = nessuno) e quanto forte: 0 leggero · 1 pieno · 2 estremo */ + stileId: string + intensita: 0 | 1 | 2 + /** GUI v2: lo sfondo (`trasparente` qui sotto resta il flag che il motore già conosce e va sempre d'accordo con questo), la tinta unita e come si guarda la trasparenza */ + sfondo: 'scena' | 'trasparente' | 'tinta' + tinta: string + anteSfondo: 'scacchi' | 'bianco' | 'nero' | 'colore' +} + +/** L'Editor box: i riquadri disegnati sulla tela di Crea (stesso formato, stessa misura) e la scena generale */ +export interface StatoBox { + scena: string + regioni: BoxRegione[] + selezionata: string | null } export interface StatoModifica { @@ -81,6 +96,8 @@ export interface StatoModifica { /** le azioni rapide sono idee che si sommano: quanta fantasia ha il modello nel mescolarle, e il piano che ne esce */ fantasia: 'fedele' | 'normale' | 'tanta' pianoIdee: { chiave: string; testo: string } | null + /** GUI v2: quanto forte è lo stile scelto fra le idee (0 leggero · 1 pieno · 2 estremo) */ + intensitaStile: 0 | 1 | 2 } export interface Avviso { @@ -100,6 +117,8 @@ interface Stato { loraAttive: LoraAttiva[] crea: StatoCrea modifica: StatoModifica + /** l'Editor box (modo Box dello Studio) */ + box: StatoBox codaAperta: boolean avvisi: Avviso[] /** l'opera aperta nel visore (galleria) */ @@ -118,6 +137,7 @@ interface Stato { setImp(i: Impostazioni): void setCrea(m: Partial): void setModifica(m: Partial): void + setBox(m: Partial): void setLoraAttive(l: LoraAttiva[]): void toggleLora(file: string, forza?: number): void avvisa(testo: string, tipo?: Avviso['tipo']): void @@ -145,12 +165,14 @@ const leggiLista = (k: string): T[] => { const CREA0: StatoCrea = { prompt: '', formato: '1:1', ris: 1024, mpLavoro: 0, quante: 1, seed: 0, casuale: true, trasparente: false, - riferimenti: [], preset: '', stile: '', campi: {}, misura: { tipo: 'auto' } + riferimenti: [], preset: '', stile: '', campi: {}, misura: { tipo: 'auto' }, + stileId: '', intensita: 1, sfondo: 'scena', tinta: '#ffffff', anteSfondo: 'scacchi' } +const BOX0: StatoBox = { scena: '', regioni: [], selezionata: null } const MOD0: StatoModifica = { base: null, versioni: [], riferimenti: [], esempio: null, modo: 'scrivi', prompt: '', mp: 1, qualita: 'alta', quante: 1, forza: 1, contesto: 0.6, ritaglia: true, segnaZona: false, riempi: false, sfuma: 10, allarga: 8, bordi: { sinistra: 0, sopra: 0, destra: 0, sotto: 0 }, trasparente: false, misura: { tipo: 'auto' }, formatoFusion: 'prima', - pianoEsempio: null, fantasia: 'normale', pianoIdee: null + pianoEsempio: null, fantasia: 'normale', pianoIdee: null, intensitaStile: 1 } /** le scelte salvate dalla 0.1 avevano i megapixel al posto della risoluzione; fino alla 1.0 c'era la qualità «turbo» (ora «alta») */ @@ -181,6 +203,7 @@ export const usaStato = create((set, get) => ({ loraAttive: leggiLista('dpi-lora'), crea: { ...daVecchio(leggiLocale('dpi-crea', CREA0)), riferimenti: [] }, modifica: daVecchioMod({ ...leggiLocale('dpi-modifica', MOD0), base: null, versioni: [], riferimenti: [], esempio: null }), + box: { ...BOX0, ...leggiLocale>('dpi-box', {}), selezionata: null }, codaAperta: false, avvisi: [], visore: null, @@ -189,7 +212,7 @@ export const usaStato = create((set, get) => ({ scarico: null, selettore: null, vai: (pagina) => set({ pagina }), - vaiOpzioni: (schedaOpzioni) => set({ pagina: 'impostazioni', schedaOpzioni }), + vaiOpzioni: (schedaOpzioni) => (schedaOpzioni === 'lora' ? set({ pagina: 'lora' }) : set({ pagina: 'impostazioni', schedaOpzioni })), setImp: (imp) => set({ imp }), setCrea: (m) => { const crea = { ...get().crea, ...m } @@ -211,6 +234,16 @@ export const usaStato = create((set, get) => ({ /* niente */ } }, + setBox: (m) => { + const box = { ...get().box, ...m } + set({ box }) + try { + const { selezionata: _s, ...resto } = box + localStorage.setItem('dpi-box', JSON.stringify(resto)) + } catch { + /* niente */ + } + }, setLoraAttive: (loraAttive) => { set({ loraAttive }) try { diff --git a/src/renderer/src/stili.ts b/src/renderer/src/stili.ts new file mode 100644 index 0000000..871328e --- /dev/null +++ b/src/renderer/src/stili.ts @@ -0,0 +1,82 @@ +// Gli stili della GUI v2: 28, in 4 scaffali, ognuno con un id stabile, un frammento di prompt in inglese e tre intensità. +// Le anteprime vere (immagini generate con Klein, stessa scena per tutti) arrivano con gli asset; finché non ci sono la tessera ha un gradiente e un'icona. +// Vale sia per Crea (si aggiunge al prompt) sia per Modifica (la categoria «Stili» trasforma la foto). + +export type Intensita = 0 | 1 | 2 +export const INTENSITA: { id: Intensita; nome: string; titolo: string }[] = [ + { id: 0, nome: 'Leggero', titolo: 'Un\'influenza lieve: la scena resta in primo piano' }, + { id: 1, nome: 'Pieno', titolo: 'Lo stile scelto, senza esagerare' }, + { id: 2, nome: 'Estremo', titolo: 'Tutto in quello stile, senza compromessi' } +] + +export interface Stile { + id: string + nome: string + scaffale: string + /** in inglese: è la lingua che il modello capisce meglio */ + frammento: string + /** gradiente di ripiego per la tessera e un'icona a emoji */ + fondo: string + icona: string +} + +const S = (id: string, nome: string, scaffale: string, frammento: string, a: string, b: string, icona: string): Stile => ({ + id, nome, scaffale, frammento, icona, fondo: `linear-gradient(135deg, ${a}, ${b})` +}) + +export const SCAFFALI: { id: string; nome: string }[] = [ + { id: 'foto', nome: 'Foto d\'oggi' }, + { id: 'disegnati', nome: 'Disegnati' }, + { id: '3d', nome: 'In 3D' }, + { id: 'grafica', nome: 'Grafica spaziale e oltre' } +] + +export const STILI: Stile[] = [ + // --- Foto d'oggi --- + S('cinema-anam', 'Cinema', 'foto', 'cinematic still, anamorphic lens flares, oval bokeh, rich color grade, 2.39:1 feel', '#0f2b46', '#e08a3c', '🎬'), + S('flash-y2k', 'Flash 2000', 'foto', 'direct on-camera flash, early-2000s compact digital camera look, harsh highlights, candid party snapshot', '#2b2b2b', '#ffe9a8', '📸'), + S('pellicola35', 'Pellicola', 'foto', 'shot on 35mm film, visible grain, soft halation, faded blacks', '#5a3b22', '#d9a066', '🎞️'), + S('moda-editoriale', 'Moda', 'foto', 'high-fashion editorial photography, studio strobes, bold color blocking, magazine cover composition', '#6b0f3a', '#f3b0c8', '👗'), + S('polaroid', 'Polaroid', 'foto', 'instant film photo, white frame, soft focus, washed warm tones, light leaks', '#c9a27a', '#fff2dc', '🖼️'), + S('notte-neon', 'Notte neon', 'foto', 'night street photography, wet asphalt reflections, magenta and cyan neon signage', '#150a3a', '#ff3df2', '🌃'), + S('documentario', 'Reportage', 'foto', 'gritty documentary photograph, available light, slightly desaturated, imperfect framing', '#3a3d40', '#9aa0a6', '📰'), + // --- Disegnati --- + S('anime-film', 'Anime', 'disegnati', 'high quality anime movie still, painterly backgrounds, soft cinematic lighting', '#2a6fdb', '#ffb7d5', '🌸'), + S('acquerello-film', 'Acquerello', 'disegnati', 'hand-painted watercolor animation background, soft gradients, gentle clouds', '#7ec8e3', '#fbe7c6', '🎨'), + S('manga-inchiostro', 'Manga', 'disegnati', 'black and white manga, screentones, dynamic inked lines', '#111111', '#e8e8e8', '✒️'), + S('gouache', 'Gouache', 'disegnati', 'contemporary gouache illustration, flat shapes, rich matte colors, subtle paper texture', '#d1495b', '#edae49', '🖌️'), + S('risograph', 'Risograph', 'disegnati', 'two-color risograph print, grainy overprint, misregistration, limited palette', '#ff4d8d', '#2d5bff', '🖨️'), + S('collage', 'Collage', 'disegnati', 'cut-paper collage, layered torn paper edges, mixed textures', '#e9c46a', '#e76f51', '✂️'), + S('linea', 'Linea unica', 'disegnati', 'minimal single continuous line drawing on white, a touch of color', '#f5f5f5', '#c9c9d6', '〰️'), + // --- In 3D --- + S('soft3d', '3D morbido', '3d', 'soft 3D render, rounded shapes, pastel studio light, subsurface scattering, friendly character look', '#ffb5e8', '#b5deff', '🧸'), + S('plastilina', 'Plastilina', '3d', 'stop-motion claymation, visible fingerprints, handmade imperfections', '#f4a261', '#2a9d8f', '🧱'), + S('vinile', 'Vinile', '3d', 'designer vinyl toy, glossy plastic, seam lines, studio product shot', '#7b2cbf', '#ff9e00', '🪀'), + S('diorama', 'Diorama', '3d', 'isometric miniature diorama, tilt-shift, clean cube base, soft shadows', '#06d6a0', '#118ab2', '🏝️'), + S('mattoncini', 'Mattoncini', '3d', 'scene built of interlocking plastic bricks and minifigures', '#e63946', '#ffd60a', '🧩'), + S('vetro', 'Vetro liquido', '3d', 'liquid glass and crystal, refractive translucent material, caustics, iridescent edges', '#3fe0ff', '#b388ff', '🔮'), + S('chrome', 'Cromo Y2K', '3d', 'chrome blobject, reflective metal, hyperreal studio HDRI, early-2000s futurism', '#8d99ae', '#edf2f4', '💿'), + // --- Grafica spaziale e oltre --- + S('cyberpunk', 'Cyberpunk', 'grafica', 'dense neon cityscape, holograms, rain, high contrast magenta/teal', '#00b8d4', '#d500f9', '🤖'), + S('synthwave', 'Synthwave', 'grafica', 'retro-futuristic 80s sunset grid, chrome lettering, purple-pink gradient sky', '#ff006e', '#3a0ca3', '🌆'), + S('aura', 'Aura', 'grafica', 'soft airbrushed aura gradients, grain, glowing blurred shapes, dreamy abstract', '#f72585', '#4cc9f0', '🫧'), + S('pixel', 'Pixel 32-bit', 'grafica', 'detailed 32-bit pixel art, limited palette, dithering', '#2d6a4f', '#95d5b2', '👾'), + S('spaziale60', 'Space age', 'grafica', '1960s space-age illustration, atomic shapes, retro NASA poster palette', '#e85d04', '#023e8a', '🚀'), + S('solarpunk', 'Solarpunk', 'grafica', 'solarpunk utopia, lush green architecture, warm sunlight, art nouveau curves', '#2d9a4a', '#ffd166', '🌿'), + S('svizzero', 'Poster svizzero', 'grafica', 'Swiss international typographic poster, strong grid, flat color, bold sans-serif', '#d00000', '#ffffff', '🔠') +] + +export const stileDa = (id: string): Stile | undefined => STILI.find((s) => s.id === id) + +/** la frase da aggiungere al prompt di Crea (descrive la scena, poi lo stile) */ +export function fraseStileCrea(s: Stile, i: Intensita): string { + if (i === 0) return `with a subtle ${s.frammento} influence` + if (i === 1) return `in this style: ${s.frammento}` + return `fully rendered as ${s.frammento}, extreme, uncompromising commitment to this style` +} + +/** l'istruzione di Modifica: trasforma la foto tenendo composizione e identità */ +export function frasiStileModifica(s: Stile, i: Intensita): string { + const forza = i === 0 ? 'Apply it subtly, keeping the original look mostly intact.' : i === 1 ? 'Apply it clearly.' : 'Apply it fully and boldly, with an extreme commitment to the style.' + return `Transform the image into this style: ${s.frammento}. Keep the composition, subjects, pose and identity unchanged. ${forza}` +} diff --git a/src/renderer/src/workflow.ts b/src/renderer/src/workflow.ts index 3ef7f15..b6d0646 100644 --- a/src/renderer/src/workflow.ts +++ b/src/renderer/src/workflow.ts @@ -69,6 +69,7 @@ export interface Workflow { } export const CATEGORIE_WF: { id: string; nome: string; icona: string; testo: string }[] = [ + { id: 'ricette', nome: 'Le sei ricette', icona: 'laboratorio', testo: 'Poche, potenti e unite: scegli una locandina' }, { id: 'personaggio', nome: 'Personaggio', icona: 'ritratto', testo: 'Una foto, tutte le pose, le espressioni, le età' }, { id: 'look', nome: 'Vestiti e look', icona: 'vestiti', testo: 'Armadio, acconciature, ruoli e costumi' }, { id: 'camera', nome: 'Camera e angoli', icona: 'camera', testo: 'Giri attorno al soggetto, inquadrature, viste' }, @@ -84,6 +85,7 @@ export const CATEGORIE_WF: { id: string; nome: string; icona: string; testo: str export { WORKFLOW_BASE } from './ricetteBase' import { E, righe, WORKFLOW_BASE } from './ricetteBase' +import { WORKFLOW_V2 } from './ricetteV2' /** il passo che nasce dal comando Camera di una ricetta (la posizione che hai scelto muovendo i cerchi) */ export const VISTA_TUA = 'La tua vista' @@ -139,10 +141,10 @@ export function inUtente(w: Workflow, id = 'u-' + crypto.randomUUID().slice(0, 8 /** tutte le ricette che si vedono: quelle di serie (meno le nascoste) e le tue */ export function elencoWorkflow(): Workflow[] { const { utente, nascoste } = usaRicette.getState() - return [...WORKFLOW_BASE.filter((w) => !nascoste.includes(w.id)), ...utente.map(daUtente)] + return [...WORKFLOW_V2.filter((w) => !nascoste.includes(w.id)), ...utente.map(daUtente)] } -export const workflowDa = (id: string): Workflow | undefined => elencoWorkflow().find((w) => w.id === id) || WORKFLOW_BASE.find((w) => w.id === id) +export const workflowDa = (id: string): Workflow | undefined => elencoWorkflow().find((w) => w.id === id) || WORKFLOW_V2.find((w) => w.id === id) || WORKFLOW_BASE.find((w) => w.id === id) /** il valore di partenza di ogni campo (gli esempi si scrivono da soli, si ritoccano) */ export function valoriIniziali(w: Workflow): Record { @@ -278,7 +280,7 @@ async function richiestaPasso(run: Run, i: number, baseCorrente: FotoBase | null const d = dimensioniRis(passo.crea.formato, Math.round((passo.crea.ris || 1024) * Math.sqrt(run.opzioni.mp))) return { ...comune, modalita: 'crea', prompt, larghezza: d.w, altezza: d.h, megapixel: (d.w * d.h) / 1048576, - immagini, seed: wf.id === 'formati' ? run.seed : -1 + immagini, seed: wf.id === 'formati' || wf.id === 'r-formati' ? run.seed : -1 } } return { diff --git a/src/shared/loraBase.ts b/src/shared/loraBase.ts new file mode 100644 index 0000000..13b7543 --- /dev/null +++ b/src/shared/loraBase.ts @@ -0,0 +1,48 @@ +// Da quali nomi dei tensori di un LoRA si capisce per quale modello è fatto. File puro (niente Electron): lo prova `node test/lora.mjs`. +// Qwen-Image 2.1 transformer_blocks.N.attn.to_q / img_mlp (flusso singolo) +// Qwen-Image 1.x txt_mlp, add_q_proj… (due flussi) +// FLUX 3 Klein single_blocks.N.(qkv|q_proj|attn_out|mlp_in|mlp_out), content_mode_blocks, txt_mode_blocks (ComfyUI li mappa: comfy/lora.py, ramo Flux3) +// FLUX.2 Klein 4B double_blocks.N.(img_attn|txt_attn|img_mlp|txt_mlp) + single_blocks.N.(linear1|linear2), 5 doppi blocchi e dimensione 3072 +// FLUX.1 / FLUX.2 grandi / SD / SDXL: non compatibili con nessuno dei modelli dell'app +import type { Famiglia, InfoLora } from './tipi' + +export interface AnalisiLora { + compatibile: InfoLora['compatibile'] + base?: string + /** per quali famiglie dell'app è buono (vuoto = nessuna / non si capisce) */ + famiglie: Famiglia[] +} + +const SEP = '[._]' +const blocco = (nome: string, resto: string): RegExp => new RegExp(`${nome}${SEP}\\d+${SEP}(?:${resto})`) + +/** `chiavi`: i nomi dei tensori; `forme`: (facoltativo) le forme, per distinguere FLUX.2 4B da 9B */ +export function analizzaChiavi(chiavi: string[], forme?: Record): AnalisiLora { + const tutte = chiavi.join('\n') + if (!chiavi.length) return { compatibile: '?', famiglie: [] } + if (/txt_mlp|add_q_proj|txt_mod|img_mod\./.test(tutte) && !/double_blocks/.test(tutte)) return { compatibile: 'no', base: 'Qwen-Image 1.x', famiglie: [] } + // FLUX 3 Klein: i blocchi «modali» sono solo suoi; `single_blocks` con qkv/attn_out/mlp_in/mlp_out pure (FLUX.1 e FLUX.2 usano linear1/linear2) + if (/(content_mode_blocks|txt_mode_blocks)/.test(tutte) || blocco('single_blocks', 'qkv|q_proj|k_proj|v_proj|attn_out|mlp_in|mlp_out').test(tutte)) { + return { compatibile: 'si', base: 'FLUX 3 Klein', famiglie: ['klein', 'klein-base'] } + } + if (/(?:double|single)_blocks/.test(tutte)) { + // FLUX.2 Klein 4B: 5 blocchi doppi e dimensione 3072; il resto (FLUX.1, FLUX.2 9B/dev) non è dell'app + const doppi = chiavi.map((k) => /double_blocks[._](\d+)/.exec(k)?.[1]).filter((x): x is string => x !== undefined).map(Number) + const maxDoppio = doppi.length ? Math.max(...doppi) : -1 + const chiaveIn = chiavi.find((k) => /double_blocks[._]0[._]img_attn[._]qkv.*(lora_A|lora_down)/.test(k)) + const dimIn = chiaveIn ? forme?.[chiaveIn]?.[1] : undefined + const flux2 = /linear1/.test(tutte) && /img_mlp|txt_mlp/.test(tutte) + if (maxDoppio >= 0 && maxDoppio <= 4 && (dimIn === undefined || dimIn === 3072) && flux2) return { compatibile: 'si', base: 'FLUX.2 Klein 4B', famiglie: ['klein4b'] } + return { compatibile: 'no', base: maxDoppio <= 4 ? 'FLUX.2' : 'FLUX.1 / FLUX.2 grande', famiglie: [] } + } + if (/lora_unet_|down_blocks|input_blocks|lora_te/.test(tutte)) return { compatibile: 'no', base: 'Stable Diffusion / SDXL', famiglie: [] } + if (/transformer_blocks\.\d+\.(attn\.to_q|img_mlp)/.test(tutte)) return { compatibile: 'si', base: 'Qwen-Image 2.1', famiglie: ['qwen'] } + return { compatibile: '?', famiglie: [] } +} + +/** un LoRA va bene con questo modello? 'si' (provato dai nomi), 'no', o '?' (non si capisce: si lascia provare) */ +export function vaBeneCon(l: { compatibile: InfoLora['compatibile']; famiglie?: Famiglia[] }, fam: Famiglia): 'si' | 'no' | '?' { + if (l.compatibile === '?') return '?' + if (!l.famiglie || !l.famiglie.length) return l.compatibile === 'si' ? 'si' : 'no' + return l.famiglie.includes(fam) ? 'si' : 'no' +} diff --git a/src/shared/modelli.ts b/src/shared/modelli.ts index ce62a7f..ce36b84 100644 --- a/src/shared/modelli.ts +++ b/src/shared/modelli.ts @@ -9,7 +9,7 @@ // I grafi di Qwen non cambiano: qwen-base manda la stessa richiesta di prima con la qualità «Alta». import type { Famiglia } from './tipi' -export type IdModello = 'qwen-base' | 'klein' | 'klein-base' +export type IdModello = 'qwen-base' | 'klein' | 'klein-base' | 'klein4b' export interface InfoModello { id: IdModello @@ -30,12 +30,13 @@ export interface InfoModello { export const MODELLI: Record = { 'qwen-base': { id: 'qwen-base', famiglia: 'qwen', turbo: false, passi: 40, cfg: null, nome: 'Qwen-Image 2.1', corto: 'Qwen', sotto: '40 passi · LoRA', lora: true, veloce: false }, - klein: { id: 'klein', famiglia: 'klein', turbo: false, passi: 8, cfg: 1, nome: 'FLUX 3 Klein', corto: 'Klein veloce', sotto: '8 passi · senza CFG · il più veloce', lora: false, veloce: true }, - 'klein-base': { id: 'klein-base', famiglia: 'klein-base', turbo: false, passi: 50, cfg: 3.5, nome: 'FLUX 3 Klein Base', corto: 'Klein Base', sotto: '50 passi · CFG 3,5 · la qualità piena', lora: false, veloce: false } + klein: { id: 'klein', famiglia: 'klein', turbo: false, passi: 8, cfg: 1, nome: 'FLUX 3 Klein', corto: 'Klein veloce', sotto: '8 passi · senza CFG · il più veloce', lora: true, veloce: true }, + 'klein-base': { id: 'klein-base', famiglia: 'klein-base', turbo: false, passi: 50, cfg: 3.5, nome: 'FLUX 3 Klein Base', corto: 'Klein Base', sotto: '50 passi · CFG 3,5 · la qualità piena', lora: true, veloce: false }, + klein4b: { id: 'klein4b', famiglia: 'klein4b', turbo: false, passi: 4, cfg: 1, nome: 'FLUX.2 Klein 4B', corto: 'Klein 4B', sotto: '4 passi · Apache-2.0 · uso commerciale libero', lora: true, veloce: true } } /** l'ordine delle pillole nell'interfaccia */ -export const ORDINE_MODELLI: IdModello[] = ['qwen-base', 'klein', 'klein-base'] +export const ORDINE_MODELLI: IdModello[] = ['qwen-base', 'klein', 'klein-base', 'klein4b'] export const MODELLO_PREDEFINITO: IdModello = 'qwen-base' export const eModello = (x: unknown): x is IdModello => typeof x === 'string' && Object.prototype.hasOwnProperty.call(MODELLI, x) @@ -44,6 +45,7 @@ export const eModello = (x: unknown): x is IdModello => typeof x === 'string' && export function modelloDa(famiglia?: unknown, _turbo?: boolean): IdModello { if (famiglia === 'klein') return 'klein' if (famiglia === 'klein-base') return 'klein-base' + if (famiglia === 'klein4b') return 'klein4b' return 'qwen-base' } @@ -69,6 +71,7 @@ export function allineaScelta(modifiche: { modelloScelto?: unknown; famiglia?: u * «FLUX 3 Klein Base · file», «FLUX 3 Klein · file» o il file del denoiser di Qwen (Turbo compreso, che ora vale Qwen base) */ export function modelloDaOpera(o: { modello?: string | null } | null | undefined): IdModello { const m = (o?.modello || '').trim() + if (/^FLUX\.2 Klein 4B\b/i.test(m)) return 'klein4b' if (/^FLUX 3 Klein Base\b/i.test(m)) return 'klein-base' if (/^FLUX 3 Klein\b/i.test(m)) return 'klein' return 'qwen-base' diff --git a/src/shared/tipi.ts b/src/shared/tipi.ts index 6a39c53..5b6925c 100644 --- a/src/shared/tipi.ts +++ b/src/shared/tipi.ts @@ -14,12 +14,53 @@ export type Modalita = | 'descrivi' // il modello di testo scrive (migliora prompt, descrive una foto, traduce) | 'traduci' // il prompt dall'italiano all'inglese con il traduttore dedicato (sul processore, senza Qwen) | 'importa' // (solo in galleria) una foto portata dentro da fuori + | 'sfondo' // toglie lo sfondo: PNG con trasparenza (BiRefNet), i pixel del soggetto restano quelli originali + | 'segmenta' // estrae da una foto la cosa indicata (riquadro o punti): SAM2 → PNG con trasparenza, pixel originali export interface LoraAttiva { file: string + /** −2…2 (0 = ignorato); l'ordine della lista è l'ordine in cui si applicano */ forza: number } +/** Un riquadro dell'Editor box: coordinate 0–1 sulla tela/foto (origine in alto a sinistra), [x0, y0, x1, y1]. */ +export type Box01 = [number, number, number, number] + +/** Una regione dell'Editor box, col suo prompt (regional prompting) */ +export interface Regione { + id: string + box: Box01 + prompt: string +} + +/** Un riquadro dell'Editor box della GUI v2: dove sta nella tela finale (frazioni 0–1) e cosa ci deve essere. + * Il motore lo trasforma in `Regione` (`adattaRichiesta`): la conversione al formato di Klein 3 o alle parole di Qwen la fa solo lui. */ +export interface BoxRegione { + id: string + /** x0, y0, x1, y1 normalizzati 0–1 sulla tela finale */ + rect: [number, number, number, number] + tipo: 'persona' | 'oggetto' | 'testo' | 'sfondo' + prompt: string + /** foto di riferimento di questo box (→ ): il motore non la usa ancora */ + rif?: string + /** guida = composizione suggerita · decisa = il modello resta lì dentro · blocca = maschera e compositing (per ora vale come decisa) */ + rigidita: 'guida' | 'decisa' | 'blocca' + ordine: number + /** l'occhio spento nell'editor: il riquadro resta ma non parte */ + nascosta?: boolean +} + +/** Lo sfondo di una creazione: la scena, trasparente (PNG con alfa) o una tinta unita (#rrggbb). */ +export type SfondoRichiesto = 'scena' | 'trasparente' | { tinta: string } + +/** Estrai fedele: cosa ritagliare con SAM2 (tutto in coordinate 0–1 sulla foto) */ +export interface OpzioniSegmenta { + box?: Box01 + punti?: { x: number; y: number; positivo: boolean }[] + /** true = il PNG è ritagliato attorno alla cosa (+ un margine); false = ha la misura della foto, con il resto trasparente */ + ritaglia?: boolean +} + export interface Bordi { sinistra: number sopra: number @@ -34,8 +75,8 @@ export interface Riquadro { h: number } -/** il modello che disegna: Qwen-Image 2.1, o FLUX 3 Klein (veloce, distillato) o FLUX 3 Klein Base (50 passi con CFG) */ -export type Famiglia = 'qwen' | 'klein' | 'klein-base' +/** il modello che disegna: Qwen-Image 2.1, FLUX 3 Klein (veloce, distillato), FLUX 3 Klein Base (50 passi con CFG) o FLUX.2 Klein 4B (Apache-2.0, 4 passi) */ +export type Famiglia = 'qwen' | 'klein' | 'klein-base' | 'klein4b' /** quello che distingue i modelli: passi per qualità, CFG e cosa sanno fare. Nomi e passi «alti» vengono da `modelli.ts` * (la mappa unica); «turbo» resta solo per le qualità salvate prima della 1.1 e vale come «alta» (`senzaTurbo`). */ @@ -46,7 +87,8 @@ const famiglia = (id: IdModello, bozza: number, massima: number): { nome: string export const FAMIGLIE: Record> = { qwen: famiglia('qwen-base', 20, 50), klein: famiglia('klein', 8, 16), - 'klein-base': famiglia('klein-base', 25, 64) + 'klein-base': famiglia('klein-base', 25, 64), + klein4b: famiglia('klein4b', 4, 8) } /** Quello che l'interfaccia chiede: il principale lo trasforma nel grafo di ComfyUI. */ @@ -94,6 +136,15 @@ export interface Richiesta { /** rifinisci/ingrandisci: di quanto ingrandire */ fattore?: number lora: LoraAttiva[] + /** Editor box (regional prompting): più riquadri, ognuno col suo prompt; `prompt` resta la descrizione di tutta la scena. Per 'crea' e 'modifica'. */ + regioni?: Regione[] + /** GUI v2, Editor box: i riquadri con il loro testo (→ `regioni`) e la scena generale (→ `prompt`, se ci sono riquadri validi) */ + box?: BoxRegione[] + promptScena?: string + /** GUI v2: lo sfondo richiesto ('trasparente' = come `trasparente`; { tinta } = una riga in più nel prompt) */ + sfondo?: SfondoRichiesto + /** 'segmenta': cosa estrarre */ + segmenta?: OpzioniSegmenta /** Turbo: LoRA di distillazione a 8 passi (CFG 1), circa 5 volte più veloce */ turbo?: boolean trasparente?: boolean @@ -190,6 +241,8 @@ export interface InfoLora { /** 'si' = Qwen-Image 2.1, 'no' = un altro modello, '?' = non si capisce */ compatibile: 'si' | 'no' | '?' base?: string + /** per quali modelli è fatto (da `analizza`: dai nomi dei tensori); vuoto = non si capisce */ + famiglie?: Famiglia[] aggiunta: number } @@ -255,6 +308,12 @@ export interface Impostazioni { testoCon4B: boolean /** secondi senza lavori dopo i quali i modelli escono dalla VRAM (0 = mai) */ scaricaDopo: number + /** interruttore «Modelli uncensored» (Opzioni): spento di serie. Con lui spento le voci `uncensored` del catalogo non si scaricano */ + uncensored: boolean + /** ha letto e accettato l'avviso sui modelli uncensored (serve anche questo per scaricarli) */ + uncensoredAccettato: boolean + /** i lavori di testo con Klein (migliora prompt, descrivi foto, Idee…) scrivono col Qwen3-VL 4B uncensored (voce `llm-huihui-4b`), se è scaricato; serve `uncensored` acceso */ + testoUncensored: boolean } /** RAM, VRAM e GPU per la barra in alto (byte; uso e temperatura in % e °C) */ @@ -283,7 +342,7 @@ export interface InfoMotore { /** Un file di modello che si può scaricare. */ export interface VoceCatalogo { id: string - tipo: 'diffusione' | 'encoder' | 'vae' | 'lora' | 'upscaler' | 'anteprima' | 'traduttore' | 'klein' | 'klein-te' | 'klein-vae' + tipo: 'diffusione' | 'encoder' | 'vae' | 'lora' | 'upscaler' | 'anteprima' | 'traduttore' | 'klein' | 'klein-te' | 'klein-vae' | 'sfondo' | 'segmentatore' | 'llm' nome: string descrizione: string file: string @@ -315,6 +374,17 @@ export interface VoceCatalogo { famiglia?: string /** non so ancora il nome vero del file: lo trovo al primo download */ daCercare?: boolean + /** il componente è «uncensored» (senza il rifiuto addestrato nei pesi): si scarica solo con l'interruttore acceso e l'avviso accettato */ + uncensored?: boolean + /** sigla della licenza dei pesi (es. 'Apache-2.0', 'Qwen Research', 'FLUX Kommunity', 'MIT') e dove si legge */ + licenza?: string + licenzaUrl?: string + /** a cosa serve nella pipeline: la UI lo mostra accanto al nome */ + ruolo?: 'denoiser' | 'encoder' | 'llm' | 'vae' | 'lora' | 'segmentatore' | 'sfondo' + /** una riga di avvertenza da mostrare prima del download (limiti d'uso, compatibilità) */ + avviso?: string + /** il repo e la revisione fissata (per riprodurre il download); senza revisione si usa `main` */ + revisione?: string } export interface StatoVoce extends VoceCatalogo { diff --git a/test/adatta.mjs b/test/adatta.mjs new file mode 100644 index 0000000..ae791fd --- /dev/null +++ b/test/adatta.mjs @@ -0,0 +1,40 @@ +// Test dell'adattatore GUI v2 → motore (src/main/motore/adatta.ts): box → regioni, sfondo → trasparente / tinta. +// +// Esecuzione: node test/adatta.mjs +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' + +const QUI = dirname(fileURLToPath(import.meta.url)) +const A = await import(pathToFileURL(join(QUI, '..', 'src', 'main', 'motore', 'adatta.ts')).href) + +let falliti = 0 +let fatti = 0 +function uguale(nome, avuto, atteso) { + fatti++ + const a = JSON.stringify(avuto) + const b = JSON.stringify(atteso) + if (a === b) return + falliti++ + console.log(`NO ${nome}\n avuto: ${a}\n atteso: ${b}`) +} +const q = { modalita: 'crea', prompt: 'old flat prompt', lora: [] } +const bx = (id, ordine, o = {}) => ({ id, rect: [0, 0, 0.5, 0.5], tipo: 'oggetto', prompt: 'p' + id, rigidita: 'decisa', ordine, ...o }) + +uguale('senza box né sfondo: invariata', A.adattaRichiesta(q), q) +const r = A.adattaRichiesta({ ...q, promptScena: 'a harbour at dusk', box: [bx('b', 2), bx('a', 1), bx('n', 0, { nascosta: true }), bx('v', 3, { prompt: ' ' })] }) +uguale('box → regioni nell\'ordine, senza nascoste né vuote', r.regioni.map((x) => x.id), ['a', 'b']) +uguale('box → riquadro e prompt', r.regioni[0], { id: 'a', box: [0, 0, 0.5, 0.5], prompt: 'pa' }) +uguale('la scena diventa il prompt', r.prompt, 'a harbour at dusk') +uguale('box senza promptScena: il prompt resta', A.adattaRichiesta({ ...q, box: [bx('a', 0)] }).prompt, 'old flat prompt') +uguale('tutti nascosti: prompt e regioni non cambiano', A.adattaRichiesta({ ...q, promptScena: 'x', box: [bx('a', 0, { nascosta: true })] }), { ...q, promptScena: 'x', box: [bx('a', 0, { nascosta: true })] }) +uguale('regioni già presenti si tengono', A.adattaRichiesta({ ...q, regioni: [{ id: 'z', box: [0, 0, 1, 1], prompt: 'zz' }], box: [bx('a', 0)] }).regioni.map((x) => x.id), ['z', 'a']) + +uguale('sfondo trasparente', A.adattaRichiesta({ ...q, sfondo: 'trasparente' }).trasparente, true) +uguale('sfondo scena: niente', A.adattaRichiesta({ ...q, sfondo: 'scena' }), { ...q, sfondo: 'scena' }) +uguale('tinta unita nel prompt', A.adattaRichiesta({ ...q, sfondo: { tinta: '#ff8800' } }).prompt, 'old flat prompt, on a plain solid #ff8800 background') +uguale('tinta senza #', A.adattaRichiesta({ ...q, sfondo: { tinta: 'ABC' } }).prompt, 'old flat prompt, on a plain solid #ABC background') +uguale('tinta non valida: ignorata', A.adattaRichiesta({ ...q, sfondo: { tinta: 'rosso' } }).prompt, 'old flat prompt') +uguale('tinta con punto finale nel prompt', A.adattaRichiesta({ ...q, prompt: 'a cat.', sfondo: { tinta: '#000' } }).prompt, 'a cat, on a plain solid #000 background') + +console.log(falliti ? `${falliti} FALLITI su ${fatti}` : `TUTTI OK (${fatti} controlli)`) +process.exit(falliti ? 1 : 0) diff --git a/test/box.mjs b/test/box.mjs new file mode 100644 index 0000000..543fda8 --- /dev/null +++ b/test/box.mjs @@ -0,0 +1,97 @@ +// Prova dell'Editor box (SENZA Electron né motore): disegna riquadri col mouse, scrivi i testi, scegli una disposizione, componi, +// e controlla la richiesta che arriva al motore (box, scena, prompt a parole, misura). +// npm run build, poi un server statico su out/renderer (porta 8765), poi: node test/box.mjs +import { chromium } from 'playwright' +import { readFileSync, mkdirSync } from 'node:fs' + +const url = process.argv[2] || 'http://localhost:8765/index.html' +const out = 'test/.out' +mkdirSync(out, { recursive: true }) +const b = await chromium.launch() +const pg = await b.newPage({ viewport: { width: 1480, height: 960 } }) +let errori = 0 +pg.on('pageerror', (e) => { errori++; console.log('[errore pagina]', e.message) }) +await pg.addInitScript(readFileSync('test/finto.js', 'utf8')) +await pg.goto(url) +const pausa = (ms) => pg.waitForTimeout(ms) +let male = 0 +const prova = (nome, ok, extra = '') => { console.log(ok ? 'OK ' : 'MALE', nome, extra); if (!ok) male++ } +await pausa(1200) + +await pg.click('.strip-modi button[data-modo="box"]') +await pausa(300) +prova('Box: la tela c\'è e non ci sono riquadri', (await pg.locator('[data-box-tela]').count()) === 1 && (await pg.locator('.box-regione').count()) === 0) + +// formato 16:9 per vedere la tela larga +await pg.click('.colonna.destra:visible button[data-formato="16:9"]') +await pausa(200) +const tela = await pg.locator('[data-box-tela]').boundingBox() +const trascina = async (fx0, fy0, fx1, fy1) => { + await pg.mouse.move(tela.x + tela.width * fx0, tela.y + tela.height * fy0) + await pg.mouse.down() + await pg.mouse.move(tela.x + tela.width * ((fx0 + fx1) / 2), tela.y + tela.height * ((fy0 + fy1) / 2), { steps: 4 }) + await pg.mouse.move(tela.x + tela.width * fx1, tela.y + tela.height * fy1, { steps: 4 }) + await pg.mouse.up() +} +await trascina(0.05, 0.1, 0.45, 0.9) +await trascina(0.55, 0.1, 0.95, 0.5) +prova('due riquadri disegnati', (await pg.locator('.box-regione:not(.disegno)').count()) === 2) + +// un clic senza trascinare non crea niente +await pg.mouse.click(tela.x + tela.width * 0.5, tela.y + tela.height * 0.95) +prova('un clic secco non crea un riquadro', (await pg.locator('.box-regione:not(.disegno)').count()) === 2) + +// il secondo: il testo (il clic secco ha deselezionato) +await pg.locator('.box-scheda .testa').nth(1).click() +await pg.locator('.box-scheda.sel textarea').fill('un cane che dorme') +await pg.locator('.box-scheda.sel button[data-id="tipo-oggetto"]').click() +// il primo +await pg.locator('.box-scheda .testa').first().click() +await pg.locator('.box-scheda.sel textarea').fill('una ragazza con un cappotto rosso') +await pg.locator('.box-scheda.sel button[data-id="tipo-persona"]').click() +prova('non propone un blocco dei pixel che il motore non supporta', (await pg.locator('[data-id="rigidita-blocca"]:enabled').count()) === 0) +await pg.fill('.colonna.destra:visible textarea >> nth=0', 'al tramonto, colori caldi') +await pg.screenshot({ path: `${out}/b1-box.png` }) + +await pg.click('button[data-azione="componi"]') +await pausa(500) +const q = await pg.evaluate(() => window.__ultima) +prova('richiesta: modalità crea con box', q.modalita === 'crea' && Array.isArray(q.box) && q.box.length === 2, JSON.stringify(q.box?.map((r) => [r.tipo, r.rigidita]))) +prova('richiesta: la scena', q.promptScena === 'al tramonto, colori caldi') +prova('richiesta: il prompt descrive i riquadri a parole', /ragazza con un cappotto rosso/.test(q.prompt) && /cane che dorme/.test(q.prompt) && /left side/.test(q.prompt) && /right side|top-right/.test(q.prompt), q.prompt.slice(0, 140)) +prova('richiesta: misura 16:9 coerente (multipli di 16)', q.larghezza % 16 === 0 && Math.abs(q.larghezza / q.altezza - 16 / 9) < 0.06, `${q.larghezza}×${q.altezza}`) +prova('richiesta: niente espansione del prompt', q.potenzia === false) + +// il lavoro è in corso: la tela mostra l'anteprima, e c'è il ritorno ai riquadri +prova("in corso: c'è «Torna ai riquadri»", (await pg.locator('button[data-azione="torna-riquadri"]').count()) === 1) +await pg.click('button[data-azione="torna-riquadri"]') +prova('tornati ai riquadri', (await pg.locator('.box-regione:not(.disegno)').count()) === 2) +await pg.screenshot({ path: `${out}/b2-box-in-corso.png` }) + +// disposizioni: tre colonne tiene i testi di prima +await pausa(300) +await pg.click('.strip-modi button[data-modo="box"]') +await pg.click('button[data-disposizione="tre"]') +await pausa(200) +prova('disposizione «tre colonne»: 3 riquadri', (await pg.locator('.box-regione:not(.disegno)').count()) >= 3) +await pg.screenshot({ path: `${out}/b3-box-tre.png` }) + +// riquadri senza testo: l'invio si ferma con un avviso +await pg.evaluate(() => { window.__ultimaPrima = window.__ultima }) +await pg.click('button[data-azione="componi"]') +await pausa(300) +prova('l\'avviso «ogni riquadro ha bisogno di un testo» compare', (await pg.locator('.avviso.errore:has-text("riquadro")').count()) >= 1) + +// i tasti: Canc toglie il selezionato +const prima = await pg.locator('.box-regione:not(.disegno)').count() +await pg.locator('.box-regione:not(.disegno)').first().click({ position: { x: 5, y: 5 } }) +await pg.keyboard.press('Delete') +prova('Canc elimina il riquadro selezionato', (await pg.locator('.box-regione:not(.disegno)').count()) === prima - 1) +await pg.click('.strip-modi button[data-modo="crea"]') +await pausa(200) +await pg.click('.strip-modi button[data-modo="box"]') +prova('i riquadri restano cambiando modo', (await pg.locator('.box-regione:not(.disegno)').count()) === prima - 1) + +await b.close() +console.log(male || errori ? `PROBLEMI: ${male} prove, ${errori} errori` : 'TUTTO BENE') +process.exit(male || errori ? 1 : 0) diff --git a/test/browser-prova.mjs b/test/browser-prova.mjs new file mode 100644 index 0000000..705c832 --- /dev/null +++ b/test/browser-prova.mjs @@ -0,0 +1,36 @@ +// Harness locale: serve SOLO la build, su una porta libera, e chiude Chromium/server anche se una prova fallisce. +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { resolve, extname, sep } from 'node:path' +import { chromium } from 'playwright' +export async function serverBuild() { + const root = resolve('out/renderer') + const tipi = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.webp': 'image/webp', '.png': 'image/png', '.woff2': 'font/woff2' } + const server = createServer(async (req, res) => { + try { + const file = resolve(root, '.' + decodeURIComponent(new URL(req.url, 'http://localhost').pathname)) + if (!file.startsWith(root + sep)) { res.writeHead(403).end(); return } + const data = await readFile(file) + res.writeHead(200, { 'Content-Type': tipi[extname(file)] || 'application/octet-stream' }).end(data) + } catch { res.writeHead(404).end() } + }) + await new Promise((ok) => server.listen(0, '127.0.0.1', ok)) + return { url: `http://127.0.0.1:${server.address().port}/index.html`, close: () => new Promise((ok) => server.close(ok)) } +} +export async function provaBrowser(fn, init) { + const server = await serverBuild() + let browser + try { + browser = await chromium.launch() + const pg = await browser.newPage({ viewport: { width: 1480, height: 1000 }, reducedMotion: 'reduce' }) + pg.setDefaultTimeout(8000) + const errori = [] + pg.on('pageerror', (e) => errori.push(e.message)) + await pg.addInitScript(await readFile('test/finto.js', 'utf8')) + if (init) await pg.addInitScript(init) + await pg.goto(server.url) + await pg.locator('.strip-modi').waitFor() + await fn(pg) + if (errori.length) throw Error('Errori pagina: ' + errori.join('\n')) + } finally { await browser?.close(); await server.close() } +} diff --git a/test/campioni-hf/AtomicChat__Qwen-Image-2.1-Turbo-GGUF.json b/test/campioni-hf/AtomicChat__Qwen-Image-2.1-Turbo-GGUF.json new file mode 100644 index 0000000..8d0cb4f --- /dev/null +++ b/test/campioni-hf/AtomicChat__Qwen-Image-2.1-Turbo-GGUF.json @@ -0,0 +1,88 @@ +{ + "repo": "AtomicChat/Qwen-Image-2.1-Turbo-GGUF", + "revisione": "main", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 2447 + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-AD-Q2_K.gguf", + "size": 2553333472, + "lfs": { + "size": 2553333472 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-AD-Q3_K.gguf", + "size": 3599812320, + "lfs": { + "size": 3599812320 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-AD-Q4_K.gguf", + "size": 4201694944, + "lfs": { + "size": 4201694944 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-AD-Q5_K.gguf", + "size": 5399168736, + "lfs": { + "size": 5399168736 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-AD-Q6_K.gguf", + "size": 6711985888, + "lfs": { + "size": 6711985888 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-BF16.gguf", + "size": 14230275808, + "lfs": { + "size": 14230275808 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-Q8_0.gguf", + "size": 7591554784, + "lfs": { + "size": 7591554784 + } + }, + { + "type": "file", + "path": "README.md", + "size": 11653 + }, + { + "type": "file", + "path": "quality-vs-size.png", + "size": 108564, + "lfs": { + "size": 108564 + } + }, + { + "type": "file", + "path": "samples-grid.png", + "size": 3587494, + "lfs": { + "size": 3587494 + } + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/AtomicChat__Qwen-Image-2.1-Turbo-Uncensored-GGUF.json b/test/campioni-hf/AtomicChat__Qwen-Image-2.1-Turbo-Uncensored-GGUF.json new file mode 100644 index 0000000..8b3cc4e --- /dev/null +++ b/test/campioni-hf/AtomicChat__Qwen-Image-2.1-Turbo-Uncensored-GGUF.json @@ -0,0 +1,88 @@ +{ + "repo": "AtomicChat/Qwen-Image-2.1-Turbo-Uncensored-GGUF", + "revisione": "main", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 3179 + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-Uncensored-AD-Q2_K.gguf", + "size": 3336289216, + "lfs": { + "size": 3336289216 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-Uncensored-AD-Q3_K.gguf", + "size": 4184140736, + "lfs": { + "size": 4184140736 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-Uncensored-AD-Q4_K.gguf", + "size": 5287517120, + "lfs": { + "size": 5287517120 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-Uncensored-AD-Q5_K.gguf", + "size": 6281292736, + "lfs": { + "size": 6281292736 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-Uncensored-AD-Q6_K.gguf", + "size": 7490550720, + "lfs": { + "size": 7490550720 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-Uncensored-BF16.gguf", + "size": 16388045536, + "lfs": { + "size": 16388045536 + } + }, + { + "type": "file", + "path": "Qwen-Image-2.1-Turbo-Uncensored-Q8_0.gguf", + "size": 8709520096, + "lfs": { + "size": 8709520096 + } + }, + { + "type": "file", + "path": "README.md", + "size": 12027 + }, + { + "type": "file", + "path": "mmproj-Qwen-Image-2.1-Turbo-Uncensored-BF16.gguf", + "size": 1162568928, + "lfs": { + "size": 1162568928 + } + }, + { + "type": "file", + "path": "mmproj-Qwen-Image-2.1-Turbo-Uncensored-F16.gguf", + "size": 1159029984, + "lfs": { + "size": 1159029984 + } + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/Comfy-Org__vae-text-encorder-for-flux-klein-4b.json b/test/campioni-hf/Comfy-Org__vae-text-encorder-for-flux-klein-4b.json new file mode 100644 index 0000000..77ac689 --- /dev/null +++ b/test/campioni-hf/Comfy-Org__vae-text-encorder-for-flux-klein-4b.json @@ -0,0 +1,56 @@ +{ + "repo": "Comfy-Org/vae-text-encorder-for-flux-klein-4b", + "revisione": "87cfba3d4b98662fb80f23921bb37bf887c97c58", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 1519 + }, + { + "type": "file", + "path": "README.md", + "size": 1737 + }, + { + "type": "file", + "path": "split_files/diffusion_models/flux-2-klein-4b.safetensors", + "size": 7751105712, + "lfs": { + "size": 7751105712 + } + }, + { + "type": "file", + "path": "split_files/diffusion_models/flux-2-klein-base-4b.safetensors", + "size": 7751105712, + "lfs": { + "size": 7751105712 + } + }, + { + "type": "file", + "path": "split_files/text_encoders/qwen_3_4b.safetensors", + "size": 8044982048, + "lfs": { + "size": 8044982048 + } + }, + { + "type": "file", + "path": "split_files/text_encoders/qwen_3_4b_fp4_flux2.safetensors", + "size": 3848213998, + "lfs": { + "size": 3848213998 + } + }, + { + "type": "file", + "path": "split_files/vae/flux2-vae.safetensors", + "size": 336211292, + "lfs": { + "size": 336211292 + } + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/Helsinki-NLP__opus-mt-it-en.json b/test/campioni-hf/Helsinki-NLP__opus-mt-it-en.json new file mode 100644 index 0000000..c1838d3 --- /dev/null +++ b/test/campioni-hf/Helsinki-NLP__opus-mt-it-en.json @@ -0,0 +1,62 @@ +{ + "repo": "Helsinki-NLP/opus-mt-it-en", + "revisione": "main", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 345 + }, + { + "type": "file", + "path": "README.md", + "size": 901 + }, + { + "type": "file", + "path": "config.json", + "size": 1381 + }, + { + "type": "file", + "path": "generation_config.json", + "size": 293 + }, + { + "type": "file", + "path": "pytorch_model.bin", + "size": 343642677, + "lfs": { + "size": 343642677 + } + }, + { + "type": "file", + "path": "source.spm", + "size": 813709 + }, + { + "type": "file", + "path": "target.spm", + "size": 789549 + }, + { + "type": "file", + "path": "tf_model.h5", + "size": 344197768, + "lfs": { + "size": 344197768 + } + }, + { + "type": "file", + "path": "tokenizer_config.json", + "size": 42 + }, + { + "type": "file", + "path": "vocab.json", + "size": 2369833 + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/ZhengPeng7__BiRefNet.json b/test/campioni-hf/ZhengPeng7__BiRefNet.json new file mode 100644 index 0000000..8f36d74 --- /dev/null +++ b/test/campioni-hf/ZhengPeng7__BiRefNet.json @@ -0,0 +1,54 @@ +{ + "repo": "ZhengPeng7/BiRefNet", + "revisione": "e2bf8e4460fc8fa32bba5ea4d94b3233d367b0e4", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 1519 + }, + { + "type": "file", + "path": ".gitignore", + "size": 1908 + }, + { + "type": "file", + "path": "BiRefNet_config.py", + "size": 298 + }, + { + "type": "file", + "path": "README.md", + "size": 9965 + }, + { + "type": "file", + "path": "birefnet.py", + "size": 91896 + }, + { + "type": "file", + "path": "config.json", + "size": 405 + }, + { + "type": "file", + "path": "handler.py", + "size": 4762 + }, + { + "type": "file", + "path": "model.safetensors", + "size": 444473596, + "lfs": { + "size": 444473596 + } + }, + { + "type": "file", + "path": "requirements.txt", + "size": 149 + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/ahmed22xa__Huihui-Qwen3-VL-4B-Instruct-abliterated-comfy.json b/test/campioni-hf/ahmed22xa__Huihui-Qwen3-VL-4B-Instruct-abliterated-comfy.json new file mode 100644 index 0000000..ea71d12 --- /dev/null +++ b/test/campioni-hf/ahmed22xa__Huihui-Qwen3-VL-4B-Instruct-abliterated-comfy.json @@ -0,0 +1,40 @@ +{ + "repo": "ahmed22xa/Huihui-Qwen3-VL-4B-Instruct-abliterated-comfy", + "revisione": "6d6fc98f9bfa783dfa4f143804525742cb5dad62", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 1519 + }, + { + "type": "file", + "path": "Huihui-Qwen3-VL-4B-Instruct-abliterated-fp8_scaled.safetensors", + "size": 5242481504, + "lfs": { + "size": 5242481504 + } + }, + { + "type": "file", + "path": "Huihui-Qwen3-VL-4B-Instruct-abliterated-int8_convrot.safetensors", + "size": 5064440625, + "lfs": { + "size": 5064440625 + } + }, + { + "type": "file", + "path": "Huihui-Qwen3-VL-4B-Instruct-abliterated.safetensors", + "size": 8875719408, + "lfs": { + "size": 8875719408 + } + }, + { + "type": "file", + "path": "README.md", + "size": 7839 + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/black-forest-labs__FLUX.2-klein-4b-fp8.json b/test/campioni-hf/black-forest-labs__FLUX.2-klein-4b-fp8.json new file mode 100644 index 0000000..dc6f7c5 --- /dev/null +++ b/test/campioni-hf/black-forest-labs__FLUX.2-klein-4b-fp8.json @@ -0,0 +1,53 @@ +{ + "repo": "black-forest-labs/FLUX.2-klein-4b-fp8", + "revisione": "5b4408e59397a4a37ccb46afe426d8ed86379441", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 1662 + }, + { + "type": "file", + "path": "LICENSE.md", + "size": 9584 + }, + { + "type": "file", + "path": "README.md", + "size": 7778 + }, + { + "type": "file", + "path": "editing.jpg", + "size": 2506178, + "lfs": { + "size": 2506178 + } + }, + { + "type": "file", + "path": "flux-2-klein-4b-fp8.safetensors", + "size": 4070624520, + "lfs": { + "size": 4070624520 + } + }, + { + "type": "file", + "path": "others.jpg", + "size": 3386946, + "lfs": { + "size": 3386946 + } + }, + { + "type": "file", + "path": "realism.jpg", + "size": 2855711, + "lfs": { + "size": 2855711 + } + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/black-forest-labs__flux-3-image-klein-comfy.json b/test/campioni-hf/black-forest-labs__flux-3-image-klein-comfy.json new file mode 100644 index 0000000..ef1167a --- /dev/null +++ b/test/campioni-hf/black-forest-labs__flux-3-image-klein-comfy.json @@ -0,0 +1,240 @@ +{ + "repo": "black-forest-labs/flux-3-image-klein-comfy", + "revisione": "feef60b0aaf63a4773ace96636ebbcf43b881312", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 1561 + }, + { + "type": "file", + "path": "LICENSE.md", + "size": 19593 + }, + { + "type": "file", + "path": "NOTICE.md", + "size": 330 + }, + { + "type": "file", + "path": "README.md", + "size": 12005 + }, + { + "type": "file", + "path": "diffusion_models/flux3-image-klein-base-bf16.safetensors", + "size": 11076957072, + "lfs": { + "size": 11076957072 + } + }, + { + "type": "file", + "path": "diffusion_models/flux3-image-klein-base-int8-convrot.safetensors", + "size": 5807427339, + "lfs": { + "size": 5807427339 + } + }, + { + "type": "file", + "path": "diffusion_models/flux3-image-klein-base-nvfp4.safetensors", + "size": 5257238192, + "lfs": { + "size": 5257238192 + } + }, + { + "type": "file", + "path": "diffusion_models/flux3-image-klein-bf16.safetensors", + "size": 11097404512, + "lfs": { + "size": 11097404512 + } + }, + { + "type": "file", + "path": "diffusion_models/flux3-image-klein-int8-convrot.safetensors", + "size": 5827874779, + "lfs": { + "size": 5827874779 + } + }, + { + "type": "file", + "path": "diffusion_models/flux3-image-klein-nvfp4.safetensors", + "size": 5277685632, + "lfs": { + "size": 5277685632 + } + }, + { + "type": "file", + "path": "launch-gallery.jpg", + "size": 6537996, + "lfs": { + "size": 6537996 + } + }, + { + "type": "file", + "path": "text_encoders/qwen3.5_9b_flux3_prompt_upsampler_bf16.safetensors", + "size": 18819722392, + "lfs": { + "size": 18819722392 + } + }, + { + "type": "file", + "path": "text_encoders/qwen3.5_9b_flux3_prompt_upsampler_int8_convrot.safetensors", + "size": 9748113996, + "lfs": { + "size": 9748113996 + } + }, + { + "type": "file", + "path": "text_encoders/qwen3.5_9b_flux3_prompt_upsampler_w4a8.safetensors", + "size": 6724090780, + "lfs": { + "size": 6724090780 + } + }, + { + "type": "file", + "path": "text_encoders/qwen3vl_4b_bf16.safetensors", + "size": 8875719408, + "lfs": { + "size": 8875719408 + } + }, + { + "type": "file", + "path": "text_encoders/qwen3vl_4b_int8_convrot.safetensors", + "size": 4858559240, + "lfs": { + "size": 4858559240 + } + }, + { + "type": "file", + "path": "vae/flux2-vae.safetensors", + "size": 336211292, + "lfs": { + "size": 336211292 + } + }, + { + "type": "file", + "path": "workflows/LICENSE.upstream", + "size": 1074 + }, + { + "type": "file", + "path": "workflows/README.md", + "size": 1433 + }, + { + "type": "file", + "path": "workflows/flux3_klein_base_bf16_i2i.json", + "size": 109041 + }, + { + "type": "file", + "path": "workflows/flux3_klein_base_bf16_mi2i.json", + "size": 116057 + }, + { + "type": "file", + "path": "workflows/flux3_klein_base_bf16_t2i.json", + "size": 84832 + }, + { + "type": "file", + "path": "workflows/flux3_klein_base_int8_i2i.json", + "size": 109227 + }, + { + "type": "file", + "path": "workflows/flux3_klein_base_int8_mi2i.json", + "size": 116243 + }, + { + "type": "file", + "path": "workflows/flux3_klein_base_int8_t2i.json", + "size": 85018 + }, + { + "type": "file", + "path": "workflows/flux3_klein_bf16_i2i.json", + "size": 107949 + }, + { + "type": "file", + "path": "workflows/flux3_klein_bf16_mi2i.json", + "size": 114965 + }, + { + "type": "file", + "path": "workflows/flux3_klein_bf16_t2i.json", + "size": 83740 + }, + { + "type": "file", + "path": "workflows/flux3_klein_int8_i2i.json", + "size": 108135 + }, + { + "type": "file", + "path": "workflows/flux3_klein_int8_mi2i.json", + "size": 115151 + }, + { + "type": "file", + "path": "workflows/flux3_klein_int8_t2i.json", + "size": 83926 + }, + { + "type": "file", + "path": "workflows/klein_ref_autumn_forest.jpg", + "size": 1213947, + "lfs": { + "size": 1213947 + } + }, + { + "type": "file", + "path": "workflows/klein_ref_great_wave.jpg", + "size": 1348397, + "lfs": { + "size": 1348397 + } + }, + { + "type": "file", + "path": "workflows/klein_ref_green_doors.jpg", + "size": 1283633, + "lfs": { + "size": 1283633 + } + }, + { + "type": "file", + "path": "workflows/klein_ref_toast.jpg", + "size": 1166604, + "lfs": { + "size": 1166604 + } + }, + { + "type": "file", + "path": "workflows/klein_ref_woman_hat.jpg", + "size": 589087, + "lfs": { + "size": 589087 + } + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/facebook__sam2.1-hiera-small.json b/test/campioni-hf/facebook__sam2.1-hiera-small.json new file mode 100644 index 0000000..53e87c9 --- /dev/null +++ b/test/campioni-hf/facebook__sam2.1-hiera-small.json @@ -0,0 +1,57 @@ +{ + "repo": "facebook/sam2.1-hiera-small", + "revisione": "ee5bba1d82bb8749febdf90f45e84b687142ba03", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 1519 + }, + { + "type": "file", + "path": "README.md", + "size": 19974 + }, + { + "type": "file", + "path": "config.json", + "size": 5698 + }, + { + "type": "file", + "path": "model.safetensors", + "size": 184305280, + "lfs": { + "size": 184305280 + } + }, + { + "type": "file", + "path": "preprocessor_config.json", + "size": 683 + }, + { + "type": "file", + "path": "processor_config.json", + "size": 95 + }, + { + "type": "file", + "path": "sam2.1_hiera_s.yaml", + "size": 3761 + }, + { + "type": "file", + "path": "sam2.1_hiera_small.pt", + "size": 184416285, + "lfs": { + "size": 184416285 + } + }, + { + "type": "file", + "path": "video_preprocessor_config.json", + "size": 705 + } + ] +} \ No newline at end of file diff --git a/test/campioni-hf/mradermacher__Qwen3-VL-8B-Instruct-Uncensored-V2-GGUF.json b/test/campioni-hf/mradermacher__Qwen3-VL-8B-Instruct-Uncensored-V2-GGUF.json new file mode 100644 index 0000000..3c2f6e7 --- /dev/null +++ b/test/campioni-hf/mradermacher__Qwen3-VL-8B-Instruct-Uncensored-V2-GGUF.json @@ -0,0 +1,128 @@ +{ + "repo": "mradermacher/Qwen3-VL-8B-Instruct-Uncensored-V2-GGUF", + "revisione": "main", + "file": [ + { + "type": "file", + "path": ".gitattributes", + "size": 2681 + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.IQ4_XS.gguf", + "size": 4593298272, + "lfs": { + "size": 4593298272 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q2_K.gguf", + "size": 3281734496, + "lfs": { + "size": 3281734496 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q3_K_L.gguf", + "size": 4431395680, + "lfs": { + "size": 4431395680 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q3_K_M.gguf", + "size": 4124162912, + "lfs": { + "size": 4124162912 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q3_K_S.gguf", + "size": 3769613152, + "lfs": { + "size": 3769613152 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q4_K_M.gguf", + "size": 5027785568, + "lfs": { + "size": 5027785568 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q4_K_S.gguf", + "size": 4802014048, + "lfs": { + "size": 4802014048 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q5_K_M.gguf", + "size": 5851114336, + "lfs": { + "size": 5851114336 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q5_K_S.gguf", + "size": 5720763232, + "lfs": { + "size": 5720763232 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q6_K.gguf", + "size": 6725901152, + "lfs": { + "size": 6725901152 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.Q8_0.gguf", + "size": 8709520224, + "lfs": { + "size": 8709520224 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.f16.gguf", + "size": 16388045664, + "lfs": { + "size": 16388045664 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.mmproj-Q8_0.gguf", + "size": 752290016, + "lfs": { + "size": 752290016 + } + }, + { + "type": "file", + "path": "Qwen3-VL-8B-Instruct-Uncensored-V2.mmproj-f16.gguf", + "size": 1159030112, + "lfs": { + "size": 1159030112 + } + }, + { + "type": "file", + "path": "README.md", + "size": 4653 + } + ] +} \ No newline at end of file diff --git a/test/catalogo.mjs b/test/catalogo.mjs new file mode 100644 index 0000000..814570a --- /dev/null +++ b/test/catalogo.mjs @@ -0,0 +1,123 @@ +// Test del catalogo (src/main/installa/catalogo.ts + scegli.ts): niente Electron, niente rete (gli alberi dei repo sono in test/campioni-hf/). +// - ogni voce con `repo` trova il suo file nell'albero vero (percorso esatto per Klein, FLUX.2 Klein 4B, BiRefNet, SAM2, LLM uncensored) +// - le voci uncensored hanno il flag, l'avviso e la guardia di scaricamento funziona +// - ogni voce ha licenza e ruolo; i modelli Qwen Research hanno l'avviso; nessuna voce uncensored è `consigliato` (non si scarica mai da sola) +// +// Esecuzione: node test/catalogo.mjs +import { readFileSync, readdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const QUI = dirname(fileURLToPath(import.meta.url)) +const { CATALOGO, motivoDiBlocco, motivoLicenza, AVVISO_UNCENSORED } = await import(pathToFileURL(join(QUI, '..', 'src', 'main', 'installa', 'catalogo.ts')).href) +const { scegli, tronco, TIPI_SAFETENSORS } = await import(pathToFileURL(join(QUI, '..', 'src', 'main', 'installa', 'scegli.ts')).href) + +let falliti = 0 +let fatti = 0 +function uguale(nome, avuto, atteso) { + fatti++ + const a = JSON.stringify(avuto) + const b = JSON.stringify(atteso) + if (a === b) return + falliti++ + console.log(`NO ${nome}\n avuto: ${a}\n atteso: ${b}`) +} + +const alberi = {} +for (const f of readdirSync(join(QUI, 'campioni-hf'))) { + const j = JSON.parse(readFileSync(join(QUI, 'campioni-hf', f), 'utf8')) + alberi[j.repo] = j +} +const trova = (v) => { + const albero = alberi[v.repo] + if (!albero) return `(manca l'albero di ${v.repo})` + const pesi = TIPI_SAFETENSORS.has(v.tipo) ? /\.(safetensors|bin)$/i : /\.gguf$/i + const f = scegli(albero.file, v.cerca, true, pesi) + return f ? f.path : null +} +const voce = (id) => CATALOGO.find((v) => v.id === id) + +// Klein 3: percorsi esatti dell'albero del 22/10/2026 (e non il ripiego largo) +uguale('klein-int8', trova(voce('klein-int8')), 'diffusion_models/flux3-image-klein-int8-convrot.safetensors') +uguale('klein-bf16', trova(voce('klein-bf16')), 'diffusion_models/flux3-image-klein-bf16.safetensors') +uguale('klein-base-int8', trova(voce('klein-base-int8')), 'diffusion_models/flux3-image-klein-base-int8-convrot.safetensors') +uguale('klein-base-bf16', trova(voce('klein-base-bf16')), 'diffusion_models/flux3-image-klein-base-bf16.safetensors') +uguale('klein-te (non il PUP)', trova(voce('klein-te')), 'text_encoders/qwen3vl_4b_int8_convrot.safetensors') +uguale('klein-vae', trova(voce('klein-vae')), 'vae/flux2-vae.safetensors') +// i ripieghi larghi, da soli, non devono mai prendere l'upsampler (qwen3.5 9b) né il Base per il veloce +for (const id of ['klein-int8', 'klein-bf16']) { + const v = voce(id) + const larga = scegli(alberi[v.repo].file, [v.cerca[v.cerca.length - 1]], true, /\.(safetensors|bin)$/i) + uguale(`ripiego largo di ${id}: non Base, non upsampler`, !!larga && !/base|upsampl/i.test(larga.path), true) +} +for (const id of ['klein-base-int8', 'klein-base-bf16']) { + const v = voce(id) + const larga = scegli(alberi[v.repo].file, [v.cerca[v.cerca.length - 1]], true, /\.(safetensors|bin)$/i) + uguale(`ripiego largo di ${id}: è Base`, !!larga && /base/i.test(larga.path), true) +} +// i nomi vecchi (flux3_image_klein_sd_int8_convrot) restano riconosciuti +{ + const v = voce('klein-int8') + const vecchi = [{ type: 'file', path: 'flux3_image_klein_sd_int8_convrot.safetensors' }, { type: 'file', path: 'flux3_image_klein_base_int8_convrot.safetensors' }] + uguale('nome vecchio klein-int8', scegli(vecchi, v.cerca, true, /\.safetensors$/i)?.path, 'flux3_image_klein_sd_int8_convrot.safetensors') + uguale('nome vecchio klein-base-int8', scegli(vecchi, voce('klein-base-int8').cerca, true, /\.safetensors$/i)?.path, 'flux3_image_klein_base_int8_convrot.safetensors') +} + +// nuove voci +uguale('FLUX.2 Klein 4B fp8', trova(voce('klein4b-fp8')), 'flux-2-klein-4b-fp8.safetensors') +uguale('FLUX.2 Klein 4B encoder', trova(voce('klein4b-te')), 'split_files/text_encoders/qwen_3_4b.safetensors') +uguale('BiRefNet', trova(voce('sfondo-birefnet')), 'model.safetensors') +uguale('SAM2 small', trova(voce('seg-sam2')), 'model.safetensors') +uguale('LLM uncensored 4B', trova(voce('llm-huihui-4b')), 'Huihui-Qwen3-VL-4B-Instruct-abliterated-int8_convrot.safetensors') +// i file accanto (config, tokenizer) ci sono nell'albero +for (const id of ['sfondo-birefnet', 'seg-sam2']) { + const v = voce(id) + const nomi = alberi[v.repo].file.map((f) => tronco(f.path)) + for (const re of v.accanto) uguale(`${id}: accanto ${re}`, nomi.some((n) => new RegExp(re, 'i').test(n)), true) +} +// i repo con revisione fissata: l'albero è stato salvato a quella revisione +for (const v of CATALOGO.filter((x) => x.repo && x.revisione)) uguale(`revisione di ${v.id}`, alberi[v.repo].revisione, v.revisione) +for (const id of ['klein-int8', 'klein-te', 'klein4b-fp8', 'klein4b-te', 'sfondo-birefnet', 'seg-sam2', 'llm-huihui-4b']) uguale(`${id} ha una revisione fissata`, /^[0-9a-f]{40}$/.test(voce(id).revisione || ''), true) + +// uncensored: flag, avviso e guardia +const unc = CATALOGO.filter((v) => v.uncensored).map((v) => v.id).sort() +uguale('le voci uncensored', unc, ['llm-huihui-4b', 'lora-uc', 'te-ac-q4', 'te-ac-q6', 'te-ac-q8', 'te-mr-q4', 'te-mr-q6', 'te-mr-q8']) +for (const v of CATALOGO.filter((x) => x.uncensored)) { + uguale(`${v.id}: avviso`, (v.avviso || "").endsWith(AVVISO_UNCENSORED), true) + uguale(`${v.id}: non consigliato (mai scaricato da solo)`, !!v.consigliato, false) + uguale(`${v.id}: guardia con interruttore spento`, /Modelli uncensored/.test(motivoDiBlocco(v, { uncensored: false, uncensoredAccettato: true }) || ''), true) + uguale(`${v.id}: guardia senza accettazione`, /accetta/.test(motivoDiBlocco(v, { uncensored: true, uncensoredAccettato: false }) || ''), true) + uguale(`${v.id}: via libera con tutto acceso`, motivoDiBlocco(v, { uncensored: true, uncensoredAccettato: true }), null) +} +// il predefinito di fabbrica è tutto spento +uguale('guardia con impostazioni vuote', !!motivoDiBlocco(voce('te-ac-q4'), {}), true) +// le voci normali non sono mai bloccate +for (const v of CATALOGO.filter((x) => !x.uncensored)) uguale(`${v.id}: mai bloccata`, motivoDiBlocco(v, { uncensored: false, uncensoredAccettato: false }), null) +// niente voce uncensored fra i denoiser di Klein (licenza Kommunity §4(d): niente scorciatoie per togliere le protezioni) +uguale('nessun denoiser Klein uncensored', CATALOGO.filter((v) => (v.tipo === 'klein' || v.tipo === 'diffusione') && v.uncensored).length, 0) + +// licenza e ruolo su tutte le voci dei modelli (non sui LoRA di terzi senza dato) +for (const v of CATALOGO.filter((x) => ['diffusione', 'klein', 'klein-te', 'klein-vae', 'sfondo', 'segmentatore', 'llm'].includes(x.tipo))) { + uguale(`${v.id}: licenza`, typeof v.licenza === 'string' && v.licenza.length > 0, true) + uguale(`${v.id}: ruolo`, typeof v.ruolo === 'string', true) +} +for (const v of CATALOGO.filter((x) => x.licenza === 'Qwen Research' && x.ruolo === 'denoiser')) uguale(`${v.id}: avviso licenza Qwen`, /Qwen Research/.test(v.avviso || ''), true) +for (const v of CATALOGO.filter((x) => x.licenza === 'FLUX Kommunity')) uguale(`${v.id}: avviso Kommunity`, /Kommunity/.test(v.avviso || ''), true) +// id unici +uguale('id unici', new Set(CATALOGO.map((v) => v.id)).size, CATALOGO.length) + +// licenza valida: tutte le voci uncensored del catalogo la hanno; una senza licenza, con Kommunity o con «non-commercial» è rifiutata anche a interruttori accesi +const TUTTO = { uncensored: true, uncensoredAccettato: true } +for (const v of CATALOGO.filter((x) => x.uncensored)) uguale(`${v.id}: licenza valida`, motivoLicenza(v), null) +const finta = { id: 'x', nome: 'Finto', uncensored: true, licenza: 'Apache-2.0', licenzaUrl: 'https://x' } +uguale('licenza Apache ok', motivoDiBlocco(finta, TUTTO), null) +uguale('senza licenza → no', /licenza dichiarata/.test(motivoDiBlocco({ ...finta, licenza: undefined }, TUTTO) || ''), true) +uguale('senza link alla licenza → no', /licenza dichiarata/.test(motivoDiBlocco({ ...finta, licenzaUrl: undefined }, TUTTO) || ''), true) +uguale('FLUX Kommunity → no', /Kommunity/.test(motivoDiBlocco({ ...finta, licenza: 'FLUX Kommunity' }, TUTTO) || ''), true) +uguale('non-commercial → no', /non permette/.test(motivoDiBlocco({ ...finta, licenza: 'CC-BY-NC-4.0' }, TUTTO) || ''), true) +uguale('Qwen Research senza avviso → no', /avviso/.test(motivoDiBlocco({ ...finta, licenza: 'Qwen Research' }, TUTTO) || ''), true) +uguale('Qwen Research con avviso → ok', motivoDiBlocco({ ...finta, licenza: 'Qwen Research', avviso: 'licenza «Qwen Research»: solo ricerca' }, TUTTO), null) +uguale('voce non uncensored: la licenza non blocca', motivoDiBlocco({ ...finta, uncensored: false, licenza: 'FLUX Kommunity' }, {}), null) + +console.log(falliti ? `\n${falliti} su ${fatti} controlli falliti` : `ok: ${fatti} controlli`) +process.exit(falliti ? 1 : 0) diff --git a/test/confronta_grafi.mjs b/test/confronta_grafi.mjs index 01a2f59..6399ff4 100644 --- a/test/confronta_grafi.mjs +++ b/test/confronta_grafi.mjs @@ -5,7 +5,7 @@ // // Esecuzione: node --experimental-strip-types test/confronta_grafi.mjs import { join, dirname } from 'node:path' -import { writeFileSync, mkdirSync } from 'node:fs' +import { writeFileSync, mkdirSync, copyFileSync } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' import { execFileSync } from 'node:child_process' import { tmpdir } from 'node:os' @@ -23,6 +23,8 @@ function normalizza(sorgente) { "constructor(readonly memoria: Memoria = 'normale') {}", "memoria: Memoria = 'normale'\n constructor(memoria: Memoria = 'normale') { this.memoria = memoria }" ) + // il regional prompting (regioni.ts) sta in un file a parte: si copia accanto e si importa con l'estensione + s = s.replace("from './regioni'", "from './regioni.ts'") return s } @@ -36,6 +38,7 @@ function estrai(commit) { return percorso } +copyFileSync(join(REPO, 'src', 'main', 'motore', 'regioni.ts'), join(TEMP, 'regioni.ts')) const moduloVecchio = await import(pathToFileURL(estrai(OLD_COMMIT)).href) const moduloNuovo = await import(pathToFileURL(estrai('HEAD')).href) diff --git a/test/controlli.mjs b/test/controlli.mjs new file mode 100644 index 0000000..15ce53d --- /dev/null +++ b/test/controlli.mjs @@ -0,0 +1,101 @@ +// Prova del catalogo dei controlli di Modifica (controlli.ts) e dei 28 stili: ogni valore produce una frase non vuota, in inglese, +// con al massimo una foto di riferimento; i valori stanno nell'id dell'idea e tornano uguali; i controlli spenti non producono nulla. +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { mkdirSync } from 'node:fs' +mkdirSync('test/.out', { recursive: true }) +buildSync({ + stdin: { contents: "export * from './src/renderer/src/controlli'; export * from './src/renderer/src/stili'; export { CATEGORIE } from './src/renderer/src/rapide'", resolveDir: process.cwd(), loader: 'ts' }, + bundle: true, platform: 'node', format: 'cjs', alias: { '@shared': resolve('src/shared') }, outfile: 'test/.out/controlli.cjs' +}) +const m = createRequire(import.meta.url)('./.out/controlli.cjs') +const { CONTROLLI, controlliDi, ideaDa, valoreDa, conControllo, scriviMulti, leggiMulti, STILI, SCAFFALI, CATEGORIE } = m + +// ---- ogni controllo, ogni valore +let frasi = 0 +const buona = (f, dove) => { + assert.ok(f && f.trim().length > 12, `${dove}: frase troppo corta`) + assert.ok((f.match(//g) || []).length <= 1, `${dove}: più di una foto di riferimento`) + assert.ok(!/[àèéìòù]/i.test(f), `${dove}: la frase deve essere in inglese`) + frasi++ +} +const idCategorie = new Set(CATEGORIE.map((c) => c.id)) +for (const c of CONTROLLI) { + assert.ok(idCategorie.has(c.categoria), `${c.id}: categoria ${c.categoria} che non esiste`) + assert.ok(c.nome && c.tooltip && c.icona, `${c.id}: manca nome, tooltip o icona`) + if (c.tipo === 'cursore') { + assert.equal(c.compila(c.neutro), null, `${c.id}: il neutro deve essere spento`) + for (let v = c.min; v <= c.max; v++) if (v !== c.neutro) buona(c.compila(v)?.frase, `${c.id}=${v}`) + if (c.etichette) assert.equal(c.etichette.length, c.max - c.min + 1, `${c.id}: un'etichetta per ogni valore`) + } else { + assert.equal(c.compila({}), null, `${c.id}: vuoto = spento`) + const prima = c.parti[0] + if (prima.tipo === 'testo') buona(c.compila({ [prima.id]: 'CIAO' })?.frase, c.id) + for (const parte of c.parti) { + if (parte.tipo === 'testo') { + // il testo da solo basta (le altre righe sono facoltative) + const base = Object.fromEntries(c.parti.filter((x) => x.tipo === 'testo').map((x) => [x.id, 'CIAO "bello"'])) + buona(c.compila(base)?.frase, `${c.id}/testo`) + assert.ok(/"bello"/.test(c.compila(base).frase), `${c.id}: i testi tra virgolette restano identici`) + continue + } + const vistiId = new Set() + for (const o of parte.opzioni) { + assert.ok(!vistiId.has(o.id), `${c.id}/${parte.id}: id doppio ${o.id}`) + vistiId.add(o.id) + assert.ok(o.nome && o.icona && o.frase, `${c.id}/${parte.id}/${o.id}: manca nome, icona o frase`) + // le righe che non bastano da sole (es. materiale senza capo) si provano con una base che le attiva + const base = c.id === 'capo' && parte.id !== 'capo' ? { capo: 'giacca' } : {} + const testi = c.parti.filter((x) => x.tipo === 'testo') + const extra = Object.fromEntries(testi.map((x) => [x.id, 'CIAO'])) + buona(c.compila({ ...base, ...extra, [parte.id]: o.id })?.frase, `${c.id}/${parte.id}/${o.id}`) + } + } + } +} + +// ---- il valore sta nell'id dell'idea e torna uguale +const eta = CONTROLLI.find((c) => c.id === 'eta') +const luce = CONTROLLI.find((c) => c.id === 'luce') +let idee = conControllo([{ id: 'altra' }], eta, '10') +assert.equal(idee.length, 2) +assert.equal(valoreDa(eta, idee), '10') +idee = conControllo(idee, eta, '-20') +assert.equal(idee.length, 2, 'cambiare il valore sostituisce, non aggiunge') +assert.equal(valoreDa(eta, idee), '-20') +assert.match(idee.find((r) => r.id.startsWith('ctl-eta:')).prompt, /younger/) +idee = conControllo(idee, eta, '0') +assert.equal(idee.length, 1, 'il neutro toglie l\'idea') +assert.equal(valoreDa(eta, idee), '0') +const v = { dir: 'tl', qualita: 'dura', colore: '#ec4899' } +idee = conControllo(idee, luce, scriviMulti(v)) +assert.deepEqual(leggiMulti(valoreDa(luce, idee)), v, 'i valori multipli tornano uguali') +assert.match(ideaDa(luce, scriviMulti(v)).prompt, /upper left.*hard direct light.*pink/s) +assert.equal(ideaDa(luce, ''), null) +// un testo con ; e = non deve rompere la lettura +const t = CONTROLLI.find((c) => c.id === 'titolo') +assert.equal(leggiMulti(scriviMulti({ testo: 'a;b=c', dove: 'c' })).testo, 'a;b=c') +assert.ok(controlliDi('luce').length >= 2 && controlliDi('persone').length >= 3) +assert.ok(ideaDa(t, scriviMulti({ testo: 'CIAO', dove: 't', come: 'meme' })).prompt.includes('"CIAO" across the top')) + +// ---- gli stili: 28, 4 scaffali da 7, id unici, frammenti in inglese e le tre intensità +assert.equal(STILI.length, 28) +assert.equal(new Set(STILI.map((s) => s.id)).size, 28, 'id degli stili doppi') +for (const sc of SCAFFALI) assert.equal(STILI.filter((s) => s.scaffale === sc.id).length, 7, `scaffale ${sc.id}`) +for (const s of STILI) { + assert.ok(s.frammento.length > 20 && s.nome && s.icona && s.fondo, `stile ${s.id} incompleto`) + for (const i of [0, 1, 2]) { buona(m.fraseStileCrea(s, i), `stile ${s.id} Crea ${i}`); buona(m.frasiStileModifica(s, i), `stile ${s.id} Modifica ${i}`) } +} +assert.notEqual(m.fraseStileCrea(STILI[0], 0), m.fraseStileCrea(STILI[0], 2), 'le intensità devono differire') +// Le scelte non sono più campi «…» da completare: tutte le categorie hanno controlli tipizzati. +for (const id of ['capelli', 'animale', 'costume', 'luogo', 'meteo', 'stagione', 'inquadratura', 'mood', 'grading']) assert.ok(CONTROLLI.some((c) => c.id === id), `manca il controllo ${id}`) +console.log(`OK: ${CONTROLLI.length} controlli, ${frasi} frasi controllate, 28 stili in 4 scaffali, valori nell'id che tornano uguali`) + +// LocalStorage rovinato e input fuori catalogo non devono produrre prompt NaN o bloccare la pagina. +assert.equal(ideaDa(eta, 'NaN'), null) +assert.equal(ideaDa(eta, '1000'), null) +assert.deepEqual(leggiMulti('testo=%ZZ;senzaSeparatore'), {}) +assert.equal(ideaDa(luce, 'dir=inesistente'), null) +assert.ok(ideaDa(t, scriviMulti({ testo: 'CIAO "MONDO"' })).prompt.includes('CIAO "MONDO"'), 'i testi da disegnare restano letterali') diff --git a/test/finto.js b/test/finto.js index 3655a81..183a723 100644 --- a/test/finto.js +++ b/test/finto.js @@ -33,7 +33,7 @@ impostazioni: { leggi: v(imp), salva: async (m) => Object.assign(imp, m) }, sistema: { controllo: v({}) }, setup: { cerca: v([]), stato: v({ installato: true, motore: true, modelli: [] }), avvia: v(true), annulla: v() }, - modelli: { stato: v([]), file: v({ diffusione: [imp.modello], encoder: [imp.encoder], vae: [imp.vae], upscaler: [imp.upscaler], klein: [], kleinTe: [], kleinVae: [] }), scarica: v({ stato: 'errore', errore: 'finto' }), annulla: v() }, + modelli: { stato: v([]), segmentatori: v([]), inutilizzati: v([]), elimina: v(), file: v({ diffusione: [imp.modello], encoder: [imp.encoder], vae: [imp.vae], upscaler: [imp.upscaler], klein: [], kleinTe: [], kleinVae: [], klein4b: [], klein4bTe: [] }), scarica: v({ stato: 'errore', errore: 'finto' }), annulla: v() }, motore: { info: v({ stato: 'pronto', porta: 8818, vramTotale: 6442450944, vramLibera: 1500000000, gpu: 'NVIDIA GeForce RTX 3060 Laptop GPU', versione: '0.39.0' }), avvia: v(), riavvia: v(), ferma: v(), log: v(['motore pronto']), libera: v(), apriComfy: v() }, lavori: { elenco: v([]), annulla: v(), svuota: v(), pulisci: v(), @@ -50,7 +50,7 @@ }, testo: { scrivi: async (q) => { window.__testo = q; return JSON.stringify([{bbox_2d:[300,380,640,880],label:'red-roofed brown wooden hut',kind:'object'},{bbox_2d:[780,180,940,340],label:'bright yellow sun',kind:'object'},{bbox_2d:[40,500,260,990],label:'woman in a green jacket, standing',kind:'person'}]) } }, galleria: { elenco: v(opere), opera: async (id) => opere.find((o) => o.id === id), importaFoto: v({}), preferita: v(), elimina: v(), mostra: v(), apriCartella: v(), copia: v(), esporta: v(), importa: v({ importate: [], altre: [] }), meta: v(null) }, - lora: { elenco: v([]), aggiorna: v(), importa: v([]), elimina: v(), scarica: v({}), apriCartella: v() }, + lora: { elenco: v([]), compatibili: v([]), aggiorna: v(), importa: v([]), elimina: v(), scarica: v({}), apriCartella: v() }, file: { scegliImmagini: v(['/foto.png']), scegli: v(null), scegliCartella: v(null), salvaTemp: async (dataUrl) => { const p = '/tmp/t' + ++n + '.png'; mappa[p] = dataUrl; return p }, diff --git a/test/foto.mjs b/test/foto.mjs index 2f01e7c..72a2c14 100644 --- a/test/foto.mjs +++ b/test/foto.mjs @@ -48,7 +48,7 @@ for (const [n, nome] of [['11-modifica', 'Modifica'], ['12-galleria', 'Galleria' } if (genera) { - await win.click('.rotaia button:has-text("Crea")') + await win.click('.strip-modi button[data-modo="crea"]') await win.fill('textarea', 'Una tazzina di caffè espresso su un tavolino di marmo in un bar di Napoli, mattina, luce morbida dalla vetrina, fotografia realistica, profondità di campo') await win.click('.segmenti button:has-text("Bozza")') await win.click('.formato:has-text("3:2")') diff --git a/test/grafi_v2.mjs b/test/grafi_v2.mjs new file mode 100644 index 0000000..cd6b086 --- /dev/null +++ b/test/grafi_v2.mjs @@ -0,0 +1,108 @@ +// Test dei grafi dell'engine v2 (src/main/motore/grafi.ts): FLUX.2 Klein 4B, LoRA multipli con pesi, senza sfondo, estrai fedele (SAM 2), riquadri nel prompt. +// grafi.ts importa altri file senza estensione: si impacchetta con esbuild in una cartella temporanea. +// +// Esecuzione: node test/grafi_v2.mjs +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { buildSync } from 'esbuild' + +const QUI = dirname(fileURLToPath(import.meta.url)) +const fuori = join(mkdtempSync(join(tmpdir(), 'daprod-grafi-')), 'grafi.mjs') +buildSync({ + entryPoints: [join(QUI, '..', 'src', 'main', 'motore', 'grafi.ts')], + bundle: true, + format: 'esm', + platform: 'node', + outfile: fuori, + alias: { '@shared': join(QUI, '..', 'src', 'shared') }, + logLevel: 'error' +}) +const G = await import(pathToFileURL(fuori).href) + +let falliti = 0 +let fatti = 0 +function uguale(nome, avuto, atteso) { + fatti++ + const a = JSON.stringify(avuto) + const b = JSON.stringify(atteso) + if (a === b) return + falliti++ + console.log(`NO ${nome}\n avuto: ${a}\n atteso: ${b}`) +} + +const imp = { + cartellaModelli: 'C:/models', cartellaGalleria: 'C:/gallery', + modello: 'qwen-image-2.1-UC-Q4_K_M.gguf', encoder: 'qwen3-vl-8b.safetensors', vae: 'ae.safetensors', upscaler: '4x.safetensors', + famiglia: 'qwen', kleinModello: 'klein4b_fp8.safetensors', kleinBase: '', kleinEncoder: 'qwen_3_4b.safetensors', kleinVae: 'flux2-vae.safetensors', + upscalerRtx: false, cacheKV: 'gpu', cacheTipo: 'default', encoderSuCpu: false, loraTurbo: '', memoria: 'auto', vramMB: 8192, traduci: false, modelloScelto: 'qwen-base' +} +const base = { + prompt: 'a red fox in a forest', negativo: '', seed: 7, passi: 4, cfg: 1, sampler: 'euler', scheduler: 'simple', megapixel: 1, + larghezza: 1024, altezza: 768, immagini: [], lora: [], quante: 1, trasparente: false +} +const tipi = (g) => Object.values(g.prompt).map((n) => n.class_type) +const nodi = (g, tipo) => Object.values(g.prompt).filter((n) => n.class_type === tipo) +const costruisci = (q, i = imp, ing = { immagini: [] }) => G.costruisci({ ...base, ...q }, i, ing, 'prova') + +// --- FLUX.2 Klein 4B: Crea --- +const k4 = costruisci({ modalita: 'crea', famiglia: 'klein4b' }) +uguale('4B: text encoder di tipo flux2', nodi(k4, 'CLIPLoader')[0].inputs.type, 'flux2') +uguale('4B: scaletta Flux2Scheduler con la misura', [nodi(k4, 'Flux2Scheduler')[0].inputs.steps, nodi(k4, 'Flux2Scheduler')[0].inputs.width, nodi(k4, 'Flux2Scheduler')[0].inputs.height], [4, 1024, 768]) +uguale('4B: CFGGuider a 1 + SamplerCustomAdvanced', [nodi(k4, 'CFGGuider')[0].inputs.cfg, nodi(k4, 'SamplerCustomAdvanced').length], [1, 1]) +uguale('4B: latente vuota Flux2', nodi(k4, 'EmptyFlux2LatentImage').length, 1) +uguale('4B: non c\'è la cache KV di Qwen', tipi(k4).includes('QwenImage21Cache'), false) +const k3 = costruisci({ modalita: 'crea', famiglia: 'klein', passi: 8 }) +uguale('Klein 3: text encoder flux3', nodi(k3, 'CLIPLoader')[0].inputs.type, 'flux3') + +// --- LoRA multipli con pesi: nell'ordine, forza 0 saltata, tetto a ±2 --- +const lora = [{ file: 'a.safetensors', forza: 0.6 }, { file: 'b.safetensors', forza: 0 }, { file: 'c.safetensors', forza: 5 }, { file: 'd.safetensors', forza: -0.5 }] +for (const fam of ['qwen', 'klein', 'klein-base', 'klein4b']) { + const g = costruisci({ modalita: 'crea', famiglia: fam, lora }) + uguale(`LoRA ${fam}: tre applicati (il 0 salta)`, nodi(g, 'LoraLoaderModelOnly').map((n) => [n.inputs.lora_name, n.inputs.strength_model]), [['a.safetensors', 0.6], ['c.safetensors', 2], ['d.safetensors', -0.5]]) +} +// in catena: ognuno prende il modello del precedente +{ + const g = costruisci({ modalita: 'crea', famiglia: 'klein4b', lora }) + const ls = Object.entries(g.prompt).filter(([, n]) => n.class_type === 'LoraLoaderModelOnly') + uguale('LoRA in catena', ls[1][1].inputs.model[0], ls[0][0]) +} +uguale('senza LoRA niente nodi LoRA', nodi(costruisci({ modalita: 'crea', famiglia: 'klein' }), 'LoraLoaderModelOnly').length, 0) + +// --- Senza sfondo --- +const ing1 = { immagini: [{ nome: 'foto.png', larghezza: 1000, altezza: 800 }] } +const sf = costruisci({ modalita: 'sfondo', immagini: ['x.png'] }, imp, ing1) +uguale('sfondo: LoadImage → DaProdSfondo → salva, nessun modello che disegna', tipi(sf).sort(), ['DaProdSfondo', 'LoadImage', 'SaveImage'].sort()) +uguale('sfondo: BiRefNet, colori dei bordi puliti', [nodi(sf, 'DaProdSfondo')[0].inputs.modello, nodi(sf, 'DaProdSfondo')[0].inputs.pulisci_bordi], ['birefnet', true]) +// Klein «senza sfondo» in Crea: BiRefNet dopo il VAE +const kt = costruisci({ modalita: 'crea', famiglia: 'klein', trasparente: true, passi: 8 }) +uguale('Klein trasparente: DaProdSfondo dopo il VAE', [tipi(kt).includes('DaProdSfondo'), tipi(kt).includes('SplitImageWithAlpha')], [true, false]) +const qt = costruisci({ modalita: 'crea', famiglia: 'qwen', trasparente: true, passi: 20 }) +uguale('Qwen trasparente: usa l\'alfa del modello, niente BiRefNet', tipi(qt).includes('DaProdSfondo'), false) + +// --- Estrai fedele (SAM 2) --- +const sg = costruisci({ modalita: 'segmenta', immagini: ['x.png'], segmenta: { box: [0.1, 0.2, 0.6, 0.9], punti: [{ x: 0.3, y: 0.4, positivo: true }] } }, imp, ing1) +uguale('segmenta: nodo SAM 2 con riquadro e punti', [nodi(sg, 'DaProdSegmenta').length, JSON.parse(nodi(sg, 'DaProdSegmenta')[0].inputs.box), JSON.parse(nodi(sg, 'DaProdSegmenta')[0].inputs.punti)], [1, [0.1, 0.2, 0.6, 0.9], [{ x: 0.3, y: 0.4, positivo: true }]]) +uguale('segmenta: ritaglia di serie', nodi(sg, 'DaProdSegmenta')[0].inputs.ritaglia, true) +let errore = '' +try { + costruisci({ modalita: 'segmenta', immagini: ['x.png'], segmenta: {} }, imp, ing1) +} catch (e) { + errore = e.message +} +uguale('segmenta: senza riquadro né punti → errore in italiano', /riquadro|punti/.test(errore), true) + +// --- Riquadri (regional prompting) --- +const regioni = [{ id: 'a', box: [0, 0, 0.5, 0.5], prompt: 'a red fox' }, { id: 'b', box: [0.5, 0.5, 1, 1], prompt: 'a blue door' }] +const testoDi = (g) => nodi(g, 'CLIPTextEncode').map((n) => n.inputs.text).concat(nodi(g, 'TextEncodeQwenImage21').map((n) => n.inputs.prompt))[0] || '' +const rk = costruisci({ modalita: 'crea', famiglia: 'klein', passi: 8, regioni }) +uguale('Klein 3 + riquadri: JSON con le coordinate', /"bbox":\[0,0,500,500\]/.test(testoDi(rk)), true) +const rq = costruisci({ modalita: 'crea', famiglia: 'qwen', passi: 20, regioni }) +uguale('Qwen + riquadri: a parole', /Compose the image with these elements/.test(testoDi(rq)) && !testoDi(rq).includes('bbox'), true) +const r4 = costruisci({ modalita: 'crea', famiglia: 'klein4b', regioni }) +uguale('Klein 4B + riquadri: a parole', /Compose the image with these elements/.test(testoDi(r4)), true) +uguale('senza riquadri il prompt è quello', testoDi(costruisci({ modalita: 'crea', famiglia: 'klein', passi: 8 })), base.prompt) + +console.log(falliti ? `${falliti} FALLITI su ${fatti}` : `TUTTI OK (${fatti} controlli)`) +process.exit(falliti ? 1 : 0) diff --git a/test/gui-v2-flussi.mjs b/test/gui-v2-flussi.mjs new file mode 100644 index 0000000..78e63fd --- /dev/null +++ b/test/gui-v2-flussi.mjs @@ -0,0 +1,52 @@ +// Regressioni GUI v2: controlli composti, scelte modello e nessun campo fittizio. +import assert from 'node:assert/strict' +import { provaBrowser } from './browser-prova.mjs' +const caso = process.argv[2] || 'controlli' +await provaBrowser(async (pg) => { + const pannello = pg.locator('.studio-corpo .colonna.destra:visible') + if (caso === 'box') { + await pg.click('[data-modo="box"]') + await pg.click('[data-disposizione="uno"]') + await pannello.locator('textarea').first().fill('una piazza al tramonto') + await pg.locator('.box-scheda.sel textarea').fill('CIAO MONDO') + await pg.click('[data-id="tipo-testo"]') + await pannello.locator('h3.chiudibile').filter({ hasText: 'Stile' }).click() + await pannello.locator('[data-id="stile-cyberpunk"]').click() + await pg.click('[data-azione="componi"]') + await pg.waitForFunction(() => !!window.__ultima) + const q = await pg.evaluate(() => window.__ultima) + assert.match(q.promptScena, /neon cityscape/i, 'stile conservato dopo adattaRichiesta') + assert.match(q.box[0].prompt, /text.*"CIAO MONDO"/, 'il tipo Testo arriva al motore con istruzioni letterali') + assert.equal(await pg.locator('[data-id="rigidita-blocca"]:enabled').count(), 0, 'nessun controllo blocca senza implementazione engine') + await pg.screenshot({ path: 'test/.out/v2-box-contratto.png' }) + return + } + await pg.click('[data-modo="modifica"]') + if (caso === 'modello') { + assert.equal(await pannello.locator('[data-id="modello-klein4b"]').count(), 1, 'Modifica deve permettere di cambiare modello') + assert.match(await pg.locator('.pillola-motore').first().innerText(), /Klein/, 'fixture usa Klein') + assert.equal(await pg.locator('.strip-modi button[title^="La pila"]').count(), 1, 'LoRA accessibili anche con Klein') + return + } + await pg.click('.attrezzi button:has-text("Dal PC")') + await pg.click('[data-id="cat-vestiti"]') + const capo = pannello.locator('[data-controllo="capo"]') + await capo.getByRole('button', { name: 'Rosa', exact: true }).click() + assert.equal(await capo.getByRole('button', { name: 'Rosa', exact: true }).getAttribute('aria-pressed'), 'true', 'il colore resta selezionato prima del capo') + await capo.locator('[data-id="ctl-capo-giacca"]').click() + await capo.locator('[data-id="ctl-capo-seta"]').click() + await pannello.getByRole('radio', { name: 'Fedele', exact: true }).click() + await pannello.locator('button.primario.grande').click() + await pg.waitForFunction(() => !!window.__ultima) + const q = await pg.evaluate(() => window.__ultima) + assert.match(q.prompt, /jacket.*pink silk/, 'la richiesta contiene capo, colore e materiale') + assert.equal(q.modalita, 'modifica') + await pg.click('[data-id="cat-testo"]') + const titolo = pannello.locator('[data-controllo="titolo"]') + await titolo.getByRole('button', { name: 'In alto', exact: true }).click() + assert.equal(await titolo.getByRole('button', { name: 'In alto', exact: true }).getAttribute('aria-pressed'), 'true', 'posizione persistente prima del testo') + await titolo.getByRole('textbox').fill('CIAO "MONDO"') + assert.equal(await titolo.getByRole('textbox').inputValue(), 'CIAO "MONDO"') + await pg.screenshot({ path: 'test/.out/v2-controlli.png' }) +}, () => { window.daprod.impostazioni.salva({ famiglia: 'klein', modelloScelto: 'klein' }) }) +console.log('OK: regressioni GUI v2 / ' + caso) diff --git a/test/gui.mjs b/test/gui.mjs new file mode 100644 index 0000000..5b2cd2c --- /dev/null +++ b/test/gui.mjs @@ -0,0 +1,21 @@ +// Foto veloci della GUI v2 (SENZA Electron né motore): Studio nei vari modi. +// npm run build, poi un server statico su out/renderer (porta 8765), poi: node test/gui.mjs +import { chromium } from 'playwright' +import { readFileSync, mkdirSync } from 'node:fs' + +const url = process.argv[2] || 'http://localhost:8765/index.html' +const out = 'test/.out' +mkdirSync(out, { recursive: true }) +const b = await chromium.launch() +const pg = await b.newPage({ viewport: { width: 1480, height: 1000 } }) +pg.on('pageerror', (e) => console.log('[errore pagina]', e.message)) +await pg.addInitScript(readFileSync('test/finto.js', 'utf8')) +await pg.goto(url) +const pausa = (ms) => pg.waitForTimeout(ms) +await pausa(1200) +await pg.click('button[data-id="genere-poster"]') +await pg.click('button[data-id="stile-synthwave"]') +await pg.click('button[data-id="sfondo-trasparente"]') +await pausa(300) +await pg.screenshot({ path: `${out}/g1-crea.png` }) +await b.close() diff --git a/test/interfaccia.mjs b/test/interfaccia.mjs index b6c3b67..064053e 100644 --- a/test/interfaccia.mjs +++ b/test/interfaccia.mjs @@ -16,16 +16,28 @@ const pausa = (ms) => pg.waitForTimeout(ms) const ultima = (campo) => pg.evaluate((c) => window.__ultima[c], campo) await pausa(1200) -await pg.click('.rotaia button:has-text("Modifica")') +await pg.click('.strip-modi button[data-modo="modifica"]') await pg.click('.attrezzi button:has-text("Dal PC")') await pausa(600) -await pg.fill('.colonna.destra textarea', 'sostituisci il cielo con un tramonto molto drammatico, nuvole rosse, luce dorata sulla casetta') +await pg.fill('.colonna.destra textarea:visible', 'sostituisci il cielo con un tramonto molto drammatico, nuvole rosse, luce dorata sulla casetta') await pg.screenshot({ path: `${out}/i1-casella-prompt.png` }) -await pg.click('.segmenti button:has-text("Estrai")') +await pg.click('button[data-id="modo-estrai"]') await pausa(1000) console.log('ricerca partita da sola?', await pg.evaluate(() => !!window.__testo), '(deve essere false)') await pg.screenshot({ path: `${out}/i2a-estrai-prima.png` }) +// Estrai in due clic (SAM 2): un clic sulla foto, poi «Ritaglia»; nessuna ricerca col modello di testo +{ + const fo = await pg.locator('.pers-foto').boundingBox() + await pg.mouse.click(fo.x + fo.width * 0.4, fo.y + fo.height * 0.5) + await pausa(300) + await pg.screenshot({ path: `${out}/i2b-estrai-punto.png` }) + await pg.click('button:has-text("Ritaglia")') + await pausa(500) + const q = await pg.evaluate(() => window.__ultima) + if (q.modalita !== 'segmenta' || !q.segmenta?.punti?.length || !q.segmenta.punti[0].positivo || q.segmenta.ritaglia !== true || q.immagini.length !== 1) throw new Error('ritaglio a due clic: richiesta sbagliata ' + JSON.stringify(q.segmenta)) + console.log('RITAGLIO a due clic: OK', JSON.stringify(q.segmenta)) +} await pg.click('button:has-text("Trova persone e oggetti")') await pausa(1200) console.log('ricerca col pulsante:', await pg.evaluate(() => window.__testo?.modalita), '(deve essere descrivi)') @@ -34,7 +46,7 @@ const f = await pg.locator('.pers-foto').boundingBox() await pg.mouse.click(f.x + f.width * 0.45, f.y + f.height * 0.7) await pausa(400) await pg.screenshot({ path: `${out}/i3-estrai-oggetto.png` }) -await pg.click('button:has-text("Estrai l\'oggetto")') +await pg.click('button:has-text("Ricrea l\'oggetto in studio")') await pausa(500) console.log('OGGETTO', await ultima('larghezza'), '×', await ultima('altezza'), '\n', await ultima('prompt')) @@ -42,7 +54,7 @@ await pg.mouse.click(f.x + f.width * 0.12, f.y + f.height * 0.85) await pg.click('.segmenti button:has-text("Disegnata")') await pausa(300) await pg.screenshot({ path: `${out}/i4-estrai-persona.png` }) -await pg.click('button:has-text("Estrai la persona")') +await pg.click('button:has-text("Ricrea la persona in studio")') await pausa(700) console.log('PERSONA', await ultima('larghezza'), '×', await ultima('altezza'), 'immagini:', (await pg.evaluate(() => window.__ultima.immagini.length)), '\n', await ultima('prompt')) await b.close() diff --git a/test/lora-ssr.mjs b/test/lora-ssr.mjs new file mode 100644 index 0000000..bbb8792 --- /dev/null +++ b/test/lora-ssr.mjs @@ -0,0 +1,25 @@ +// Rendering React lato server: niente browser, mouse, tastiera o motore. +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { mkdirSync } from 'node:fs' +const require = createRequire(import.meta.url) +const { renderToStaticMarkup } = require('react-dom/server') +const React = require('react') +// Zustand usa altrimenti lo snapshot iniziale nel rendering lato server. +React.useSyncExternalStore = (_subscribe, getSnapshot) => getSnapshot() +globalThis.window = { daprod: {} } +globalThis.localStorage = { getItem: () => null, setItem() {} } +mkdirSync('test/.out', { recursive: true }) +buildSync({ stdin: { contents: "export { Lora } from './src/renderer/src/pagine/Lora'; export { usaStato } from './src/renderer/src/stato'", resolveDir: process.cwd() }, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', external: ['react', 'react-dom'], alias: { '@shared': resolve('src/shared') }, outfile: 'test/.out/lora-ssr.cjs' }) +const { Lora, usaStato } = require('./.out/lora-ssr.cjs') +usaStato.setState({ lore: [{ file: 'test.safetensors', nome: 'Test Qwen', forza: 1, parole: '', dimensione: 1, compatibile: 'si', aggiunta: 0 }], loraAttive: [{ file: 'test.safetensors', forza: 0.5 }] }) +usaStato.getInitialState = usaStato.getState +const html = renderToStaticMarkup(React.createElement(Lora)) +assert.match(html, /Pila LoRA/, 'la pila ordinata è separata dalla libreria') +assert.match(html, /Sposta prima/) +assert.match(html, /Sposta dopo/) +assert.match(html, /Forza di Test Qwen/) +assert.match(html, /min="-2"/) +console.log('OK: gestore LoRA renderizzato con pila, ordine e pesi accessibili') diff --git a/test/lora.mjs b/test/lora.mjs new file mode 100644 index 0000000..235e8cd --- /dev/null +++ b/test/lora.mjs @@ -0,0 +1,50 @@ +// Test del riconoscimento dei LoRA dai nomi dei tensori (src/shared/loraBase.ts). +// +// Esecuzione: node test/lora.mjs +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' + +const QUI = dirname(fileURLToPath(import.meta.url)) +const L = await import(pathToFileURL(join(QUI, '..', 'src', 'shared', 'loraBase.ts')).href) + +let falliti = 0 +let fatti = 0 +function uguale(nome, avuto, atteso) { + fatti++ + const a = JSON.stringify(avuto) + const b = JSON.stringify(atteso) + if (a === b) return + falliti++ + console.log(`NO ${nome}\n avuto: ${a}\n atteso: ${b}`) +} +const ris = (c, f) => { + const a = L.analizzaChiavi(c, f) + return [a.compatibile, a.base, a.famiglie] +} + +uguale('vuoto', ris([]), ['?', undefined, []]) +uguale('Qwen 2.1', ris(['transformer_blocks.3.attn.to_q.lora_A.weight', 'transformer_blocks.3.img_mlp.net.0.proj.lora_B.weight']), ['si', 'Qwen-Image 2.1', ['qwen']]) +uguale('Qwen 1.x', ris(['transformer_blocks.0.txt_mlp.net.0.lora_A.weight']), ['no', 'Qwen-Image 1.x', []]) +uguale('SDXL', ris(['lora_unet_down_blocks_0_attentions_0.lora_down.weight']), ['no', 'Stable Diffusion / SDXL', []]) +uguale('Klein 3 (single_blocks qkv)', ris(['diffusion_model.single_blocks.2.qkv.lora_A.weight']), ['si', 'FLUX 3 Klein', ['klein', 'klein-base']]) +uguale('Klein 3 (content_mode_blocks)', ris(['diffusion_model.content_mode_blocks.0.attn.lora_A.weight']), ['si', 'FLUX 3 Klein', ['klein', 'klein-base']]) + +const doppi = (n) => Array.from({ length: n }, (_, i) => `diffusion_model.double_blocks.${i}.img_attn.qkv.lora_A.weight`) +const flux2 = (n) => [...doppi(n), 'diffusion_model.double_blocks.0.img_mlp.0.lora_A.weight', 'diffusion_model.double_blocks.0.txt_mlp.0.lora_A.weight', 'diffusion_model.single_blocks.0.linear1.lora_A.weight'] +uguale('Klein 4B (5 blocchi doppi)', ris(flux2(5)), ['si', 'FLUX.2 Klein 4B', ['klein4b']]) +uguale('Klein 4B con forma 3072', ris(flux2(5), { 'diffusion_model.double_blocks.0.img_attn.qkv.lora_A.weight': [16, 3072] }), ['si', 'FLUX.2 Klein 4B', ['klein4b']]) +uguale('FLUX.2 grande (8 doppi)', ris(flux2(8)), ['no', 'FLUX.1 / FLUX.2 grande', []]) +uguale('FLUX.2 9B (forma 4096)', ris(flux2(5), { 'diffusion_model.double_blocks.0.img_attn.qkv.lora_A.weight': [16, 4096] }), ['no', 'FLUX.2', []]) +uguale('sconosciuto', ris(['foo.bar.weight']), ['?', undefined, []]) + +// vaBeneCon +uguale('Qwen con Qwen', L.vaBeneCon({ compatibile: 'si', famiglie: ['qwen'] }, 'qwen'), 'si') +uguale('Qwen con Klein', L.vaBeneCon({ compatibile: 'si', famiglie: ['qwen'] }, 'klein'), 'no') +uguale('Klein 3 vale per Base', L.vaBeneCon({ compatibile: 'si', famiglie: ['klein', 'klein-base'] }, 'klein-base'), 'si') +uguale('Klein 3 non per 4B', L.vaBeneCon({ compatibile: 'si', famiglie: ['klein', 'klein-base'] }, 'klein4b'), 'no') +uguale('vecchio LoRA senza famiglie (si) vale', L.vaBeneCon({ compatibile: 'si' }, 'qwen'), 'si') +uguale('vecchio LoRA senza famiglie (no)', L.vaBeneCon({ compatibile: 'no' }, 'qwen'), 'no') +uguale('non si capisce → si lascia provare', L.vaBeneCon({ compatibile: '?' }, 'klein'), '?') + +console.log(falliti ? `${falliti} FALLITI su ${fatti}` : `TUTTI OK (${fatti} controlli)`) +process.exit(falliti ? 1 : 0) diff --git a/test/misura-run.mjs b/test/misura-run.mjs new file mode 100644 index 0000000..1d2a438 --- /dev/null +++ b/test/misura-run.mjs @@ -0,0 +1,30 @@ +// Esegue UN comando esplicito e registra tempo reale, esito e log, anche quando fallisce. +// Esempio: node test/misura-run.mjs node test/controlli.mjs +import { spawn } from 'node:child_process' +import { mkdirSync, appendFileSync, createWriteStream } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { resolve } from 'node:path' +const args = process.argv.slice(2) +if (!args.length) throw new Error('Indica il comando da misurare') +const vietati = ['foto', 'setup', 'modifica', 'console', 'vetrina', 'impacchettata'] +if (args.some((x) => vietati.some((n) => x.replaceAll('\\', '/').endsWith(`test/${n}.mjs`)))) throw new Error('Test Electron con dati reali vietato: usa avvio-isolato.mjs') +const dir = resolve('test/.out/verifiche') +mkdirSync(dir, { recursive: true }) +const inizio = new Date().toISOString() +const nome = inizio.replaceAll(/[:.]/g, '-') + '-' + args.join('-').replaceAll(/[^a-zA-Z0-9_-]/g, '_') +const log = resolve(dir, nome + '.log') +const stream = createWriteStream(log) +const prima = performance.now() +// npm.cmd è uno script di Windows: il solo wrapper npm usa cmd; gli argomenti sono limitati ai task noti. +const npm = args[0] === 'npm' +if (npm && !['run typecheck', 'run build', 'test', 'run test:gui'].includes(args.slice(1).join(' '))) throw new Error('Task npm non ammesso') +const child = spawn(npm && process.platform === 'win32' ? 'cmd.exe' : args[0], npm && process.platform === 'win32' ? ['/d', '/s', '/c', 'npm.cmd', ...args.slice(1)] : args.slice(1), { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) +for (const s of [child.stdout, child.stderr]) s.on('data', (b) => { stream.write(b); process.stdout.write(b) }) +child.on('error', (e) => stream.write(e.stack + '\n')) +child.on('close', (code, signal) => { + const voce = { comando: args.join(' '), inizio, fine: new Date().toISOString(), wall_s: Number(((performance.now() - prima) / 1000).toFixed(3)), exit_code: code, signal, log } + appendFileSync(resolve(dir, 'durate.jsonl'), JSON.stringify(voce) + '\n') + stream.end() + console.log('\nMISURA ' + JSON.stringify(voce)) + process.exitCode = code ?? 1 +}) diff --git a/test/modelli.mjs b/test/modelli.mjs index 28039c9..397d0d9 100644 --- a/test/modelli.mjs +++ b/test/modelli.mjs @@ -22,8 +22,8 @@ function uguale(nome, avuto, atteso) { } // la mappa -uguale('tre modelli, in ordine', M.ORDINE_MODELLI, ['qwen-base', 'klein', 'klein-base']) -uguale('le chiavi della mappa', Object.keys(M.MODELLI).sort(), ['klein', 'klein-base', 'qwen-base']) +uguale('quattro modelli, in ordine', M.ORDINE_MODELLI, ['qwen-base', 'klein', 'klein-base', 'klein4b']) +uguale('le chiavi della mappa', Object.keys(M.MODELLI).sort(), ['klein', 'klein-base', 'klein4b', 'qwen-base']) uguale('qwen-base', [M.MODELLI['qwen-base'].famiglia, M.MODELLI['qwen-base'].passi, M.MODELLI['qwen-base'].turbo, M.MODELLI['qwen-base'].lora], ['qwen', 40, false, true]) uguale('klein', [M.MODELLI.klein.famiglia, M.MODELLI.klein.passi, M.MODELLI.klein.cfg, M.MODELLI.klein.veloce], ['klein', 8, 1, true]) uguale('klein-base', [M.MODELLI['klein-base'].famiglia, M.MODELLI['klein-base'].passi, M.MODELLI['klein-base'].cfg], ['klein-base', 50, 3.5]) diff --git a/test/modifica.mjs b/test/modifica.mjs index 3f5b52b..66cdb2d 100644 --- a/test/modifica.mjs +++ b/test/modifica.mjs @@ -29,7 +29,7 @@ await win.click('.visore button:has-text("Modifica")') await pausa(1500) // Espandi a 16:9 -await win.click('.segmenti button:has-text("Espandi")') +await win.click('.modi-modifica button[data-id="modo-espandi"]') await win.click('.chip:has-text("+25% ovunque")') await win.fill('.colonna.destra textarea', 'the rest of a cozy Neapolitan café interior') await pausa(500) diff --git a/test/pila-lora.mjs b/test/pila-lora.mjs new file mode 100644 index 0000000..188a20d --- /dev/null +++ b/test/pila-lora.mjs @@ -0,0 +1,18 @@ +// Prove senza desktop: ordine, limiti della forza e compatibilità della pila LoRA. +import assert from 'node:assert/strict' +import { spostaLora, forzaLora, compatibilitaLora } from '../src/renderer/src/pilaLora.ts' +const pila = [{ file: 'a', forza: 1 }, { file: 'b', forza: 0.5 }, { file: 'c', forza: 0 }] +assert.deepEqual(spostaLora(pila, 'b', -1).map(x => x.file), ['b', 'a', 'c']) +assert.deepEqual(spostaLora(pila, 'b', 1).map(x => x.file), ['a', 'c', 'b']) +assert.deepEqual(spostaLora(pila, 'a', -1), pila) +assert.deepEqual(spostaLora(pila, 'manca', 1), pila) +assert.deepEqual(pila.map(x => x.file), ['a', 'b', 'c'], 'non muta lo stato precedente') +assert.equal(forzaLora(3), 2) +assert.equal(forzaLora(-3), -2) +assert.equal(forzaLora(NaN), 0) +assert.equal(compatibilitaLora({ compatibile: 'si' }, 'qwen'), 'si') +assert.equal(compatibilitaLora({ compatibile: 'si' }, 'klein'), 'no', 'il vecchio si significa solo Qwen') +assert.equal(compatibilitaLora({ compatibile: '?', famiglie: ['klein', 'klein-base'] }, 'klein'), 'si') +assert.equal(compatibilitaLora({ compatibile: 'si', famiglie: ['qwen'] }, 'klein'), 'no') +assert.equal(compatibilitaLora({ compatibile: '?', famiglie: [] }, 'klein'), '?') +console.log('OK: pila LoRA (ordine immutabile, forza, compatibilità esplicita)') diff --git a/test/regioni.mjs b/test/regioni.mjs new file mode 100644 index 0000000..caa9c05 --- /dev/null +++ b/test/regioni.mjs @@ -0,0 +1,64 @@ +// Test del regional prompting (src/main/motore/regioni.ts): riquadri → testo per Klein 3 (JSON con le coordinate) e per Qwen/Klein 4B (a parole). +// +// Esecuzione: node test/regioni.mjs +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' + +const QUI = dirname(fileURLToPath(import.meta.url)) +const R = await import(pathToFileURL(join(QUI, '..', 'src', 'main', 'motore', 'regioni.ts')).href) + +let falliti = 0 +let fatti = 0 +function uguale(nome, avuto, atteso) { + fatti++ + const a = JSON.stringify(avuto) + const b = JSON.stringify(atteso) + if (a === b) return + falliti++ + console.log(`NO ${nome}\n avuto: ${a}\n atteso: ${b}`) +} + +// pulizia +uguale('niente regioni', R.regioniPronte(undefined), []) +uguale('prompt vuoto scartato', R.regioniPronte([{ id: 'a', box: [0, 0, 0.5, 0.5], prompt: ' ' }]), []) +uguale('riquadro troppo piccolo scartato', R.regioniPronte([{ id: 'a', box: [0.1, 0.1, 0.11, 0.5], prompt: 'cat' }]), []) +uguale('riquadro con 3 numeri scartato', R.regioniPronte([{ id: 'a', box: [0, 0, 1], prompt: 'cat' }]), []) +uguale('NaN scartato', R.regioniPronte([{ id: 'a', box: [0, 0, NaN, 1], prompt: 'cat' }]), []) +uguale('riquadro capovolto e fuori tela', R.regioniPronte([{ id: 'x', box: [1.2, 0.8, 0.5, -0.2], prompt: ' a red\n ball ' }]), [{ id: 'obj_1', box: [0.5, 0, 1, 0.8], prompt: 'a red ball' }]) +uguale('id obj_N in fila, saltando gli scartati', R.regioniPronte([ + { id: 'a', box: [0, 0, 0.5, 0.5], prompt: 'one' }, + { id: 'b', box: [0, 0, 0.5, 0.5], prompt: '' }, + { id: 'c', box: [0.5, 0.5, 1, 1], prompt: 'two' } +]).map((r) => r.id), ['obj_1', 'obj_2']) +const venti = Array.from({ length: 20 }, (_, i) => ({ id: `r${i}`, box: [0, 0, 0.5, 0.5], prompt: `p${i}` })) +uguale('al massimo 16', R.regioniPronte(venti).length, 16) + +// Klein 3: bbox [y_min, x_min, y_max, x_max] su 0–1000 +uguale('bbox di Klein', R.bboxKlein([0.1, 0.2, 0.6, 0.9]), [200, 100, 900, 600]) +const reg = R.regioniPronte([ + { id: 'a', box: [0, 0, 0.5, 0.5], prompt: 'a red fox' }, + { id: 'b', box: [0.5, 0.5, 1, 1], prompt: 'a blue door.' } +]) +const pk = R.promptKleinConBox('a forest at dawn', reg) +uguale('Klein: didascalia con gli id', pk.startsWith('A forest at dawn. A red fox . A blue door . ['), true) +const json = JSON.parse(pk.slice(pk.indexOf('[{'))) +uguale('Klein: righe JSON', json, [ + { id: 'obj_1', bbox: [0, 0, 500, 500], desc: 'A red fox' }, + { id: 'obj_2', bbox: [500, 500, 1000, 1000], desc: 'A blue door.' } +]) +const pm = JSON.parse(R.promptKleinConBox('', reg, true).slice(R.promptKleinConBox('', reg, true).indexOf('[{'))) +uguale('Klein modifica: tgt_bbox', pm[0], { id: 'obj_1', from: 'target', src_bbox: null, tgt_bbox: [0, 0, 500, 500], desc: 'A red fox' }) +uguale('senza regioni il testo non cambia', R.promptKleinConBox('x', []), 'x') + +// Qwen / Klein 4B: a parole +uguale('dove: in alto a sinistra, piccolo', R.dove([0.02, 0.02, 0.2, 0.2]), 'small, at the top left of the frame') +uguale('dove: al centro', R.dove([0.35, 0.35, 0.65, 0.65]), 'in the center of the frame') +uguale('dove: grande a destra', R.dove([0.5, 0.1, 1, 0.9]), 'large, at the right of the frame') +const pq = R.promptConPosizioni('a street', reg) +uguale('Qwen: scena + elementi', pq.startsWith('A street. Compose the image with these elements, each in its place:'), true) +uguale('Qwen: nessun JSON', pq.includes('{'), false) +uguale('Qwen modifica', R.promptConPosizioni('', reg, true).startsWith('Apply these changes, each one in its place:'), true) +uguale('senza regioni (Qwen)', R.promptConPosizioni('x', []), 'x') + +console.log(falliti ? `${falliti} FALLITI su ${fatti}` : `TUTTI OK (${fatti} controlli)`) +process.exit(falliti ? 1 : 0) diff --git a/test/ricette-v2.mjs b/test/ricette-v2.mjs new file mode 100644 index 0000000..c05009b --- /dev/null +++ b/test/ricette-v2.mjs @@ -0,0 +1,27 @@ +// Le sei ricette nuove, i loro passi veri e la lettura delle ricette storiche. +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { mkdirSync } from 'node:fs' +globalThis.window = { daprod: {} } +globalThis.localStorage = { getItem: () => null, setItem() {} } +mkdirSync('test/.out', { recursive: true }) +buildSync({ entryPoints: ['src/renderer/src/workflow.ts'], bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', alias: { '@shared': resolve('src/shared') }, outfile: 'test/.out/ricette-v2.cjs' }) +const { elencoWorkflow, workflowDa, valoriIniziali } = createRequire(import.meta.url)('./.out/ricette-v2.cjs') +const elenco = elencoWorkflow() +assert.deepEqual(elenco.map(w => w.nome), ['Foglio', 'Mondi', 'Mix', 'Set', 'Formati', 'Rinnova']) +for (const w of elenco) { + const v = valoriIniziali(w) + const passi = w.passi({ v, nRif: 2, camera: false }) + assert.ok(passi.length, `${w.nome}: almeno un passo`) + assert.equal(new Set(passi.map(p => p.etichetta)).size, passi.length, `${w.nome}: etichette univoche`) + assert.ok(passi.every(p => p.prompt.trim().length > 10)) + for (const g of w.ingressi.filter(g => g.tipo === 'scelta')) for (const o of g.opzioni) { + assert.ok(w.passi({ v: { ...v, [g.id]: o.id }, nRif: 2, camera: false }).length, `${w.nome}/${o.id}`) + } +} +assert.ok(workflowDa('turnaround'), 'il PNG vecchio trova ancora il suo workflow') +assert.ok(workflowDa('esempio'), 'il percorso Esempio rimane accessibile da Mix') +assert.ok(elenco.find(w => w.nome === 'Set').passi({ v: { soggetti: 'gatto\ncane', tipo: 'icone' }, nRif: 0, camera: false }).every(p => p.trasparente)) +console.log('OK: sei ricette, tutte le varianti producono passi, Set trasparente, compatibilità storica') diff --git a/test/ricette.mjs b/test/ricette.mjs index d32b431..e5918c4 100644 --- a/test/ricette.mjs +++ b/test/ricette.mjs @@ -19,17 +19,17 @@ await pausa(1200) // la rotaia non ha più il Laboratorio prova('rotaia senza Laboratorio', (await pg.locator('.rotaia button:has-text("Laboratorio")').count()) === 0) -await pg.click('.rotaia button:has-text("Modifica")') +await pg.click('.strip-modi button[data-modo="modifica"]') await pausa(300) -const voci = (await pg.locator('.attrezzi .segmenti button:visible').allInnerTexts()).map((x) => x.trim()).join('|') -prova(`selettore: ${voci}`, voci === 'Scrivi|Espandi|Ricette|Estrai') +const voci = (await pg.locator('.modi-modifica button:visible').allInnerTexts()).map((x) => x.trim()).join('|') +prova(`selettore: ${voci}`, voci === 'Scrivi|Espandi|Estrai') // il catalogo -await pg.click('.attrezzi .segmenti button:has-text("Ricette")') +await pg.click('.strip-modi button[data-modo="ricette"]') await pausa(500) await pg.screenshot({ path: `${out}/r1-catalogo.png` }) -prova('catalogo con le carte', (await pg.locator('.carta-wf').count()) > 20) -prova("Unisci foto e Come l'esempio nel catalogo", (await pg.locator('.carta-wf:has-text("Unisci foto")').count()) === 1 && (await pg.locator(".carta-wf:has-text(\"Come l'esempio\")").count()) === 1) +prova('catalogo: le sei ricette', (await pg.locator('.carta-wf').count()) === 6) +prova('Mix nel catalogo (Unisci e Come esempio ci stanno dentro)', (await pg.locator('.carta-wf:has-text("Mix")').count()) === 1) // una ricetta normale: colonna di destra con i passi e Avvia await pg.locator('.carta-wf').first().click() @@ -38,29 +38,28 @@ await pg.screenshot({ path: `${out}/r2-ricetta.png` }) prova('ricetta normale: colonna destra con Avvia', (await pg.locator('.colonna.destra button:has-text("Avvia")').count()) === 1) // Unisci foto: la griglia di Modifica e il pulsante di sempre -await pg.locator('.carta-wf:has-text("Unisci foto")').click() +await pg.locator('.carta-wf:has-text("Mix")').click() await pausa(500) await pg.screenshot({ path: `${out}/r3-unisci.png` }) prova('Unisci: c\'è il ritorno a "tutte le ricette"', (await pg.locator('.attrezzi button:has-text("tutte le ricette")').count()) === 1) prova('Unisci: pulsante "crea la foto nuova"', (await pg.locator('.colonna.destra button:has-text("crea la foto nuova")').count()) === 1) await pg.locator('.fusion button:has-text("Dal PC")').first().click() await pausa(500) -await pg.fill('.colonna.destra textarea', 'la ragazza di in una strada di Tokyo') +await pg.fill('.colonna.destra textarea:visible', 'la ragazza di in una strada di Tokyo') await pg.click('.colonna.destra button:has-text("crea la foto nuova")') await pausa(500) const q = await pg.evaluate(() => window.__ultima) prova('Unisci: accodato un lavoro "crea" con la foto', !!q && q.modalita === 'crea' && q.immagini?.length === 1) // Come l'esempio -await pg.click('.attrezzi button:has-text("tutte le ricette")') +await pg.click(".attrezzi button:has-text(\"Come l'esempio\")") await pausa(300) -await pg.locator(".carta-wf:has-text(\"Come l'esempio\")").click() await pausa(500) await pg.screenshot({ path: `${out}/r4-esempio.png` }) prova("Esempio: pulsante \"Crea come l'esempio\"", (await pg.locator(".colonna.destra button:has-text(\"Crea come l'esempio\")").count()) === 1) // tornando a Scrivi la pagina è quella di sempre -await pg.click('.attrezzi .segmenti button:has-text("Scrivi")') +await pg.click('.strip-modi button[data-modo="modifica"]') await pausa(300) prova('Scrivi: pulsante Modifica', (await pg.locator('.colonna.destra button:has-text("Modifica")').count()) >= 1) await b.close() diff --git a/test/spazio.mjs b/test/spazio.mjs index 1b41dce..52b766f 100644 --- a/test/spazio.mjs +++ b/test/spazio.mjs @@ -23,6 +23,9 @@ function uguale(nome, avuto, atteso) { uguale('qwen', S.occupaImmagine('qwen'), 'immagine:qwen') uguale('klein', S.occupaImmagine('klein'), 'immagine:klein') uguale('klein-base', S.occupaImmagine('klein-base'), 'immagine:klein-base') +uguale('klein4b', S.occupaImmagine('klein4b'), 'immagine:klein4b') +uguale('da Klein 4B a Qwen si libera', S.cambioDiModello('immagine:klein4b', 'immagine:qwen').libera, true) +uguale('fase di scarico Klein 4B', S.faseScarico('immagine:klein4b'), 'Scarico FLUX.2 Klein 4B') uguale('testo col 4B', S.occupaTesto(true), 'testo:4b') uguale('testo con l\'8B', S.occupaTesto(false), 'testo:8b') uguale('testo senza dire niente = 8B', S.occupaTesto(undefined), 'testo:8b') diff --git a/test/studio-layout.mjs b/test/studio-layout.mjs new file mode 100644 index 0000000..377cd38 --- /dev/null +++ b/test/studio-layout.mjs @@ -0,0 +1,49 @@ +// Layout dello Studio: il pannello dei controlli sta a SINISTRA del palco in tutti i modi (Crea, Modifica, Box, Ricette), +// e in Crea il pulsante CREA sta fuori dallo scorrimento, in fondo al pannello. +// npm run build, poi un server statico su out/renderer (porta 8765), poi: node test/studio-layout.mjs +import { chromium } from 'playwright' +import { readFileSync } from 'node:fs' +import assert from 'node:assert/strict' + +const url = process.argv[2] || 'http://localhost:8765/index.html' +const b = await chromium.launch() +const pg = await b.newPage({ viewport: { width: 1480, height: 1000 } }) +pg.on('pageerror', (e) => console.log('[errore pagina]', e.message)) +await pg.addInitScript(readFileSync('test/finto.js', 'utf8')) +await pg.goto(url) +await pg.waitForTimeout(1200) + +const misure = {} +for (const modo of ['crea', 'modifica', 'box', 'ricette']) { + await pg.click(`button[data-modo="${modo}"]`) + await pg.waitForTimeout(400) + misure[modo] = await pg.evaluate(() => { + const visibile = (el) => el.getBoundingClientRect().width > 0 + const colonna = [...document.querySelectorAll('.studio-corpo .colonna.destra')].find(visibile) + const pagina = colonna && colonna.closest('.pagina') + if (!colonna || !pagina) return null + const c = colonna.getBoundingClientRect() + const p = pagina.getBoundingClientRect() + // il pannello è il più a sinistra della pagina (l'ordine visivo, non quello del DOM: il CSS lo mette per primo) + const aSinistra = Math.abs(c.left - p.left) < 2 && c.right < p.right + const azione = colonna.querySelector('.azione-prima') + const scorri = colonna.querySelector(':scope > .scorri') + return { + pannelloASinistra: aSinistra, + pannelloLarghezza: Math.round(c.width), + scorrevole: !!scorri && getComputedStyle(scorri).overflowY === 'auto', + azioneFuoriDalloScorrimento: !!azione && !scorri.contains(azione), + } + }) + console.log(modo, JSON.stringify(misure[modo])) + assert.ok(misure[modo], `${modo}: pannello e palco visibili`) + assert.equal(misure[modo].pannelloASinistra, true, `${modo}: il pannello dei controlli sta a sinistra del palco`) +} +// in Crea il pulsante CREA è fuori dallo scorrimento (resta in fondo anche con il pannello scorso) +await pg.click('button[data-modo="crea"]') +await pg.waitForTimeout(300) +assert.equal(misure.crea.azioneFuoriDalloScorrimento, true, 'Crea: CREA fisso in fondo al pannello') +assert.equal(misure.crea.scorrevole, true, 'Crea: il pannello scorre') + +await b.close() +console.log('OK: Studio con pannello a sinistra in Crea, Modifica, Box e Ricette; CREA fisso in fondo') diff --git a/test/uncensored-ssr.mjs b/test/uncensored-ssr.mjs new file mode 100644 index 0000000..2ea7e1f --- /dev/null +++ b/test/uncensored-ssr.mjs @@ -0,0 +1,30 @@ +// Verifica del gate Uncensored senza avviare browser o scaricare modelli. +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { mkdirSync } from 'node:fs' +const require = createRequire(import.meta.url) +const React = require('react') +const { renderToStaticMarkup } = require('react-dom/server') +globalThis.window = { daprod: {} } +globalThis.localStorage = { getItem: () => null, setItem() {} } +mkdirSync('test/.out', { recursive: true }) +buildSync({ stdin: { contents: "export { LaboratorioUncensored } from './src/renderer/src/componenti/LaboratorioUncensored'; export { Impostazioni } from './src/renderer/src/pagine/Impostazioni'; export { usaStato } from './src/renderer/src/stato'", resolveDir: process.cwd() }, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', external: ['react', 'react-dom'], alias: { '@shared': resolve('src/shared') }, outfile: 'test/.out/uncensored-ssr.cjs' }) +const { LaboratorioUncensored, Impostazioni, usaStato } = require('./.out/uncensored-ssr.cjs') +const voce = { id: 'fixture', nome: 'Componente di prova', tipo: 'encoder', uncensored: true, licenza: 'Qwen Research', byte: 1, presente: false } +const props = { imp: {}, catalogo: [voce], download: {}, salva: async () => {}, scarica: async () => { throw Error('mai scaricare nel rendering') } } +const render = imp => renderToStaticMarkup(React.createElement(LaboratorioUncensored, { ...props, imp })) +assert.doesNotMatch(render({}), /Componente di prova/, 'catalogo nascosto di serie') +assert.doesNotMatch(render({ uncensored: true }), /Componente di prova/, 'flag senza consenso insufficiente') +assert.doesNotMatch(render({ uncensored: true, uncensoredAccettato: false }), /Componente di prova/, 'consenso obsoleto insufficiente') +const acceso = render({ uncensored: true, uncensoredAccettato: true }) +assert.match(acceso, /Componente di prova/) +assert.match(acceso, /Uncensored/) +assert.match(acceso, /Qwen Research/) +assert.match(acceso, /Scarica/) +React.useSyncExternalStore = (_subscribe, getSnapshot) => getSnapshot() +usaStato.setState({ imp: { modello: '', encoder: '', vae: '', kleinModello: '', kleinBase: '', kleinEncoder: '', kleinVae: '', famiglia: 'qwen', predefiniti: { passi: 40, cfg: 1, megapixel: 1 } } }) +const pagina = renderToStaticMarkup(React.createElement(Impostazioni)) +assert.match(pagina, /LABORATORIO UNCENSORED/, 'laboratorio raggiungibile dalla pagina Impostazioni') +console.log('OK: laboratorio Uncensored, gate, licenza e integrazione nella pagina')